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/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 + requested reasoning effort. Each variant already carries the
34
- * full `{id,value}` set (per-model vocabulary), so we pick the matching variant
35
- * and return its parameters verbatim the client never constructs these by
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 + requested reasoning effort. Each variant already carries the
72
- * full `{id,value}` set (per-model vocabulary), so we pick the matching variant
73
- * and return its parameters verbatim the client never constructs these by
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
- // No variant metadata: fall back to a bare effort param if requested.
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
- const candidates = model.variants.filter((v) => !isFast(v));
85
- const pool = candidates.length > 0 ? candidates : model.variants;
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 = pool.find((v) => effortOf(v) === opts.reasoningEffort);
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
- const byDefault = pool.find((v) => (wantMax ? v.isDefaultMax : v.isDefaultNonMax));
96
- const base = (byDefault ?? pool[0]).parameterValues;
97
- return buildRequestedModelParams(base, {
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: wantMax,
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, { models, fetchedAt: Date.now() }))
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 = { models, fetchedAt: Date.now() };
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 = { models, fetchedAt: Date.now() };
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 Desktop webview from a
10
- * model or variant display name: HTML/markup chars (`< > & " ' \``), parentheses,
11
- * Tabs/newlines collapse to single spaces; dots and unicode letters are preserved so names
12
- * stay readable. Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
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 (mi.variants.length === 0)
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
- for (const v of mi.variants) {
29
- const base = safeLabel(v.displayName || v.key || "default");
30
- let key = base;
31
- let suffix = 2;
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 = `${base}--${suffix++}`;
79
+ key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
34
80
  usedKeys.add(key);
35
- const params = {};
36
- for (const p of v.parameterValues) {
37
- params[p.id] = p.value;
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: mi.maxContext ?? 200000,
79
- output: 4096,
143
+ context,
144
+ output,
80
145
  },
81
146
  };
82
- const variants = modelInfoVariants(mi);
83
- if (variants)
84
- config.variants = 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
- out[m.id] = modelInfoToConfig(m, {
92
- thinkingSuffix: !!m.supportsThinking && ambiguous.has(baseName(m)),
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 (!cached || cached.models.length === 0) {
202
- // Config runs before auth.loader and has no getAuth(); read the durable
203
- // store (normally the same source getAuth() uses).
204
- const auth = await authFromStore();
205
- if (auth) {
206
- const accessToken = await resolveAccessToken(auth);
207
- if (accessToken) {
208
- try {
209
- const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
210
- return modelsToConfig(models);
211
- }
212
- catch {
213
- // discovery failed — leave the list empty
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
- return modelsToConfig(cached.models);
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
+ }