cursor-opencode-provider 0.1.5 → 0.2.2
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/DISCLAIMER.md +9 -0
- package/README.md +38 -8
- package/dist/context/build.js +3 -2
- package/dist/context/rules.d.ts +1 -0
- package/dist/context/rules.js +1 -0
- package/dist/language-model.d.ts +52 -6
- package/dist/language-model.js +435 -63
- package/dist/models.d.ts +29 -4
- package/dist/models.js +134 -17
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +145 -43
- package/dist/protocol/blob-store.d.ts +2 -0
- package/dist/protocol/blob-store.js +4 -0
- package/dist/protocol/conversation-bind.d.ts +18 -0
- package/dist/protocol/conversation-bind.js +66 -0
- package/dist/protocol/interactions.d.ts +23 -0
- package/dist/protocol/interactions.js +136 -0
- package/dist/protocol/messages.js +421 -13
- package/dist/protocol/request.d.ts +30 -0
- package/dist/protocol/request.js +23 -8
- package/dist/protocol/stream.js +9 -3
- package/dist/protocol/tool-call-bridge.d.ts +44 -0
- package/dist/protocol/tool-call-bridge.js +480 -0
- package/dist/protocol/tools.d.ts +35 -12
- package/dist/protocol/tools.js +179 -74
- package/dist/session.d.ts +26 -1
- package/dist/session.js +2 -2
- package/dist/shared.d.ts +4 -0
- package/dist/shared.js +4 -0
- package/dist/transport/connect.js +11 -1
- package/package.json +3 -2
package/dist/models.d.ts
CHANGED
|
@@ -2,6 +2,10 @@ export type ModelParameterValue = {
|
|
|
2
2
|
id: string;
|
|
3
3
|
value: string;
|
|
4
4
|
};
|
|
5
|
+
/** Dedicated OpenCode provider-option key for a selected Cursor variant. */
|
|
6
|
+
export declare const CURSOR_VARIANT_PARAMETERS_KEY = "cursorVariantParameters";
|
|
7
|
+
/** Real Cursor model id used when an OpenCode entry has a synthetic id. */
|
|
8
|
+
export declare const CURSOR_WIRE_MODEL_ID_KEY = "cursorModelId";
|
|
5
9
|
export type ModelVariant = {
|
|
6
10
|
key: string;
|
|
7
11
|
parameterValues: ModelParameterValue[];
|
|
@@ -16,13 +20,27 @@ export type ModelInfo = {
|
|
|
16
20
|
supportsThinking?: boolean;
|
|
17
21
|
supportsAgent?: boolean;
|
|
18
22
|
maxContext?: number;
|
|
23
|
+
/** Context window when max-mode is on (proto field 16). */
|
|
24
|
+
maxContextForMaxMode?: number;
|
|
19
25
|
supportsMaxMode?: boolean;
|
|
20
26
|
variants: ModelVariant[];
|
|
21
27
|
};
|
|
22
28
|
export type ModelCache = {
|
|
23
29
|
models: ModelInfo[];
|
|
24
30
|
fetchedAt: number;
|
|
31
|
+
/** Absent or mismatched → treat cache as stale (forces AvailableModels refetch). */
|
|
32
|
+
schemaVersion?: number;
|
|
25
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Read only the dedicated variant payload generated by the plugin. OpenCode
|
|
36
|
+
* merges model, agent, and variant options into one providerOptions namespace;
|
|
37
|
+
* treating that whole object as Cursor parameters would leak unrelated options
|
|
38
|
+
* onto the wire.
|
|
39
|
+
*/
|
|
40
|
+
export declare function extractCursorVariantParameters(providerOptions: Record<string, unknown> | undefined): ModelParameterValue[] | undefined;
|
|
41
|
+
export declare function resolveCursorWireModelId(providerOptions: Record<string, unknown> | undefined, fallback: string): string;
|
|
42
|
+
/** True when resolved params select the long-context (1m) tier. */
|
|
43
|
+
export declare function paramsImplyMaxMode(params: ModelParameterValue[]): boolean;
|
|
26
44
|
export declare function isCacheFresh(cache: ModelCache, ttlMs?: number): boolean;
|
|
27
45
|
export declare function cacheFilePath(cacheDir: string): string;
|
|
28
46
|
export declare function readCache(cacheDir: string): Promise<ModelCache | null>;
|
|
@@ -30,14 +48,21 @@ export declare function writeCache(cacheDir: string, cache: ModelCache): Promise
|
|
|
30
48
|
export declare function mapAvailableModelsResponse(raw: Record<string, unknown>): ModelInfo[];
|
|
31
49
|
/**
|
|
32
50
|
* Resolve the parameter values to send in `requested_model.parameters` for a
|
|
33
|
-
* given model +
|
|
34
|
-
* full `{id,value}` set (per-model vocabulary
|
|
35
|
-
*
|
|
36
|
-
* hand.
|
|
51
|
+
* given model + the variant opencode selected. Each variant already carries
|
|
52
|
+
* the full `{id,value}` set (per-model vocabulary — effort, fast, thinking,
|
|
53
|
+
* context, …), so we pick the matching variant and return its parameters
|
|
54
|
+
* verbatim — the client never constructs these by hand.
|
|
55
|
+
*
|
|
56
|
+
* `picked` is the user-selected variant paramMap (opencode sends it under
|
|
57
|
+
* `providerOptions.cursor`); when present it wins over effort/maxMode hints
|
|
58
|
+
* so every param the user chose (context, fast, …) is forwarded to Cursor.
|
|
59
|
+
* Hints (`reasoningEffort`, `maxMode`) live beside the dedicated picked array,
|
|
60
|
+
* so the picked values are already isolated and can be matched verbatim.
|
|
37
61
|
*/
|
|
38
62
|
export declare function resolveVariantParameters(model: ModelInfo | undefined, opts?: {
|
|
39
63
|
reasoningEffort?: string;
|
|
40
64
|
maxMode?: boolean;
|
|
65
|
+
picked?: ModelParameterValue[];
|
|
41
66
|
}): ModelParameterValue[];
|
|
42
67
|
export declare function fetchModels(token: string, options?: {
|
|
43
68
|
baseURL?: string;
|
package/dist/models.js
CHANGED
|
@@ -1,11 +1,69 @@
|
|
|
1
|
-
import { MODEL_CACHE_FILE, MODEL_CACHE_TTL_MS } from "./shared.js";
|
|
1
|
+
import { MODEL_CACHE_FILE, MODEL_CACHE_SCHEMA_VERSION, MODEL_CACHE_TTL_MS } from "./shared.js";
|
|
2
2
|
import { unaryAvailableModels } from "./transport/connect.js";
|
|
3
3
|
import { buildRequestedModelParams } from "./protocol/thinking.js";
|
|
4
4
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
/** Dedicated OpenCode provider-option key for a selected Cursor variant. */
|
|
8
|
+
export const CURSOR_VARIANT_PARAMETERS_KEY = "cursorVariantParameters";
|
|
9
|
+
/** Real Cursor model id used when an OpenCode entry has a synthetic id. */
|
|
10
|
+
export const CURSOR_WIRE_MODEL_ID_KEY = "cursorModelId";
|
|
11
|
+
/**
|
|
12
|
+
* Read only the dedicated variant payload generated by the plugin. OpenCode
|
|
13
|
+
* merges model, agent, and variant options into one providerOptions namespace;
|
|
14
|
+
* treating that whole object as Cursor parameters would leak unrelated options
|
|
15
|
+
* onto the wire.
|
|
16
|
+
*/
|
|
17
|
+
export function extractCursorVariantParameters(providerOptions) {
|
|
18
|
+
const raw = providerOptions?.[CURSOR_VARIANT_PARAMETERS_KEY];
|
|
19
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
const params = [];
|
|
22
|
+
for (const item of raw) {
|
|
23
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
24
|
+
return undefined;
|
|
25
|
+
const { id, value } = item;
|
|
26
|
+
if (typeof id !== "string" || typeof value !== "string")
|
|
27
|
+
return undefined;
|
|
28
|
+
params.push({ id, value });
|
|
29
|
+
}
|
|
30
|
+
return params;
|
|
31
|
+
}
|
|
32
|
+
export function resolveCursorWireModelId(providerOptions, fallback) {
|
|
33
|
+
const value = providerOptions?.[CURSOR_WIRE_MODEL_ID_KEY];
|
|
34
|
+
return typeof value === "string" && value.trim() ? value : fallback;
|
|
35
|
+
}
|
|
36
|
+
/** True when resolved params select the long-context (1m) tier. */
|
|
37
|
+
export function paramsImplyMaxMode(params) {
|
|
38
|
+
return params.some((p) => p.id === "context" && p.value === "1m");
|
|
39
|
+
}
|
|
40
|
+
// Cursor encodes a model's context window as a variant parameter `id: "context"`
|
|
41
|
+
// whose value is a tier string — the same 200k / 272k / 300k / 1m the IDE's
|
|
42
|
+
// picker shows. The base tier rides on the default non-max variant; the 1M
|
|
43
|
+
// tier (when the model supports max mode) rides on the default max variant.
|
|
44
|
+
// `context_token_limit` (#15) / `context_token_limit_for_max_mode` (#16) are
|
|
45
|
+
// also defined on AvailableModel but the server often leaves them empty — the
|
|
46
|
+
// variant param is the primary source (request flags may still populate them).
|
|
47
|
+
const CONTEXT_TIER_TO_TOKENS = {
|
|
48
|
+
"200k": 200_000,
|
|
49
|
+
"272k": 272_000,
|
|
50
|
+
"300k": 300_000,
|
|
51
|
+
"1m": 1_000_000,
|
|
52
|
+
};
|
|
53
|
+
function variantContextTokens(v) {
|
|
54
|
+
const raw = v?.parameterValues.find((p) => p.id === "context")?.value;
|
|
55
|
+
if (!raw)
|
|
56
|
+
return undefined;
|
|
57
|
+
const mapped = CONTEXT_TIER_TO_TOKENS[raw];
|
|
58
|
+
if (mapped !== undefined)
|
|
59
|
+
return mapped;
|
|
60
|
+
const n = Number(raw);
|
|
61
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
62
|
+
}
|
|
7
63
|
// ── Cache helpers ──
|
|
8
64
|
export function isCacheFresh(cache, ttlMs = MODEL_CACHE_TTL_MS) {
|
|
65
|
+
if (cache.schemaVersion !== MODEL_CACHE_SCHEMA_VERSION)
|
|
66
|
+
return false;
|
|
9
67
|
return Date.now() - cache.fetchedAt < ttlMs;
|
|
10
68
|
}
|
|
11
69
|
export function cacheFilePath(cacheDir) {
|
|
@@ -53,12 +111,22 @@ export function mapAvailableModelsResponse(raw) {
|
|
|
53
111
|
isDefaultMax: !!(v.isDefaultMaxConfig ?? v.is_default_max_config ?? false),
|
|
54
112
|
});
|
|
55
113
|
}
|
|
114
|
+
// Derive the context window from the variant `context` param (primary
|
|
115
|
+
// source). Base tier ← default non-max variant; max tier ← default max
|
|
116
|
+
// variant. Fall back to the (often empty) proto fields, then undefined.
|
|
117
|
+
// Prefer a non-1m context when isDefaultNonMax is missing so a leading
|
|
118
|
+
// max-mode variant cannot inflate the base window.
|
|
119
|
+
const variantBaseContext = variantContextTokens(variants.find((v) => v.isDefaultNonMax)) ??
|
|
120
|
+
variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context" && p.value !== "1m"))) ??
|
|
121
|
+
variantContextTokens(variants.find((v) => v.parameterValues.some((p) => p.id === "context")));
|
|
122
|
+
const variantMaxContext = variantContextTokens(variants.find((v) => v.isDefaultMax));
|
|
56
123
|
models.push({
|
|
57
124
|
id: name,
|
|
58
125
|
displayName: (e.clientDisplayName ?? e.client_display_name ?? name),
|
|
59
126
|
supportsThinking: !!(e.supportsThinking ?? e.supports_thinking ?? false),
|
|
60
127
|
supportsAgent: !!(e.supportsAgent ?? e.supports_agent ?? false),
|
|
61
|
-
maxContext: (e.contextTokenLimit ?? e.context_token_limit ?? undefined),
|
|
128
|
+
maxContext: (variantBaseContext ?? e.contextTokenLimit ?? e.context_token_limit ?? undefined),
|
|
129
|
+
maxContextForMaxMode: (variantMaxContext ?? e.contextTokenLimitForMaxMode ?? e.context_token_limit_for_max_mode ?? undefined),
|
|
62
130
|
supportsMaxMode: !!(e.supportsMaxMode ?? e.supports_max_mode ?? false),
|
|
63
131
|
variants,
|
|
64
132
|
});
|
|
@@ -68,23 +136,54 @@ export function mapAvailableModelsResponse(raw) {
|
|
|
68
136
|
// ── Variant resolution ──
|
|
69
137
|
/**
|
|
70
138
|
* Resolve the parameter values to send in `requested_model.parameters` for a
|
|
71
|
-
* given model +
|
|
72
|
-
* full `{id,value}` set (per-model vocabulary
|
|
73
|
-
*
|
|
74
|
-
* hand.
|
|
139
|
+
* given model + the variant opencode selected. Each variant already carries
|
|
140
|
+
* the full `{id,value}` set (per-model vocabulary — effort, fast, thinking,
|
|
141
|
+
* context, …), so we pick the matching variant and return its parameters
|
|
142
|
+
* verbatim — the client never constructs these by hand.
|
|
143
|
+
*
|
|
144
|
+
* `picked` is the user-selected variant paramMap (opencode sends it under
|
|
145
|
+
* `providerOptions.cursor`); when present it wins over effort/maxMode hints
|
|
146
|
+
* so every param the user chose (context, fast, …) is forwarded to Cursor.
|
|
147
|
+
* Hints (`reasoningEffort`, `maxMode`) live beside the dedicated picked array,
|
|
148
|
+
* so the picked values are already isolated and can be matched verbatim.
|
|
75
149
|
*/
|
|
76
150
|
export function resolveVariantParameters(model, opts = {}) {
|
|
151
|
+
const picked = opts.picked?.length ? opts.picked.map((p) => ({ ...p })) : undefined;
|
|
77
152
|
if (!model || model.variants.length === 0) {
|
|
78
|
-
|
|
153
|
+
if (picked?.length)
|
|
154
|
+
return picked;
|
|
79
155
|
return opts.reasoningEffort ? [{ id: "effort", value: opts.reasoningEffort }] : [];
|
|
80
156
|
}
|
|
81
157
|
const effortOf = (v) => v.parameterValues.find((p) => p.id === "effort" || p.id === "reasoning")?.value;
|
|
82
158
|
const isFast = (v) => v.parameterValues.find((p) => p.id === "fast")?.value === "true";
|
|
159
|
+
const contextOf = (v) => v.parameterValues.find((p) => p.id === "context")?.value;
|
|
160
|
+
const isMaxVariant = (v) => v.isDefaultMax || contextOf(v) === "1m";
|
|
83
161
|
const wantMax = opts.maxMode ?? false;
|
|
84
|
-
|
|
85
|
-
|
|
162
|
+
// Non-fast pool for hint-based resolution only. Exact `picked` matching
|
|
163
|
+
// searches all variants so Fast selections are not silently dropped.
|
|
164
|
+
const pool = model.variants.filter((v) => !isFast(v));
|
|
165
|
+
const scoped = pool.length > 0 ? pool : model.variants;
|
|
166
|
+
// 1. Variant explicitly picked by opencode (verbatim — preserves context, fast, …).
|
|
167
|
+
if (picked?.length) {
|
|
168
|
+
const signature = new Set(picked.map((p) => `${p.id}=${p.value}`));
|
|
169
|
+
const exact = model.variants.find((v) => v.parameterValues.every((p) => signature.has(`${p.id}=${p.value}`)) &&
|
|
170
|
+
signature.size === v.parameterValues.length);
|
|
171
|
+
if (exact) {
|
|
172
|
+
return buildRequestedModelParams(exact.parameterValues, {
|
|
173
|
+
reasoningEffort: opts.reasoningEffort,
|
|
174
|
+
maxMode: wantMax,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// Unmatched: forward the stripped pick so the TUI choice is not discarded.
|
|
178
|
+
return picked;
|
|
179
|
+
}
|
|
180
|
+
// When maxMode is requested, prefer max-tier variants for effort matching
|
|
181
|
+
// so "high + max" lands on 1m high rather than the first 300k high.
|
|
182
|
+
const maxScoped = wantMax ? scoped.filter(isMaxVariant) : [];
|
|
183
|
+
const effortPool = maxScoped.length > 0 ? maxScoped : scoped;
|
|
184
|
+
// 2. Effort hint (e.g. user passed reasoningEffort through the CLI).
|
|
86
185
|
if (opts.reasoningEffort) {
|
|
87
|
-
const match =
|
|
186
|
+
const match = effortPool.find((v) => effortOf(v) === opts.reasoningEffort);
|
|
88
187
|
if (match) {
|
|
89
188
|
return buildRequestedModelParams(match.parameterValues, {
|
|
90
189
|
reasoningEffort: opts.reasoningEffort,
|
|
@@ -92,11 +191,17 @@ export function resolveVariantParameters(model, opts = {}) {
|
|
|
92
191
|
});
|
|
93
192
|
}
|
|
94
193
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
194
|
+
// 3. Max-mode hint → prefer the default max variant (1m context).
|
|
195
|
+
if (wantMax) {
|
|
196
|
+
const max = scoped.find((v) => v.isDefaultMax) ?? scoped.find((v) => contextOf(v) === "1m");
|
|
197
|
+
if (max)
|
|
198
|
+
return buildRequestedModelParams(max.parameterValues, { maxMode: true });
|
|
199
|
+
}
|
|
200
|
+
// 4. Default non-max variant.
|
|
201
|
+
const byDefault = scoped.find((v) => v.isDefaultNonMax) ?? scoped[0];
|
|
202
|
+
return buildRequestedModelParams(byDefault.parameterValues, {
|
|
98
203
|
reasoningEffort: opts.reasoningEffort,
|
|
99
|
-
maxMode:
|
|
204
|
+
maxMode: false,
|
|
100
205
|
});
|
|
101
206
|
}
|
|
102
207
|
// ── Fetch + cache orchestration ──
|
|
@@ -110,7 +215,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
|
|
|
110
215
|
if (cached && isCacheFresh(cached)) {
|
|
111
216
|
// Background refresh (fire and forget)
|
|
112
217
|
fetchModels(token, options)
|
|
113
|
-
.then((models) => writeCache(cacheDir, {
|
|
218
|
+
.then((models) => writeCache(cacheDir, {
|
|
219
|
+
models,
|
|
220
|
+
fetchedAt: Date.now(),
|
|
221
|
+
schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
|
|
222
|
+
}))
|
|
114
223
|
.catch(() => {
|
|
115
224
|
/* background refresh failure is non-fatal */
|
|
116
225
|
});
|
|
@@ -120,7 +229,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
|
|
|
120
229
|
if (cached) {
|
|
121
230
|
try {
|
|
122
231
|
const models = await fetchModels(token, options);
|
|
123
|
-
const newCache = {
|
|
232
|
+
const newCache = {
|
|
233
|
+
models,
|
|
234
|
+
fetchedAt: Date.now(),
|
|
235
|
+
schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
|
|
236
|
+
};
|
|
124
237
|
await writeCache(cacheDir, newCache);
|
|
125
238
|
return models;
|
|
126
239
|
}
|
|
@@ -130,7 +243,11 @@ export async function discoverModels(token, cacheDir, options = {}) {
|
|
|
130
243
|
}
|
|
131
244
|
// No cache → must fetch
|
|
132
245
|
const models = await fetchModels(token, options);
|
|
133
|
-
const newCache = {
|
|
246
|
+
const newCache = {
|
|
247
|
+
models,
|
|
248
|
+
fetchedAt: Date.now(),
|
|
249
|
+
schemaVersion: MODEL_CACHE_SCHEMA_VERSION,
|
|
250
|
+
};
|
|
134
251
|
await writeCache(cacheDir, newCache);
|
|
135
252
|
return models;
|
|
136
253
|
}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -11,5 +11,7 @@ import { type ModelInfo } from "./models.js";
|
|
|
11
11
|
export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
|
|
12
12
|
export declare function modelInfoToConfig(mi: ModelInfo, options?: {
|
|
13
13
|
thinkingSuffix?: boolean;
|
|
14
|
+
contextTier?: "base" | "long";
|
|
14
15
|
}): Record<string, any>;
|
|
16
|
+
export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;
|
|
15
17
|
export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
|
package/dist/plugin.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
|
-
import { CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
|
|
1
|
+
import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
|
|
2
2
|
import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtPayload } from "./auth.js";
|
|
3
|
-
import { readCache, discoverModels, isCacheFresh } from "./models.js";
|
|
3
|
+
import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh } from "./models.js";
|
|
4
4
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
5
5
|
import { readStoredAuth } from "./context/auth-store.js";
|
|
6
6
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
7
7
|
const MODULE_URL = new URL("./index.js", import.meta.url).href;
|
|
8
8
|
/**
|
|
9
|
-
* Strip characters that break rendering in the OpenCode
|
|
10
|
-
* model or variant display name:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Strip characters and markup that break rendering in the OpenCode TUI/GUI from
|
|
10
|
+
* a model or variant display name:
|
|
11
|
+
* • HTML tags (e.g. `<span style="…">Medium</span>`) — the IDE colours variant
|
|
12
|
+
* suffixes with a CSS var that doesn't exist in OpenCode, so the raw markup
|
|
13
|
+
* would show as literal text. We drop the tags and keep the inner text
|
|
14
|
+
* ("Medium").
|
|
15
|
+
* • HTML/markup chars (`< > & " ' \``), parentheses.
|
|
16
|
+
* • Tabs/newlines collapse to single spaces.
|
|
17
|
+
* Dots and unicode letters are preserved so names stay readable.
|
|
18
|
+
* Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
|
|
13
19
|
*/
|
|
14
20
|
function safeLabel(value) {
|
|
15
21
|
return (value
|
|
22
|
+
.replace(/<[^>]+>/g, "")
|
|
16
23
|
.replace(/[()<>&"'`]/g, "")
|
|
17
24
|
.replace(/\s+/g, " ")
|
|
18
25
|
.trim() || "default");
|
|
@@ -20,26 +27,69 @@ function safeLabel(value) {
|
|
|
20
27
|
function baseName(mi) {
|
|
21
28
|
return safeLabel(mi.displayName ?? mi.id);
|
|
22
29
|
}
|
|
23
|
-
function modelInfoVariants(mi) {
|
|
24
|
-
if (
|
|
30
|
+
function modelInfoVariants(mi, variants) {
|
|
31
|
+
if (variants.length === 0)
|
|
25
32
|
return undefined;
|
|
26
33
|
const entries = {};
|
|
27
34
|
const usedKeys = new Set();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const baseName = safeLabel(mi.displayName ?? mi.id);
|
|
36
|
+
// Each variant's key is `safeLabel(displayName)` (the IDE's own label, e.g.
|
|
37
|
+
// "Opus 4.8 1M High Fast Thinking") so the picker matches what the user
|
|
38
|
+
// sees in Cursor. Two variants can sanitize to the same name when the IDE
|
|
39
|
+
// wraps a differentiator in `<span>…</span>` (e.g. Composer's "Fast"
|
|
40
|
+
// suffix collapses to the bare model name after stripping). To guarantee
|
|
41
|
+
// every variant stays pickable:
|
|
42
|
+
// 1. If the sanitized displayName matches the model name itself, suffix
|
|
43
|
+
// it with distinguishing params (or "default") so it never collides
|
|
44
|
+
// with the model entry in the variant panel.
|
|
45
|
+
// 2. If two variants still collide, tag the later one with its params.
|
|
46
|
+
// Suffixes must stay free of `()` / markup chars — same constraint as
|
|
47
|
+
// safeLabel (issue #2); use spaced tokens, not parenthetical tags.
|
|
48
|
+
const tagDims = (p) => {
|
|
49
|
+
const labels = [];
|
|
50
|
+
for (const d of p) {
|
|
51
|
+
if (d.id === "fast" && d.value === "true")
|
|
52
|
+
labels.push("Fast");
|
|
53
|
+
else if (d.id === "thinking" && d.value === "true")
|
|
54
|
+
labels.push("Thinking");
|
|
55
|
+
else if (d.id === "context")
|
|
56
|
+
labels.push(d.value);
|
|
57
|
+
}
|
|
58
|
+
if (labels.length > 0)
|
|
59
|
+
return ` ${labels.join(" ")}`;
|
|
60
|
+
// No params at all — still disambiguate from the model name itself.
|
|
61
|
+
if (p.length === 0)
|
|
62
|
+
return "";
|
|
63
|
+
return " default";
|
|
64
|
+
};
|
|
65
|
+
for (const v of variants) {
|
|
66
|
+
const sanitized = safeLabel(v.displayName || v.key || "default");
|
|
67
|
+
let key = sanitized;
|
|
68
|
+
// Never let a variant key equal the model name — that would make the
|
|
69
|
+
// variant entry indistinguishable from the model entry in pickers that
|
|
70
|
+
// collapse them.
|
|
71
|
+
if (key === baseName && !usedKeys.has(key)) {
|
|
72
|
+
key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
|
|
73
|
+
}
|
|
74
|
+
else if (usedKeys.has(key)) {
|
|
75
|
+
key = `${sanitized}${tagDims(v.parameterValues)}`;
|
|
76
|
+
}
|
|
77
|
+
let n = 2;
|
|
32
78
|
while (usedKeys.has(key))
|
|
33
|
-
key = `${
|
|
79
|
+
key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
|
|
34
80
|
usedKeys.add(key);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
entries[key] = params;
|
|
81
|
+
entries[key] = {
|
|
82
|
+
[CURSOR_VARIANT_PARAMETERS_KEY]: v.parameterValues.map((p) => ({ ...p })),
|
|
83
|
+
};
|
|
40
84
|
}
|
|
41
85
|
return entries;
|
|
42
86
|
}
|
|
87
|
+
function isLongContextVariant(v) {
|
|
88
|
+
return v.parameterValues.some((p) => p.id === "context" && p.value === "1m");
|
|
89
|
+
}
|
|
90
|
+
function variantsForTier(mi, tier) {
|
|
91
|
+
return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
|
|
92
|
+
}
|
|
43
93
|
/**
|
|
44
94
|
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
45
95
|
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
@@ -66,31 +116,71 @@ export function thinkingSuffixBaseNames(models) {
|
|
|
66
116
|
return ambiguous;
|
|
67
117
|
}
|
|
68
118
|
export function modelInfoToConfig(mi, options = {}) {
|
|
119
|
+
const contextTier = options.contextTier ?? "base";
|
|
120
|
+
const variants = variantsForTier(mi, contextTier);
|
|
69
121
|
let name = baseName(mi);
|
|
70
122
|
if (options.thinkingSuffix)
|
|
71
123
|
name += " Thinking";
|
|
124
|
+
if (contextTier === "long")
|
|
125
|
+
name += " 1M";
|
|
126
|
+
// OpenCode's context limit is static per model entry, while Cursor's context
|
|
127
|
+
// tier is a variant parameter. Long-context choices are therefore emitted as
|
|
128
|
+
// separate OpenCode entries by modelsToConfig.
|
|
129
|
+
const context = contextTier === "long"
|
|
130
|
+
? (mi.maxContextForMaxMode ?? 1_000_000)
|
|
131
|
+
: (mi.maxContext ?? 200_000);
|
|
132
|
+
// OpenCode's overflow/compaction/UI use limit.context; generation and
|
|
133
|
+
// thinking budgets use limit.output. models.dev 1M peers advertise
|
|
134
|
+
// 64k–128k output — a tiny cap makes long-context sessions feel broken
|
|
135
|
+
// even when the 1M input window is correct.
|
|
136
|
+
const output = contextTier === "long" ? 128_000 : 32_000;
|
|
72
137
|
const config = {
|
|
73
138
|
name,
|
|
74
139
|
reasoning: mi.supportsThinking ?? false,
|
|
75
140
|
tool_call: mi.supportsAgent ?? true,
|
|
76
141
|
temperature: false,
|
|
77
142
|
limit: {
|
|
78
|
-
context
|
|
79
|
-
output
|
|
143
|
+
context,
|
|
144
|
+
output,
|
|
80
145
|
},
|
|
81
146
|
};
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
config.variants =
|
|
147
|
+
const variantConfig = modelInfoVariants(mi, variants);
|
|
148
|
+
if (variantConfig)
|
|
149
|
+
config.variants = variantConfig;
|
|
150
|
+
if (contextTier === "long") {
|
|
151
|
+
const defaultVariant = variants.find((v) => v.isDefaultMax) ?? variants[0];
|
|
152
|
+
if (defaultVariant) {
|
|
153
|
+
config.options = {
|
|
154
|
+
[CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
|
|
155
|
+
[CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
85
159
|
return config;
|
|
86
160
|
}
|
|
87
|
-
function modelsToConfig(models) {
|
|
161
|
+
export function modelsToConfig(models) {
|
|
88
162
|
const ambiguous = thinkingSuffixBaseNames(models);
|
|
89
163
|
const out = {};
|
|
164
|
+
const usedIds = new Set(models.map((m) => m.id));
|
|
90
165
|
for (const m of models) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
166
|
+
const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
|
|
167
|
+
const baseVariants = variantsForTier(m, "base");
|
|
168
|
+
const longVariants = variantsForTier(m, "long");
|
|
169
|
+
if (baseVariants.length > 0 || longVariants.length === 0) {
|
|
170
|
+
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
|
|
171
|
+
}
|
|
172
|
+
if (longVariants.length === 0)
|
|
173
|
+
continue;
|
|
174
|
+
if (baseVariants.length === 0) {
|
|
175
|
+
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
let longId = `${m.id}-1m`;
|
|
179
|
+
let suffix = 2;
|
|
180
|
+
while (usedIds.has(longId))
|
|
181
|
+
longId = `${m.id}-1m-${suffix++}`;
|
|
182
|
+
usedIds.add(longId);
|
|
183
|
+
out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
94
184
|
}
|
|
95
185
|
return out;
|
|
96
186
|
}
|
|
@@ -198,27 +288,39 @@ export async function CursorPlugin(input) {
|
|
|
198
288
|
}
|
|
199
289
|
async function loadModels() {
|
|
200
290
|
const cached = await readCache(cacheDir);
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
291
|
+
if (cached?.models.length && isCacheFresh(cached)) {
|
|
292
|
+
return modelsToConfig(cached.models);
|
|
293
|
+
}
|
|
294
|
+
// Config runs before auth.loader and has no getAuth(); read the durable
|
|
295
|
+
// store (normally the same source getAuth() uses). Refresh missing, expired,
|
|
296
|
+
// or old-schema caches here so this process materializes the new model set.
|
|
297
|
+
const auth = await authFromStore();
|
|
298
|
+
if (auth) {
|
|
299
|
+
const accessToken = await resolveAccessToken(auth);
|
|
300
|
+
if (accessToken) {
|
|
301
|
+
try {
|
|
302
|
+
const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
|
|
303
|
+
return modelsToConfig(models);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// No usable cache and discovery failed — leave the list empty.
|
|
215
307
|
}
|
|
216
308
|
}
|
|
217
|
-
return {};
|
|
218
309
|
}
|
|
219
|
-
|
|
310
|
+
// Preserve stale-on-failure/offline behavior for an existing cache.
|
|
311
|
+
return cached?.models.length ? modelsToConfig(cached.models) : {};
|
|
220
312
|
}
|
|
221
313
|
return {
|
|
314
|
+
async "chat.params"(hookInput, output) {
|
|
315
|
+
if (hookInput.model.providerID !== CURSOR_PROVIDER_ID)
|
|
316
|
+
return;
|
|
317
|
+
// OpenCode's compaction pipeline invokes the LLM with agent="compaction".
|
|
318
|
+
// Carry that stable runtime fact into LanguageModelV3 providerOptions so
|
|
319
|
+
// the provider never has to guess from an empty tool list.
|
|
320
|
+
if (hookInput.agent === "compaction") {
|
|
321
|
+
output.options[CURSOR_COMPACTION_OPTION] = true;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
222
324
|
async config(cfg) {
|
|
223
325
|
cfg.provider ??= {};
|
|
224
326
|
const models = await loadModels();
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
export declare function setConversationBlob(conversationId: string, blobId: Uint8Array, blobData: Uint8Array): string;
|
|
11
11
|
export declare function getConversationBlob(conversationId: string, blobId: Uint8Array): Uint8Array | undefined;
|
|
12
12
|
export declare function conversationBlobCount(conversationId: string): number;
|
|
13
|
+
/** Drop all blobs for a conversation (compaction conversation reset). */
|
|
14
|
+
export declare function clearConversationBlobs(conversationId: string): void;
|
|
13
15
|
/** SHA-256 content hashes are 32 non-text bytes — never echo those as content. */
|
|
14
16
|
export declare function isBlobIdHash(blobId: Uint8Array): boolean;
|
|
15
17
|
export declare function resetConversationBlobsForTests(): void;
|
|
@@ -34,6 +34,10 @@ export function getConversationBlob(conversationId, blobId) {
|
|
|
34
34
|
export function conversationBlobCount(conversationId) {
|
|
35
35
|
return byConversation.get(conversationId)?.size ?? 0;
|
|
36
36
|
}
|
|
37
|
+
/** Drop all blobs for a conversation (compaction conversation reset). */
|
|
38
|
+
export function clearConversationBlobs(conversationId) {
|
|
39
|
+
byConversation.delete(conversationId);
|
|
40
|
+
}
|
|
37
41
|
/** SHA-256 content hashes are 32 non-text bytes — never echo those as content. */
|
|
38
42
|
export function isBlobIdHash(blobId) {
|
|
39
43
|
if (blobId.length !== 32)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const MAX_ACTIVE_CONVERSATION_BINDINGS = 256;
|
|
2
|
+
export declare function resetConversationBindingsForTests(): void;
|
|
3
|
+
/** Deterministic UUID (version-4 shape) from an arbitrary session key. */
|
|
4
|
+
export declare function sessionIdToUuid(sessionId: string): string;
|
|
5
|
+
/** Current Cursor conversation_id for an OpenCode session key, creating the default binding if needed. */
|
|
6
|
+
export declare function peekConversationId(sessionKey: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the Cursor conversation_id for this OpenCode session.
|
|
9
|
+
* When `reset` is true (compaction/summary), discard prior Cursor state and
|
|
10
|
+
* mint a new id so TurnEnded cache_read cannot keep overflowing OpenCode.
|
|
11
|
+
*/
|
|
12
|
+
export declare function bindConversationId(sessionKey: string | undefined, opts?: {
|
|
13
|
+
reset?: boolean;
|
|
14
|
+
}): {
|
|
15
|
+
conversationId: string;
|
|
16
|
+
reset: boolean;
|
|
17
|
+
previousId?: string;
|
|
18
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { clearCheckpoint } from "./checkpoint.js";
|
|
3
|
+
import { clearConversationBlobs } from "./blob-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* OpenCode session key → active Cursor conversation_id.
|
|
6
|
+
*
|
|
7
|
+
* Normally the binding is the deterministic UUID from the OpenCode session id
|
|
8
|
+
* (CLI-shaped sticky conversation). Compaction/summary turns mint a fresh id
|
|
9
|
+
* and drop the prior checkpoint + blobs so Cursor no longer bills the old
|
|
10
|
+
* cached conversation against OpenCode's newly compacted prompt.
|
|
11
|
+
*/
|
|
12
|
+
const activeBySession = new Map();
|
|
13
|
+
export const MAX_ACTIVE_CONVERSATION_BINDINGS = 256;
|
|
14
|
+
function rememberConversation(sessionKey, conversationId) {
|
|
15
|
+
// Map insertion order is the LRU order. Refresh existing sessions on use.
|
|
16
|
+
activeBySession.delete(sessionKey);
|
|
17
|
+
activeBySession.set(sessionKey, conversationId);
|
|
18
|
+
while (activeBySession.size > MAX_ACTIVE_CONVERSATION_BINDINGS) {
|
|
19
|
+
const oldest = activeBySession.entries().next().value;
|
|
20
|
+
if (!oldest)
|
|
21
|
+
break;
|
|
22
|
+
activeBySession.delete(oldest[0]);
|
|
23
|
+
clearCheckpoint(oldest[1]);
|
|
24
|
+
clearConversationBlobs(oldest[1]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function resetConversationBindingsForTests() {
|
|
28
|
+
activeBySession.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Deterministic UUID (version-4 shape) from an arbitrary session key. */
|
|
31
|
+
export function sessionIdToUuid(sessionId) {
|
|
32
|
+
const hash = createHash("sha256").update(`cursor-opencode-provider:conv:${sessionId}`).digest();
|
|
33
|
+
const bytes = Buffer.from(hash.subarray(0, 16));
|
|
34
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
35
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
36
|
+
const hex = bytes.toString("hex");
|
|
37
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
38
|
+
}
|
|
39
|
+
/** Current Cursor conversation_id for an OpenCode session key, creating the default binding if needed. */
|
|
40
|
+
export function peekConversationId(sessionKey) {
|
|
41
|
+
let id = activeBySession.get(sessionKey);
|
|
42
|
+
if (!id) {
|
|
43
|
+
id = sessionIdToUuid(sessionKey);
|
|
44
|
+
}
|
|
45
|
+
rememberConversation(sessionKey, id);
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the Cursor conversation_id for this OpenCode session.
|
|
50
|
+
* When `reset` is true (compaction/summary), discard prior Cursor state and
|
|
51
|
+
* mint a new id so TurnEnded cache_read cannot keep overflowing OpenCode.
|
|
52
|
+
*/
|
|
53
|
+
export function bindConversationId(sessionKey, opts) {
|
|
54
|
+
if (!sessionKey) {
|
|
55
|
+
return { conversationId: crypto.randomUUID(), reset: !!opts?.reset };
|
|
56
|
+
}
|
|
57
|
+
if (opts?.reset) {
|
|
58
|
+
const previousId = activeBySession.get(sessionKey) ?? sessionIdToUuid(sessionKey);
|
|
59
|
+
clearCheckpoint(previousId);
|
|
60
|
+
clearConversationBlobs(previousId);
|
|
61
|
+
const conversationId = crypto.randomUUID();
|
|
62
|
+
rememberConversation(sessionKey, conversationId);
|
|
63
|
+
return { conversationId, reset: true, previousId };
|
|
64
|
+
}
|
|
65
|
+
return { conversationId: peekConversationId(sessionKey), reset: false };
|
|
66
|
+
}
|