cursor-opencode-provider 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
17
17
  - **OpenCode integration** — registers a `cursor` provider with auth hooks and cached model list
18
18
  - **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
19
19
  - **Model discovery** — fetches available models from Cursor's API and caches them locally
20
+ - **Image input** — advertises vision only for supported models and forwards OpenCode image attachments to Cursor
20
21
  - **Streaming** — bidirectional Connect-RPC Runs with stale-session rotation, health checks, semantic/read-idle deadlines, bounded replay-safe recovery, and activity-aware held tool continuations
21
22
  - **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts, including catalog-aware native subagent execution: exact advertised custom agents win, `unspecified` / `generalPurpose` select `general`, read-oriented `bugbot` / `security-review` select `explore`, and `cursor-guide` selects enabled Kilo `scout` before `explore`; MiMo `actor` is used instead of its work-item `task` tool when advertised. The bridge also covers the Pi read/bash/edit/write/grep/find/ls request/result range, enforces the exact current OpenCode tool catalog, mirrors finalized display-only todo/plan state, and strips OpenCode's `read` XML envelope before returning content to Cursor.
22
23
  - **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
@@ -47,7 +48,7 @@ Add the package to OpenCode config. OpenCode installs npm plugins with Bun at st
47
48
  }
48
49
  ```
49
50
 
50
- Pin a version if you want: `"cursor-opencode-provider@0.3.2"`.
51
+ Pin a version if you want: `"cursor-opencode-provider@0.4.0"`.
51
52
 
52
53
  You can also install it yourself first:
53
54
 
@@ -117,7 +118,7 @@ browser login or an API key. `CURSOR_API_KEY` is also picked up automatically.
117
118
  }
118
119
  ```
119
120
 
120
- Pin a version if you want: `"cursor-opencode-provider@0.3.2/plugin/opencode2"`.
121
+ Pin a version if you want: `"cursor-opencode-provider@0.4.0/plugin/opencode2"`.
121
122
 
122
123
  OpenCode 2.0 installs the published package into its host cache and loads the AI SDK
123
124
  entry from there (`aisdk:cursor-opencode-provider`). No extra env vars are required.
@@ -197,7 +198,7 @@ Feature parity with the classic plugin, and the two places 2.0 differs:
197
198
  | `chat.params` flags compaction turns | **No `chat.params` in 2.0** — the compaction agent is detected in `ctx.session.hook("context")` and correlated by session id |
198
199
 
199
200
  > **Beta.** OpenCode 2.0 and its plugin API are both beta and still changing. This
200
- > entrypoint is built against `@opencode-ai/plugin@next` (`0.0.0-next-16281`) and is
201
+ > entrypoint is built against `@opencode-ai/plugin@next` (`0.0.0-next-17155`) and is
201
202
  > guarded by a compile-time conformance check, but expect churn.
202
203
 
203
204
  ### Authenticate
@@ -259,6 +260,28 @@ OpenCode’s context limit is static per model entry, while Cursor’s long-cont
259
260
 
260
261
  The Run still uses Cursor’s original model id; OpenCode’s synthetic `-1m` id is only for picking and limits.
261
262
 
