cursor-opencode-provider 0.8.0 → 0.8.3

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.
Files changed (132) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +230 -44
  3. package/SECURITY.md +60 -12
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +23 -4
  14. package/dist/context/build.js +65 -10
  15. package/dist/context/dynamic-catalog.js +6 -1
  16. package/dist/context/env.js +27 -3
  17. package/dist/context/epoch.js +9 -5
  18. package/dist/context/frozen.js +6 -2
  19. package/dist/context/git.d.ts +5 -2
  20. package/dist/context/git.js +17 -7
  21. package/dist/context/index.d.ts +2 -0
  22. package/dist/context/index.js +2 -0
  23. package/dist/context/paths.d.ts +45 -18
  24. package/dist/context/paths.js +72 -22
  25. package/dist/context/skills-bridge.d.ts +22 -0
  26. package/dist/context/skills-bridge.js +20 -0
  27. package/dist/context/skills.d.ts +34 -0
  28. package/dist/context/skills.js +211 -0
  29. package/dist/debug.js +13 -2
  30. package/dist/early-tool-rows.d.ts +60 -0
  31. package/dist/early-tool-rows.js +96 -0
  32. package/dist/errors.d.ts +49 -7
  33. package/dist/errors.js +163 -8
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +117 -13
  36. package/dist/host-interrupt.d.ts +12 -0
  37. package/dist/host-interrupt.js +18 -0
  38. package/dist/host-plan-file.d.ts +28 -0
  39. package/dist/host-plan-file.js +76 -0
  40. package/dist/host-steer.d.ts +71 -0
  41. package/dist/host-steer.js +232 -0
  42. package/dist/image-input.d.ts +18 -0
  43. package/dist/image-input.js +122 -30
  44. package/dist/image-save.d.ts +10 -2
  45. package/dist/image-save.js +33 -21
  46. package/dist/index.d.ts +2 -2
  47. package/dist/language-model.d.ts +149 -30
  48. package/dist/language-model.js +3127 -673
  49. package/dist/lost-request.d.ts +39 -0
  50. package/dist/lost-request.js +111 -0
  51. package/dist/model-config.d.ts +12 -1
  52. package/dist/model-config.js +78 -30
  53. package/dist/models.d.ts +26 -3
  54. package/dist/models.js +101 -45
  55. package/dist/opencode2/catalog.d.ts +1 -0
  56. package/dist/opencode2/catalog.js +2 -0
  57. package/dist/opencode2/image-save-tool.d.ts +8 -0
  58. package/dist/opencode2/image-save-tool.js +84 -0
  59. package/dist/opencode2/tool-results.d.ts +18 -0
  60. package/dist/opencode2/tool-results.js +66 -0
  61. package/dist/opencode2/types.d.ts +15 -0
  62. package/dist/parallel-step.d.ts +65 -0
  63. package/dist/parallel-step.js +102 -0
  64. package/dist/plugin-opencode2.js +233 -65
  65. package/dist/plugin.js +97 -23
  66. package/dist/pricing-data.d.ts +40 -3
  67. package/dist/pricing-data.js +40 -3
  68. package/dist/pricing.d.ts +1 -1
  69. package/dist/pricing.js +7 -4
  70. package/dist/process-shared.d.ts +14 -0
  71. package/dist/process-shared.js +18 -0
  72. package/dist/protocol/apply-patch.d.ts +25 -13
  73. package/dist/protocol/apply-patch.js +79 -30
  74. package/dist/protocol/ask-question.d.ts +12 -1
  75. package/dist/protocol/ask-question.js +77 -3
  76. package/dist/protocol/blob-store.d.ts +5 -1
  77. package/dist/protocol/blob-store.js +10 -6
  78. package/dist/protocol/client-version.js +6 -4
  79. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  80. package/dist/protocol/conversation-cache-header.js +149 -0
  81. package/dist/protocol/conversation-persistence.d.ts +27 -1
  82. package/dist/protocol/conversation-persistence.js +628 -126
  83. package/dist/protocol/conversation-state.d.ts +8 -1
  84. package/dist/protocol/conversation-state.js +28 -7
  85. package/dist/protocol/create-plan.d.ts +47 -25
  86. package/dist/protocol/create-plan.js +33 -23
  87. package/dist/protocol/host-notes.d.ts +31 -0
  88. package/dist/protocol/host-notes.js +85 -0
  89. package/dist/protocol/interactions.d.ts +26 -17
  90. package/dist/protocol/interactions.js +80 -31
  91. package/dist/protocol/messages.js +164 -21
  92. package/dist/protocol/request.d.ts +40 -19
  93. package/dist/protocol/request.js +55 -28
  94. package/dist/protocol/sent-history-images.d.ts +49 -0
  95. package/dist/protocol/sent-history-images.js +102 -0
  96. package/dist/protocol/switch-mode.d.ts +66 -27
  97. package/dist/protocol/switch-mode.js +147 -73
  98. package/dist/protocol/token-details.d.ts +2 -0
  99. package/dist/protocol/token-details.js +11 -1
  100. package/dist/protocol/tool-call-bridge.d.ts +6 -0
  101. package/dist/protocol/tool-call-bridge.js +24 -0
  102. package/dist/protocol/tools.d.ts +90 -9
  103. package/dist/protocol/tools.js +543 -151
  104. package/dist/protocol/turn-provenance.d.ts +19 -4
  105. package/dist/protocol/turn-provenance.js +77 -20
  106. package/dist/replay-safety.d.ts +22 -4
  107. package/dist/replay-safety.js +86 -27
  108. package/dist/retry-budget.d.ts +21 -0
  109. package/dist/retry-budget.js +94 -0
  110. package/dist/runtime-files.d.ts +23 -0
  111. package/dist/runtime-files.js +581 -0
  112. package/dist/session.d.ts +129 -10
  113. package/dist/session.js +198 -35
  114. package/dist/shared.d.ts +1 -1
  115. package/dist/shared.js +1 -1
  116. package/dist/shell-timeout.d.ts +54 -10
  117. package/dist/shell-timeout.js +362 -92
  118. package/dist/stream-write.d.ts +20 -0
  119. package/dist/stream-write.js +87 -0
  120. package/dist/terminal-runner.d.ts +15 -0
  121. package/dist/terminal-runner.js +528 -0
  122. package/dist/todo-store.d.ts +2 -0
  123. package/dist/todo-store.js +19 -0
  124. package/dist/transport/connect.d.ts +10 -1
  125. package/dist/transport/connect.js +124 -27
  126. package/dist/usage.d.ts +37 -17
  127. package/dist/usage.js +85 -34
  128. package/package.json +5 -3
  129. package/dist/plan-execution-kickoff.d.ts +0 -57
  130. package/dist/plan-execution-kickoff.js +0 -186
  131. package/dist/protocol/thinking.d.ts +0 -15
  132. package/dist/protocol/thinking.js +0 -17
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Synthesis of OpenCode `apply_patch` envelopes from Cursor's native write/edit
3
- * exec requests.
2
+ * Synthesis of OpenCode `apply_patch` (2.0: `patch`) envelopes from Cursor's
3
+ * native write/edit exec requests.
4
4
  *
