cursor-opencode-provider 0.6.0 → 0.6.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/README.md CHANGED
@@ -368,8 +368,12 @@ bun run typecheck # type-check without emit
368
368
  bun test # run unit tests
369
369
  bun run test:node-http2 # Node-specific HTTP/2 detach regression
370
370
  bun run test:watch # watch mode
371
+ bun run generate:pricing # refresh src/pricing-data.ts from Cursor docs
372
+ bun run check:pricing # fixture coverage for known model ids
371
373
  ```
372
374
 
375
+ Before any version bump or `v*` tag, run `generate:pricing` and `check:pricing` and commit mapping/`pricing-data.ts` updates. Publish CI regenerates rates from the live docs; an unmapped Cursor display name fails the job.
376
+
373
377
  ## Architecture
374
378
 
375
379
  ```
@@ -16,6 +16,7 @@ async function listLocalPlugins(dir) {
16
16
  catch {
17
17
  return [];
18
18
  }
19
+ entries.sort();
19
20
  for (const name of entries) {
20
21
  if (!/\.(m?[jt]s)$/.test(name))
21
22
  continue;
@@ -109,6 +109,7 @@ async function expandGlob(pattern, workspaceRoot) {
109
109
  catch {
110
110
  return;
111
111
  }
112
+ entries.sort();
112
113
  for (const name of entries) {
113
114
  if (name === "node_modules" || name === ".git")
114
115
  continue;
@@ -63,6 +63,7 @@ async function scanSkillsRoot(root, out) {
63
63
  catch {
64
64
  return;
65
65
  }
66
+ entries.sort();
66
67
  for (const name of entries) {
67
68
  const skillMd = path.join(root, name, "SKILL.md");
68
69
  if (!(await exists(skillMd)))
@@ -625,12 +625,18 @@ async function startSession(modelId, token, callOptions, options, startOptions)
625
625
  // Compaction must not reuse the prior conversation; its first normal turn
626
626
  // must also rebase so the summary-agent checkpoint cannot replace the normal
627
627
  // system prompt and OpenCode's newly compacted history.
628
+ const lifecycle = !allowTools && !isCompaction && !recovery;
628
629
  let bound = resuming
629
630
  ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
630
- : bindConversationId(sessionKey, { reset: resetState.reset || recovery?.kind === "rebase" });
631
- let conversationState = resuming
632
- ? resumeRecovery.checkpoint
633
- : (bound.reset ? undefined : getCheckpoint(bound.conversationId));
631
+ : bindConversationId(sessionKey, {
632
+ reset: resetState.reset || recovery?.kind === "rebase",
633
+ ephemeral: lifecycle,
634
+ });
635
+ let conversationState = lifecycle
636
+ ? undefined
637
+ : resuming
638
+ ? resumeRecovery.checkpoint
639
+ : (bound.reset ? undefined : getCheckpoint(bound.conversationId));
634
640
  let checkpointGraph = conversationState
635
641
  ? inspectConversationBlobGraph(bound.conversationId, conversationState)
636
642
  : { count: 0, bytes: 0, complete: true };
@@ -685,12 +691,13 @@ async function startSession(modelId, token, callOptions, options, startOptions)
685
691
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
686
692
  // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
687
693
  // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
688
- const modeReminder = isCompaction
694
+ const startedWithCheckpoint = !!conversationState;
695
+ const modeReminder = isCompaction || startedWithCheckpoint || lifecycle
689
696
  ? undefined
690
697
  : takeActiveCursorModeReminder(sessionKey, {
691
698
  advertisedTools: cursorTools.map((tool) => tool.name),
692
699
  });
693
- const kickoffWarning = isCompaction
700
+ const kickoffWarning = isCompaction || startedWithCheckpoint || lifecycle
694
701
  ? undefined
695
702
  : takePlanExecutionKickoffWarning(sessionKey);
696
703
  const systemPrompt = [
@@ -898,7 +905,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
898
905
  displayToolCalls: 0,
899
906
  execRequests: 0,
900
907
  },
901
- openCodeSessionId: sessionKey,
908
+ openCodeSessionId: lifecycle ? undefined : sessionKey,
902
909
  postCompactionRebase: isCompaction,
903
910
  toolCatalog: snapshotToolCatalog(sessionKey),
904
911
  stream,
@@ -1700,7 +1707,10 @@ export async function pump(session, controller, ids, abortSignal) {
1700
1707
  const counters = te ? cursorUsageCountersFromTurnEnded(te) : undefined;
1701
1708
  const usage = settledUsage ?? (te
1702
1709
  ? tokenDetails
1703
- ? buildLanguageModelV3UsageFromCounters(counters, { contextTotalTokens: tokenDetails.usedTokens })
1710
+ ? buildLanguageModelV3UsageFromCounters(counters, {
1711
+ contextTotalTokens: tokenDetails.usedTokens,
1712
+ priorContextTokens: session.cacheDiagnostics?.priorTokenDetails?.usedTokens,
1713
+ })
1704
1714
  : emptyLanguageModelV3Usage()
1705
1715
  : emptyLanguageModelV3Usage());
1706
1716
  const providerMetadata = te
@@ -2790,6 +2800,9 @@ export function spanEndParts(opts) {
2790
2800
  out.push({ type: "text-end", id: opts.textId });
2791
2801
  return out;
2792
2802
  }
2803
+ function toolsInFixedOrder(tools) {
2804
+ return tools.map((tool) => ({ ...tool })).sort((left, right) => (left.name ?? "").localeCompare(right.name ?? ""));
2805
+ }
2793
2806
  /** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
2794
2807
  export function computeAllowTools(toolCount, toolChoice) {
2795
2808
  return toolCount > 0 && toolChoice?.type !== "none";
@@ -2797,7 +2810,7 @@ export function computeAllowTools(toolCount, toolChoice) {
2797
2810
  export async function resolveTurnToolState(input) {
2798
2811
  const { sessionKey, incomingTools, isCompaction } = input;
2799
2812
  if (sessionKey && incomingTools.length > 0) {
2800
- rememberToolCatalog(sessionKey, incomingTools.map((tool) => ({ ...tool })));
2813
+ rememberToolCatalog(sessionKey, toolsInFixedOrder(incomingTools));
2801
2814
  }
2802
2815
  // Advertisement and permission are deliberately independent.
2803
2816
  //
@@ -2818,12 +2831,12 @@ export async function resolveTurnToolState(input) {
2818
2831
  // still refuse execution and cannot duplicate the real turn's side effects.
2819
2832
  let advertisedTools;
2820
2833
  if (incomingTools.length > 0) {
2821
- advertisedTools = incomingTools;
2834
+ advertisedTools = toolsInFixedOrder(incomingTools);
2822
2835
  }
2823
2836
  else if (sessionKey) {
2824
2837
  const cached = toolCatalogBySession.get(sessionKey)
2825
2838
  ?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
2826
- advertisedTools = cached.map((tool) => ({ ...tool }));
2839
+ advertisedTools = toolsInFixedOrder(cached);
2827
2840
  }
2828
2841
  else {
2829
2842
  // Without a session key there is no safe sibling-correlation key. Preserve
@@ -24,6 +24,7 @@ export declare function peekConversationId(sessionKey: string): string;
24
24
  */
25
25
  export declare function bindConversationId(sessionKey: string | undefined, opts?: {
26
26
  reset?: boolean;
27
+ ephemeral?: boolean;
27
28
  }): {
28
29
  conversationId: string;
29
30
  reset: boolean;
@@ -76,6 +76,9 @@ export function peekConversationId(sessionKey) {
76
76
  * mint a new id so TurnEnded cache_read cannot keep overflowing OpenCode.
77
77
  */
78
78
  export function bindConversationId(sessionKey, opts) {
79
+ if (opts?.ephemeral) {
80
+ return { conversationId: crypto.randomUUID(), reset: false };
81
+ }
79
82
  if (!sessionKey) {
80
83
  return { conversationId: crypto.randomUUID(), reset: !!opts?.reset };
81
84
  }
@@ -249,7 +249,7 @@ export function encodeJsonAsValue(v) {
249
249
  }
250
250
  else if (typeof v === "object") {
251
251
  const st = [];
252
- for (const [key, val] of Object.entries(v)) {
252
+ for (const [key, val] of Object.entries(v).sort(([left], [right]) => left.localeCompare(right))) {
253
253
  if (val === undefined)
254
254
  continue;
255
255
  const entry = [];
@@ -57,7 +57,9 @@ export function resolveToolServerIdentity(opencodeName, defaultServer = "opencod
57
57
  * reconstructed in `mcpRealToolName`.
58
58
  */
59
59
  export function toolsToDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
60
- return tools.map((t) => {
60
+ return [...tools]
61
+ .sort((left, right) => left.name.localeCompare(right.name))
62
+ .map((t) => {
61
63
  const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
62
64
  const collisionSafeAlias = COLLISION_SAFE_ALIASES.has(t.name);
63
65
  return {
package/dist/usage.d.ts CHANGED
@@ -10,6 +10,8 @@ export type CursorUsageCounters = {
10
10
  export type CursorUsageOptions = {
11
11
  /** Cursor checkpoint occupancy, including the current turn's output. */
12
12
  contextTotalTokens?: number;
13
+ /** Previous turn's occupancy. When Cursor's cache read covers this window, do not dilute the hit by multi-step TurnEnded aggregates. */
14
+ priorContextTokens?: number;
13
15
  };
14
16
  export type CursorCacheDiagnosticStats = {
15
17
  sessionKey?: string;
package/dist/usage.js CHANGED
@@ -32,9 +32,17 @@ export function buildLanguageModelV3UsageFromCounters(counters, options = {}) {
32
32
  // TurnEnded can aggregate several internal model calls, so its absolute input
33
33
  // and cache counts can exceed the final checkpoint occupancy. Preserve the
34
34
  // cache proportions while normalizing the partition to Cursor's context total.
35
- const cacheRead = rawInput > 0
35
+ const proportionalRead = rawInput > 0
36
36
  ? Math.min(input, Math.round(input * rawCacheRead / rawInput))
37
37
  : 0;
38
+ const priorContext = options.priorContextTokens;
39
+ const prefixRead = typeof priorContext === "number"
40
+ && Number.isFinite(priorContext)
41
+ && priorContext > 0
42
+ && rawCacheRead >= priorContext
43
+ ? Math.min(input, Math.trunc(priorContext))
44
+ : 0;
45
+ const cacheRead = Math.min(input, Math.max(proportionalRead, prefixRead));
38
46
  const cacheWrite = rawInput > 0
39
47
  ? Math.min(input - cacheRead, Math.round(input * rawCacheWrite / rawInput))
40
48
  : 0;
@@ -170,9 +178,13 @@ export function formatTurnUsageValidation(counters, usage, tokenDetails, context
170
178
  : undefined;
171
179
  const rawCached = counters.cacheRead + counters.cacheWrite;
172
180
  const sentCached = cacheRead + cacheWrite;
181
+ const proportionalCached = counters.inputTokens > 0 && input > 0
182
+ ? Math.round(input * rawCached / counters.inputTokens)
183
+ : 0;
173
184
  const cacheRatioMatch = tokenDetails
174
185
  ? counters.inputTokens > 0 && input > 0
175
186
  ? Math.abs(rawCached / counters.inputTokens - sentCached / input) <= 1 / input
187
+ || (sentCached >= proportionalCached && sentCached <= input)
176
188
  : rawCached === 0 && sentCached === 0
177
189
  : undefined;
178
190
  const status = input === inputParts &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",