cursor-opencode-provider 0.3.0 → 0.3.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.
@@ -7,6 +7,7 @@ import { buildEnv } from "../context/env.js";
7
7
  import { ensureOpencodeProjectDir } from "../context/paths.js";
8
8
  import { trace, traceRequestContextPaths } from "../debug.js";
9
9
  import { cursorExecVariantByRequestName } from "./exec-variants.js";
10
+ import { APPLY_PATCH_TOOL, buildAddFilePatch, buildUpdateFilePatch, planSubstringEdit, } from "./apply-patch.js";
10
11
  import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
11
12
  // Exec variant field number whose reply is the server-initiated request_context
12
13
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
@@ -260,11 +261,15 @@ const CURSOR_INTERNAL_KEYS = new Set([
260
261
  "close_stdin",
261
262
  "output_notification",
262
263
  "file_output_threshold_bytes",
264
+ // WriteArgs #4. A request for WriteSuccess.file_content_after_write, which we
265
+ // do not populate; harmless to omit, but it is not tool input.
263
266
  "return_file_content_after_write",
264
- "file_bytes",
265
- "encoding_hint",
266
267
  "ignore",
267
268
  ]);
269
+ // WriteArgs #5/#6 are deliberately NOT internal: `file_bytes` carries the file
270
+ // content itself whenever Cursor sends it instead of `file_text` (its own
271
+ // LocalWriteExecutor prefers bytes when non-empty), and `encoding_hint` is how
272
+ // that payload is decoded. The `write` mapping consumes both explicitly.
268
273
  /** Required content fields where an empty string is meaningful (for example, truncating a file). */
269
274
  const PRESERVE_EMPTY_STRING_KEYS = new Set([
270
275
  "content",
@@ -476,6 +481,92 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
476
481
  },
477
482
  };
478
483
  }
