cursor-opencode-provider 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -483,6 +483,125 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
483
483
  }
484
484
  /** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
485
485
  const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
486
+ /** Return the number of non-overlapping occurrences, stopping after ambiguity. */
487
+ function replacementOccurrenceCount(source, needle) {
488
+ if (!needle)
489
+ return 0;
490
+ const first = source.indexOf(needle);
491
+ if (first === -1)
492
+ return 0;
493
+ return source.indexOf(needle, first + needle.length) === -1 ? 1 : 2;
494
+ }
495
+ function previousLineStart(source, start) {
496
+ if (start <= 0)
497
+ return 0;
498
+ const before = source[start - 1] === "\n" ? start - 2 : start - 1;
499
+ return source.lastIndexOf("\n", before) + 1;
500
+ }
501
+ function followingLineEnd(source, end) {
502
+ if (end >= source.length)
503
+ return source.length;
504
+ const newline = source.indexOf("\n", end);
505
+ return newline === -1 ? source.length : newline + 1;
506
+ }
507
+ /**
508
+ * Collapse a whole-file replacement into one unique, line-bounded substring
509
+ * edit. Cursor's legacy edit executor computes a complete new file internally;
510
+ * OpenCode's `edit` tool instead wants old/new substrings.
511
+ */
512
+ function planWholeFileEdit(source, target) {
513
+ if (!source || source === target)
514
+ return undefined;
515
+ let prefix = 0;
516
+ const shared = Math.min(source.length, target.length);
517
+ while (prefix < shared && source[prefix] === target[prefix])
518
+ prefix++;
519
+ let suffix = 0;
520
+ while (suffix < source.length - prefix
521
+ && suffix < target.length - prefix
522
+ && source[source.length - suffix - 1] === target[target.length - suffix - 1])
523
+ suffix++;
524
+ const sourceChangeEnd = source.length - suffix;
525
+ const targetChangeEnd = target.length - suffix;
526
+ let start = prefix === 0 ? 0 : source.lastIndexOf("\n", prefix - 1) + 1;
527
+ let end = sourceChangeEnd >= source.length
528
+ ? source.length
529
+ : followingLineEnd(source, sourceChangeEnd);
530
+ // A pure insertion can initially select no source text. Include an adjacent
531
+ // line because OpenCode edit deliberately rejects an empty oldString.
532
+ if (start === end) {
533
+ if (start > 0)
534
+ start = previousLineStart(source, start);
535
+ else
536
+ end = followingLineEnd(source, end);
537
+ }
538
+ const replacement = () => ({
539
+ oldString: source.slice(start, end),
540
+ newString: source.slice(start, prefix)
541
+ + target.slice(prefix, targetChangeEnd)
542
+ + source.slice(sourceChangeEnd, end),
543
+ });
544
+ let result = replacement();
545
+ while (replacementOccurrenceCount(source, result.oldString) !== 1) {
546
+ const priorStart = start;
547
+ const priorEnd = end;
548
+ if (start > 0)
549
+ start = previousLineStart(source, start);
550
+ if (end < source.length)
551
+ end = followingLineEnd(source, end);
552
+ if (start === priorStart && end === priorEnd)
553
+ return undefined;
554
+ result = replacement();
555
+ }
556
+ return result;
557
+ }
558
+ /**
559
+ * Preserve a legacy Cursor edit's intent when its internal executor follows
560
+ * `edit_tool_call` with a whole-file `write_args` request.
561
+ *
562
+ * The result field intentionally remains `write_result`: that is the response
563
+ * Cursor is awaiting even though OpenCode executes the mutation via `edit`.
564
+ */
565
+ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames, editPath, workspaceRoot) {
566
+ if (parsed.resultField !== "write_result" || parsed.toolName !== "write")
567
+ return false;
568
+ const advertised = new Set(advertisedToolNames);
569
+ if (!advertised.has("edit") && !advertised.has(APPLY_PATCH_TOOL))
570
+ return false;
571
+ const filePath = str(parsed.args.filePath);
572
+ const content = stringValue(parsed.args.content);
573
+ if (!filePath || content === undefined || !editPath)
574
+ return false;
575
+ const root = workspaceRoot ?? process.cwd();
576
+ const absolute = path.resolve(root, filePath);
577
+ if (absolute !== path.resolve(root, editPath))
578
+ return false;
579
+ let source;
580
+ try {
581
+ const stat = fs.statSync(absolute);
582
+ if (!stat.isFile() || stat.size > MAX_EDIT_SOURCE_BYTES)
583
+ return false;
584
+ if (Buffer.byteLength(content, "utf8") > MAX_EDIT_SOURCE_BYTES)
585
+ return false;
586
+ source = fs.readFileSync(absolute, "utf8");
587
+ }
588
+ catch {
589
+ // Missing targets are creations and must stay writes. Other read failures
590
+ // also fall back to the original host permission path without guessing.
591
+ return false;
592
+ }
593
+ const replacement = planWholeFileEdit(source, content);
594
+ if (!replacement)
595
+ return false;
596
+ parsed.toolName = "edit";
597
+ parsed.args = {
598
+ filePath,
599
+ oldString: replacement.oldString,
600
+ newString: replacement.newString,
601
+ };
602
+ parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
603
+ return true;
604
+ }
486
605
  /**
487
606
  * Express a native Cursor write/edit as `apply_patch` when the host swapped the
488
607
  * edit-tool family out from under us.
@@ -1202,9 +1321,9 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1202
1321
  // Cursor's native read_args lands here; `mcp_args` reads land in
1203
1322
  // mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
1204
1323
  // native channel, so this is the path that matters in practice.
1205
- const content = unwrapReadOutput(output);
1206
1324
  const statPath = extractPathTag(output) ?? readPath;
1207
1325
  const outputMetadata = parseOpenCodeReadMetadata(output);
1326
+ const content = restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
1208
1327
  const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
1209
1328
  const rangeApplied = readRangeApplied(resultMetadata, totalLines);
1210
1329
  // `truncated` alone is not enough: it is set here, and models still assert
@@ -1452,6 +1571,53 @@ function parseOpenCodeReadMetadata(output) {
1452
1571
  return { totalLines: Number(complete[1]) };
1453
1572
  return {};
1454
1573
  }
1574
+ /**
1575
+ * OpenCode's numbered read envelope cannot represent the terminator after the
1576
+ * final line. Cursor's legacy edit executor performs exact replacement against
1577
+ * this content, so dropping a real LF/CRLF makes otherwise-valid edits fail and
1578
+ * encourages a whole-file rewrite. Restore only that final terminator for an
1579
+ * unbounded, confirmed-complete read; paged/capped reads stay untouched.
1580
+ */
1581
+ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetadata, workspaceRoot) {
1582
+ if (!readPath
1583
+ || metadata.totalLines === undefined
1584
+ || metadata.startLine !== undefined
1585
+ || metadata.endLine !== undefined
1586
+ || num(resultMetadata?.offset) !== undefined
1587
+ || num(resultMetadata?.limit) !== undefined
1588
+ || content.endsWith("\n"))
1589
+ return content;
1590
+ const absolute = path.isAbsolute(readPath)
1591
+ ? readPath
1592
+ : path.resolve(workspaceRoot ?? process.cwd(), readPath);
1593
+ let fd;
1594
+ try {
1595
+ fd = fs.openSync(absolute, "r");
1596
+ const size = fs.fstatSync(fd).size;
1597
+ if (size === 0)
1598
+ return content;
1599
+ const tail = Buffer.alloc(Math.min(2, size));
1600
+ fs.readSync(fd, tail, 0, tail.length, size - tail.length);
1601
+ if (tail.length >= 2 && tail[tail.length - 2] === 0x0d && tail[tail.length - 1] === 0x0a) {
1602
+ return `${content}\r\n`;
1603
+ }
1604
+ if (tail[tail.length - 1] === 0x0a)
1605
+ return `${content}\n`;
1606
+ }
1607
+ catch {
1608
+ // The host read result remains authoritative when the local path cannot be
1609
+ // inspected (deleted/raced/permission changed after the tool completed).
1610
+ }
1611
+ finally {
1612
+ if (fd !== undefined) {
1613
+ try {
1614
+ fs.closeSync(fd);
1615
+ }
1616
+ catch { /* already closed */ }
1617
+ }
1618
+ }
1619
+ return content;
1620
+ }
1455
1621
  /**
1456
1622
  * OpenCode's `read` caps output at 50 KB (`tool/read.ts` MAX_BYTES = 50 * 1024)
1457
1623
  * and cuts on a whole-line boundary, then appends
package/dist/session.d.ts CHANGED
@@ -112,6 +112,14 @@ export type CursorSession = {
112
112
  * or when we bridge the completed display call into an OpenCode tool-call.
113
113
  */
