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.
- package/CHANGELOG.md +95 -0
- package/README.md +204 -37
- package/SECURITY.md +20 -8
- package/dist/activity.d.ts +73 -1
- package/dist/activity.js +231 -5
- package/dist/agent-url.js +37 -9
- package/dist/auth-renewal.d.ts +1 -1
- package/dist/auth-renewal.js +58 -26
- package/dist/billing.d.ts +105 -0
- package/dist/billing.js +212 -0
- package/dist/classic-tool-results.d.ts +1 -0
- package/dist/classic-tool-results.js +42 -0
- package/dist/context/build.d.ts +1 -0
- package/dist/context/build.js +32 -5
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/paths.d.ts +1 -1
- package/dist/context/paths.js +7 -4
- package/dist/debug.js +13 -2
- package/dist/early-tool-rows.d.ts +60 -0
- package/dist/early-tool-rows.js +96 -0
- package/dist/errors.d.ts +49 -7
- package/dist/errors.js +163 -8
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.js +4 -3
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +114 -23
- package/dist/language-model.js +2290 -469
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +3 -1
- package/dist/model-config.js +50 -29
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/plugin-opencode2.js +185 -25
- package/dist/plugin.js +28 -5
- package/dist/pricing-data.d.ts +26 -2
- package/dist/pricing-data.js +26 -2
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +7 -4
- package/dist/process-shared.d.ts +14 -0
- package/dist/process-shared.js +18 -0
- package/dist/protocol/apply-patch.d.ts +25 -13
- package/dist/protocol/apply-patch.js +79 -30
- package/dist/protocol/blob-store.d.ts +5 -1
- package/dist/protocol/blob-store.js +10 -6
- package/dist/protocol/client-version.js +6 -4
- package/dist/protocol/conversation-cache-header.d.ts +23 -0
- package/dist/protocol/conversation-cache-header.js +149 -0
- package/dist/protocol/conversation-persistence.d.ts +27 -1
- package/dist/protocol/conversation-persistence.js +628 -126
- package/dist/protocol/conversation-state.d.ts +8 -1
- package/dist/protocol/conversation-state.js +28 -7
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/messages.js +91 -8
- package/dist/protocol/request.d.ts +35 -19
- package/dist/protocol/request.js +53 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/tool-call-bridge.d.ts +2 -0
- package/dist/protocol/tool-call-bridge.js +10 -0
- package/dist/protocol/tools.d.ts +74 -8
- package/dist/protocol/tools.js +431 -141
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +16 -4
- package/dist/replay-safety.js +34 -25
- package/dist/retry-budget.d.ts +21 -0
- package/dist/retry-budget.js +94 -0
- package/dist/runtime-files.d.ts +23 -0
- package/dist/runtime-files.js +581 -0
- package/dist/session.d.ts +94 -9
- package/dist/session.js +153 -34
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/shell-timeout.d.ts +54 -10
- package/dist/shell-timeout.js +362 -92
- package/dist/stream-write.d.ts +20 -0
- package/dist/stream-write.js +87 -0
- package/dist/terminal-runner.d.ts +15 -0
- package/dist/terminal-runner.js +528 -0
- package/dist/transport/connect.d.ts +8 -1
- package/dist/transport/connect.js +103 -23
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +63 -29
- package/package.json +3 -2
- package/dist/protocol/thinking.d.ts +0 -15
- 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
|
|
50
|
+
/** Strip synthetic context suffixes (`…-1m`, `…-500k`, `…-500k-2-fast`, …). */
|
|
51
51
|
export function wireModelIdForPricing(modelId) {
|
|
52
|
-
const match = /^(.*)
|
|
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
|
|
83
|
-
|
|
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
|
|
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`)
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
75
|
-
*
|
|
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
|
-
* -
|
|
80
|
-
*
|
|
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
|
|
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`)
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
75
|
-
*
|
|
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
|
-
* -
|
|
80
|
-
*
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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 (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
14
|
+
cache.resolution = undefined;
|
|
13
15
|
}
|
|
14
16
|
export function resolveClientVersion() {
|
|
15
|
-
|
|
16
|
-
return
|
|
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
|
-
/**
|
|
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 {};
|