5
5
  * ## Why this exists
6
6
  *
@@ -36,10 +36,12 @@
36
36
  * `ctx.session.context(...)` hook exposing a mutable tool roster, and lists
37
37
  * "Select `apply_patch` versus `edit` + `write`" as work to move out of the
38
38
  * registry. Once that lands, a host-side opt-out replaces this shim.
39
- * - OpenCode 2.0 (`packages/core`) has **no** such gate: `apply_patch`, `edit`
40
- * and `write` are all registered unconditionally in `tool/builtins.ts`. This
41
- * translation is therefore 1.x-only in practice, but it is keyed off the
42
- * advertised set rather than a version check, so it simply never fires on 2.0.
39
+ * - OpenCode 2.0 (`packages/core`) keeps the gate under a new name. The tool is
40
+ * `patch` (still `{ patchText }`, same envelope format), and
41
+ * `tool/plugin/patch.ts` applies the same model-id predicate in its session
42
+ * `context`/`compaction`/`generate` hooks, deleting `edit` and `write` for GPT models and
43
+ * `patch` for everyone else. Both names are recognised here; which one the
44
+ * host offers is read from the advertised set rather than a version check.
43
45
  *
44
46
  * ## Why translation, not advertisement
45
47
  *
@@ -59,7 +61,7 @@
59
61
  * native requests arrive for every model. Those names are absent from
60
62
  * the catalog, so the request is refused before it reaches OpenCode.
61
63
  *
62
- * ## Format notes (verified against `packages/opencode/src/patch/index.ts`)
64
+ * ## Format notes (verified against `packages/opencode/src/patch/index.ts` and 2.0's `packages/util/src/patch.ts`)
63
65
  *
64
66
  * - `*** Add File:` **overwrites an existing file** — the tool's `add` case uses
