cursor-opencode-provider 0.8.1 → 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 (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  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 +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
package/dist/pricing.js CHANGED
@@ -47,9 +47,9 @@ export function validateOpenCodeModelCost(value, path = "cost") {
47
47
  export function isOpenCodeModelCost(value) {
48
48
  return validateOpenCodeModelCost(value).valid;
49
49
  }
50
- /** Strip synthetic OpenCode suffixes (`…-1m`, `…-1m-2`, `…-1m-fast`, …). */
50
+ /** Strip synthetic context suffixes (`…-1m`, `…-500k`, `…-500k-2-fast`, …). */
51
51
  export function wireModelIdForPricing(modelId) {
52
- const match = /^(.*)-1m(?:-\d+)?(-fast)?$/.exec(modelId);
52
+ const match = /^(.*)-\d+(?:\.\d+)?[km](?:-\d+)?(-fast)?$/.exec(modelId);
53
53
  if (match) {
54
54
  return { baseId: `${match[1]}${match[2] ?? ""}`, longContextEntry: true };
55
55
  }
@@ -79,8 +79,11 @@ function asMutableCost(value) {
79
79
  * Synthetic `-fast` entries are stored as their own keys.
80
80
  */
81
81
  export function getCursorModelCost(modelId) {
82
- const { baseId } = wireModelIdForPricing(modelId);
83
- const found = CURSOR_MODEL_COSTS[baseId];
82
+ const costs = CURSOR_MODEL_COSTS;
83
+ // A priced wire id wins over suffix stripping (`-<n>k` / `-<n>m` may be real).
84
+ const found = Object.hasOwn(costs, modelId)
85
+ ? costs[modelId]
86
+ : costs[wireModelIdForPricing(modelId).baseId];
84
87
  if (!found)
85
88
  return undefined;
86
89
  return asMutableCost(found);
@@ -0,0 +1,14 @@
1
+ /**
2
+ * State shared by every copy of this package in one process.
3
+ *
4
+ * OpenCode 2 imports a separate copy of a plugin's module graph for each
5
+ * location (project directory) it serves, all in one realm. Module-level state
6
+ * then exists once per copy, so per-account work such as credential renewal ran
7
+ * once per location. Keep that state here instead.
8
+ *
9
+ * Store plain data only (maps, strings, numbers, promises): every copy has its
10
+ * own classes, so an instance stored by one copy fails `instanceof` in another.
11
+ * Name keys with a version suffix (`….v1`) and bump it when the stored shape
12
+ * changes, so two package versions in one process never read each other's data.
13
+ */
14
+ export declare function processShared<T>(name: string, create: () => T): T;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * State shared by every copy of this package in one process.
3
+ *
4
+ * OpenCode 2 imports a separate copy of a plugin's module graph for each
5
+ * location (project directory) it serves, all in one realm. Module-level state
6
+ * then exists once per copy, so per-account work such as credential renewal ran
7
+ * once per location. Keep that state here instead.
8
+ *
9
+ * Store plain data only (maps, strings, numbers, promises): every copy has its
10
+ * own classes, so an instance stored by one copy fails `instanceof` in another.
11
+ * Name keys with a version suffix (`….v1`) and bump it when the stored shape
12
+ * changes, so two package versions in one process never read each other's data.
13
+ */
14
+ export function processShared(name, create) {
15
+ const key = Symbol.for(`cursor-opencode-provider.${name}`);
16
+ const registry = globalThis;
17
+ return registry[key] ??= create();
18
+ }
@@ -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,15 +73,25 @@
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 declare const APPLY_PATCH_TOOL = "apply_patch";
89
+ /** OpenCode 2.0 name for the same tool (renamed from `apply_patch`). */
90
+ export declare const PATCH_TOOL = "patch";
91
+ export type HostPatchTool = typeof APPLY_PATCH_TOOL | typeof PATCH_TOOL;
92
+ /** The patch tool this host advertises in place of `edit`/`write`, if any. */
93
+ export declare function hostPatchTool(advertised: ReadonlySet<string>): HostPatchTool | undefined;
94
+ export declare function isHostPatchTool(name: string): name is HostPatchTool;
83
95
  /** One `@@` chunk: replace `oldLines` with `newLines`. */
84
96
  export type UpdateChunk = {
85
97
  oldLines: string[];
@@ -89,9 +101,9 @@ export type UpdateChunk = {
89
101
  * Whole-file write as `*** Add File:`. Valid whether or not the target exists —
90
102
  * OpenCode's add case overwrites unconditionally.
91
103
  */
92
- export declare function buildAddFilePatch(filePath: string, content: string): string;
104
+ export declare function buildAddFilePatch(filePath: string, content: string, tool?: HostPatchTool): string;
93
105
  /** Targeted replacement as `*** Update File:` with one `@@` chunk per change. */
94
- export declare function buildUpdateFilePatch(filePath: string, chunks: UpdateChunk[]): string;
106
+ export declare function buildUpdateFilePatch(filePath: string, chunks: UpdateChunk[], tool?: HostPatchTool): string;
95
107
  export type SubstringEditPlan = {
96
108
  ok: true;
97
109
  chunks: UpdateChunk[];
@@ -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
  }
@@ -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 {};