263
+ #### Context limit sources
264
+
265
+ The provider resolves each catalog context limit in this order:
266
+
267
+ 1. Live `AvailableModels` metadata (variant `context` parameters first, then context-limit fields)
268
+ 2. Cursor’s published model table, generated from [`docs.md`](https://cursor.com/docs.md) alongside pricing data
269
+ 3. A conservative `200000` fallback when neither source publishes a limit
270
+
271
+ The docs fallback only fills static catalog metadata. It does not invent Cursor variants or enable a long-context tier that `AvailableModels` did not advertise.
272
+
273
+ #### Image input
274
+
275
+ The provider advertises `text` + `image` input only when the selected Cursor model supports images; model output remains text. This works in the classic plugin, the 1.18 v2 plugin, and OpenCode 2.0.
276
+
277
+ Image support is resolved in this order:
278
+
279
+ 1. Live `AvailableModels.supportsImages` metadata, including authoritative `false` values
280
+ 2. The `Capabilities` column in Cursor's [`docs.md`](https://cursor.com/docs.md), generated alongside context and pricing metadata
281
+ 3. Text-only when neither source describes the model
282
+
283
+ OpenCode image file parts are decoded from bytes, base64/data URLs, local file URLs, or HTTP(S) URLs and sent through Cursor's `UserMessage.selected_context.selected_images` field. On fresh or rebased Runs, the provider also harvests image `file-data` from tool results and `file` parts from assistant history; previously sent history images are deduplicated by content hash per OpenCode session. Held-open continuation results remain text-only because Cursor's exec-result channel has no image field. Attachments are limited to 20 MiB total, matching OpenCode's desktop attachment budget. PDF, audio, and video inputs are not advertised or silently discarded.
284
+
262
285
  #### Max mode
263
286
 
264
287
  Cursor IDE has a separate **Max Mode** toggle that sets `requested_model.max_mode` and selects the default max / 1m variant. OpenCode has no equivalent custom toggle, so this provider approximates it:
@@ -337,6 +360,7 @@ OpenCode
337
360
  | `src/opencode2/` | 2.0-only catalog mapping, integration/auth, and local API types |
338
361
  | `src/plugin-core.ts` | Host-neutral SDK factory, package matching, API base/telemetry resolution |
339
362
  | `src/model-config.ts` | Cursor model → OpenCode model mapping shared by every plugin surface |
363
+ | `src/image-input.ts` | AI SDK image attachment validation, decoding, and size limits |
340
364
  | `src/pricing.ts` / `src/pricing-data.ts` | Cursor docs token rates → classic `cost` and OpenCode 2.0 cost tiers |
341
365
  | `src/index.ts` | `createCursor` factory; default export is `CursorPlugin` |
342
366
  | `src/language-model.ts` | AI SDK `LanguageModelV3` adapter (`doStream`, `doGenerate`) |
@@ -426,4 +450,4 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
426
450
 
427
451
  ## License
428
452
 
429
- MIT
453
+ MIT
@@ -0,0 +1,31 @@
1
+ export declare const MAX_CURSOR_IMAGE_INPUT_BYTES: number;
2
+ export type CursorImageInput = {
3
+ data: Uint8Array;
4
+ filename: string;
5
+ mimeType: string;
6
+ };
7
+ export type CursorHistoryImageExtraction = {
8
+ images: CursorImageInput[];
9
+ hashes: string[];
10
+ candidateCount: number;
11
+ duplicateCount: number;
12
+ };
13
+ export type CursorPromptImageExtraction = CursorHistoryImageExtraction & {
14
+ userImageCount: number;
15
+ };
16
+ export declare function hasCursorUserImages(lastUser: Record<string, unknown> | undefined): boolean;
17
+ export declare function assertCursorUserImageSupport(lastUser: Record<string, unknown> | undefined, supportsImages: boolean, modelId: string): void;
18
+ export declare function extractCursorUserImages(lastUser: Record<string, unknown> | undefined, signal?: AbortSignal, maxBytes?: number): Promise<CursorImageInput[]>;
19
+ export declare function extractCursorHistoryImages(prompt: readonly unknown[], options: {
20
+ supportsImages: boolean;
21
+ seenHashes?: ReadonlySet<string>;
22
+ signal?: AbortSignal;
23
+ maxBytes?: number;
24
+ filenameOffset?: number;
25
+ }): Promise<CursorHistoryImageExtraction>;
26
+ export declare function extractCursorPromptImages(prompt: readonly unknown[], lastUser: Record<string, unknown> | undefined, options: {
27
+ supportsImages: boolean;
28
+ seenHistoryHashes?: ReadonlySet<string>;
29
+ signal?: AbortSignal;
30
+ maxBytes?: number;
31
+ }): Promise<CursorPromptImageExtraction>;
@@ -0,0 +1,281 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { UnsupportedFunctionalityError } from "@ai-sdk/provider";
6
+ export const MAX_CURSOR_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
7
+ export function hasCursorUserImages(lastUser) {
8
+ return !!lastUser && Array.isArray(lastUser.content) && lastUser.content.some((part) => {
9
+ if (!part || typeof part !== "object")
10
+ return false;
11
+ const file = part;
12
+ return file.type === "file" && typeof file.mediaType === "string" && file.mediaType.startsWith("image/");
13
+ });
14
+ }
15
+ function unsupported(functionality, message) {
16
+ throw new UnsupportedFunctionalityError({ functionality, message });
17
+ }
18
+ export function assertCursorUserImageSupport(lastUser, supportsImages, modelId) {
19
+ if (!hasCursorUserImages(lastUser) || supportsImages)
20
+ return;
21
+ unsupported("image input", `Cursor model ${JSON.stringify(modelId)} does not support image input`);
22
+ }
23
+ function decodeBase64(value) {
24
+ const normalized = value.replace(/\s/g, "");
25
+ if (!normalized || !/^[A-Za-z0-9+/_-]*={0,2}$/.test(normalized)) {
26
+ return unsupported("image input", "Cursor provider received invalid base64 image data");
27
+ }
28
+ const data = Uint8Array.from(Buffer.from(normalized, "base64"));
29
+ if (data.length === 0) {
30
+ return unsupported("image input", "Cursor provider received an empty image");
31
+ }
32
+ return data;
33
+ }
34
+ function decodeDataUrl(value) {
35
+ if (!value.startsWith("data:")) {
36
+ return unsupported("image input", "Cursor provider supports base64-encoded image data URLs only");
37
+ }
38
+ const commaIndex = value.indexOf(",", 5);
39
+ if (commaIndex < 0) {
40
+ return unsupported("image input", "Cursor provider supports base64-encoded image data URLs only");
41
+ }
42
+ const metadata = value.slice(5, commaIndex);
43
+ const finalSeparator = metadata.lastIndexOf(";");
44
+ if (finalSeparator < 0 || metadata.slice(finalSeparator + 1) !== "base64") {
45
+ return unsupported("image input", "Cursor provider supports base64-encoded image data URLs only");
46
+ }
47
+ const firstSeparator = metadata.indexOf(";");
48
+ const mimeType = metadata.slice(0, firstSeparator);
49
+ return {
50
+ data: decodeBase64(value.slice(commaIndex + 1)),
51
+ mimeType: mimeType || undefined,
52
+ };
53
+ }
54
+ function inferImageMimeType(data) {
55
+ if (data.length >= 8 &&
56
+ data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 &&
57
+ data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a)
58
+ return "image/png";
59
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
60
+ return "image/jpeg";
61
+ }
62
+ if (data.length >= 6) {
63
+ const header = Buffer.from(data.subarray(0, 6)).toString("ascii");
64
+ if (header === "GIF87a" || header === "GIF89a")
65
+ return "image/gif";
66
+ }
67
+ if (data.length >= 12 &&
68
+ Buffer.from(data.subarray(0, 4)).toString("ascii") === "RIFF" &&
69
+ Buffer.from(data.subarray(8, 12)).toString("ascii") === "WEBP")
70
+ return "image/webp";
71
+ return undefined;
72
+ }
73
+ function assertImageSize(size, remaining) {
74
+ if (size > remaining) {
75
+ unsupported("image input", `Cursor provider image attachments exceed the ${MAX_CURSOR_IMAGE_INPUT_BYTES / 1024 / 1024} MiB limit`);
76
+ }
77
+ }
78
+ function cursorImageBudget(value) {
79
+ if (!Number.isFinite(value))
80
+ return MAX_CURSOR_IMAGE_INPUT_BYTES;
81
+ return Math.min(MAX_CURSOR_IMAGE_INPUT_BYTES, Math.max(0, Math.floor(value)));
82
+ }
83
+ function imageContentHash(data) {
84
+ return createHash("sha256").update(data).digest("hex");
85
+ }
86
+ async function readResponseBytes(response, remaining) {
87
+ const declaredLength = Number(response.headers.get("content-length"));
88
+ if (Number.isFinite(declaredLength) && declaredLength > remaining) {
89
+ assertImageSize(declaredLength, remaining);
90
+ }
91
+ if (!response.body) {
92
+ const data = new Uint8Array(await response.arrayBuffer());
93
+ assertImageSize(data.length, remaining);
94
+ return data;
95
+ }
96
+ const chunks = [];
97
+ let total = 0;
98
+ const reader = response.body.getReader();
99
+ try {
100
+ while (true) {
101
+ const { done, value } = await reader.read();
102
+ if (done)
103
+ break;
104
+ total += value.length;
105
+ if (total > remaining) {
106
+ await reader.cancel();
107
+ assertImageSize(total, remaining);
108
+ }
109
+ chunks.push(value);
110
+ }
111
+ }
112
+ finally {
113
+ reader.releaseLock();
114
+ }
115
+ const data = new Uint8Array(total);
116
+ let offset = 0;
117
+ for (const chunk of chunks) {
118
+ data.set(chunk, offset);
119
+ offset += chunk.length;
120
+ }
121
+ return data;
122
+ }
123
+ async function resolveImageData(value, remaining, signal) {
124
+ if (value instanceof Uint8Array)
125
+ return { data: Uint8Array.from(value) };
126
+ if (typeof value === "string") {
127
+ return value.startsWith("data:") ? decodeDataUrl(value) : { data: decodeBase64(value) };
128
+ }
129
+ if (value.protocol === "data:")
130
+ return decodeDataUrl(value.href);
131
+ if (value.protocol === "file:") {
132
+ const filePath = fileURLToPath(value);
133
+ const info = await stat(filePath);
134
+ assertImageSize(info.size, remaining);
135
+ return { data: Uint8Array.from(await readFile(filePath)), filename: path.basename(filePath) };
136
+ }
137
+ if (value.protocol !== "http:" && value.protocol !== "https:") {
138
+ return unsupported("image URL input", `Cursor provider does not support image URL protocol ${JSON.stringify(value.protocol)}`);
139
+ }
140
+ const response = await fetch(value, { signal });
141
+ if (!response.ok) {
142
+ return unsupported("image URL input", `Cursor provider could not fetch image URL (HTTP ${response.status})`);
143
+ }
144
+ return {
145
+ data: await readResponseBytes(response, remaining),
146
+ mimeType: response.headers.get("content-type")?.split(";", 1)[0]?.trim() || undefined,
147
+ filename: path.basename(value.pathname) || undefined,
148
+ };
149
+ }
150
+ async function decodeCursorImagePart(file, remaining, signal, defaultFilename, resolveLimit = remaining) {
151
+ if (typeof file.mediaType !== "string" || !file.mediaType.startsWith("image/")) {
152
+ return unsupported("file input", `Cursor provider supports image attachments only, not ${JSON.stringify(file.mediaType)}`);
153
+ }
154
+ if (!(file.data instanceof Uint8Array) && typeof file.data !== "string" && !(file.data instanceof URL)) {
155
+ return unsupported("image input", "Cursor provider received an invalid image data value");
156
+ }
157
+ const resolved = await resolveImageData(file.data, resolveLimit, signal);
158
+ assertImageSize(resolved.data.length, remaining);
159
+ const declaredMimeType = file.mediaType;
160
+ const mimeType = declaredMimeType === "image/*"
161
+ ? (resolved.mimeType?.startsWith("image/") ? resolved.mimeType : inferImageMimeType(resolved.data))
162
+ : declaredMimeType;
163
+ if (!mimeType?.startsWith("image/") || mimeType === "image/*") {
164
+ return unsupported("image input", "Cursor provider could not determine the image media type");
165
+ }
166
+ return {
167
+ data: resolved.data,
168
+ filename: (typeof file.filename === "string" && file.filename ? path.basename(file.filename) : undefined) ??
169
+ resolved.filename ??
170
+ defaultFilename,
171
+ mimeType,
172
+ };
173
+ }
174
+ export async function extractCursorUserImages(lastUser, signal, maxBytes = MAX_CURSOR_IMAGE_INPUT_BYTES) {
175
+ if (!lastUser || !Array.isArray(lastUser.content))
176
+ return [];
177
+ const byteBudget = cursorImageBudget(maxBytes);
178
+ const images = [];
179
+ let totalBytes = 0;
180
+ for (const part of lastUser.content) {
181
+ if (!part || typeof part !== "object")
182
+ continue;
183
+ const file = part;
184
+ if (file.type !== "file")
185
+ continue;
186
+ const image = await decodeCursorImagePart(file, byteBudget - totalBytes, signal, `image-${images.length + 1}`);
187
+ totalBytes += image.data.length;
188
+ images.push(image);
189
+ }
190
+ return images;
191
+ }
192
+ function cursorHistoryImageParts(prompt) {
193
+ const parts = [];
194
+ for (const message of prompt) {
195
+ if (!message || typeof message !== "object")
196
+ continue;
197
+ const record = message;
198
+ if (!Array.isArray(record.content))
199
+ continue;
200
+ if (record.role === "assistant") {
201
+ for (const part of record.content) {
202
+ if (!part || typeof part !== "object")
203
+ continue;
204
+ const file = part;
205
+ if (file.type === "file" &&
206
+ typeof file.mediaType === "string" &&
207
+ file.mediaType.startsWith("image/"))
208
+ parts.push(file);
209
+ }
210
+ continue;
211
+ }
212
+ if (record.role !== "tool")
213
+ continue;
214
+ for (const part of record.content) {
215
+ if (!part || typeof part !== "object")
216
+ continue;
217
+ const toolResult = part;
218
+ if (toolResult.type !== "tool-result" || !toolResult.output || typeof toolResult.output !== "object") {
219
+ continue;
220
+ }
221
+ const output = toolResult.output;
222
+ if (output.type !== "content" || !Array.isArray(output.value))
223
+ continue;
224
+ for (const value of output.value) {
225
+ if (!value || typeof value !== "object")
226
+ continue;
227
+ const file = value;
228
+ if (file.type === "file-data" &&
229
+ typeof file.mediaType === "string" &&
230
+ file.mediaType.startsWith("image/"))
231
+ parts.push(file);
232
+ }
233
+ }
234
+ }
235
+ return parts;
236
+ }
237
+ export async function extractCursorHistoryImages(prompt, options) {
238
+ const candidates = cursorHistoryImageParts(prompt);
239
+ if (!options.supportsImages) {
240
+ return { images: [], hashes: [], candidateCount: candidates.length, duplicateCount: 0 };
241
+ }
242
+ const maxBytes = cursorImageBudget(options.maxBytes ?? MAX_CURSOR_IMAGE_INPUT_BYTES);
243
+ const images = [];
244
+ const hashes = [];
245
+ const hashesThisTurn = new Set();
246
+ let duplicateCount = 0;
247
+ let totalBytes = 0;
248
+ for (const file of candidates) {
249
+ // Resolve against the per-image cap first so a previously sent duplicate
250
+ // does not fail merely because little combined budget remains this turn.
251
+ const image = await decodeCursorImagePart(file, MAX_CURSOR_IMAGE_INPUT_BYTES, options.signal, `image-${(options.filenameOffset ?? 0) + images.length + 1}`, MAX_CURSOR_IMAGE_INPUT_BYTES);
252
+ const hash = imageContentHash(image.data);
253
+ if (options.seenHashes?.has(hash) || hashesThisTurn.has(hash)) {
254
+ duplicateCount++;
255
+ continue;
256
+ }
257
+ assertImageSize(image.data.length, maxBytes - totalBytes);
258
+ totalBytes += image.data.length;
259
+ images.push(image);
260
+ hashes.push(hash);
261
+ hashesThisTurn.add(hash);
262
+ }
263
+ return { images, hashes, candidateCount: candidates.length, duplicateCount };
264
+ }
265
+ export async function extractCursorPromptImages(prompt, lastUser, options) {
266
+ const maxBytes = cursorImageBudget(options.maxBytes ?? MAX_CURSOR_IMAGE_INPUT_BYTES);
267
+ const userImages = await extractCursorUserImages(lastUser, options.signal, maxBytes);
268
+ const userBytes = userImages.reduce((total, image) => total + image.data.length, 0);
269
+ const history = await extractCursorHistoryImages(prompt, {
270
+ supportsImages: options.supportsImages,
271
+ seenHashes: options.seenHistoryHashes,
272
+ signal: options.signal,
273
+ maxBytes: maxBytes - userBytes,
274
+ filenameOffset: userImages.length,
275
+ });
276
+ return {
277
+ ...history,
278
+ images: [...userImages, ...history.images],
279
+ userImageCount: userImages.length,
280
+ };
281
+ }
@@ -24,6 +24,8 @@ import { resolveAgentUrl } from "./agent-url.js";
24
24
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
25
25
  import { isCompactionSession } from "./compaction-marker.js";
