cursor-opencode-provider 0.3.2 → 0.4.1
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 +38 -7
- package/dist/context/paths.d.ts +21 -4
- package/dist/context/paths.js +47 -5
- package/dist/image-input.d.ts +31 -0
- package/dist/image-input.js +281 -0
- package/dist/language-model.d.ts +1 -1
- package/dist/language-model.js +118 -12
- package/dist/model-config.js +10 -2
- 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 +8 -4
- package/dist/opencode2/types.d.ts +9 -3
- package/dist/opencode2/types.js +1 -1
- package/dist/plugin-opencode2.js +13 -5
- package/dist/pricing-data.d.ts +221 -1
- package/dist/pricing-data.js +221 -1
- package/dist/protocol/messages.js +21 -0
- package/dist/protocol/request.d.ts +2 -0
- package/dist/protocol/request.js +14 -5
- package/dist/protocol/tools.d.ts +8 -0
- package/dist/protocol/tools.js +167 -1
- package/dist/session.d.ts +17 -2
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/usage.d.ts +12 -3
- package/dist/usage.js +20 -16
- package/package.json +16 -4
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.1"`.
|
|
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.1/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,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`) |
|
|
@@ -355,11 +379,17 @@ The provider adds OpenCode-specific system guidance to normal tool-capable conve
|
|
|
355
379
|
|
|
356
380
|
If this guidance causes issues, update `buildOpenCodeInteractionGuidance` in [`src/language-model.ts`](src/language-model.ts) and its focused coverage in [`test/prompt-history.test.ts`](test/prompt-history.test.ts).
|
|
357
381
|
|
|
382
|
+
### Cursor edit handshakes
|
|
383
|
+
|
|
384
|
+
Cursor's legacy `edit_tool_call` is implemented internally as a correlated `read_args` followed by a whole-file `write_args`; the write is transport mechanics, not a new decision to replace OpenCode's `edit` tool. The provider retains that correlation and exposes an existing-file mutation as one unique, line-bounded OpenCode `edit`. It still returns Cursor's expected typed `write_result`. New-file creation remains a `write`, and an `apply_patch`-only catalog receives the same targeted change as an `*** Update File:` patch.
|
|
385
|
+
|
|
386
|
+
The prerequisite native read also preserves an existing final LF/CRLF. OpenCode's numbered read envelope cannot represent that terminator, and dropping it made Cursor's exact edit matcher reject valid replacements, retry reads, and eventually rewrite the file. This restoration applies only to confirmed-complete, unbounded reads; paged and capped reads keep their existing truncation behavior.
|
|
387
|
+
|
|
358
388
|
### `apply_patch` models (GPT-5 and friends)
|
|
359
389
|
|
|
360
390
|
OpenCode 1.x does not simply add `apply_patch` for GPT-series models — it **removes** `edit` and `write` from the tool catalog and advertises `apply_patch` in their place (`ToolRegistry.tools`; the model id must contain `gpt-` and neither `oss` nor `gpt-4`). Cursor keeps sending its native write/edit requests regardless, so without translation every file change on a `gpt-5*` model is refused as an unavailable tool.
|
|
361
391
|
|
|
362
|
-
The provider translates transparently:
|
|
392
|
+
The provider translates transparently: an uncorrelated native write becomes an `*** Add File:` patch, while a correlated legacy edit or Pi edit becomes a minimal `*** Update File:` chunk widened to whole lines. This is keyed purely off what the host advertises — it is inert whenever `edit`/`write` are offered normally, and inert again when the host offers neither those nor `apply_patch`. An edit that cannot be expressed faithfully (target unreadable, or the text to replace is absent or ambiguous) is refused with a specific message rather than applied to the wrong region.
|
|
363
393
|
|
|
364
394
|
OpenCode 2.0 does not perform this substitution, so the path is 1.x-only in practice. See [`src/protocol/apply-patch.ts`](src/protocol/apply-patch.ts) for the upstream references.
|
|
365
395
|
|
|
@@ -401,7 +431,8 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
|
|
|
401
431
|
| Visible `<shell_metadata>` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. |
|
|
402
432
|
| `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. `handling=unsupported` is a known Cursor-native capability without a safe OpenCode AI SDK bridge; `unknown request field` indicates new protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. |
|
|
403
433
|
| Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`; the default is `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) and reproduce the issue. |
|
|
404
|
-
|
|
|
434
|
+
| OpenCode compacts immediately after a tool-heavy task | Upgrade to a version containing the cumulative-usage fix. Cursor's final `TurnEnded` counters cover the entire held multi-step Run; older provider versions exposed that aggregate as the final request's context size, which made OpenCode compact far too early. Fixed versions keep the raw counters in `providerMetadata.cursor` and report a request-local context estimate instead. |
|
|
435
|
+
| Cache **write** tokens always show as `0` | Cursor's `TurnEnded` protobuf includes `cache_write` (field 4), but captured Runs set it to `0` even when `cache_read` is large. Single-step Runs expose those counters directly. For multi-step Runs they remain in `providerMetadata.cursor` because Cursor reports only a cumulative whole-Run total, which cannot safely be used as one OpenCode request's context usage. With debug logging, confirm `turn_ended raw wire fields: … f4:wt0=0`. |
|
|
405
436
|
|
|
406
437
|
## Security
|
|
407
438
|
|
|
@@ -410,7 +441,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
410
441
|
## Known limitations
|
|
411
442
|
|
|
412
443
|
- **Personal use / ToS** — this provider speaks Cursor’s private agent protocol (CLI-shaped client identity). Use only with an account you own; Cursor may change or restrict the API without notice.
|
|
413
|
-
- **`TurnEnded
|
|
444
|
+
- **`TurnEnded` usage is whole-Run usage** — on a held multi-step Cursor Run, the final `interaction_update.turn_ended` counters aggregate every model invocation across tool boundaries. OpenCode usage is request-local and uses the latest assistant message to decide compaction, so publishing the aggregate there both double-counts earlier steps and causes premature compaction. The provider therefore uses a current-context estimate for multi-step AI SDK usage and preserves the exact cumulative counters in `providerMetadata.cursor`; single-step Runs still map the counters directly. Cursor also consistently sends `cache_write=0` in captured Runs, so no alternate write count is available.
|
|
414
445
|
- **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` built from OpenCode project context (workspace env, `AGENTS.md` / `instructions`, `.opencode` agents/skills/plugins, git, layout, plus `.claude`/`.agents` skill fallbacks). Its canonical root is also used by the [injected system guidance](#injected-system-guidance). Same discovery as OpenCode — including `.cursor/` paths only when listed in `instructions`. Cursor-only cloud/sandbox marketplace surfaces are omitted. `env.workspace_paths` / `process_working_directory` stay on the real git workspace; Cursor's metadata root (`project_folder`, MCP `workspace_project_dir`, terminals/transcripts) is advertised under `<host-cache>/projects/<slug>/` (default `~/.cache/opencode/projects/…`) so dumps like `agent-tools/` do not land in the repo. OpenCode remains the permission authority: its coarse allow/ask/deny configuration is not fabricated into Cursor's unrelated allow/block instruction-list messages.
|
|
415
446
|
- **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, using the full canonical tool-definition identity required by native `get_mcp_tools`; exec still reconstructs the full OpenCode tool id.
|
|
416
447
|
- **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` / `create_plan_tool_call`) into advertised OpenCode `todowrite`; the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name; known-but-unsupported and future unknown exec variants fail explicitly rather than receiving a guessed response that could deadlock the Run.
|
|
@@ -426,4 +457,4 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
426
457
|
|
|
427
458
|
## License
|
|
428
459
|
|
|
429
|
-
MIT
|
|
460
|
+
MIT
|
package/dist/context/paths.d.ts
CHANGED
|
@@ -26,15 +26,32 @@ type CompatDetector = () => CompatDetectResult;
|
|
|
26
26
|
*/
|
|
27
27
|
export declare function setHostCacheDirOverride(dir: string | undefined): void;
|
|
28
28
|
export declare function getHostCacheDirOverride(): string | undefined;
|
|
29
|
+
declare const HOST_CACHE_DIRS: readonly ["mimocode", "kilo", "opencode"];
|
|
30
|
+
export type HostCacheDir = (typeof HOST_CACHE_DIRS)[number];
|
|
31
|
+
/**
|
|
32
|
+
* Host cache-dir name inferred from the running binary (`argv[0]` /
|
|
33
|
+
* `process.execPath`). A source-checkout provider can't be located by install
|
|
34
|
+
* path, but the binary that loaded it is authoritative: when this process IS
|
|
35
|
+
* `mimo` / `kilocode`, the cache root is that host's, not OpenCode's. Mirrors
|
|
36
|
+
* OCP `detect()` binaryHint semantics and tolerates the leading-dot Kilo binary
|
|
37
|
+
* (`.kilo`). Only `argv[0]` is inspected — later argv entries are arguments,
|
|
38
|
+
* not binary identity, and could otherwise cause false positives (e.g. an
|
|
39
|
+
* `--opencode-config=…` flag under a fork).
|
|
40
|
+
*/
|
|
41
|
+
export declare function hostCacheDirFromProcess(argv?: readonly string[], execPath?: string): HostCacheDir | undefined;
|
|
29
42
|
/**
|
|
30
43
|
* Resolve the host cache directory without an override.
|
|
31
44
|
*
|
|
32
45
|
* Explicit host environment wins. Otherwise, an installed provider inherits
|
|
33
|
-
* the host-named cache containing its module
|
|
34
|
-
*
|
|
35
|
-
* config directory installed is not evidence that it
|
|
46
|
+
* the host-named cache containing its module; a source checkout is attributed
|
|
47
|
+
* by the running binary identity (see {@link hostCacheDirFromProcess}). Merely
|
|
48
|
+
* having another host's config directory installed is not evidence that it
|
|
49
|
+
* owns this process.
|
|
36
50
|
*/
|
|
37
|
-
export declare function resolveHostCacheDir(env?: HostPathEnv, moduleUrl?: string
|
|
51
|
+
export declare function resolveHostCacheDir(env?: HostPathEnv, moduleUrl?: string, processIdentity?: {
|
|
52
|
+
argv?: readonly string[];
|
|
53
|
+
execPath?: string;
|
|
54
|
+
}): string;
|
|
38
55
|
/**
|
|
39
56
|
* Best-effort: if `@opencode-compat/profile` is installed, adopt `detect().profile.paths.cacheDir`
|
|
40
57
|
* when the host is supported. No-op when OCP is absent or detection fails.
|
package/dist/context/paths.js
CHANGED
|
@@ -48,15 +48,52 @@ export function setHostCacheDirOverride(dir) {
|
|
|
48
48
|
export function getHostCacheDirOverride() {
|
|
49
49
|
return hostCacheDirOverride;
|
|
50
50
|
}
|
|
51
|
+
const HOST_CACHE_DIRS = ["mimocode", "kilo", "opencode"];
|
|
52
|
+
/**
|
|
53
|
+
* Host cache-dir name inferred from the running binary (`argv[0]` /
|
|
54
|
+
* `process.execPath`). A source-checkout provider can't be located by install
|
|
55
|
+
* path, but the binary that loaded it is authoritative: when this process IS
|
|
56
|
+
* `mimo` / `kilocode`, the cache root is that host's, not OpenCode's. Mirrors
|
|
57
|
+
* OCP `detect()` binaryHint semantics and tolerates the leading-dot Kilo binary
|
|
58
|
+
* (`.kilo`). Only `argv[0]` is inspected — later argv entries are arguments,
|
|
59
|
+
* not binary identity, and could otherwise cause false positives (e.g. an
|
|
60
|
+
* `--opencode-config=…` flag under a fork).
|
|
61
|
+
*/
|
|
62
|
+
export function hostCacheDirFromProcess(argv = process.argv, execPath = process.execPath) {
|
|
63
|
+
for (const raw of [argv[0], execPath]) {
|
|
64
|
+
if (!raw)
|
|
65
|
+
continue;
|
|
66
|
+
const name = path.basename(String(raw)).toLowerCase().replace(/^\.+/, "");
|
|
67
|
+
if (name === "mimo" ||
|
|
68
|
+
name === "mimocode" ||
|
|
69
|
+
name.startsWith("mimo-") ||
|
|
70
|
+
name.includes("mimocode")) {
|
|
71
|
+
return "mimocode";
|
|
72
|
+
}
|
|
73
|
+
if (name === "kilo" ||
|
|
74
|
+
name === "kilocode" ||
|
|
75
|
+
name.startsWith("kilo-") ||
|
|
76
|
+
name.includes("kilocode")) {
|
|
77
|
+
return "kilo";
|
|
78
|
+
}
|
|
79
|
+
if (name === "opencode" ||
|
|
80
|
+
name.startsWith("opencode-") ||
|
|
81
|
+
name.includes("opencode")) {
|
|
82
|
+
return "opencode";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
51
87
|
/**
|
|
52
88
|
* Resolve the host cache directory without an override.
|
|
53
89
|
*
|
|
54
90
|
* Explicit host environment wins. Otherwise, an installed provider inherits
|
|
55
|
-
* the host-named cache containing its module
|
|
56
|
-
*
|
|
57
|
-
* config directory installed is not evidence that it
|
|
91
|
+
* the host-named cache containing its module; a source checkout is attributed
|
|
92
|
+
* by the running binary identity (see {@link hostCacheDirFromProcess}). Merely
|
|
93
|
+
* having another host's config directory installed is not evidence that it
|
|
94
|
+
* owns this process.
|
|
58
95
|
*/
|
|
59
|
-
export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.url) {
|
|
96
|
+
export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.url, processIdentity = {}) {
|
|
60
97
|
const mimoHome = env.MIMOCODE_HOME;
|
|
61
98
|
if (mimoHome && mimoHome.length > 0) {
|
|
62
99
|
return path.join(mimoHome, "cache");
|
|
@@ -74,12 +111,17 @@ export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.u
|
|
|
74
111
|
modulePath = undefined;
|
|
75
112
|
}
|
|
76
113
|
if (modulePath) {
|
|
77
|
-
for (const host of
|
|
114
|
+
for (const host of HOST_CACHE_DIRS) {
|
|
78
115
|
const root = path.resolve(cacheHome, host);
|
|
79
116
|
if (modulePath === root || modulePath.startsWith(`${root}${path.sep}`))
|
|
80
117
|
return root;
|
|
81
118
|
}
|
|
82
119
|
}
|
|
120
|
+
// Source checkout: the module path can't name the host, but the binary that
|
|
121
|
+
// loaded us can (running as `mimo` / `kilocode` / `opencode`).
|
|
122
|
+
const host = hostCacheDirFromProcess(processIdentity.argv ?? process.argv, processIdentity.execPath ?? process.execPath);
|
|
123
|
+
if (host)
|
|
124
|
+
return path.join(cacheHome, host);
|
|
83
125
|
return path.join(cacheHome, "opencode");
|
|
84
126
|
}
|
|
85
127
|
/**
|
|
@@ -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.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[
|
|
|
85
85
|
export declare function estimateTokens(chars: number): number;
|
|
86
86
|
/** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
|
|
87
87
|
export declare function estimatePromptTokens(prompt: LanguageModelV3CallOptions["prompt"]): number;
|
|
88
|
-
/** Preserve
|
|
88
|
+
/** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
|
|
89
89
|
export declare function cursorTurnEndedProviderMetadata(te: Record<string, unknown>): {
|
|
90
90
|
cursor: {
|
|
91
91
|
usageVersion: number;
|