Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/provider/language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ export class CursorLanguageModel implements LanguageModelV3 {
readonly specificationVersion = "v3" as const;
readonly modelId: string;
readonly provider: string;
// Images are passed inline as base64 data, so no URLs are fetched natively.
// The local Cursor agent has no attachment channel, so file parts (images,
// directories, other media) are noted as text in message-map rather than
// fetched or attached; no URLs are resolved natively.
readonly supportedUrls: Record<string, RegExp[]> = {};

constructor(
Expand Down
106 changes: 64 additions & 42 deletions src/provider/message-map.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { LanguageModelV3Prompt } from "@ai-sdk/provider";
import type { SDKImage, SDKUserMessage } from "@cursor/sdk";
import { fileURLToPath } from "node:url";
import type {
LanguageModelV3FilePart,
LanguageModelV3Prompt,
} from "@ai-sdk/provider";
import type { SDKUserMessage } from "@cursor/sdk";

/**
* Caps on inlined tool payloads in the flattened transcript. Tool outputs are
Expand Down Expand Up @@ -32,17 +36,19 @@ function truncate(text: string, cap: number): string {
* The Cursor agent keeps its own per-agent conversation memory, but opencode
* re-sends the whole history each turn. To stay correct without double-counting
* context, we create a fresh agent per turn (see language-model.ts) and flatten
* the entire prompt into one transcript message. Images from the final user
* turn are attached natively so multimodal models can see them.
* the entire prompt into one transcript message. File attachments (images and
* other files alike) are noted as text rather than attached natively: the
* Cursor LOCAL SDK agent — the only backend this chat path uses — cannot accept
* images in any form (URL images throw "URL images are only supported for cloud
* SDK agents"; inline base64 images fail the run with an empty `status:"error"`,
* surfacing as "Cursor run ended with status error" on an `@image` mention).
*/
export function promptToCursorMessage(
prompt: LanguageModelV3Prompt,
): SDKUserMessage {
const lines: string[] = [];
const images: SDKImage[] = [];

prompt.forEach((message, index) => {
const isLast = index === prompt.length - 1;
prompt.forEach((message) => {
switch (message.role) {
case "system":
lines.push(`# System\n${message.content}`);
Expand All @@ -51,16 +57,10 @@ export function promptToCursorMessage(
const text: string[] = [];
for (const part of message.content) {
if (part.type === "text") text.push(part.text);
else if (
part.type === "file" &&
part.mediaType.startsWith("image/")
) {
const image = fileToImage(part.data, part.mediaType);
// Only attach images natively for the final user turn; earlier ones
// are referenced by transcript order.
if (isLast && image) images.push(image);
text.push("[image attached]");
}
// File parts (images and other files) can't be forwarded to the
// local Cursor agent, so note them as text instead of dropping
// them — the agent still learns a file was referenced.
else if (part.type === "file") text.push(fileNote(part));
}
lines.push(`# User\n${text.join("\n")}`);
break;
Expand Down Expand Up @@ -96,27 +96,56 @@ export function promptToCursorMessage(
}
});

const out: SDKUserMessage = { text: lines.join("\n\n") };
if (images.length > 0) out.images = images;
return out;
return { text: lines.join("\n\n") };
}

function fileToImage(
data: string | Uint8Array | URL,
mediaType: string,
): SDKImage | undefined {
if (data instanceof URL) return { url: data.toString() };
if (typeof data === "string") {
// Either a URL or already-base64 encoded data.
if (/^https?:\/\//i.test(data)) return { url: data };
return { data, mimeType: mediaType };
}
if (data instanceof Uint8Array) {
return { data: Buffer.from(data).toString("base64"), mimeType: mediaType };
}
/**
* A short text note standing in for a file attachment that can't be forwarded
* to the local Cursor agent.
*
* opencode hands `@`-mentions to the provider as file parts; `text/plain` and
* directory mentions are already inlined as text upstream, so what reaches the
* provider here is images and other media/binaries. The Cursor LOCAL SDK agent
* (the only backend this chat path uses) cannot accept any of them:
* - `{ url }` images throw `ConfigurationError: URL images are only supported
* for cloud SDK agents`,
* - `{ data, mimeType }` inline-base64 images fail the run with an empty
* `status:"error"` (the "Cursor run ended with status error" a user hits on
* an `@image` mention — regardless of model).
* So rather than attaching — and failing the whole turn — we note the file as
* text. The run completes and the agent still learns a file was referenced.
*/
function fileNote(part: LanguageModelV3FilePart): string {
const name = part.filename ?? describeSource(part.data) ?? "file";
return `[attached file: ${name} (${part.mediaType}) — not forwarded to Cursor]`;
}

/** Matches strings with a URL scheme followed by `://` (http, https, file, …). */
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;

function describeSource(data: string | Uint8Array | URL): string | undefined {
// file:// sources become filesystem paths: the agent runs locally with
// workspace tools that operate on paths, so a path is actionable where a
// file:// href is not.
if (data instanceof URL)
return data.protocol === "file:" ? fileUrlToPath(data) : data.href;
// Only accept strings that look like a URL. AI-SDK file parts may carry
// raw base64 in `data` (no `data:` prefix); returning that verbatim would
// inline the entire blob into the note text.
if (typeof data === "string" && URL_SCHEME.test(data))
return data.startsWith("file://") ? fileUrlToPath(data) : data;
return undefined;
}

/** Convert a file:// URL to a filesystem path, falling back to the href when invalid. */
function fileUrlToPath(url: string | URL): string {
try {
return fileURLToPath(url);
} catch {
return typeof url === "string" ? url : url.href;
}
}

/**
* Extract only the final user turn as a Cursor message. Used when resuming a
* pooled agent that already remembers the prior conversation, so we send just
Expand All @@ -130,17 +159,10 @@ export function latestUserMessage(
if (!last || last.role !== "user") return undefined;

const text: string[] = [];
const images: SDKImage[] = [];
for (const part of last.content) {
if (part.type === "text") text.push(part.text);
else if (part.type === "file" && part.mediaType.startsWith("image/")) {
const image = fileToImage(part.data, part.mediaType);
if (image) images.push(image);
text.push("[image attached]");
}
else if (part.type === "file") text.push(fileNote(part));
}

const out: SDKUserMessage = { text: text.join("\n") };
if (images.length > 0) out.images = images;
return out;
return { text: text.join("\n") };
}
Loading