114
114
  displayToolCalls: Map<string, Record<string, unknown>>;
115
+ /**
116
+ * Legacy edit calls whose authoritative exec path is still in progress.
117
+ * Cursor implements these as read -> whole-file write; retaining the path
118
+ * lets the pump expose the final mutation to OpenCode as a targeted edit.
119
+ */
120
+ editToolCalls?: Map<string, {
121
+ path: string;
122
+ }>;
115
123
  /** Monotonic synthetic exec ids for bridged (display-only) OpenCode tool calls. */
116
124
  nextBridgedExecId: number;
117
125
  /** KV blob store: blob_id (hex) → data, for Cursor's out-of-band payload channel. */
@@ -132,8 +140,8 @@ export type CursorSession = {
132
140
  allowTools: boolean;
133
141
  /**
134
142
  * Best-effort token usage for this held-open Run. Updated from text/tool
135
- * activity and replaced by TurnEnded when the turn completes. Emitted on
136
- * tool-calls finishes so OpenCode does not store all-zero usage mid-loop.
143
+ * activity and replaced by TurnEnded only for a single-step Run. Emitted on
144
+ * tool-call finishes so OpenCode does not store all-zero usage mid-loop.
137
145
  */
138
146
  usageEstimate: {
139
147
  inputTokens: number;
@@ -142,6 +150,13 @@ export type CursorSession = {
142
150
  cacheWrite: number;
143
151
  reasoningTokens: number;
144
152
  };
153
+ /**
154
+ * True after this held Run has already crossed an OpenCode tool-call
155
+ * boundary. Cursor's eventual TurnEnded counters then cover the whole Run,
156
+ * not only the final doStream request, so they cannot be used as that
157
+ * request's context usage.
158
+ */
159
+ hadToolCallBoundary?: boolean;
145
160
  /**
146
161
  * True while a doStream pull() is actively reading this session's frames.
147
162
  * Prevents a late cancel/abort from a prior ReadableStream from destroying
package/dist/shared.d.ts CHANGED
@@ -10,7 +10,7 @@ export declare const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableMo
10
10
  export declare const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
11
11
  export declare const MODEL_CACHE_FILE = "cursor-models.json";
12
12
  /** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
13
- export declare const MODEL_CACHE_SCHEMA_VERSION = 2;
13
+ export declare const MODEL_CACHE_SCHEMA_VERSION = 3;
14
14
  export declare const MODEL_CACHE_TTL_MS = 86400000;
15
15
  export declare const VERSION_CACHE_FILE = "cursor-client-version.json";
16
16
  export declare const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
package/dist/shared.js CHANGED
@@ -10,7 +10,7 @@ export const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
10
10
  export const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
11
11
  export const MODEL_CACHE_FILE = "cursor-models.json";
12
12
  /** Bumped when the on-disk model cache shape/semantics change (forces refetch). */
13
- export const MODEL_CACHE_SCHEMA_VERSION = 2;
13
+ export const MODEL_CACHE_SCHEMA_VERSION = 3;
14
14
  export const MODEL_CACHE_TTL_MS = 86_400_000;
15
15
  export const VERSION_CACHE_FILE = "cursor-client-version.json";
16
16
  export const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
package/dist/usage.d.ts CHANGED
@@ -2,8 +2,9 @@ import type { LanguageModelV3Usage } from "@ai-sdk/provider";
2
2
  /** Non-negative integer counter from a Cursor `turn_ended` field. */
3
3
  export declare function turnEndedCounter(te: Record<string, unknown>, key: string): number;
4
4
  /**
5
- * Map authoritative Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
6
- * flatten via input total = noCache + cacheRead + cacheWrite).
5
+ * Map request-local Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
6
+ * flatten via input total = noCache + cacheRead + cacheWrite). Only use this
7
+ * for a Run with no prior tool boundary; multi-step TurnEnded is cumulative.
7
8
  *
8
9
  * Contract: `input_tokens` is non-cached input; cache fields are separate.
9
10
  * Cursor's agent wire currently sends `cache_write=0` even when later turns
@@ -18,7 +19,15 @@ export type CursorUsageEstimate = {
18
19
  cacheWrite: number;
19
20
  reasoningTokens: number;
20
21
  };
21
- /** Mid-turn finish (e.g. tool-calls) before `turn_ended` — keep non-zero estimates. */
22
+ /**
23
+ * Request-local usage for a held Cursor Run.
24
+ *
25
+ * `est.inputTokens` tracks the seed plus delivered tool results, while
26
+ * `est.outputTokens` accumulates model output across earlier tool boundaries.
27
+ * Move prior output into the current input estimate and report only this
28
+ * doStream request's output. Cache counters are intentionally absent: the
29
+ * only counters Cursor exposes at TurnEnded are cumulative for the whole Run.
30
+ */
22
31
  export declare function buildLanguageModelV3UsageFromEstimate(est: CursorUsageEstimate, promptTokens: number, outputChars: number, estimateCharsToTokens: (chars: number) => number): LanguageModelV3Usage;
23
32
  /** Mirror Kilo `Session.getUsage` / AI SDK flat fields from V3 nested usage. */
24
33
  export declare function kiloShapedUsageFromV3(usage: LanguageModelV3Usage): {
package/dist/usage.js CHANGED
@@ -6,8 +6,9 @@ export function turnEndedCounter(te, key) {
6
6
  : 0;
7
7
  }
8
8
  /**
9
- * Map authoritative Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
10
- * flatten via input total = noCache + cacheRead + cacheWrite).
9
+ * Map request-local Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
10
+ * flatten via input total = noCache + cacheRead + cacheWrite). Only use this
11
+ * for a Run with no prior tool boundary; multi-step TurnEnded is cumulative.
11
12
  *
12
13
  * Contract: `input_tokens` is non-cached input; cache fields are separate.
13
14
  * Cursor's agent wire currently sends `cache_write=0` even when later turns
@@ -34,27 +35,30 @@ export function buildLanguageModelV3UsageFromTurnEnded(te) {
34
35
  },
35
36
  };
36
37
  }
37
- /** Mid-turn finish (e.g. tool-calls) before `turn_ended` — keep non-zero estimates. */
38
+ /**
39
+ * Request-local usage for a held Cursor Run.
40
+ *
41
+ * `est.inputTokens` tracks the seed plus delivered tool results, while
42
+ * `est.outputTokens` accumulates model output across earlier tool boundaries.
43
+ * Move prior output into the current input estimate and report only this
44
+ * doStream request's output. Cache counters are intentionally absent: the
45
+ * only counters Cursor exposes at TurnEnded are cumulative for the whole Run.
46
+ */
38
47
  export function buildLanguageModelV3UsageFromEstimate(est, promptTokens, outputChars, estimateCharsToTokens) {
39
- const hasInputBreakdown = est.cacheRead > 0 || est.cacheWrite > 0 || est.inputTokens > 0;
40
- const inputNoCache = hasInputBreakdown ? est.inputTokens : promptTokens;
41
- const cacheRead = est.cacheRead;
42
- const cacheWrite = est.cacheWrite;
43
- const outputFromChars = estimateCharsToTokens(outputChars);
44
- const outputText = est.outputTokens > 0 ? est.outputTokens : outputFromChars;
45
- const reasoning = est.reasoningTokens;
46
- const outputTotal = outputText + reasoning;
48
+ const outputText = estimateCharsToTokens(outputChars);
49
+ const priorOutput = Math.max(0, est.outputTokens - outputText);
50
+ const inputNoCache = Math.max(promptTokens, est.inputTokens + priorOutput);
47
51
  return {
48
52
  inputTokens: {
49
- total: inputNoCache + cacheRead + cacheWrite,
53
+ total: inputNoCache,
50
54
  noCache: inputNoCache,
51
- cacheRead,
52
- cacheWrite,
55
+ cacheRead: 0,
56
+ cacheWrite: 0,
53
57
  },
54
58
  outputTokens: {
55
- total: outputTotal,
59
+ total: outputText,
56
60
  text: outputText,
57
- reasoning: reasoning > 0 ? reasoning : undefined,
61
+ reasoning: undefined,
58
62
  },
59
63
  };
60
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,6 +43,18 @@
43
43
  "./errors": {
44
44
  "types": "./dist/errors.d.ts",
45
45
  "import": "./dist/errors.js"
46
+ },
47
+ "./models": {
48
+ "types": "./dist/models.d.ts",
49
+ "import": "./dist/models.js"
50
+ },
51
+ "./pricing": {
52
+ "types": "./dist/pricing.d.ts",
53
+ "import": "./dist/pricing.js"
54
+ },
55
+ "./auth": {
56
+ "types": "./dist/auth.d.ts",
57
+ "import": "./dist/auth.js"
46
58
  }
47
59
  },
48
60
  "files": [
@@ -64,7 +76,7 @@
64
76
  "prepublishOnly": "bun run generate:pricing && bun run build"
65
77
  },
66
78
  "dependencies": {
67
- "@ai-sdk/provider": "3.0.14",
79
+ "@ai-sdk/provider": "3.0.15",
68
80
  "protobufjs": "^7.6.5"
69
81
  },
70
82
  "peerDependencies": {
@@ -77,8 +89,8 @@
77
89
  }
78
90
  },
79
91
  "devDependencies": {
80
- "@opencode-ai/plugin": "^1.18.13",
81
- "@opencode-ai/plugin-next": "npm:@opencode-ai/plugin@0.0.0-next-16281",
92
+ "@opencode-ai/plugin": "^1.18.16",
93
+ "@opencode-ai/plugin-next": "npm:@opencode-ai/plugin@0.0.0-next-17155",
82
94
  "@tsconfig/node22": "^22.0.5",
83
95
  "@types/node": "^22.20.1",
84
96
  "typescript": "^5.9.3"