feat(core): resolve prompt attachments

This commit is contained in:
Dax Raad 2026-07-06 15:04:23 -04:00
commit 4d100de194
29 changed files with 610 additions and 426 deletions

View file

@ -1,41 +1,6 @@
export * as Mime from "./mime.js"
import { Effect, FileSystem, Option } from "effect"
import { fileURLToPath } from "url"
import { FSUtil } from "./fs-util"
const SAMPLE_BYTES = 8192
export const resolve = Effect.fn("Mime.resolve")(function* (uri: string) {
const data = dataSample(uri)
if (data) return detect(data)
const target = yield* Effect.try({
try: () => localPath(uri),
catch: () => new Error("Invalid file URI"),
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!target) return "application/octet-stream"
const fs = yield* FSUtil.Service
const local = yield* Effect.scoped(
Effect.gen(function* () {
const info = yield* fs.stat(target)
if (info.type === "Directory") return { type: "directory" as const }
if (info.type !== "File") return
const file = yield* fs.open(target)
return {
type: "file" as const,
sample: Option.getOrElse(yield* file.readAlloc(FileSystem.Size(SAMPLE_BYTES)), () => new Uint8Array()),
}
}),
).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (local?.type === "directory") return "application/x-directory"
if (local?.type === "file") return detect(local.sample)
return "application/octet-stream"
})
function detect(bytes: Uint8Array) {
export function detect(bytes: Uint8Array) {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
@ -52,25 +17,6 @@ function detect(bytes: Uint8Array) {
return isText(bytes) ? "text/plain" : "application/octet-stream"
}
function dataSample(uri: string) {
if (!uri.startsWith("data:")) return
const comma = uri.indexOf(",")
if (comma === -1) return new Uint8Array()
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) {
return Buffer.from(payload.slice(0, Math.ceil((SAMPLE_BYTES * 4) / 3) + 4), "base64").subarray(0, SAMPLE_BYTES)
}
return new TextEncoder().encode(payload.slice(0, SAMPLE_BYTES))
}
function localPath(uri: string) {
if (!URL.canParse(uri)) return
const url = new URL(uri)
if (url.protocol !== "file:") return
return fileURLToPath(url)
}
function startsWith(bytes: Uint8Array, prefix: number[]) {
return prefix.every((value, index) => bytes[index] === value)
}

View file

@ -9,7 +9,7 @@ import { WorkspaceV2 } from "./workspace"
import { ModelV2 } from "./model"
import { Location } from "./location"
import { SessionMessage } from "./session/message"
import { Prompt } from "./session/prompt"
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { EventV2 } from "./event"
import { Database } from "./database/database"
@ -121,6 +121,10 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export class AttachmentError extends Schema.TaggedErrorClass<AttachmentError>()("Session.AttachmentError", {
uri: Schema.String,
message: Schema.String,
}) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
@ -135,6 +139,7 @@ export type Error =
| MessageDecodeError
| OperationUnavailableError
| PromptConflictError
| AttachmentError
| BusyError
| SkillNotFoundError
| CommandV2.NotFoundError
@ -189,7 +194,7 @@ export interface Interface {
prompt: PromptInput.Prompt
delivery?: SessionInput.Delivery
resume?: boolean
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError | AttachmentError>
readonly command: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@ -203,7 +208,7 @@ export interface Interface {
resume?: boolean
}) => Effect.Effect<
SessionInput.Admitted,
NotFoundError | PromptConflictError | CommandV2.NotFoundError | CommandV2.EvaluationError
NotFoundError | PromptConflictError | AttachmentError | CommandV2.NotFoundError | CommandV2.EvaluationError
>
readonly shell: (input: {
id?: EventV2.ID
@ -721,40 +726,98 @@ const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: Pro
const files = input.files
? yield* Effect.forEach(
input.files,
(file) =>
Effect.gen(function* () {
const mime = yield* Mime.resolve(file.uri)
const content = mime === "text/plain" ? yield* readTextAttachment(fs, file.uri) : undefined
return { ...file, mime, ...(content === undefined ? {} : { content }) }
}),
(file) => materializeAttachment(fs, file),
{ concurrency: 8 },
)
: undefined
return Prompt.make({ text: input.text, agents: input.agents, files })
})
function readTextAttachment(fs: FSUtil.Interface, uri: string) {
if (uri.startsWith("data:")) return Effect.succeed(undefined)
return Effect.try({
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(function* (
fs: FSUtil.Interface,
input: PromptInput.FileAttachment,
) {
const resolved = input.uri.startsWith("data:")
? {
bytes: yield* decodeDataURL(input.uri),
source: { type: "inline" as const },
start: undefined,
end: undefined,
name: undefined,
}
: yield* readFileAttachment(fs, input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri: input.uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
})
const mime = Mime.detect(resolved.bytes)
const content =
mime === "text/plain" && resolved.start !== undefined
? Buffer.from(
Buffer.from(resolved.bytes).toString("utf8").split("\n").slice(resolved.start - 1, resolved.end).join("\n"),
)
: resolved.bytes
return FileAttachment.create({
data: Base64.make(Buffer.from(content).toString("base64")),
mime,
source: resolved.source,
name: input.name ?? resolved.name,
description: input.description,
mention: input.mention,
})
})
const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (fs: FSUtil.Interface, uri: string) {
const url = yield* Effect.try({
try: () => new URL(uri),
catch: () => new Error("Invalid attachment URI"),
}).pipe(
Effect.flatMap((url) => {
if (url.protocol !== "file:") return Effect.succeed(undefined)
const start = positiveInt(url.searchParams.get("start"))
const end = positiveInt(url.searchParams.get("end"))
catch: () => new AttachmentError({ uri, message: `Invalid attachment URI: ${uri}` }),
})
if (url.protocol !== "file:")
return yield* new AttachmentError({ uri, message: `Unsupported attachment URI: ${uri}` })
const start = positiveInt(url.searchParams.get("start"))
const end = positiveInt(url.searchParams.get("end"))
const target = yield* Effect.try({
try: () => {
url.search = ""
url.hash = ""
return Effect.try({
try: () => fileURLToPath(url),
catch: () => new Error("Invalid file URI"),
}).pipe(
Effect.flatMap((target) => fs.readFileString(target)),
Effect.map((content) => (start === undefined ? content : content.split("\n").slice(start - 1, end).join("\n"))),
)
}),
Effect.catch(() => Effect.succeed(undefined)),
return fileURLToPath(url)
},
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
})
const info = yield* fs.stat(target).pipe(
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
)
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({
uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
})
const bytes = yield* fs.readFile(target).pipe(
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })),
)
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target) }
})
function decodeDataURL(uri: string) {
return Effect.try({
try: () => {
const comma = uri.indexOf(",")
if (comma === -1) throw new Error("Invalid data URL")
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (!metadata.split(";").some((part) => part.toLowerCase() === "base64"))
return Buffer.from(decodeURIComponent(payload))
const bytes = Buffer.from(payload, "base64")
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
return bytes
},
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
})
}
function positiveInt(value: string | null) {

View file

@ -106,7 +106,11 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
const serialize = (message: SessionMessage.Message) => {
if (message.type === "user") {
const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? []
const files =
message.files?.map(
(file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? []
return [`[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "assistant") {

View file

@ -7,7 +7,7 @@ import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { Prompt } from "./prompt"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionSchema } from "./schema"
import { SessionInputTable, SessionMessageTable } from "./sql"

View file

@ -1 +0,0 @@
export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"

View file

@ -9,12 +9,14 @@ import {
} from "@opencode-ai/llm"
import { Option, Schema } from "effect"
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
data: file.uri,
data: file.data,
filename: file.name,
metadata: file.description === undefined ? undefined : { description: file.description },
})
@ -23,37 +25,22 @@ const textAttachment = (file: FileAttachment) =>
Message.make({
role: "user",
content: [
`Attached file: ${file.name ?? file.uri}`,
`Source: ${file.uri}`,
`Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
"",
file.content ?? readTextData(file.uri) ?? "[Attachment content unavailable]",
Buffer.from(file.data, "base64").toString("utf8"),
]
.filter((line): line is string => line !== undefined)
.join("\n"),
metadata: {
attachment: {
uri: file.uri,
source: file.source,
name: file.name,
description: file.description,
},
},
})
function readTextData(uri: string) {
if (!uri.startsWith("data:")) return
const comma = uri.indexOf(",")
if (comma === -1) return
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) return Buffer.from(payload, "base64").toString("utf8")
try {
return decodeURIComponent(payload)
} catch {
return
}
}
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const toolInput = (tool: SessionMessage.AssistantTool) =>
@ -160,7 +147,10 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({
id: message.id,
role: "user",
content: [{ type: "text", text: message.text }, ...files.filter((file) => file.mime !== "text/plain").map(media)],
content: [
{ type: "text", text: message.text },
...files.filter((file) => imageMimes.has(file.mime)).map(media),
],
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),

View file

@ -2,7 +2,7 @@ import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from
import { directoryColumn, pathColumn } from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Prompt } from "./prompt"
import type { Prompt } from "@opencode-ai/schema/prompt"
import type { SessionInput } from "./input"
import type { Snapshot } from "../snapshot"
import { PermissionV1 } from "../v1/permission"