484
+ /** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
485
+ const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
486
+ /**
487
+ * Express a native Cursor write/edit as `apply_patch` when the host swapped the
488
+ * edit-tool family out from under us.
489
+ *
490
+ * OpenCode 1.x removes `edit` and `write` from the catalog for GPT models and
491
+ * advertises `apply_patch` in their place — see the module comment in
492
+ * `./apply-patch.ts` for the upstream reasoning and citations. Cursor keeps
493
+ * using its native write/edit exec channel regardless, so without this the
494
+ * request is refused as an unavailable tool and the model loses the ability to
495
+ * change files at all.
496
+ *
497
+ * Keyed purely off the advertised set: this is inert whenever the host offers
498
+ * `write`/`edit` normally, and equally inert when it offers neither those nor
499
+ * `apply_patch` (the caller's unavailable-tool rejection still applies).
500
+ */
501
+ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceRoot) {
502
+ if (parsed.toolName !== "write" && parsed.toolName !== "edit")
503
+ return;
504
+ const advertised = new Set(advertisedToolNames);
505
+ if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
506
+ return;
507
+ const requested = parsed.toolName;
508
+ const filePath = str(parsed.args.filePath);
509
+ const refuse = (reason) => {
510
+ parsed.toolName = APPLY_PATCH_TOOL;
511
+ parsed.args = {};
512
+ parsed.localError =
513
+ `Cursor ${requested} request cannot be expressed as an apply_patch call: ${reason}. ` +
514
+ "The host advertises `apply_patch` instead of `edit`/`write` for this model.";
515
+ };
516
+ if (!filePath) {
517
+ refuse("no target path was provided");
518
+ return;
519
+ }
520
+ let patchText;
521
+ if (requested === "write") {
522
+ const content = stringValue(parsed.args.content);
523
+ if (content === undefined) {
524
+ refuse("no file content was provided");
525
+ return;
526
+ }
527
+ // `*** Add File:` overwrites an existing target, so a whole-file write maps
528
+ // directly without reading the current contents or diffing.
529
+ patchText = buildAddFilePatch(filePath, content);
530
+ }
531
+ else {
532
+ const oldString = stringValue(parsed.args.oldString);
533
+ const newString = stringValue(parsed.args.newString);
534
+ if (oldString === undefined || newString === undefined) {
535
+ refuse("the replacement is missing its old or new text");
536
+ return;
537
+ }
538
+ // apply_patch matches whole lines, so a substring edit has to be widened to
539
+ // the lines it touches — which requires the file the model is editing.
540
+ const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
541
+ let source;
542
+ try {
543
+ // Bounded because this read is synchronous on the Run pump. Cursor's own
544
+ // executor uses the same 50 MB threshold before reading a file it is
545
+ // about to write.
546
+ const size = fs.statSync(absolute).size;
547
+ if (size > MAX_EDIT_SOURCE_BYTES) {
548
+ refuse(`the target file is ${Math.round(size / 1024 / 1024)} MB, too large to patch`);
549
+ return;
550
+ }
551
+ source = fs.readFileSync(absolute, "utf8");
552
+ }
553
+ catch (e) {
554
+ refuse(`the target file could not be read (${e.message})`);
555
+ return;
556
+ }
557
+ const plan = planSubstringEdit(source, oldString, newString, parsed.args.replaceAll === true);
558
+ if (!plan.ok) {
559
+ refuse(plan.reason);
560
+ return;
561
+ }
562
+ patchText = buildUpdateFilePatch(filePath, plan.chunks);
563
+ }
564
+ parsed.toolName = APPLY_PATCH_TOOL;
565
+ parsed.args = { patchText };
566
+ // apply_patch's output does not carry opencode `write`'s <path> tag, so keep
567
+ // the requested path for the typed Cursor result.
568
+ parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
569
+ }
479
570
  export function parseExecServerMessage(msg) {
480
571
  const id = msg.id;
481
572
  if (id === undefined)
@@ -707,7 +798,13 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
707
798
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
708
799
  if (filePath)
709
800
  args.filePath = filePath;
710
- const content = stringValue(cleaned.content) ?? stringValue(cleaned.file_text) ?? stringValue(cleaned.fileText);
801
+ // Cursor's LocalWriteExecutor prefers `file_bytes` whenever it is
802
+ // non-empty and only falls back to `file_text`; mirror that order so a
803
+ // byte-encoded write is not silently seen as empty content.
804
+ const bytes = bytesValue(cleaned.file_bytes) ?? bytesValue(cleaned.fileBytes);
805
+ const content = bytes !== undefined
806
+ ? decodeWriteBytes(bytes, str(cleaned.encoding_hint) ?? str(cleaned.encodingHint))
807
+ : stringValue(cleaned.content) ?? stringValue(cleaned.file_text) ?? stringValue(cleaned.fileText);
711
808
  if (content !== undefined)
712
809
  args.content = content;
713
810
  return { toolName: "write", args };
@@ -781,6 +878,32 @@ function str(v) {
781
878
  function stringValue(v) {
782
879
  return typeof v === "string" ? v : undefined;
783
880
  }
881
+ /** A non-empty protobuf `bytes` field, or undefined when absent/empty. */
882
+ function bytesValue(v) {
883
+ if (v instanceof Uint8Array)
884
+ return v.length > 0 ? v : undefined;
885
+ if (Array.isArray(v) && v.every((b) => typeof b === "number")) {
886
+ return v.length > 0 ? Uint8Array.from(v) : undefined;
887
+ }
888
+ return undefined;
889
+ }
890
+ /**
891
+ * Decode `WriteArgs.file_bytes`. `encoding_hint` names the file's original
892
+ * encoding; OpenCode's `write` takes a string, so anything Node cannot decode
893
+ * falls back to UTF-8 rather than dropping the write.
894
+ */
895
+ function decodeWriteBytes(bytes, encodingHint) {
896
+ const encoding = encodingHint?.trim().toLowerCase().replace(/[_ ]/g, "-");
897
+ if (encoding && encoding !== "utf-8" && encoding !== "utf8") {
898
+ try {
899
+ return new TextDecoder(encoding, { fatal: false }).decode(bytes);
900
+ }
901
+ catch {
902
+ trace(`write: unsupported encoding_hint ${JSON.stringify(encodingHint)} — decoding as utf-8`);
903
+ }
904
+ }
905
+ return new TextDecoder("utf-8").decode(bytes);
906
+ }
784
907
  function num(v) {
785
908
  if (typeof v === "number" && Number.isFinite(v))
786
909
  return v;
@@ -1076,16 +1199,22 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1076
1199
  return { error: { path: readPath, error } };
1077
1200
  // Strip opencode's <path>/<content> envelope so Cursor's model receives
1078
1201
  // raw file content and can't echo the wrapper into subsequent writes.
1079
- // reads route here only for native read_args; most arrive via mcp_result.
1202
+ // Cursor's native read_args lands here; `mcp_args` reads land in
1203
+ // mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
1204
+ // native channel, so this is the path that matters in practice.
1080
1205
  const content = unwrapReadOutput(output);
1081
1206
  const statPath = extractPathTag(output) ?? readPath;
1082
1207
  const outputMetadata = parseOpenCodeReadMetadata(output);
1083
1208
  const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
1084
1209
  const rangeApplied = readRangeApplied(resultMetadata, totalLines);
1210
+ // `truncated` alone is not enough: it is set here, and models still assert
1211
+ // the partial content is the whole file. Cursor's own executor puts the
1212
+ // limit marker in the output text for the same reason, so append one.
1213
+ const notice = readTruncationNotice(output, resultMetadata);
1085
1214
  return {
1086
1215
  success: {
1087
1216
  path: readPath,
1088
- content,
1217
+ content: notice ? `${content}\n\n${notice}` : content,
1089
1218
  total_lines: totalLines,
1090
1219
  file_size: readFileSize(statPath),
1091
1220
  truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
@@ -1117,25 +1246,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1117
1246
  },
1118
1247
  };
1119
1248
  }
1120
- case "write_result":
1249
+ case "write_result": {
1250
+ // `apply_patch` output carries no <path> tag, so prefer the path recorded
1251
+ // when the request was remapped away from `write`.
1252
+ const remappedPath = str(resultMetadata?.path);
1121
1253
  if (error)
1122
- return { error: { path: "", error } };
1254
+ return { error: { path: remappedPath ?? "", error } };
1123
1255
  return {
1124
1256
  success: {
1125
- path: extractPathTag(output) ?? "",
1257
+ path: remappedPath ?? extractPathTag(output) ?? "",
1126
1258
  lines_created: countLines(output),
1127
1259
  file_size: output.length,
1128
1260
  },
1129
1261
  };
1262
+ }
1130
1263
  case "pi_write_result":
1131
1264
  // PiWriteExecSuccess is just { output }; error is { error }.
1132
1265
  if (error)
1133
1266
  return { error: { error } };
1134
1267
  return { success: { output: output || "Wrote file successfully." } };
1135
- case "pi_read_result":
1268
+ case "pi_read_result": {
1136
1269
  if (error)
1137
1270
  return { error: { error } };
1138
- return { success: { output: unwrapReadOutput(output) } };
1271
+ // Pi results carry truncation structurally (PiReadExecSuccess field 2),
1272
+ // which is how Cursor's own executors report a capped payload.
1273
+ const content = unwrapReadOutput(output);
1274
+ const truncation = readTruncationMessage(output, content);
1275
+ return { success: { output: content, ...(truncation ? { truncation } : {}) } };
1276
+ }
1139
1277
  case "pi_bash_result":
1140
1278
  case "pi_edit_result":
1141
1279
  case "pi_grep_result":
@@ -1204,21 +1342,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1204
1342
  },
1205
1343
  };