26
26
  import { getSessionDirectory } from "./session-directory.js";
27
+ import { assertCursorUserImageSupport, extractCursorPromptImages } from "./image-input.js";
28
+ import { resolveCursorModelSupportsImages } from "./model-metadata.js";
27
29
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
28
30
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
29
31
  import { readAllFieldsStrict } from "./protocol/struct.js";
@@ -36,12 +38,14 @@ let _availableModelsMtimeMs = -1;
36
38
  // session so the new Cursor conversation is still born with tool definitions;
37
39
  // execution remains disabled for the summary turn itself.
38
40
  const toolCatalogBySession = new Map();
41
+ const sentHistoryImageHashesBySession = new Map();
39
42
  // A compaction Run uses its own summary-agent system prompt. Its opaque Cursor
40
43
  // checkpoint must never become the base for the resumed normal agent: doing so
41
44
  // suppresses OpenCode's compacted prompt/system seed and makes Cursor narrate
42
45
  // tool use instead of emitting exec requests. Rebase once on the next turn.
43
46
  const postCompactionRebaseBySession = new Set();
44
47
  export const MAX_TURN_STATE_SESSIONS = 256;
48
+ const MAX_SENT_HISTORY_IMAGES_PER_SESSION = 256;
45
49
  const DEFAULT_RETRY_POLICY = {
46
50
  maxAttempts: 3,
47
51
  baseDelayMs: 500,
@@ -251,6 +255,39 @@ function rememberPostCompactionRebase(sessionKey) {
251
255
  postCompactionRebaseBySession.delete(oldest);
252
256
  }
253
257
  }
