cursor-opencode-provider 0.3.1 → 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 +29 -4
- package/dist/image-input.d.ts +31 -0
- package/dist/image-input.js +281 -0
- package/dist/language-model.js +68 -1
- package/dist/model-config.js +15 -5
- package/dist/model-metadata.d.ts +10 -0
- package/dist/model-metadata.js +12 -0
- package/dist/models.d.ts +1 -0
- package/dist/models.js +8 -0
- package/dist/opencode2/catalog.js +10 -5
- package/dist/opencode2/types.d.ts +22 -4
- package/dist/opencode2/types.js +1 -1
- package/dist/plugin-opencode2.js +13 -5
- package/dist/pricing-data.d.ts +414 -0
- package/dist/pricing-data.js +414 -0
- package/dist/pricing.d.ts +62 -0
- package/dist/pricing.js +127 -0
- package/dist/protocol/messages.js +21 -0
- package/dist/protocol/request.d.ts +2 -0
- package/dist/protocol/request.js +14 -5
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/package.json +20 -5
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.
|
|
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.
|
|
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-
|
|
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,8 @@ 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 |
|
|
364
|
+
| `src/pricing.ts` / `src/pricing-data.ts` | Cursor docs token rates → classic `cost` and OpenCode 2.0 cost tiers |
|
|
340
365
|
| `src/index.ts` | `createCursor` factory; default export is `CursorPlugin` |
|
|
341
366
|
| `src/language-model.ts` | AI SDK `LanguageModelV3` adapter (`doStream`, `doGenerate`) |
|
|
342
367
|
| `src/session.ts` | Held-open agent Run session and pending exec correlation |
|
|
@@ -425,4 +450,4 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
425
450
|
|
|
426
451
|
## License
|
|
427
452
|
|
|
428
|
-
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
|
+
}
|
package/dist/language-model.js
CHANGED
|
@@ -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(
|
|
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;
|
package/dist/model-config.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, parseCursorContextLimit, } from "./models.js";
|
|
2
|
+
import { applyCursorModelCost } from "./pricing.js";
|
|
3
|
+
import { getDocumentedCursorModelContext, resolveCursorModelSupportsImages, } from "./model-metadata.js";
|
|
2
4
|
/**
|
|
3
5
|
* Cursor `ModelInfo` → OpenCode model-config mapping.
|
|
4
6
|
*
|
|
@@ -146,19 +148,26 @@ export function modelInfoToConfig(mi, options = {}) {
|
|
|
146
148
|
// OpenCode's context limit is static per model entry, while Cursor's context
|
|
147
149
|
// tier is a variant parameter. Long-context choices are therefore emitted as
|
|
148
150
|
// separate OpenCode entries by modelsToConfig.
|
|
151
|
+
const documentedContext = getDocumentedCursorModelContext(mi.id);
|
|
149
152
|
const context = contextTier === "long"
|
|
150
|
-
? (mi.maxContextForMaxMode ?? 1_000_000)
|
|
151
|
-
: (mi.maxContext ?? 200_000);
|
|
153
|
+
? (mi.maxContextForMaxMode ?? documentedContext?.maxContextForMaxMode ?? 1_000_000)
|
|
154
|
+
: (mi.maxContext ?? documentedContext?.maxContext ?? 200_000);
|
|
152
155
|
// OpenCode's overflow/compaction/UI use limit.context; generation and
|
|
153
156
|
// thinking budgets use limit.output. models.dev 1M peers advertise
|
|
154
157
|
// 64k–128k output — a tiny cap makes long-context sessions feel broken
|
|
155
158
|
// even when the 1M input window is correct.
|
|
156
159
|
const output = contextTier === "long" ? 128_000 : 32_000;
|
|
160
|
+
const supportsImages = resolveCursorModelSupportsImages(mi.id, mi.supportsImages);
|
|
157
161
|
const config = {
|
|
158
162
|
name,
|
|
163
|
+
attachment: supportsImages,
|
|
159
164
|
reasoning: mi.supportsThinking ?? false,
|
|
160
165
|
tool_call: mi.supportsAgent ?? true,
|
|
161
166
|
temperature: false,
|
|
167
|
+
modalities: {
|
|
168
|
+
input: supportsImages ? ["text", "image"] : ["text"],
|
|
169
|
+
output: ["text"],
|
|
170
|
+
},
|
|
162
171
|
limit: {
|
|
163
172
|
context,
|
|
164
173
|
output,
|
|
@@ -187,12 +196,13 @@ export function modelsToConfig(models) {
|
|
|
187
196
|
const baseVariants = variantsForTier(m, "base");
|
|
188
197
|
const longVariants = variantsForTier(m, "long");
|
|
189
198
|
if (baseVariants.length > 0 || longVariants.length === 0) {
|
|
190
|
-
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
|
|
199
|
+
out[m.id] = applyCursorModelCost(m.id, modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" }));
|
|
191
200
|
}
|
|
192
201
|
if (longVariants.length === 0)
|
|
193
202
|
continue;
|
|
194
203
|
if (baseVariants.length === 0) {
|
|
195
|
-
|
|
204
|
+
// No separate base tier — the wire id itself is the long-context entry.
|
|
205
|
+
out[m.id] = applyCursorModelCost(`${m.id}-1m`, modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" }));
|
|
196
206
|
continue;
|
|
197
207
|
}
|
|
198
208
|
let longId = `${m.id}-1m`;
|
|
@@ -200,7 +210,7 @@ export function modelsToConfig(models) {
|
|
|
200
210
|
while (usedIds.has(longId))
|
|
201
211
|
longId = `${m.id}-1m-${suffix++}`;
|
|
202
212
|
usedIds.add(longId);
|
|
203
|
-
out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
213
|
+
out[longId] = applyCursorModelCost(longId, modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" }));
|
|
204
214
|
}
|
|
205
215
|
return out;
|
|
206
216
|
}
|
|
@@ -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
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),
|