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.
- package/CHANGELOG.md +174 -0
- package/README.md +230 -44
- package/SECURITY.md +60 -12
- 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 +23 -4
- package/dist/context/build.js +65 -10
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/frozen.js +6 -2
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +45 -18
- package/dist/context/paths.js +72 -22
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +34 -0
- package/dist/context/skills.js +211 -0
- 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-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +122 -30
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +149 -30
- package/dist/language-model.js +3127 -673
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +12 -1
- package/dist/model-config.js +78 -30
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +233 -65
- package/dist/plugin.js +97 -23
- package/dist/pricing-data.d.ts +40 -3
- package/dist/pricing-data.js +40 -3
- 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/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- 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/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +164 -21
- package/dist/protocol/request.d.ts +40 -19
- package/dist/protocol/request.js +55 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +6 -0
- package/dist/protocol/tool-call-bridge.js +24 -0
- package/dist/protocol/tools.d.ts +90 -9
- package/dist/protocol/tools.js +543 -151
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +22 -4
- package/dist/replay-safety.js +86 -27
- 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 +129 -10
- package/dist/session.js +198 -35
- 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/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +10 -1
- package/dist/transport/connect.js +124 -27
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +85 -34
- package/package.json +5 -3
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
- package/dist/protocol/thinking.d.ts +0 -15
- package/dist/protocol/thinking.js +0 -17
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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 {};
|