cursor-opencode-provider 0.8.0 → 0.8.1
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 +79 -0
- package/README.md +28 -9
- package/SECURITY.md +43 -7
- package/dist/context/build.d.ts +22 -4
- package/dist/context/build.js +33 -5
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/frozen.js +6 -2
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +44 -17
- package/dist/context/paths.js +65 -18
- 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/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +119 -28
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/language-model.d.ts +45 -17
- package/dist/language-model.js +925 -292
- package/dist/model-config.d.ts +9 -0
- package/dist/model-config.js +28 -1
- 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/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 +48 -40
- package/dist/plugin.js +69 -18
- package/dist/pricing-data.d.ts +14 -1
- package/dist/pricing-data.js +14 -1
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +74 -14
- package/dist/protocol/request.d.ts +5 -0
- package/dist/protocol/request.js +2 -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 +4 -0
- package/dist/protocol/tool-call-bridge.js +14 -0
- package/dist/protocol/tools.d.ts +16 -1
- package/dist/protocol/tools.js +113 -11
- package/dist/replay-safety.d.ts +6 -0
- package/dist/replay-safety.js +56 -6
- package/dist/session.d.ts +35 -1
- package/dist/session.js +45 -1
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +2 -0
- package/dist/transport/connect.js +23 -6
- package/dist/usage.js +23 -6
- package/package.json +3 -2
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode's own plan file per session, resolved while the session runs under
|
|
3
|
+
* the `plan` agent. CreatePlan records the plan there so the `plan_exit` review
|
|
4
|
+
* reads exactly the file the user approves.
|
|
5
|
+
*
|
|
6
|
+
* Resolution is structural: session `slug` / `time.created` from the classic
|
|
7
|
+
* client, project VCS and worktree from the plugin input, and the location
|
|
8
|
+
* from OpenCode's `Session.plan` rule (or an injected host path bridge).
|
|
9
|
+
*/
|
|
10
|
+
export declare function hostPlanFileFor(sessionID: string | undefined): string | undefined;
|
|
11
|
+
/** True once the session's plan file is known, including known to be absent. */
|
|
12
|
+
export declare function isHostPlanFileResolved(sessionID: string | undefined): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Record a session's plan file. `null` records that the host defines none, so
|
|
15
|
+
* it is not looked up again; `undefined` forgets it, so the next request retries.
|
|
16
|
+
*/
|
|
17
|
+
export declare function setHostPlanFile(sessionID: string, file: string | null | undefined): void;
|
|
18
|
+
export declare function resetHostPlanFilesForTests(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve and remember the host plan file for one session. A session without
|
|
21
|
+
* one is remembered as such; a failed lookup is not, so a later request retries.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveHostPlanFile(input: {
|
|
24
|
+
sessionID: string;
|
|
25
|
+
getSession: (sessionID: string) => Promise<unknown>;
|
|
26
|
+
worktree: string | undefined;
|
|
27
|
+
vcs: boolean;
|
|
28
|
+
}): Promise<string | undefined>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode's own plan file per session, resolved while the session runs under
|
|
3
|
+
* the `plan` agent. CreatePlan records the plan there so the `plan_exit` review
|
|
4
|
+
* reads exactly the file the user approves.
|
|
5
|
+
*
|
|
6
|
+
* Resolution is structural: session `slug` / `time.created` from the classic
|
|
7
|
+
* client, project VCS and worktree from the plugin input, and the location
|
|
8
|
+
* from OpenCode's `Session.plan` rule (or an injected host path bridge).
|
|
9
|
+
*/
|
|
10
|
+
import { hostPlanFilePath } from "./context/paths.js";
|
|
11
|
+
import { errorMessage, trace } from "./debug.js";
|
|
12
|
+
/** Session → its plan file, or `null` when the host defines none for it. */
|
|
13
|
+
const hostPlanFiles = new Map();
|
|
14
|
+
const MAX_HOST_PLAN_FILES = 256;
|
|
15
|
+
export function hostPlanFileFor(sessionID) {
|
|
16
|
+
const key = sessionID?.trim();
|
|
17
|
+
return (key ? hostPlanFiles.get(key) : undefined) ?? undefined;
|
|
18
|
+
}
|
|
19
|
+
/** True once the session's plan file is known, including known to be absent. */
|
|
20
|
+
export function isHostPlanFileResolved(sessionID) {
|
|
21
|
+
const key = sessionID?.trim();
|
|
22
|
+
return !!key && hostPlanFiles.has(key);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Record a session's plan file. `null` records that the host defines none, so
|
|
26
|
+
* it is not looked up again; `undefined` forgets it, so the next request retries.
|
|
27
|
+
*/
|
|
28
|
+
export function setHostPlanFile(sessionID, file) {
|
|
29
|
+
const key = sessionID.trim();
|
|
30
|
+
if (!key)
|
|
31
|
+
return;
|
|
32
|
+
hostPlanFiles.delete(key);
|
|
33
|
+
if (file === undefined || file === "")
|
|
34
|
+
return;
|
|
35
|
+
hostPlanFiles.set(key, file);
|
|
36
|
+
while (hostPlanFiles.size > MAX_HOST_PLAN_FILES) {
|
|
37
|
+
const oldest = hostPlanFiles.keys().next().value;
|
|
38
|
+
if (!oldest)
|
|
39
|
+
break;
|
|
40
|
+
hostPlanFiles.delete(oldest);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function resetHostPlanFilesForTests() {
|
|
44
|
+
hostPlanFiles.clear();
|
|
45
|
+
}
|
|
46
|
+
function record(value) {
|
|
47
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
48
|
+
? value
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve and remember the host plan file for one session. A session without
|
|
53
|
+
* one is remembered as such; a failed lookup is not, so a later request retries.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveHostPlanFile(input) {
|
|
56
|
+
try {
|
|
57
|
+
const response = record(await input.getSession(input.sessionID));
|
|
58
|
+
const info = record(response?.data) ?? response;
|
|
59
|
+
const slug = typeof info?.slug === "string" ? info.slug : "";
|
|
60
|
+
const created = record(info?.time)?.created;
|
|
61
|
+
if (!slug || typeof created !== "number" || !input.worktree) {
|
|
62
|
+
setHostPlanFile(input.sessionID, null);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
const file = hostPlanFilePath({ worktree: input.worktree, vcs: input.vcs, created, slug });
|
|
66
|
+
setHostPlanFile(input.sessionID, file ?? null);
|
|
67
|
+
if (file)
|
|
68
|
+
trace(`host-plan-file: sessionID=${input.sessionID} file=${file}`);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
trace(`host-plan-file: unresolved sessionID=${input.sessionID} err=${errorMessage(error)}`);
|
|
73
|
+
setHostPlanFile(input.sessionID, undefined);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/image-input.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type CursorHistoryImageExtraction = {
|
|
|
9
9
|
hashes: string[];
|
|
10
10
|
candidateCount: number;
|
|
11
11
|
duplicateCount: number;
|
|
12
|
+
/** Unreadable optional tool media; explicit user attachments still fail loudly. */
|
|
13
|
+
omittedCount?: number;
|
|
12
14
|
};
|
|
13
15
|
export type CursorPromptImageExtraction = CursorHistoryImageExtraction & {
|
|
14
16
|
userImageCount: number;
|
|
@@ -23,6 +25,22 @@ export declare function extractCursorHistoryImages(prompt: readonly unknown[], o
|
|
|
23
25
|
maxBytes?: number;
|
|
24
26
|
filenameOffset?: number;
|
|
25
27
|
}): Promise<CursorHistoryImageExtraction>;
|
|
28
|
+
/**
|
|
29
|
+
* Decode the images a host tool returned (its own `file-data` / `image-data`
|
|
30
|
+
* parts, or the `file` parts OpenCode moves into the trailing
|
|
31
|
+
* `Attached media from tool result:` message) for a held-Run exec result.
|
|
32
|
+
* Non-image parts are skipped; a part that cannot be decoded is dropped so the
|
|
33
|
+
* text result is still delivered.
|
|
34
|
+
*/
|
|
35
|
+
export declare function extractCursorToolResultImages(parts: readonly unknown[], options?: {
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
maxBytes?: number;
|
|
38
|
+
maxImages?: number;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
images: CursorImageInput[];
|
|
41
|
+
hashes: string[];
|
|
42
|
+
omittedCount: number;
|
|
43
|
+
}>;
|
|
26
44
|
export declare function extractCursorPromptImages(prompt: readonly unknown[], lastUser: Record<string, unknown> | undefined, options: {
|
|
27
45
|
supportsImages: boolean;
|
|
28
46
|
seenHistoryHashes?: ReadonlySet<string>;
|
package/dist/image-input.js
CHANGED
|
@@ -4,8 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { UnsupportedFunctionalityError } from "@ai-sdk/provider";
|
|
6
6
|
export const MAX_CURSOR_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
|
|
7
|
+
function isToolMediaCaption(message) {
|
|
8
|
+
if (!message || !Array.isArray(message.content))
|
|
9
|
+
return false;
|
|
10
|
+
const first = message.content[0];
|
|
11
|
+
return first?.type === "text" && first.text === "Attached media from tool result:";
|
|
12
|
+
}
|
|
7
13
|
export function hasCursorUserImages(lastUser) {
|
|
8
|
-
return !!lastUser && Array.isArray(lastUser.content) && lastUser.content.some((part) => {
|
|
14
|
+
return !isToolMediaCaption(lastUser) && !!lastUser && Array.isArray(lastUser.content) && lastUser.content.some((part) => {
|
|
9
15
|
if (!part || typeof part !== "object")
|
|
10
16
|
return false;
|
|
11
17
|
const file = part;
|
|
@@ -20,18 +26,20 @@ export function assertCursorUserImageSupport(lastUser, supportsImages, modelId)
|
|
|
20
26
|
return;
|
|
21
27
|
unsupported("image input", `Cursor model ${JSON.stringify(modelId)} does not support image input`);
|
|
22
28
|
}
|
|
23
|
-
function decodeBase64(value) {
|
|
29
|
+
function decodeBase64(value, remaining) {
|
|
24
30
|
const normalized = value.replace(/\s/g, "");
|
|
25
31
|
if (!normalized || !/^[A-Za-z0-9+/_-]*={0,2}$/.test(normalized)) {
|
|
26
32
|
return unsupported("image input", "Cursor provider received invalid base64 image data");
|
|
27
33
|
}
|
|
34
|
+
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
|
35
|
+
assertImageSize(Math.floor((normalized.length - padding) * 3 / 4), remaining);
|
|
28
36
|
const data = Uint8Array.from(Buffer.from(normalized, "base64"));
|
|
29
37
|
if (data.length === 0) {
|
|
30
38
|
return unsupported("image input", "Cursor provider received an empty image");
|
|
31
39
|
}
|
|
32
40
|
return data;
|
|
33
41
|
}
|
|
34
|
-
function decodeDataUrl(value) {
|
|
42
|
+
function decodeDataUrl(value, remaining) {
|
|
35
43
|
if (!value.startsWith("data:")) {
|
|
36
44
|
return unsupported("image input", "Cursor provider supports base64-encoded image data URLs only");
|
|
37
45
|
}
|
|
@@ -47,7 +55,7 @@ function decodeDataUrl(value) {
|
|
|
47
55
|
const firstSeparator = metadata.indexOf(";");
|
|
48
56
|
const mimeType = metadata.slice(0, firstSeparator);
|
|
49
57
|
return {
|
|
50
|
-
data: decodeBase64(value.slice(commaIndex + 1)),
|
|
58
|
+
data: decodeBase64(value.slice(commaIndex + 1), remaining),
|
|
51
59
|
mimeType: mimeType || undefined,
|
|
52
60
|
};
|
|
53
61
|
}
|
|
@@ -121,13 +129,15 @@ async function readResponseBytes(response, remaining) {
|
|
|
121
129
|
return data;
|
|
122
130
|
}
|
|
123
131
|
async function resolveImageData(value, remaining, signal) {
|
|
124
|
-
if (value instanceof Uint8Array)
|
|
132
|
+
if (value instanceof Uint8Array) {
|
|
133
|
+
assertImageSize(value.length, remaining);
|
|
125
134
|
return { data: Uint8Array.from(value) };
|
|
135
|
+
}
|
|
126
136
|
if (typeof value === "string") {
|
|
127
|
-
return value.startsWith("data:") ? decodeDataUrl(value) : { data: decodeBase64(value) };
|
|
137
|
+
return value.startsWith("data:") ? decodeDataUrl(value, remaining) : { data: decodeBase64(value, remaining) };
|
|
128
138
|
}
|
|
129
139
|
if (value.protocol === "data:")
|
|
130
|
-
return decodeDataUrl(value.href);
|
|
140
|
+
return decodeDataUrl(value.href, remaining);
|
|
131
141
|
if (value.protocol === "file:") {
|
|
132
142
|
const filePath = fileURLToPath(value);
|
|
133
143
|
const info = await stat(filePath);
|
|
@@ -183,21 +193,33 @@ export async function extractCursorUserImages(lastUser, signal, maxBytes = MAX_C
|
|
|
183
193
|
const file = part;
|
|
184
194
|
if (file.type !== "file")
|
|
185
195
|
continue;
|
|
186
|
-
const image = await decodeCursorImagePart(file, byteBudget - totalBytes, signal, `image-${images.length + 1}`);
|
|
196
|
+
const image = await decodeCursorImagePart(toolImagePart(file) ?? file, byteBudget - totalBytes, signal, `image-${images.length + 1}`);
|
|
187
197
|
totalBytes += image.data.length;
|
|
188
198
|
images.push(image);
|
|
189
199
|
}
|
|
190
200
|
return images;
|
|
191
201
|
}
|
|
192
|
-
|
|
202
|
+
/** Normalize OpenCode / AI SDK attachment shapes before either delivery path. */
|
|
203
|
+
function toolImagePart(part) {
|
|
204
|
+
if (!part || typeof part !== "object")
|
|
205
|
+
return undefined;
|
|
206
|
+
const file = part;
|
|
207
|
+
if (!["file", "file-data", "image-data", "image-url", "media", "image"].includes(String(file.type)))
|
|
208
|
+
return undefined;
|
|
209
|
+
const mediaType = typeof file.mediaType === "string" ? file.mediaType
|
|
210
|
+
: file.type === "image-url" ? "image/*" : file.mime;
|
|
211
|
+
if (typeof mediaType !== "string" || !mediaType.startsWith("image/"))
|
|
212
|
+
return undefined;
|
|
213
|
+
const source = file.url ?? file.uri;
|
|
214
|
+
const data = file.data ?? (typeof source === "string" && /^(?:https?|file):/.test(source) && URL.canParse(source)
|
|
215
|
+
? new URL(source) : source);
|
|
216
|
+
return { ...file, mediaType, data };
|
|
217
|
+
}
|
|
218
|
+
function pushImageFileParts(parts, content, toolResult = false) {
|
|
193
219
|
for (const part of content) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (file.type === "file" &&
|
|
198
|
-
typeof file.mediaType === "string" &&
|
|
199
|
-
file.mediaType.startsWith("image/"))
|
|
200
|
-
parts.push(file);
|
|
220
|
+
const file = toolImagePart(part);
|
|
221
|
+
if (file?.type === "file")
|
|
222
|
+
parts.push({ file, toolResult });
|
|
201
223
|
}
|
|
202
224
|
}
|
|
203
225
|
function cursorHistoryImageParts(prompt) {
|
|
@@ -222,7 +244,7 @@ function cursorHistoryImageParts(prompt) {
|
|
|
222
244
|
if (record.role === "user") {
|
|
223
245
|
if (i === lastUserIndex)
|
|
224
246
|
continue;
|
|
225
|
-
pushImageFileParts(parts, record.content);
|
|
247
|
+
pushImageFileParts(parts, record.content, isToolMediaCaption(record));
|
|
226
248
|
continue;
|
|
227
249
|
}
|
|
228
250
|
if (record.role === "assistant") {
|
|
@@ -244,11 +266,9 @@ function cursorHistoryImageParts(prompt) {
|
|
|
244
266
|
for (const value of output.value) {
|
|
245
267
|
if (!value || typeof value !== "object")
|
|
246
268
|
continue;
|
|
247
|
-
const file = value;
|
|
248
|
-
if (file
|
|
249
|
-
|
|
250
|
-
file.mediaType.startsWith("image/"))
|
|
251
|
-
parts.push(file);
|
|
269
|
+
const file = toolImagePart(value);
|
|
270
|
+
if (file)
|
|
271
|
+
parts.push({ file, toolResult: true });
|
|
252
272
|
}
|
|
253
273
|
}
|
|
254
274
|
}
|
|
@@ -264,11 +284,22 @@ export async function extractCursorHistoryImages(prompt, options) {
|
|
|
264
284
|
const hashes = [];
|
|
265
285
|
const hashesThisTurn = new Set();
|
|
266
286
|
let duplicateCount = 0;
|
|
287
|
+
let omittedCount = 0;
|
|
267
288
|
let totalBytes = 0;
|
|
268
|
-
for (const file of candidates) {
|
|
289
|
+
for (const { file, toolResult } of candidates) {
|
|
269
290
|
// Resolve against the per-image cap first so a previously sent duplicate
|
|
270
291
|
// does not fail merely because little combined budget remains this turn.
|
|
271
|
-
|
|
292
|
+
let image;
|
|
293
|
+
try {
|
|
294
|
+
image = await decodeCursorImagePart(file, MAX_CURSOR_IMAGE_INPUT_BYTES, options.signal, `image-${(options.filenameOffset ?? 0) + images.length + 1}`);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
options.signal?.throwIfAborted();
|
|
298
|
+
if (!toolResult || (error instanceof Error && error.name === "AbortError"))
|
|
299
|
+
throw error;
|
|
300
|
+
omittedCount++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
272
303
|
const hash = imageContentHash(image.data);
|
|
273
304
|
if (options.seenHashes?.has(hash) || hashesThisTurn.has(hash)) {
|
|
274
305
|
duplicateCount++;
|
|
@@ -280,15 +311,71 @@ export async function extractCursorHistoryImages(prompt, options) {
|
|
|
280
311
|
hashes.push(hash);
|
|
281
312
|
hashesThisTurn.add(hash);
|
|
282
313
|
}
|
|
283
|
-
return { images, hashes, candidateCount: candidates.length, duplicateCount };
|
|
314
|
+
return { images, hashes, candidateCount: candidates.length, duplicateCount, ...(omittedCount > 0 ? { omittedCount } : {}) };
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Decode the images a host tool returned (its own `file-data` / `image-data`
|
|
318
|
+
* parts, or the `file` parts OpenCode moves into the trailing
|
|
319
|
+
* `Attached media from tool result:` message) for a held-Run exec result.
|
|
320
|
+
* Non-image parts are skipped; a part that cannot be decoded is dropped so the
|
|
321
|
+
* text result is still delivered.
|
|
322
|
+
*/
|
|
323
|
+
export async function extractCursorToolResultImages(parts, options = {}) {
|
|
324
|
+
const maxBytes = cursorImageBudget(options.maxBytes ?? MAX_CURSOR_IMAGE_INPUT_BYTES);
|
|
325
|
+
const images = [];
|
|
326
|
+
const hashes = [];
|
|
327
|
+
let totalBytes = 0;
|
|
328
|
+
let omittedCount = 0;
|
|
329
|
+
options.signal?.throwIfAborted();
|
|
330
|
+
for (const part of parts) {
|
|
331
|
+
options.signal?.throwIfAborted();
|
|
332
|
+
const file = toolImagePart(part);
|
|
333
|
+
if (!file)
|
|
334
|
+
continue;
|
|
335
|
+
if (images.length >= (options.maxImages ?? Infinity) || totalBytes >= maxBytes) {
|
|
336
|
+
omittedCount++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const image = await decodeCursorImagePart(file, maxBytes - totalBytes, options.signal, `image-${images.length + 1}`);
|
|
341
|
+
options.signal?.throwIfAborted();
|
|
342
|
+
totalBytes += image.data.length;
|
|
343
|
+
images.push(image);
|
|
344
|
+
hashes.push(imageContentHash(image.data));
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
// Tool media is optional: a missing file or failed download must not
|
|
348
|
+
// strand every pending exec. Cancellation still belongs to the caller.
|
|
349
|
+
options.signal?.throwIfAborted();
|
|
350
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
351
|
+
throw error;
|
|
352
|
+
omittedCount++;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { images, hashes, omittedCount };
|
|
284
356
|
}
|
|
285
357
|
export async function extractCursorPromptImages(prompt, lastUser, options) {
|
|
286
358
|
const maxBytes = cursorImageBudget(options.maxBytes ?? MAX_CURSOR_IMAGE_INPUT_BYTES);
|
|
287
|
-
const
|
|
359
|
+
const toolCaption = isToolMediaCaption(lastUser);
|
|
360
|
+
const caption = toolCaption && options.supportsImages
|
|
361
|
+
? await extractCursorToolResultImages(lastUser.content, { signal: options.signal, maxBytes })
|
|
362
|
+
: undefined;
|
|
363
|
+
const captionHashes = [];
|
|
364
|
+
const seenHashes = new Set(options.seenHistoryHashes);
|
|
365
|
+
let captionDuplicates = 0;
|
|
366
|
+
const userImages = toolCaption ? (caption?.images ?? []).filter((_image, index) => {
|
|
367
|
+
const hash = caption.hashes[index];
|
|
368
|
+
if (seenHashes.has(hash)) {
|
|
369
|
+
captionDuplicates++;
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
seenHashes.add(hash);
|
|
373
|
+
captionHashes.push(hash);
|
|
374
|
+
return true;
|
|
375
|
+
}) : await extractCursorUserImages(lastUser, options.signal, maxBytes);
|
|
288
376
|
const userBytes = userImages.reduce((total, image) => total + image.data.length, 0);
|
|
289
377
|
// Seed history dedupe with this-turn last-user hashes so the same bytes on an
|
|
290
378
|
// earlier user/assistant/tool message are not attached twice in one Run.
|
|
291
|
-
const seenHashes = new Set(options.seenHistoryHashes);
|
|
292
379
|
for (const image of userImages)
|
|
293
380
|
seenHashes.add(imageContentHash(image.data));
|
|
294
381
|
const history = await extractCursorHistoryImages(prompt, {
|
|
@@ -300,7 +387,11 @@ export async function extractCursorPromptImages(prompt, lastUser, options) {
|
|
|
300
387
|
});
|
|
301
388
|
return {
|
|
302
389
|
...history,
|
|
390
|
+
hashes: [...captionHashes, ...history.hashes],
|
|
391
|
+
duplicateCount: history.duplicateCount + captionDuplicates,
|
|
303
392
|
images: [...userImages, ...history.images],
|
|
304
|
-
userImageCount: userImages.length,
|
|
393
|
+
userImageCount: toolCaption ? 0 : userImages.length,
|
|
394
|
+
...((history.omittedCount ?? 0) + (caption?.omittedCount ?? 0) > 0
|
|
395
|
+
? { omittedCount: (history.omittedCount ?? 0) + (caption?.omittedCount ?? 0) } : {}),
|
|
305
396
|
};
|
|
306
397
|
}
|
package/dist/image-save.d.ts
CHANGED
|
@@ -17,16 +17,24 @@ export declare const IMAGE_PERMISSION_DENIED_PREFIX = "CursorImagePermissionDeni
|
|
|
17
17
|
export type ImageSaveToolContext = {
|
|
18
18
|
worktree: string;
|
|
19
19
|
directory: string;
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Classic OpenCode 1.x `ToolContext.ask`. A host whose tool context has no
|
|
22
|
+
* permission prompt (OpenCode 2.0's public `ToolContext`) passes `null`
|
|
23
|
+
* explicitly: the commit then writes after containment, gated only by the
|
|
24
|
+
* tool's catalog permission. A missing `ask` is refused, never treated as
|
|
25
|
+
* permission.
|
|
26
|
+
*/
|
|
27
|
+
ask: ((input: {
|
|
21
28
|
permission: string;
|
|
22
29
|
patterns: string[];
|
|
23
30
|
always: string[];
|
|
24
31
|
metadata: Record<string, unknown>;
|
|
25
|
-
})
|
|
32
|
+
}) => Promise<void>) | null;
|
|
26
33
|
};
|
|
27
34
|
export type ImageSaveResult = {
|
|
28
35
|
title: string;
|
|
29
36
|
output: string;
|
|
37
|
+
bytes: number;
|
|
30
38
|
attachments?: Array<{
|
|
31
39
|
type: "file";
|
|
32
40
|
mime: string;
|
package/dist/image-save.js
CHANGED
|
@@ -126,31 +126,42 @@ export async function executeCursorImageSave(args, ctx) {
|
|
|
126
126
|
// case `assertExternalDirectory` exists for. Asking only for `edit` would
|
|
127
127
|
// quietly skip a boundary the host enforces for every one of its own writes.
|
|
128
128
|
const editPattern = path.relative(realRoot(workspace), contained.path);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
if (typeof ctx.ask === "function") {
|
|
130
|
+
try {
|
|
131
|
+
if (!isInsideProject(contained.path, workspace, ctx.directory)) {
|
|
132
|
+
const parentDir = path.dirname(contained.path);
|
|
133
|
+
const glob = path.join(parentDir, "*").replaceAll("\\", "/");
|
|
134
|
+
await ctx.ask({
|
|
135
|
+
permission: "external_directory",
|
|
136
|
+
patterns: [glob],
|
|
137
|
+
always: [glob],
|
|
138
|
+
metadata: { filepath: contained.path, parentDir },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
133
141
|
await ctx.ask({
|
|
134
|
-
permission: "
|
|
135
|
-
patterns: [
|
|
136
|
-
always: [
|
|
137
|
-
metadata: {
|
|
142
|
+
permission: "edit",
|
|
143
|
+
patterns: [editPattern],
|
|
144
|
+
always: ["*"],
|
|
145
|
+
metadata: {
|
|
146
|
+
filepath: contained.path,
|
|
147
|
+
mime: image.mime,
|
|
148
|
+
bytes: image.data.length,
|
|
149
|
+
source: "cursor-generate-image",
|
|
150
|
+
},
|
|
138
151
|
});
|
|
139
152
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
filepath: contained.path,
|
|
146
|
-
mime: image.mime,
|
|
147
|
-
bytes: image.data.length,
|
|
148
|
-
source: "cursor-generate-image",
|
|
149
|
-
},
|
|
150
|
-
});
|
|
153
|
+
catch (error) {
|
|
154
|
+
const reason = errorMessage(error);
|
|
155
|
+
trace(`image save: permission refused path=${JSON.stringify(contained.path)} reason=${reason}`);
|
|
156
|
+
throw new Error(`${IMAGE_PERMISSION_DENIED_PREFIX} ${reason}`);
|
|
157
|
+
}
|
|
151
158
|
}
|
|
152
|
-
|
|
153
|
-
|
|
159
|
+
else if (ctx.ask === null) {
|
|
160
|
+
trace(`image save: host cannot prompt; writing after containment ` +
|
|
161
|
+
`path=${JSON.stringify(contained.path)}`);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const reason = "this host did not provide a permission prompt for the write";
|
|
154
165
|
trace(`image save: permission refused path=${JSON.stringify(contained.path)} reason=${reason}`);
|
|
155
166
|
throw new Error(`${IMAGE_PERMISSION_DENIED_PREFIX} ${reason}`);
|
|
156
167
|
}
|
|
@@ -185,5 +196,6 @@ export async function executeCursorImageSave(args, ctx) {
|
|
|
185
196
|
return {
|
|
186
197
|
title: editPattern,
|
|
187
198
|
output: `Saved the generated image to ${contained.path} (${image.data.length} bytes).`,
|
|
199
|
+
bytes: image.data.length,
|
|
188
200
|
};
|
|
189
201
|
}
|
package/dist/language-model.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { type CursorSession } from "./session.js";
|
|
|
7
7
|
import { CursorProviderError } from "./errors.js";
|
|
8
8
|
import { type ModelInfo } from "./models.js";
|
|
9
9
|
import type { SeedHistoryMessage } from "./protocol/request.js";
|
|
10
|
+
import { type CursorImageInput } from "./image-input.js";
|
|
10
11
|
type PromptIdentity = {
|
|
11
12
|
hostAgent?: string;
|
|
12
13
|
systemPromptHash?: string;
|
|
@@ -36,7 +37,7 @@ export type CursorRetryPolicy = {
|
|
|
36
37
|
};
|
|
37
38
|
export declare function resolveRetryPolicy(options: CursorRetryOptions | undefined): CursorRetryPolicy;
|
|
38
39
|
export declare function connectFrameError(payload: string): CursorProviderError;
|
|
39
|
-
/** Restore the
|
|
40
|
+
/** Restore the epoch catalog for the next nonempty host turn. */
|
|
40
41
|
export declare function restoreTurnToolCatalog(sessionKey: string, tools: OpencodeToolDef[]): void;
|
|
41
42
|
/** Record the authoritative host todo list for an OpenCode session. */
|
|
42
43
|
export declare function rememberMirroredTodos(sessionKey: string | undefined, todos: ReadonlyArray<Record<string, unknown>>): void;
|
|
@@ -48,6 +49,8 @@ export declare function snapshotMirroredTodosBySession(sessionKey: string | unde
|
|
|
48
49
|
* churn have a single no-op definition.
|
|
49
50
|
*/
|
|
50
51
|
export declare function promptIdentityWouldRemint(_previous: PromptIdentity, _current: PromptIdentity): boolean;
|
|
52
|
+
/** Test helper: content hashes recorded after a successful held-Run image write. */
|
|
53
|
+
export declare function snapshotSentHistoryImageHashesForTests(sessionKey: string): string[];
|
|
51
54
|
type V3Part = LanguageModelV3StreamPart;
|
|
52
55
|
export declare function createCursorLanguageModel(modelId: string, providerId: string, options: CreateCursorOptions): LanguageModelV3;
|
|
53
56
|
export declare function pumpWithRecovery(input: {
|
|
@@ -96,12 +99,14 @@ export declare function isProperCatalogSubset(incoming: ReadonlyArray<{
|
|
|
96
99
|
}>): boolean;
|
|
97
100
|
/**
|
|
98
101
|
* An open parent Run for this OpenCode session whose catalog strictly contains
|
|
99
|
-
* the incoming tools is treated as an in-session helper
|
|
100
|
-
*
|
|
102
|
+
* the incoming tools is treated as an in-session helper unless the request
|
|
103
|
+
* carries a result for that parent's pending call. A result can precede a new
|
|
104
|
+
* user message or agent-change reminder and still needs to reach the parent.
|
|
105
|
+
* Helper calls must not cancel/supersede the parent.
|
|
101
106
|
*/
|
|
102
107
|
export declare function shouldIsolateInSessionHelper(openCodeSessionId: string | undefined, incomingTools: ReadonlyArray<{
|
|
103
108
|
name?: string;
|
|
104
|
-
}
|
|
109
|
+
}>, historicalResults?: ExtractedToolResult[]): boolean;
|
|
105
110
|
/** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
|
|
106
111
|
export declare const FRESH_TURN_DRAIN_TIMEOUT_MS = 8000;
|
|
107
112
|
/**
|
|
@@ -119,6 +124,9 @@ export declare const FRESH_TURN_PENDING_CANCEL_REASON = "Host started a new user
|
|
|
119
124
|
*/
|
|
120
125
|
export declare function preparePriorSessionForFreshTurn(openCodeSessionId: string | undefined, opts?: {
|
|
121
126
|
timeoutMs?: number;
|
|
127
|
+
/** Tool results already in this prompt; deliver any that match held pendings before cancel. */
|
|
128
|
+
toolResults?: ExtractedToolResult[];
|
|
129
|
+
hostAgent?: string;
|
|
122
130
|
}): Promise<"drained" | "settled-only" | "busy" | "none">;
|
|
123
131
|
/**
|
|
124
132
|
* Write error/reject results for every still-open non-bridged pending on the
|
|
@@ -135,33 +143,46 @@ export declare function cancelPendingExecsForFreshTurn(session: CursorSession):
|
|
|
135
143
|
export declare function drainSessionUntilTurnEnded(session: CursorSession, opts?: {
|
|
136
144
|
timeoutMs?: number;
|
|
137
145
|
}): Promise<"turn-ended" | "busy" | "timeout" | "interrupted" | "skipped">;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
*/
|
|
144
|
-
export declare function deliverContinuationResults(session: CursorSession, trailingToolResults: ExtractedToolResult[]): CursorSession | undefined;
|
|
146
|
+
export declare function deliverContinuationResults(session: CursorSession, trailingToolResults: ExtractedToolResult[],
|
|
147
|
+
/** Host primary agent of the request that carries these results. */
|
|
148
|
+
delivery?: {
|
|
149
|
+
hostAgent?: string;
|
|
150
|
+
}): CursorSession | undefined;
|
|
145
151
|
/**
|
|
146
152
|
* Read the held-open stream, emitting stream parts, until the turn boundary:
|
|
147
|
-
* -
|
|
148
|
-
*
|
|
153
|
+
* - tool call(s) for one Cursor generation → emit each tool-call as it
|
|
154
|
+
* arrives; finish "tool-calls" once Cursor's listed count (field 27) is
|
|
155
|
+
* met (or immediately when the process has never seen field 27); KEEP the
|
|
156
|
+
* session open for results on the next doStream call;
|
|
149
157
|
* - turn_ended → finish "stop" and close the session;
|
|
150
|
-
* - transport EOF before turn_ended → throw for one fresh-Run recovery
|
|
158
|
+
* - transport EOF before turn_ended → throw for one fresh-Run recovery
|
|
159
|
+
* (after host tools were already emitted, finish "tool-calls" instead and
|
|
160
|
+
* let the continuation rebase).
|
|
151
161
|
*/
|
|
152
162
|
export declare function pump(session: CursorSession, controller: ReadableStreamDefaultController<V3Part>, ids: {
|
|
153
163
|
textId: string;
|
|
154
164
|
reasoningId: string;
|
|
155
165
|
}, abortSignal?: AbortSignal): Promise<void>;
|
|
156
|
-
type
|
|
166
|
+
type ToolResultContent = {
|
|
157
167
|
toolCallId: string;
|
|
158
|
-
sessionId: string;
|
|
159
|
-
execId: number;
|
|
160
168
|
toolName: string;
|
|
161
169
|
output: string;
|
|
162
170
|
error?: string;
|
|
171
|
+
/** Host media parts of this result (own content, or the trailing media message). */
|
|
172
|
+
media?: unknown[];
|
|
173
|
+
/** `media` decoded for the exec result, with content hashes in the same order. */
|
|
174
|
+
images?: CursorImageInput[];
|
|
175
|
+
imageHashes?: string[];
|
|
176
|
+
/** Host updates that a binary read cannot carry in its output oneof. */
|
|
177
|
+
notes?: string;
|
|
178
|
+
};
|
|
179
|
+
type ExtractedToolResult = ToolResultContent & {
|
|
180
|
+
sessionId: string;
|
|
181
|
+
execId: number;
|
|
163
182
|
};
|
|
164
183
|
export declare function extractTrailingToolResults(prompt: LanguageModelV3CallOptions["prompt"]): ExtractedToolResult[];
|
|
184
|
+
/** Decode only image-bearing pending execs, under one continuation-wide budget. */
|
|
185
|
+
export declare function decodeTrailingToolImages(session: CursorSession, results: ExtractedToolResult[], signal?: AbortSignal, maxBytes?: number): Promise<ExtractedToolResult[]>;
|
|
165
186
|
/** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
|
|
166
187
|
export declare function hasApprovedUncorrelatedPlanStageResult(prompt: LanguageModelV3CallOptions["prompt"]): boolean;
|
|
167
188
|
/**
|
|
@@ -179,6 +200,7 @@ export declare function groundCheckpointTurnText(userText: string, checkpoint: b
|
|
|
179
200
|
*/
|
|
180
201
|
export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[], isCompaction: boolean, workspaceRoot: string, options?: {
|
|
181
202
|
knownMcpServers?: Iterable<string>;
|
|
203
|
+
allowTools?: boolean;
|
|
182
204
|
}): string | undefined;
|
|
183
205
|
/** Rough char→token estimate for mid-turn usage before TurnEnded arrives. */
|
|
184
206
|
export declare function estimateTokens(chars: number): number;
|
|
@@ -250,6 +272,12 @@ export declare function spanEndParts(opts: {
|
|
|
250
272
|
}>;
|
|
251
273
|
/** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
|
|
252
274
|
export declare function computeAllowTools(toolCount: number, toolChoice: LanguageModelV3CallOptions["toolChoice"] | undefined): boolean;
|
|
275
|
+
/**
|
|
276
|
+
* OpenCode title/summary take the first non-empty line of the answer. After a
|
|
277
|
+
* lifecycle refuse, Cursor often narrates that tools are unavailable — drop
|
|
278
|
+
* that so it does not become the session title.
|
|
279
|
+
*/
|
|
280
|
+
export declare function shouldDropLifecycleToollessText(text: string, refusedExec: boolean): boolean;
|
|
253
281
|
export declare function resolveTurnToolState(input: {
|
|
254
282
|
sessionKey?: string;
|
|
255
283
|
incomingTools: OpencodeToolDef[];
|