cursor-opencode-provider 0.2.1 → 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/plugin.js CHANGED
@@ -1,4 +1,4 @@
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
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";
@@ -129,6 +129,11 @@ export function modelInfoToConfig(mi, options = {}) {
129
129
  const context = contextTier === "long"
130
130
  ? (mi.maxContextForMaxMode ?? 1_000_000)
131
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;
132
137
  const config = {
133
138
  name,
134
139
  reasoning: mi.supportsThinking ?? false,
@@ -136,7 +141,7 @@ export function modelInfoToConfig(mi, options = {}) {
136
141
  temperature: false,
137
142
  limit: {
138
143
  context,
139
- output: 4096,
144
+ output,
140
145
  },
141
146
  };
142
147
  const variantConfig = modelInfoVariants(mi, variants);
@@ -306,6 +311,16 @@ export async function CursorPlugin(input) {
306
311
  return cached?.models.length ? modelsToConfig(cached.models) : {};
307
312
  }
308
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
+ },
309
324
  async config(cfg) {
310
325
  cfg.provider ??= {};
311
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
+ }
@@ -0,0 +1,23 @@
1
+ export type InteractionQueryWireInfo = {
2
+ id?: number;
3
+ variantField?: number;
4
+ variantName?: string;
5
+ };
6
+ export type HandledInteraction = {
7
+ id: number;
8
+ variantField: number;
9
+ variantName: string;
10
+ outcome: "rejected" | "acknowledged" | "failed";
11
+ reply: Uint8Array;
12
+ };
13
+ export declare class UnsupportedInteractionQueryError extends Error {
14
+ constructor(info: InteractionQueryWireInfo);
15
+ }
16
+ /**
17
+ * Inspect the raw wrapper as well as the decoded object. protobufjs drops
18
+ * fields introduced by newer Cursor schemas; raw inspection lets us fail fast
19
+ * instead of accidentally restoring the heartbeat-only deadlock.
20
+ */
21
+ export declare function inspectInteractionQueryWire(agentServerPayload: Uint8Array): InteractionQueryWireInfo;
22
+ /** Build the immediate typed response required by Cursor's Run RPC. */
23
+ export declare function handleInteractionQuery(query: Record<string, unknown>, agentServerPayload: Uint8Array): HandledInteraction;
@@ -0,0 +1,136 @@
1
+ import { encodeMessage } from "./messages.js";
2
+ import { readAllFields } from "./struct.js";
3
+ const HEADLESS_REASON = "This Cursor interaction requires UI approval and is not available through the OpenCode provider.";
4
+ const variantNames = {
5
+ 2: "web_search_request_query",
6
+ 3: "ask_question_interaction_query",
7
+ 4: "switch_mode_request_query",
8
+ 7: "create_plan_request_query",
9
+ 8: "setup_vm_environment_args",
10
+ 9: "web_fetch_request_query",
11
+ 10: "pr_management_request_query",
12
+ 11: "mcp_auth_request_query",
13
+ 12: "generate_image_request_query",
14
+ 13: "replace_env_args",
15
+ 14: "connect_scm_request_query",
16
+ };
17
+ export class UnsupportedInteractionQueryError extends Error {
18
+ constructor(info) {
19
+ const variant = info.variantField === undefined
20
+ ? "missing variant"
21
+ : `unsupported variant field #${info.variantField}`;
22
+ const id = info.id === undefined ? "missing id" : `id=${info.id}`;
23
+ super(`Cursor interaction query cannot be handled (${id}, ${variant})`);
24
+ this.name = "UnsupportedInteractionQueryError";
25
+ }
26
+ }
27
+ /**
28
+ * Inspect the raw wrapper as well as the decoded object. protobufjs drops
29
+ * fields introduced by newer Cursor schemas; raw inspection lets us fail fast
30
+ * instead of accidentally restoring the heartbeat-only deadlock.
31
+ */
32
+ export function inspectInteractionQueryWire(agentServerPayload) {
33
+ const queryBytes = readAllFields(agentServerPayload)
34
+ .find((field) => field.fn === 7 && field.wt === 2)?.bytes;
35
+ if (!queryBytes)
36
+ return {};
37
+ // InteractionQuery.id is a proto3 uint32. Cursor commonly uses id=0, whose
38
+ // default scalar value is omitted from the wire; absence therefore means
39
+ // zero, not a malformed/missing correlation id.
40
+ let id = 0;
41
+ let variantField;
42
+ for (const field of readAllFields(queryBytes)) {
43
+ if (field.fn === 1 && field.wt === 0)
44
+ id = field.varint;
45
+ else if (field.wt === 2 && variantField === undefined)
46
+ variantField = field.fn;
47
+ }
48
+ return {
49
+ id,
50
+ variantField,
51
+ variantName: variantField === undefined ? undefined : variantNames[variantField],
52
+ };
53
+ }
54
+ /** Build the immediate typed response required by Cursor's Run RPC. */
55
+ export function handleInteractionQuery(query, agentServerPayload) {
56
+ const info = inspectInteractionQueryWire(agentServerPayload);
57
+ if (info.id === undefined || info.variantField === undefined || !info.variantName) {
58
+ throw new UnsupportedInteractionQueryError(info);
59
+ }
60
+ if (typeof query.id === "number" && query.id !== info.id) {
61
+ throw new Error(`Cursor interaction query id mismatch: decoded=${query.id} wire=${info.id}`);
62
+ }
63
+ let response;
64
+ let outcome;
65
+ switch (info.variantField) {
66
+ case 2:
67
+ response = { web_search_request_response: { rejected: { reason: HEADLESS_REASON } } };
68
+ outcome = "rejected";
69
+ break;
70
+ case 3:
71
+ response = {
72
+ ask_question_interaction_response: {
73
+ result: { rejected: { reason: "Questions must be asked through OpenCode's question tool." } },
74
+ },
75
+ };
76
+ outcome = "rejected";
77
+ break;
78
+ case 4:
79
+ response = { switch_mode_request_response: { rejected: { reason: HEADLESS_REASON } } };
80
+ outcome = "rejected";
81
+ break;
82
+ case 7:
83
+ // Cursor CLI's headless fallback acknowledges plan creation without a
84
+ // client-side URI; the plan remains in conversation state/checkpoints.
85
+ response = { create_plan_request_response: { result: { success: {}, plan_uri: "" } } };
86
+ outcome = "acknowledged";
87
+ break;
88
+ case 8:
89
+ // OpenCode owns the local environment; there is no Cursor VM to create.
90
+ response = { setup_vm_environment_result: { success: {} } };
91
+ outcome = "acknowledged";
92
+ break;
93
+ case 9:
94
+ response = { web_fetch_request_response: { rejected: { reason: HEADLESS_REASON } } };
95
+ outcome = "rejected";
96
+ break;
97
+ case 10:
98
+ response = { pr_management_result: { rejected: { reason: HEADLESS_REASON } } };
99
+ outcome = "rejected";
100
+ break;
101
+ case 11:
102
+ response = { mcp_auth_request_response: { rejected: { reason: HEADLESS_REASON } } };
103
+ outcome = "rejected";
104
+ break;
105
+ case 12:
106
+ response = { generate_image_request_response: { rejected: { reason: HEADLESS_REASON } } };
107
+ outcome = "rejected";
108
+ break;
109
+ case 13:
110
+ response = {
111
+ replace_env_result: {
112
+ failure: {
113
+ error_message: "Environment replacement is not supported by the OpenCode provider.",
114
+ setup_logs: "",
115
+ },
116
+ },
117
+ };
118
+ outcome = "failed";
119
+ break;
120
+ case 14:
121
+ response = { connect_scm_request_response: { rejected: { reason: HEADLESS_REASON } } };
122
+ outcome = "rejected";
123
+ break;
124
+ default:
125
+ throw new UnsupportedInteractionQueryError(info);
126
+ }
127
+ return {
128
+ id: info.id,
129
+ variantField: info.variantField,
130
+ variantName: info.variantName,
131
+ outcome,
132
+ reply: encodeMessage("AgentClientMessage", {
133
+ interaction_response: { id: info.id, ...response },
134
+ }),
135
+ };
136
+ }