1206
1344
  }
1207
- case "mcp_result":
1345
+ case "mcp_result": {
1208
1346
  if (error)
1209
1347
  return { error: { error } };
1210
1348
  // opencode built-ins (read/write/grep/…) are advertised as MCP tools, so
1211
1349
  // a read call returns through mcp_result. Scope the unwrap to toolName
1212
1350
  // "read" so a non-read MCP tool whose output merely contains a
1213
1351
  // "<content>"-like block is never rewritten.
1352
+ if (toolName !== "read") {
1353
+ return { success: { content: [{ text: { text: output } }], is_error: false } };
1354
+ }
1355
+ // Carry the truncation notice as its own content item: the file content
1356
+ // item stays byte-exact, so it can still never be echoed into a write.
1357
+ const notice = readTruncationNotice(output);
1214
1358
  return {
1215
1359
  success: {
1216
1360
  content: [
1217
- { text: { text: toolName === "read" ? unwrapReadOutput(output) : output } },
1361
+ { text: { text: unwrapReadOutput(output) } },
1362
+ ...(notice ? [{ text: { text: notice } }] : []),
1218
1363
  ],
1219
1364
  is_error: false,
1220
1365
  },
1221
1366
  };
1367
+ }
1222
1368
  case "subagent_result": {
1223
1369
  const task = parseOpenCodeTaskOutput(output);
1224
1370
  if (error || task.state === "error") {
@@ -1270,22 +1416,114 @@ function extractPathTag(output) {
1270
1416
  const m = output.match(/<path>([^<]+)<\/path>/);
1271
1417
  return m?.[1];
1272
1418
  }
1419
+ /**
1420
+ * Isolate opencode's read footer: the last blank-line-separated block before
1421
+ * the closing `</content>`. Scanning the whole envelope would let a file that
1422
+ * merely *quotes* a footer (this repository's own docs and tests do) pass for a
1423
+ * truncated read. The body can never contain a blank line — every source line
1424
+ * is rendered as `N: …`, so even an empty line keeps its `N: ` prefix.
1425
+ *
1426
+ * Falls back to the full string when the envelope is absent, preserving
1427
+ * behavior for non-standard read output.
1428
+ */
1429
+ function readEnvelopeFooter(output) {
1430
+ const close = output.lastIndexOf("\n</content>");
1431
+ if (close === -1)
1432
+ return output;
1433
+ const start = output.lastIndexOf("\n\n", close);
1434
+ if (start === -1)
1435
+ return output;
1436
+ return output.slice(start + 2, close);
1437
+ }
1273
1438
  /** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
1274
1439
  function parseOpenCodeReadMetadata(output) {
1275
- const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(output);
1440
+ const footer = readEnvelopeFooter(output);
1441
+ const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
1276
1442
  if (showing) {
1277
1443
  return {
1278
1444
  startLine: Number(showing[1]),
1279
1445
  endLine: Number(showing[2]),
1280
1446
  ...(showing[3] ? { totalLines: Number(showing[3]) } : {}),
1281
- outputCapped: output.includes("(Output capped at "),
1447
+ outputCapped: footer.includes("(Output capped at "),
1282
1448
  };
1283
1449
  }
1284
- const complete = /\(End of file - total (\d+) lines?\)/.exec(output);
1450
+ const complete = /\(End of file - total (\d+) lines?\)/.exec(footer);
1285
1451
  if (complete)
1286
1452
  return { totalLines: Number(complete[1]) };
1287
1453
  return {};
1288
1454
  }
1455
+ /**
1456
+ * OpenCode's `read` caps output at 50 KB (`tool/read.ts` MAX_BYTES = 50 * 1024)
1457
+ * and cuts on a whole-line boundary, then appends
1458
+ * "(Output capped at 50 KB. Showing lines X-Y. Use offset=N to continue.)".
1459
+ *
1460
+ * `unwrapReadOutput` strips that footer along with the envelope, deliberately:
1461
+ * Cursor's model echoes whatever it is handed straight back into the next
1462
+ * write, so anything left in the content stream can end up written into the
1463
+ * file. But dropping the notice with no replacement is worse — the model then
1464
+ * believes a capped read is the complete file, rewrites it from what it has,
1465
+ * and everything past the cap is destroyed.
1466
+ *
1467
+ * Cursor's own CLI never truncates silently. Its local executor annotates every
1468
+ * capped payload ("50KB limit reached", "[Showing last …KB of line N (50KB
1469
+ * limit). Full output: …]") and returns structured truncation metadata. Mirror
1470
+ * that, keeping the signal out of the file content itself so it still cannot be
1471
+ * echoed into a write.
1472
+ */
1473
+ function readTruncationSummary(output) {
1474
+ const meta = parseOpenCodeReadMetadata(output);
1475
+ if (meta.startLine === undefined || meta.endLine === undefined)
1476
+ return undefined;
1477
+ // A complete read reports "(End of file …)" and never reaches this shape.
1478
+ if (meta.totalLines !== undefined && meta.endLine >= meta.totalLines && !meta.outputCapped)
1479
+ return undefined;
1480
+ return {
1481
+ startLine: meta.startLine,
1482
+ endLine: meta.endLine,
1483
+ nextOffset: meta.endLine + 1,
1484
+ capped: meta.outputCapped === true,
1485
+ ...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
1486
+ };
1487
+ }
1488
+ /**
1489
+ * Model-visible replacement for the stripped footer.
1490
+ *
1491
+ * Only for reads the model did not ask to bound. A caller that passed an
1492
+ * explicit offset/limit already knows it asked for a slice — warning there
1493
+ * would cry wolf on every deliberate paged read.
1494
+ */
1495
+ function readTruncationNotice(output, resultMetadata) {
1496
+ const summary = readTruncationSummary(output);
1497
+ if (!summary)
1498
+ return undefined;
1499
+ const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
1500
+ if (rangeRequested && !summary.capped)
1501
+ return undefined;
1502
+ const range = summary.totalLines !== undefined
1503
+ ? `lines ${summary.startLine}-${summary.endLine} of ${summary.totalLines}`
1504
+ : `lines ${summary.startLine}-${summary.endLine}`;
1505
+ return (`[Partial read: the content above is ${range}` +
1506
+ (summary.capped ? ", capped at the host's 50 KB output limit" : "") +
1507
+ `. It is NOT the complete file. Continue with offset=${summary.nextOffset} before ` +
1508
+ `acting on the whole file; writing the content above back would delete everything ` +
1509
+ `after line ${summary.endLine}.]`);
1510
+ }
1511
+ /** agent.v1.PiTruncation for a capped OpenCode read. */
1512
+ function readTruncationMessage(output, content) {
1513
+ const summary = readTruncationSummary(output);
1514
+ if (!summary)
1515
+ return undefined;
1516
+ return {
1517
+ truncated: true,
1518
+ truncated_by: summary.capped ? "bytes" : "lines",
1519
+ ...(summary.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
1520
+ output_lines: Math.max(0, summary.endLine - summary.startLine + 1),
1521
+ output_bytes: Buffer.byteLength(content, "utf8"),
1522
+ ...(summary.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
1523
+ };
1524
+ }
1525
+ /** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
1526
+ const OPENCODE_READ_MAX_BYTES = 50 * 1024;
1289
1527
  function readRangeApplied(resultMetadata, totalLines) {
1290
1528
  const offset = num(resultMetadata?.offset);
1291
1529
  const limit = num(resultMetadata?.limit);
@@ -322,8 +322,12 @@ function lastPrivateMarker(output, marker, valuePattern) {
322
322
  return last;
323
323
  }
324
324
  function parseOpenCodeTimeout(output) {
325
- const re = /<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\.[\s\S]*?<\/shell_metadata>\s*$/;
326
- const match = re.exec(output);
325
+ const closeTag = "</shell_metadata>";
326
+ const closeAt = output.lastIndexOf(closeTag);
327
+ if (closeAt === -1 || output.slice(closeAt + closeTag.length).trim() !== "")
328
+ return undefined;
329
+ const header = /<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\./;
330
+ const match = header.exec(output.slice(0, closeAt));
327
331
  if (!match || match.index === undefined)
328
332
  return undefined;
329
333
  return { output: withoutMarker(output, match.index), timeoutMs: Number(match[1]) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.3.0",
3
+ "version": "0.3.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",
@@ -58,11 +58,14 @@
58
58
  "test": "bun test",
59
59
  "test:node-http2": "bun run build && node test/node-http2-cleanup.mjs",
60
60
  "test:watch": "bun test --watch",
61
- "prepublishOnly": "bun run build"
61
+ "generate:pricing": "bun run scripts/generate-cursor-pricing.ts",
62
+ "generate:pricing:check": "bun run scripts/generate-cursor-pricing.ts -- --check",
63
+ "check:pricing": "bun run scripts/check-cursor-pricing-coverage.ts",
64
+ "prepublishOnly": "bun run generate:pricing && bun run build"
62
65
  },
63
66
  "dependencies": {
64
- "@ai-sdk/provider": "3.0.8",
65
- "protobufjs": "^7.4.0"
67
+ "@ai-sdk/provider": "3.0.14",
68
+ "protobufjs": "^7.6.5"
66
69
  },
67
70
  "peerDependencies": {
68
71
  "@opencode-ai/plugin": "^1.17.13",
@@ -74,10 +77,10 @@
74
77
  }
75
78
  },
76
79
  "devDependencies": {
77
- "@opencode-ai/plugin": "^1.17.13",
78
- "@opencode-ai/plugin-next": "^0.0.0-next-16281",
79
- "@tsconfig/node22": "^22.0.1",
80
- "@types/node": "^22.15.3",
81
- "typescript": "^5.8.3"
80
+ "@opencode-ai/plugin": "^1.18.13",
81
+ "@opencode-ai/plugin-next": "npm:@opencode-ai/plugin@0.0.0-next-16281",
82
+ "@tsconfig/node22": "^22.0.5",
83
+ "@types/node": "^22.20.1",
84
+ "typescript": "^5.9.3"
82
85
  }
83
86
  }