65
67
  * `oldContent = ""` and never stats the target (upstream test: "adds file
@@ -71,37 +73,75 @@
71
73
  * - Every payload line is prefixed with `+`, `-`, or a space, and the parser
72
74
  * only breaks on *unprefixed* `***` / `@@`. Content lines that themselves
73
75
  * begin with `***` or `@@` therefore survive a round trip unharmed.
74
- * - `Add File` content is rejoined with `\n` and normalized to exactly one
75
- * trailing newline. A write of content with no trailing newline gains one.
76
+ * - `Add File` content is rejoined with `\n`; the tool appends a final newline
77
+ * only when nonempty content lacks one. Keep the trailing empty payload line
78
+ * so blank lines survive. Nonempty unterminated content cannot round-trip.
79
+ * - OpenCode 2's `patch` parser strips a trailing `\r` from every payload
80
+ * line, unlike 1.x `apply_patch`. Refuse that lossy encoding on `patch`.
76
81
  * - Update chunks match **whole lines** via `seekSequence`, unlike OpenCode
77
82
  * `edit`, which is substring-based. Substring edits must be expanded to line
78
83
  * boundaries before they can be expressed as a patch — see `planSubstringEdit`.
79
- * - Renames are rejected by OpenCode 2.0's implementation ("apply_patch moves
80
- * are not supported yet"), so `*** Move to:` is never emitted here.
84
+ * - Cursor's write/edit requests never rename, so `*** Move to:` is never
85
+ * emitted here.
81
86
  */
87
+ /** OpenCode 1.x name for the patch tool. */
82
88
  export const APPLY_PATCH_TOOL = "apply_patch";
89
+ /** OpenCode 2.0 name for the same tool (renamed from `apply_patch`). */
90
+ export const PATCH_TOOL = "patch";
91
+ /** The patch tool this host advertises in place of `edit`/`write`, if any. */
92
+ export function hostPatchTool(advertised) {
93
+ if (advertised.has(APPLY_PATCH_TOOL))
94
+ return APPLY_PATCH_TOOL;
95
+ if (advertised.has(PATCH_TOOL))
96
+ return PATCH_TOOL;
97
+ return undefined;
98
+ }
99
+ export function isHostPatchTool(name) {
100
+ return name === APPLY_PATCH_TOOL || name === PATCH_TOOL;
101
+ }
83
102
  const BEGIN = "*** Begin Patch";
84
103
  const END = "*** End Patch";
85
104
  /**
86
105
  * Whole-file write as `*** Add File:`. Valid whether or not the target exists —
87
106
  * OpenCode's add case overwrites unconditionally.
88
107
  */
89
- export function buildAddFilePatch(filePath, content) {
90
- const lines = splitLines(content);
108
+ export function buildAddFilePatch(filePath, content, tool = APPLY_PATCH_TOOL) {
109
+ assertPatchPath(filePath);
110
+ assertPatchText(content, tool);
111
+ if (content.startsWith("\uFEFF\uFEFF"))
112
+ throw new Error("the patch tool cannot preserve repeated leading byte-order marks");
113
+ if (content && !content.endsWith("\n"))
114
+ throw new Error("the patch tool cannot preserve content without a final newline");
115
+ const lines = content.split("\n");
91
116
  return [BEGIN, `*** Add File: ${filePath}`, ...lines.map((line) => `+${line}`), END].join("\n");
92
117
  }
93
118
  /** Targeted replacement as `*** Update File:` with one `@@` chunk per change. */
94
- export function buildUpdateFilePatch(filePath, chunks) {
119
+ export function buildUpdateFilePatch(filePath, chunks, tool = APPLY_PATCH_TOOL) {
120
+ assertPatchPath(filePath);
95
121
  const body = [];
96
122
  for (const chunk of chunks) {
97
123
  body.push("@@");
98
- for (const line of chunk.oldLines)
99
- body.push(`-${line}`);
100
- for (const line of chunk.newLines)
101
- body.push(`+${line}`);
124
+ for (const [prefix, lines] of [["-", chunk.oldLines], ["+", chunk.newLines]]) {
125
+ for (const line of lines) {
126
+ if (line.includes("\n"))
127
+ throw new Error("a patch payload line contains a newline");
128
+ assertPatchText(line, tool);
129
+ body.push(`${prefix}${line}`);
130
+ }
131
+ }
102
132
  }
103
133
  return [BEGIN, `*** Update File: ${filePath}`, ...body, END].join("\n");
104
134
  }
135
+ function assertPatchPath(filePath) {
136
+ if (!filePath || filePath.trim() !== filePath || /[\0\r\n]/.test(filePath)) {
137
+ throw new Error("the target path cannot be represented exactly in a patch header");
138
+ }
139
+ }
140
+ function assertPatchText(text, tool) {
141
+ if (tool === PATCH_TOOL && /\r(?:\n|$)/.test(text)) {
142
+ throw new Error("the patch tool strips carriage returns from line endings; this content cannot be preserved");
143
+ }
144
+ }
105
145
  /**
106
146
  * Expand a substring replacement to the whole lines it touches, because
107
147
  * `apply_patch` matches line sequences rather than substrings.
@@ -124,6 +164,13 @@ export function planSubstringEdit(source, oldString, newString, replaceAll = fal
124
164
  reason: `the text to replace appears ${offsets.length} times; it must be unique`,
125
165
  };
126
166
  }
167
+ const target = replaceAll ? source.replaceAll(oldString, () => newString) : source.replace(oldString, () => newString);
168
+ if (target.startsWith("\uFEFF\uFEFF") || (source.startsWith("\uFEFF") && !target.startsWith("\uFEFF"))) {
169
+ return { ok: false, reason: "the patch tool preserves one leading byte-order mark; this replacement cannot be preserved" };
170
+ }
171
+ if (target && !target.endsWith("\n")) {
172
+ return { ok: false, reason: "the patch tool cannot preserve content without a final newline" };
173
+ }
127
174
  const targets = replaceAll ? offsets : offsets.slice(0, 1);
128
175
  const chunks = [];
129
176
  let previousEnd = -1;
@@ -138,22 +185,24 @@ export function planSubstringEdit(source, oldString, newString, replaceAll = fal
138
185
  previousEnd = end;
139
186
  const before = source.slice(start, offset);
140
187
  const after = source.slice(offset + oldString.length, end);
141
- chunks.push({
142
- oldLines: splitLines(source.slice(start, end)),
143
- newLines: splitLines(before + newString + after),
144
- });
188
+ const oldLines = source.slice(start, end).split("\n");
189
+ // The host removes one final source terminator before matching. Interior
190
+ // empty lines are real context; stripping them deletes blank lines.
191
+ if (end === source.length && oldLines.at(-1) === "")
192
+ oldLines.pop();
193
+ chunks.push({ oldLines, newLines: (before + newString + after).split("\n") });
194
+ }
195
+ // The host also removes one terminator before applying updates, then adds it
196
+ // back only when the last line is nonempty. Restore an untouched blank tail
197
+ // explicitly; an EOF replacement already carries its complete terminator.
198
+ if (previousEnd < source.length && (target === "\n" || target.endsWith("\n\n"))) {
199
+ chunks.push({ oldLines: [], newLines: [""] });
145
200
  }
146
201
  return { ok: true, chunks };
147
202
  }
148
- /**
149
- * Split into patch payload lines. A trailing newline is dropped so it is not
150
- * emitted as a spurious empty line — OpenCode re-adds exactly one on apply.
151
- */
152
- function splitLines(text) {
153
- const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
154
- return normalized.split("\n");
155
- }
156
203
  function lineStart(source, offset) {
204
+ if (offset === 0)
205
+ return 0;
157
206
  const at = source.lastIndexOf("\n", offset - 1);
158
207
  return at === -1 ? 0 : at + 1;
159
208
  }
@@ -76,6 +76,13 @@ export declare function decodeAskQuestionQuery(queryBytes: Uint8Array): DecodedA
76
76
  export declare function isCatchAllOptionLabel(label: string): boolean;
77
77
  /** Options as Cursor would display them: trailing catch-all removed. */
78
78
  export declare function displayOptions(question: CursorAskQuestionItem): CursorAskQuestionOption[];
79
+ /** OpenCode `Question.Prompt.header`: short label, max 30 chars. */
80
+ export declare function questionHeader(title: string): string;
81
+ /**
82
+ * Fill required OpenCode `question` fields when a model call omits them.
83
+ * Live OC1 failure: SchemaError Missing key at ["questions"][0]["header"].
84
+ */
85
+ export declare function normalizeOpencodeQuestionArgs(args: Record<string, unknown>): Record<string, unknown>;
79
86
  export type OpencodeQuestionInput = {
80
87
  questions: Array<{
81
88
  question: string;
@@ -100,12 +107,16 @@ export declare function asyncResult(): AskQuestionResultMessage;
100
107
  /**
101
108
  * Pull each question's answer text out of a host `question` tool output.
102
109
  *
103
- * OpenCode returns prose (`metadata.answers` does not cross the AI SDK
110
+ * OpenCode 1.x returns prose (`metadata.answers` does not cross the AI SDK
104
111
  * boundary):
105
112
  *
106
113
  * User has answered your questions: "<q1>"="<a, b>", "<q2>"="Unanswered". You
107
114
  * can now continue with the user's answers in mind.
108
115
  *
116
+ * OpenCode 2.0 sends the tool's declared output as JSON
117
+ * (`{ "answers": [["Yes"], …] }`) and does not put that prose on the AI SDK
118
+ * tool-result. Answers are positional.
119
+ *
109
120
  * The prose is located by its `"<question>"="` anchor — robust
110
121
  * against commas, quotes and `"="` inside question or answer text, and against
111
122
  * duplicate questions. Returns undefined for a question whose anchor is
@@ -183,7 +183,8 @@ export function displayOptions(question) {
183
183
  return question.options.slice(0, -1);
184
184
  return question.options.slice();
185
185
  }
186
- function header(title) {
186
+ /** OpenCode `Question.Prompt.header`: short label, max 30 chars. */
187
+ export function questionHeader(title) {
187
188
  const trimmed = title.trim();
188
189
  if (!trimmed)
189
190
  return "Question";
@@ -191,6 +192,40 @@ function header(title) {
191
192
  ? trimmed.slice(0, HEADER_MAX_LENGTH - 1).trimEnd() + "…"
192
193
  : trimmed;
193
194
  }
195
+ /**
196
+ * Fill required OpenCode `question` fields when a model call omits them.
197
+ * Live OC1 failure: SchemaError Missing key at ["questions"][0]["header"].
198
+ */
199
+ export function normalizeOpencodeQuestionArgs(args) {
200
+ if (!Array.isArray(args.questions))
201
+ return args;
202
+ return {
203
+ ...args,
204
+ questions: args.questions.map((item) => {
205
+ if (!item || typeof item !== "object" || Array.isArray(item))
206
+ return item;
207
+ const q = item;
208
+ const prompt = typeof q.question === "string" ? q.question : "";
209
+ const existing = typeof q.header === "string" ? q.header : "";
210
+ const options = Array.isArray(q.options)
211
+ ? q.options.map((opt) => {
212
+ if (!opt || typeof opt !== "object" || Array.isArray(opt))
213
+ return opt;
214
+ const o = opt;
215
+ return {
216
+ ...o,
217
+ ...(o.description === undefined ? { description: "" } : {}),
218
+ };
219
+ })
220
+ : q.options;
221
+ return {
222
+ ...q,
223
+ header: questionHeader(existing.trim() || prompt),
224
+ options,
225
+ };
226
+ }),
227
+ };
228
+ }
194
229
  /**
195
230
  * Build the OpenCode `question` tool input. `Question.Prompt` requires
196
231
  * `question`, `header`, and `options[{label, description}]`; Cursor options
@@ -200,7 +235,7 @@ export function askQuestionToolInput(args) {
200
235
  return {
201
236
  questions: args.questions.map((question) => ({
202
237
  question: question.prompt,
203
- header: header(args.title),
238
+ header: questionHeader(args.title),
204
239
  options: displayOptions(question).map((option) => ({
205
240
  label: option.label,
206
241
  description: "",
@@ -220,18 +255,25 @@ export function asyncResult() {
220
255
  /**
221
256
  * Pull each question's answer text out of a host `question` tool output.
222
257
  *
223
- * OpenCode returns prose (`metadata.answers` does not cross the AI SDK
258
+ * OpenCode 1.x returns prose (`metadata.answers` does not cross the AI SDK
224
259
  * boundary):
225
260
  *
226
261
  * User has answered your questions: "<q1>"="<a, b>", "<q2>"="Unanswered". You
227
262
  * can now continue with the user's answers in mind.
228
263
  *
264
+ * OpenCode 2.0 sends the tool's declared output as JSON
265
+ * (`{ "answers": [["Yes"], …] }`) and does not put that prose on the AI SDK
266
+ * tool-result. Answers are positional.
267
+ *
229
268
  * The prose is located by its `"<question>"="` anchor — robust
230
269
  * against commas, quotes and `"="` inside question or answer text, and against
231
270
  * duplicate questions. Returns undefined for a question whose anchor is
232
271
  * absent, which is treated as unanswered rather than guessed at.
233
272
  */
234
273
  export function parseAnswerSegments(questions, output) {
274
+ const fromJson = parseJsonAnswerSegments(output, questions.length);
275
+ if (fromJson)
276
+ return fromJson;
235
277
  const answers = [];
236
278
  let cursor = 0;
237
279
  for (const question of questions) {
@@ -260,6 +302,38 @@ export function parseAnswerSegments(questions, output) {
260
302
  }
261
303
  return answers;
262
304
  }
305
+ function cellText(cell) {
306
+ if (typeof cell === "string")
307
+ return cell;
308
+ if (typeof cell === "number" || typeof cell === "boolean")
309
+ return String(cell);
310
+ if (Array.isArray(cell)) {
311
+ const parts = cell.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
312
+ return parts.length > 0 ? parts.join(", ") : undefined;
313
+ }
314
+ return undefined;
315
+ }
316
+ /** OpenCode 2 `question` output `{ answers: string[][] }`, or a JSON array of cells. */
317
+ function parseJsonAnswerSegments(output, count) {
318
+ const trimmed = output.trim();
319
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
320
+ return undefined;
321
+ let parsed;
322
+ try {
323
+ parsed = JSON.parse(trimmed);
324
+ }
325
+ catch {
326
+ return undefined;
327
+ }
328
+ const cells = Array.isArray(parsed)
329
+ ? parsed
330
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.answers)
331
+ ? parsed.answers
332
+ : undefined;
333
+ if (!cells)
334
+ return undefined;
335
+ return Array.from({ length: count }, (_, index) => cellText(cells[index]));
336
+ }
263
337
  /** OpenCode's placeholder for a question the user left blank. */
264
338
  const UNANSWERED = "Unanswered";
265
339
  /**
@@ -43,8 +43,12 @@ export declare function snapshotConversationBlobs(conversationId: string): Conve
43
43
  * Keep only blobs reachable from the latest checkpoint and snapshot them.
44
44
  * Malformed/unknown state or a missing referenced hash falls back to retaining
45
45
  * every blob: cache size must never come at the cost of restart correctness.
46
+ * `keepInMemory`: snapshot only, for a Run still in progress, whose next
47
+ * checkpoint may reference blobs this one does not.
46
48
  */
47
- export declare function compactConversationBlobs(conversationId: string, checkpoint: Uint8Array | undefined): ConversationBlobCompaction;
49
+ export declare function compactConversationBlobs(conversationId: string, checkpoint: Uint8Array | undefined, options?: {
50
+ keepInMemory?: boolean;
51
+ }): ConversationBlobCompaction;
48
52
  /** Replace the in-memory blob bucket when restoring a persisted conversation. */
49
53
  export declare function restoreConversationBlobs(conversationId: string, blobs: readonly ConversationBlobSnapshot[]): void;
50
54
  /** Drop all blobs for a conversation (compaction conversation reset). */
@@ -91,8 +91,10 @@ export function snapshotConversationBlobs(conversationId) {
91
91
  * Keep only blobs reachable from the latest checkpoint and snapshot them.
92
92
  * Malformed/unknown state or a missing referenced hash falls back to retaining
93
93
  * every blob: cache size must never come at the cost of restart correctness.
94
+ * `keepInMemory`: snapshot only, for a Run still in progress, whose next
95
+ * checkpoint may reference blobs this one does not.
94
96
  */
95
- export function compactConversationBlobs(conversationId, checkpoint) {
97
+ export function compactConversationBlobs(conversationId, checkpoint, options) {
96
98
  const current = byConversation.get(conversationId) ?? new Map();
97
99
  const before = retainedBlobStats(current);
98
100
  const beforeCount = before.count;
@@ -117,13 +119,15 @@ export function compactConversationBlobs(conversationId, checkpoint) {
117
119
  return isBlobIdHash(id) ? undefined : id;
118
120
  });
119
121
  const retained = new Map([...current].filter(([id]) => reachable.has(id)));
120
- if (retained.size > 0)
121
- byConversation.set(conversationId, retained);
122
- else
123
- byConversation.delete(conversationId);
122
+ if (!options?.keepInMemory) {
123
+ if (retained.size > 0)
124
+ byConversation.set(conversationId, retained);
125
+ else
126
+ byConversation.delete(conversationId);
127
+ }
124
128
  const afterBytes = [...retained.values()].reduce((total, data) => total + data.length, 0);
125
129
  return {
126
- blobs: snapshotConversationBlobs(conversationId),
130
+ blobs: [...retained].map(([id, data]) => ({ id, data: Uint8Array.from(data) })),
127
131
  beforeCount,
128
132
  beforeBytes,
129
133
  afterCount: retained.size,
@@ -3,17 +3,19 @@ import path from "node:path";
3
3
  import { FALLBACK_CLIENT_VERSION, MODEL_CACHE_TTL_MS, VERSION_CACHE_FILE, } from "../shared.js";
4
4
  import { opencodeGlobalCacheDir } from "../context/paths.js";
5
5
  import { withAbortDeadline } from "../deadline.js";
6
+ import { processShared } from "../process-shared.js";
6
7
  const INSTALL_URL = "https://cursor.com/install";
7
8
  const REMOTE_TIMEOUT_MS = 5_000;
8
9
  const BUILD_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9A-Za-z][0-9A-Za-z.-]*$/;
9
10
  const CLIENT_VERSION_RE = /^cli-[0-9A-Za-z][0-9A-Za-z._-]*$/;
10
- let cachedResolution;
11
+ // Once per process, not per copy of this package the host loads.
12
+ const cache = processShared("client-version.v1", () => ({}));
11
13
  export function resetClientVersionCache() {
12
- cachedResolution = undefined;
14
+ cache.resolution = undefined;
13
15
  }
14
16
  export function resolveClientVersion() {
15
- cachedResolution ??= resolve();
16
- return cachedResolution;
17
+ cache.resolution ??= resolve();
18
+ return cache.resolution;
17
19
  }
18
20
  async function resolve() {
19
21
  const env = process.env.CURSOR_CLIENT_VERSION?.trim();
@@ -0,0 +1,23 @@
1
+ import type { FileHandle } from "node:fs/promises";
2
+ export declare const MAX_CACHE_HEADER_BYTES: number;
3
+ export declare const MAX_CACHE_HEADER_INPUT_BYTES: number;
4
+ export type ConversationCacheHeader = {
5
+ schemaVersion: number;
6
+ sessionKey: string;
7
+ conversationId: string;
8
+ updatedAt: number;
9
+ };
10
+ export type CacheHeaderResult = {
11
+ status: "metadata";
12
+ value: ConversationCacheHeader;
13
+ } | {
14
+ status: "invalid" | "indeterminate";
15
+ };
16
+ /** A prefix is a retention hint, never evidence of payload/checksum validity. */
17
+ export declare function parseConversationCacheHeader(bytes: Uint8Array): CacheHeaderResult | undefined;
18
+ /**
19
+ * Inspect an opened generation without reading its checkpoint/blobs/context.
20
+ * Positional reads leave the handle's offset at zero for subsequent full loading.
21
+ * Stop BOTH streams on success; merely breaking consumption leaves inflation live.
22
+ */
23
+ export declare function readConversationCacheHeader(handle: FileHandle, signal?: AbortSignal): Promise<CacheHeaderResult>;
@@ -0,0 +1,149 @@
1
+ import { Readable } from "node:stream";
2
+ import { createGunzip } from "node:zlib";
3
+ import { CONVERSATION_CACHE_SCHEMA_VERSION } from "../shared.js";
4
+ export const MAX_CACHE_HEADER_BYTES = 8 * 1024;
5
+ export const MAX_CACHE_HEADER_INPUT_BYTES = 128 * 1024;
6
+ /**
7
+ * Schema 3 wrote the same canonical fields 1-4 prefix as the current schema, so
8
+ * its persisted age is trustworthy even though its body is no longer loadable.
9
+ */
10
+ const OLDEST_CANONICAL_PREFIX_SCHEMA_VERSION = 3;
11
+ /** A prefix is a retention hint, never evidence of payload/checksum validity. */
12
+ export function parseConversationCacheHeader(bytes) {
13
+ let position = 0;
14
+ const incomplete = Symbol("incomplete header");
15
+ const indeterminate = Symbol("noncanonical header");
16
+ const varint = () => {
17
+ let value = 0n;
18
+ for (let index = 0; index < 10; index++) {
19
+ if (position === bytes.length)
20
+ throw incomplete;
21
+ const byte = bytes[position++];
22
+ if (index === 9 && byte > 1)
23
+ throw new Error("invalid cache header varint");
24
+ value |= BigInt(byte & 127) << BigInt(index * 7);
25
+ if (!(byte & 128))
26
+ return value;
27
+ }
28
+ throw new Error("invalid cache header varint");
29
+ };
30
+ const string = () => {
31
+ const length = varint();
32
+ if (length > BigInt(MAX_CACHE_HEADER_BYTES))
33
+ throw indeterminate;
34
+ const end = position + Number(length);
35
+ if (end > bytes.length)
36
+ throw incomplete;
37
+ const value = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(position, end));
38
+ position = end;
39
+ return value;
40
+ };
41
+ try {
42
+ // Only our writer's canonical prefix is eligible for metadata-only pruning.
43
+ // Other protobuf orders remain loadable through the full decoder.
44
+ if (varint() !== 8n)
45
+ return { status: "indeterminate" };
46
+ // A newer schema may lay out its prefix differently; never age it here.
47
+ const schemaVersion = varint();
48
+ if (schemaVersion < BigInt(OLDEST_CANONICAL_PREFIX_SCHEMA_VERSION) ||
49
+ schemaVersion > BigInt(CONVERSATION_CACHE_SCHEMA_VERSION))
50
+ return { status: "indeterminate" };
51
+ if (varint() !== 18n)
52
+ return { status: "indeterminate" };
53
+ const sessionKey = string();
54
+ if (varint() !== 26n)
55
+ return { status: "indeterminate" };
56
+ const conversationId = string();
57
+ if (varint() !== 32n)
58
+ return { status: "indeterminate" };
59
+ const updatedAt = Number(varint());
60
+ if (!sessionKey || !conversationId || !Number.isSafeInteger(updatedAt) || updatedAt < 0) {
61
+ return { status: "invalid" };
62
+ }
63
+ return {
64
+ status: "metadata",
65
+ value: { schemaVersion: Number(schemaVersion), sessionKey, conversationId, updatedAt },
66
+ };
67
+ }
68
+ catch (error) {
69
+ // A fragmented varint/string is normal streaming input, not corrupt data.
70
+ if (error === incomplete)
71
+ return undefined;
72
+ if (error === indeterminate)
73
+ return { status: "indeterminate" };
74
+ return { status: "invalid" };
75
+ }
76
+ }
77
+ /**
78
+ * Inspect an opened generation without reading its checkpoint/blobs/context.
79
+ * Positional reads leave the handle's offset at zero for subsequent full loading.
80
+ * Stop BOTH streams on success; merely breaking consumption leaves inflation live.
81
+ */
82
+ export function readConversationCacheHeader(handle, signal) {
83
+ signal?.throwIfAborted();
84
+ return new Promise((resolve, reject) => {
85
+ const prefix = Buffer.alloc(MAX_CACHE_HEADER_BYTES);
86
+ let length = 0;
87
+ let inputBytes = 0;
88
+ let settled = false;
89
+ let pendingRead;
90
+ // Own the positional reads ourselves. Destroying a ReadStream can close an
91
+ // externally supplied fd on some runtimes, even with autoClose:false.
92
+ const input = Readable.from((async function* () {
93
+ let position = 0;
94
+ while (!settled && position < MAX_CACHE_HEADER_INPUT_BYTES) {
95
+ const chunk = Buffer.alloc(Math.min(4096, MAX_CACHE_HEADER_INPUT_BYTES - position));
96
+ const read = handle.read(chunk, 0, chunk.length, position);
97
+ pendingRead = read;
98
+ const { bytesRead } = await read;
99
+ pendingRead = undefined;
100
+ if (!bytesRead || settled)
101
+ return;
102
+ position += bytesRead;
103
+ inputBytes += bytesRead;
104
+ yield chunk.subarray(0, bytesRead);
105
+ }
106
+ })(), { objectMode: false, highWaterMark: 4096 });
107
+ const unzip = createGunzip({ chunkSize: 1024 });
108
+ const finish = (value, error) => {
109
+ if (settled)
110
+ return;
111
+ settled = true;
112
+ signal?.removeEventListener("abort", abort);
113
+ input.unpipe(unzip);
114
+ input.destroy();
115
+ unzip.destroy();
116
+ // A read-ahead operation must settle before the caller reuses/closes the
117
+ // pinned handle. The async generator cannot start another after settled.
118
+ void Promise.resolve(pendingRead).then(() => {
119
+ if (error)
120
+ reject(error);
121
+ else
122
+ resolve(value);
123
+ }, reject);
124
+ };
125
+ const abort = () => finish(undefined, signal?.reason ?? new Error("cache inspection aborted"));
126
+ input.on("error", (error) => finish(undefined, error));
127
+ unzip.on("error", () => finish({
128
+ status: inputBytes >= MAX_CACHE_HEADER_INPUT_BYTES ? "indeterminate" : "invalid",
129
+ }));
130
+ unzip.on("end", () => finish({
131
+ status: inputBytes >= MAX_CACHE_HEADER_INPUT_BYTES ? "indeterminate" : "invalid",
132
+ }));
133
+ unzip.on("data", (chunk) => {
134
+ const available = Math.min(chunk.length, prefix.length - length);
135
+ prefix.set(chunk.subarray(0, available), length);
136
+ length += available;
137
+ const result = parseConversationCacheHeader(prefix.subarray(0, length));
138
+ if (result)
139
+ finish(result);
140
+ else if (length === prefix.length)
141
+ finish({ status: "indeterminate" });
142
+ });
143
+ signal?.addEventListener("abort", abort, { once: true });
144
+ if (signal?.aborted)
145
+ abort();
146
+ else
147
+ input.pipe(unzip);
148
+ });
149
+ }
@@ -13,19 +13,39 @@ export type PersistedConversation = {
13
13
  systemPromptHash?: string;
14
14
  /** JSON-encoded TurnProvenance (what this provider emitted last). */
15
15
  turnProvenance?: string;
16
+ /**
17
+ * Host note bodies the last turn ended without delivering (ordered; encoded
18
+ * via encodePersistedHostNotes). The Cursor checkpoint does not hold them.
19
+ */
20
+ hostNote?: string;
21
+ /** Bounded early-steer delivery receipts for the saved checkpoint. */
22
+ earlySteers?: string;
23
+ /** JSON lost-request record: user text sent on a checkpoint Cursor never advanced. */
24
+ lostRequest?: string;
25
+ /** Content hashes of the history images `sentImagesCheckpoint` holds. */
26
+ sentImageHashes?: string[];
27
+ /** SHA-256 hex of the checkpoint `sentImageHashes` belongs to; without it no image counts as held. */
28
+ sentImagesCheckpoint?: string;
16
29
  };
17
30
  export type ConversationLoadStatus = "restored" | "missing" | "invalid" | "expired";
18
31
  export type PersistedConversationLoad = {
19
32
  status: ConversationLoadStatus;
20
33
  value?: PersistedConversation;
21
34
  };
35
+ type PersistenceTestPhase = "maintenance-start" | "before-quarantine" | "after-quarantine" | "full-decode" | "directory-opened" | "before-write";
36
+ declare let testHook: ((phase: PersistenceTestPhase, filePath: string) => Promise<void> | void) | undefined;
22
37
  export declare function conversationCacheDirectoryPath(cacheDir: string): string;
23
38
  export declare function conversationCacheFilePath(cacheDir: string, sessionKey: string): string;
24
- /** Create the per-session cache directory and prune records older than 24 hours once per process. */
39
+ /** Readiness includes directory permissions only; maintenance never gates hydration. */
25
40
  export declare function initializeConversationPersistence(cacheDir: string, now?: number): Promise<void>;
26
41
  export declare function getPersistedConversation(cacheDir: string, sessionKey: string, now?: number): Promise<PersistedConversation | undefined>;
27
42
  /** Read a snapshot together with the reason restart hydration did not restore it. */
28
43
  export declare function loadPersistedConversation(cacheDir: string, sessionKey: string, now?: number): Promise<PersistedConversationLoad>;
44
+ /** Turn provenance of the current snapshot, without decoding its payload. */
45
+ export declare function getPersistedTurnProvenance(cacheDir: string, sessionKey: string, now?: number): Promise<{
46
+ conversationId: string;
47
+ turnProvenance: string;
48
+ } | undefined>;
29
49
  /** Replace one session's durable snapshot and refresh its 24-hour lease. */
30
50
  export declare function persistConversation(cacheDir: string, value: Omit<PersistedConversation, "updatedAt" | "postCompactionRebase" | "toolCatalog"> & {
31
51
  postCompactionRebase?: boolean;
@@ -33,4 +53,10 @@ export declare function persistConversation(cacheDir: string, value: Omit<Persis
33
53
  }, now?: number): Promise<void>;
34
54
  /** Delete only the expected binding so an overlapping newer turn cannot be removed. */
35
55
  export declare function deletePersistedConversation(cacheDir: string, sessionKey: string, expectedConversationId?: string): Promise<void>;
56
+ /** Full snapshots still held in memory; the store should keep heads only. */
57
+ export declare function retainedConversationSnapshotsForTests(): Promise<number>;
36
58
  export declare function resetConversationPersistenceForTests(): void;
59
+ /** Deterministic maintenance barriers/race injection; not a request-path API. */
60
+ export declare function setConversationPersistenceHookForTests(hook?: typeof testHook): void;
61
+ export declare function waitForConversationMaintenanceForTests(cacheDir: string, now?: number): Promise<void>;
62
+ export {};