258
+ function sentHistoryImageHashes(sessionKey) {
259
+ if (!sessionKey)
260
+ return undefined;
261
+ const hashes = sentHistoryImageHashesBySession.get(sessionKey);
262
+ if (!hashes)
263
+ return undefined;
264
+ sentHistoryImageHashesBySession.delete(sessionKey);
265
+ sentHistoryImageHashesBySession.set(sessionKey, hashes);
266
+ return hashes;
267
+ }
268
+ function rememberSentHistoryImageHashes(sessionKey, hashes) {
269
+ if (!sessionKey || hashes.length === 0)
270
+ return;
271
+ const remembered = sentHistoryImageHashesBySession.get(sessionKey) ?? new Set();
272
+ for (const hash of hashes) {
273
+ remembered.delete(hash);
274
+ remembered.add(hash);
275
+ while (remembered.size > MAX_SENT_HISTORY_IMAGES_PER_SESSION) {
276
+ const oldest = remembered.values().next().value;
277
+ if (!oldest)
278
+ break;
279
+ remembered.delete(oldest);
280
+ }
281
+ }
282
+ sentHistoryImageHashesBySession.delete(sessionKey);
283
+ sentHistoryImageHashesBySession.set(sessionKey, remembered);
284
+ while (sentHistoryImageHashesBySession.size > MAX_TURN_STATE_SESSIONS) {
285
+ const oldest = sentHistoryImageHashesBySession.keys().next().value;
286
+ if (!oldest)
287
+ break;
288
+ sentHistoryImageHashesBySession.delete(oldest);
289
+ }
290
+ }
254
291
  export function createCursorLanguageModel(modelId, providerId, options) {
255
292
  // Host Path.cache / explicit override wins over XDG heuristics for this process.
256
293
  if (options.cacheDir)
@@ -484,9 +521,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
484
521
  `sessionKey=${sessionKey ?? "(none)"} ` +
485
522
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
486
523
  }
524
+ const lastUser = [...prompt].reverse().find((message) => message.role === "user");
487
525
  const userText = recovery?.kind === "rebase"
488
526
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
489
- : (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
527
+ : (extractUserText(lastUser) || ".");
490
528
  // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
491
529
  // one plugin instance per project). OpenCode 2.0 runs one daemon across many
492
530
  // projects, so its static `options.workspaceRoot` is only a last-resort
@@ -521,6 +559,30 @@ async function startSession(modelId, token, callOptions, options, startOptions)
521
559
  : undefined;
522
560
  const hintMaxMode = !!(providerOptions?.maxMode ?? false);
523
561
  const modelInfo = _availableModels?.find((m) => m.id === cursorModelId);
562
+ const supportsImages = resolveCursorModelSupportsImages(cursorModelId, modelInfo?.supportsImages);
563
+ if (!resuming) {
564
+ assertCursorUserImageSupport(lastUser, supportsImages, cursorModelId);
565
+ }
566
+ const imageExtraction = resuming
567
+ ? { images: [], hashes: [], candidateCount: 0, duplicateCount: 0, userImageCount: 0 }
568
+ : await extractCursorPromptImages(prompt, lastUser, {
569
+ supportsImages,
570
+ // Content hashes are retained for the OpenCode session so growing
571
+ // history does not re-upload old screenshots. A recovery rebase opens
572
+ // a new Cursor conversation, so it must resend the same payload.
573
+ seenHistoryHashes: recovery?.kind === "rebase"
574
+ ? undefined
575
+ : sentHistoryImageHashes(sessionKey),
576
+ signal: callOptions.abortSignal,
577
+ });
578
+ if (!supportsImages && imageExtraction.candidateCount > 0) {
579
+ trace(`image input: dropped ${imageExtraction.candidateCount} tool/assistant history image(s); ` +
580
+ `model=${cursorModelId} does not support images`);
581
+ }
582
+ if (imageExtraction.duplicateCount > 0) {
583
+ trace(`image input: skipped ${imageExtraction.duplicateCount} previously sent history image(s)`);
584
+ }
585
+ const images = imageExtraction.images;
524
586
  const parameterValues = resolveVariantParameters(modelInfo, {
525
587
  reasoningEffort,
526
588
  maxMode: hintMaxMode,
@@ -566,6 +628,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
566
628
  : (bound.reset ? undefined : getCheckpoint(conversationId));
567
629
  const reqBytes = buildRunRequest({
568
630
  text: userText,
631
+ images,
569
632
  modelId: cursorModelId,
570
633
  conversationId,
571
634
  systemPrompt: conversationState ? undefined : systemPrompt,
@@ -601,6 +664,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
601
664
  `tools=${tools.length} incomingTools=${incomingTools.length} compaction=${isCompaction} ` +
602
665
  `skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
603
666
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
667
+ `images=${images.length} imageBytes=${images.reduce((total, image) => total + image.data.length, 0)} ` +
604
668
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
605
669
  `checkpointLen=${conversationState?.length ?? 0} reset=${bound.reset} ` +
606
670
  `resume=${resuming} ` +
@@ -614,6 +678,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
614
678
  trace(`hash checkpoint sha256=${sha(conversationState)}`);
615
679
  try {
616
680
  await writeWithBackpressure(stream, reqBytes, "initial Run request");
681
+ rememberSentHistoryImageHashes(sessionKey, imageExtraction.hashes);
617
682
  }
618
683
  catch (error) {
619
684
  stream.destroy();
@@ -1604,6 +1669,8 @@ function toolResultOutputToText(output) {
1604
1669
  return { text: JSON.stringify(o.value ?? null), isError };
1605
1670
  }
1606
1671
  if (o.type === "content" && Array.isArray(o.value)) {
1672
+ // Held-open continuation exec frames are text-only. Media is intentionally
1673
+ // omitted here and harvested from the full prompt when a fresh Run opens.
1607
1674
  const text = o.value
1608
1675
  .map((c) => {
1609
1676
  const cp = c;
@@ -1,5 +1,6 @@
1
1
  import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, parseCursorContextLimit, } from "./models.js";
2
2
  import { applyCursorModelCost } from "./pricing.js";
3
+ import { getDocumentedCursorModelContext, resolveCursorModelSupportsImages, } from "./model-metadata.js";
3
4
  /**
4
5
  * Cursor `ModelInfo` → OpenCode model-config mapping.
5
6
  *
@@ -147,19 +148,26 @@ export function modelInfoToConfig(mi, options = {}) {
147
148
  // OpenCode's context limit is static per model entry, while Cursor's context
148
149
  // tier is a variant parameter. Long-context choices are therefore emitted as
149
150
  // separate OpenCode entries by modelsToConfig.
151
+ const documentedContext = getDocumentedCursorModelContext(mi.id);
150
152
  const context = contextTier === "long"
151
- ? (mi.maxContextForMaxMode ?? 1_000_000)
152
- : (mi.maxContext ?? 200_000);
153
+ ? (mi.maxContextForMaxMode ?? documentedContext?.maxContextForMaxMode ?? 1_000_000)
154
+ : (mi.maxContext ?? documentedContext?.maxContext ?? 200_000);
153
155
  // OpenCode's overflow/compaction/UI use limit.context; generation and
154
156
  // thinking budgets use limit.output. models.dev 1M peers advertise
155
157
  // 64k–128k output — a tiny cap makes long-context sessions feel broken
156
158
  // even when the 1M input window is correct.
157
159
  const output = contextTier === "long" ? 128_000 : 32_000;
160
+ const supportsImages = resolveCursorModelSupportsImages(mi.id, mi.supportsImages);
158
161
  const config = {
159
162
  name,
163
+ attachment: supportsImages,
160
164
  reasoning: mi.supportsThinking ?? false,
161
165
  tool_call: mi.supportsAgent ?? true,
162
166
  temperature: false,
167
+ modalities: {
168
+ input: supportsImages ? ["text", "image"] : ["text"],
169
+ output: ["text"],
170
+ },
163
171
  limit: {
164
172
  context,
165
173
  output,
@@ -0,0 +1,10 @@
1
+ export type CursorModelContext = {
2
+ maxContext?: number;
3
+ maxContextForMaxMode?: number;
4
+ };
5
+ export type CursorModelCapabilities = {
6
+ supportsImages: boolean;
7
+ };
8
+ export declare function getDocumentedCursorModelContext(modelId: string): CursorModelContext | undefined;
9
+ export declare function getDocumentedCursorModelCapabilities(modelId: string): CursorModelCapabilities | undefined;
10
+ export declare function resolveCursorModelSupportsImages(modelId: string, availableModelsValue?: boolean): boolean;
@@ -0,0 +1,12 @@
1
+ import { CURSOR_MODEL_CAPABILITIES, CURSOR_MODEL_CONTEXTS } from "./pricing-data.js";
2
+ export function getDocumentedCursorModelContext(modelId) {
3
+ const context = CURSOR_MODEL_CONTEXTS[modelId];
4
+ return context ? { ...context } : undefined;
5
+ }
6
+ export function getDocumentedCursorModelCapabilities(modelId) {
7
+ const capabilities = CURSOR_MODEL_CAPABILITIES[modelId];
8
+ return capabilities ? { ...capabilities } : undefined;
9
+ }
10
+ export function resolveCursorModelSupportsImages(modelId, availableModelsValue) {
11
+ return availableModelsValue ?? getDocumentedCursorModelCapabilities(modelId)?.supportsImages ?? false;
12
+ }
package/dist/models.d.ts CHANGED
@@ -19,6 +19,7 @@ export type ModelInfo = {
19
19
  family?: string;
20
20
  supportsThinking?: boolean;
21
21
  supportsAgent?: boolean;
22
+ supportsImages?: boolean;
22
23
  maxContext?: number;
23
24
  /** Context window when max-mode is on (proto field 16). */
24
25
  maxContextForMaxMode?: number;
package/dist/models.js CHANGED
@@ -109,6 +109,7 @@ function normalizeModelInfo(value) {
109
109
  const family = optionalString(value, ["family"]);
110
110
  const supportsThinking = optionalBoolean(value, ["supportsThinking", "supports_thinking"]);
111
111
  const supportsAgent = optionalBoolean(value, ["supportsAgent", "supports_agent"]);
112
+ const supportsImages = optionalBoolean(value, ["supportsImages", "supports_images"]);
112
113
  const supportsMaxMode = optionalBoolean(value, ["supportsMaxMode", "supports_max_mode"]);
113
114
  const maxContext = optionalPositiveNumber(value, ["maxContext", "contextTokenLimit", "context_token_limit"]);
114
115
  const maxContextForMaxMode = optionalPositiveNumber(value, ["maxContextForMaxMode", "contextTokenLimitForMaxMode", "context_token_limit_for_max_mode"]);
@@ -116,6 +117,7 @@ function normalizeModelInfo(value) {
116
117
  family === null ||
117
118
  supportsThinking === null ||
118
119
  supportsAgent === null ||
120
+ supportsImages === null ||
119
121
  supportsMaxMode === null ||
120
122
  maxContext === null ||
121
123
  maxContextForMaxMode === null) {
@@ -127,6 +129,7 @@ function normalizeModelInfo(value) {
127
129
  ...(family === undefined ? {} : { family }),
128
130
  ...(supportsThinking === undefined ? {} : { supportsThinking }),
129
131
  ...(supportsAgent === undefined ? {} : { supportsAgent }),
132
+ ...(supportsImages === undefined ? {} : { supportsImages }),
130
133
  ...(maxContext === undefined ? {} : { maxContext }),
131
134
  ...(maxContextForMaxMode === undefined ? {} : { maxContextForMaxMode }),
132
135
  ...(supportsMaxMode === undefined ? {} : { supportsMaxMode }),
@@ -293,11 +296,16 @@ export function mapAvailableModelsResponse(raw) {
293
296
  variantContextTokens(variants.find((v) => variantContextTokens(v) !== 1_000_000)) ??
294
297
  variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context")));
295
298
  const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax));
299
+ const supportsImages = optionalBoolean(e, ["supportsImages", "supports_images"]);
300
+ if (supportsImages === null) {
301
+ throw new Error(`AvailableModels returned invalid supportsImages for ${name}`);
302
+ }
296
303
  models.push({
297
304
  id: name,
298
305
  displayName: (e.clientDisplayName ?? e.client_display_name ?? name),
299
306
  supportsThinking: apiBoolean(e, ["supportsThinking", "supports_thinking"]),
300
307
  supportsAgent: apiBoolean(e, ["supportsAgent", "supports_agent"]),
308
+ ...(supportsImages === undefined ? {} : { supportsImages }),
301
309
  maxContext: variantBaseContext ?? positiveNumber(e.contextTokenLimit ?? e.context_token_limit),
302
310
  maxContextForMaxMode: variantMaxContext ??
303
311
  positiveNumber(e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode),
@@ -58,12 +58,16 @@ function applyModelEntry(draft, id, entry) {
58
58
  model.modelID = wireId;
59
59
  model.providerID = CURSOR_PROVIDER_ID;
60
60
  model.name = entry.name;
61
+ const inputModalities = Array.isArray(entry.modalities?.input)
62
+ ? entry.modalities.input.filter((modality) => typeof modality === "string")
63
+ : ["text"];
64
+ const outputModalities = Array.isArray(entry.modalities?.output)
65
+ ? entry.modalities.output.filter((modality) => typeof modality === "string")
66
+ : ["text"];
61
67
  model.capabilities = {
62
68
  tools: entry.tool_call !== false,
63
- // The provider does not convert file/image prompt parts, so text-only is
64
- // the honest declaration. Revisit alongside Cursor's `supports_images`.
65
- input: ["text"],
66
- output: ["text"],
69
+ input: inputModalities,
70
+ output: outputModalities,
67
71
  };
68
72
  model.limit = { context: entry.limit.context, output: entry.limit.output };
69
73
  model.variants = variants;