diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index 252f9ca0b2..f625cf3c5d 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
-import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
+import { DateTime, Effect, Exit, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
@@ -23,6 +23,7 @@ import { InstallationVersion } from "./installation/version"
import { Slug } from "./util/slug"
import { ProjectTable } from "./project/sql"
import path from "path"
+import { fileURLToPath } from "url"
import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
@@ -37,6 +38,7 @@ import { SessionCompaction } from "./session/compaction"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
+import { FileSystem } from "./filesystem"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
import { SkillV2 } from "./skill"
@@ -419,7 +421,14 @@ export const layer = Layer.effect(
// continues from the reverted boundary rather than stale post-boundary history.
if (session.revert)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
- const prompt = resolvePrompt(input.prompt)
+ const hasFileURL = input.prompt.files?.some(
+ (file) => URL.canParse(file.uri) && new URL(file.uri).protocol === "file:",
+ )
+ const prompt = !input.prompt.files?.length
+ ? Prompt.make({ text: input.prompt.text, agents: input.prompt.agents })
+ : hasFileURL
+ ? yield* resolvePrompt(input.prompt).pipe(Effect.provide(locations.get(session.location)))
+ : resolveInlinePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
@@ -458,7 +467,9 @@ export const layer = Layer.effect(
text: skill.content,
})
if (input.resume !== false)
- yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
+ yield* execution
+ .resume(input.sessionID)
+ .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
@@ -524,7 +535,9 @@ export const layer = Layer.effect(
timestamp: yield* DateTime.now,
text: input.text,
})
- yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
+ yield* execution
+ .resume(input.sessionID)
+ .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
@@ -569,19 +582,231 @@ export const defaultLayer = layer.pipe(
Layer.orDie,
)
-const resolvePrompt = (input: PromptInput.Prompt) =>
- Prompt.make({
- text: input.text,
- agents: input.agents,
- files: input.files?.map((file) => {
- const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
- const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
- return {
- ...file,
- mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
- }
- }),
+const MAX_TEXT_ATTACHMENT_BYTES = 256 * 1024
+const MAX_DIRECTORY_ENTRIES = 200
+
+const textMime = (mime: string) =>
+ mime.startsWith("text/") ||
+ mime === "application/json" ||
+ mime === "application/xml" ||
+ mime === "application/yaml" ||
+ mime === "application/x-yaml" ||
+ mime === "application/toml" ||
+ mime === "application/x-toml" ||
+ mime.endsWith("+json") ||
+ mime.endsWith("+xml")
+
+const mediaMime = (mime: string) => mime.startsWith("image/") || mime === "application/pdf"
+
+const textExtensions = new Set([
+ ".c",
+ ".cc",
+ ".cpp",
+ ".css",
+ ".go",
+ ".html",
+ ".java",
+ ".js",
+ ".jsx",
+ ".json",
+ ".jsonc",
+ ".md",
+ ".mjs",
+ ".py",
+ ".rs",
+ ".sh",
+ ".sql",
+ ".ts",
+ ".tsx",
+ ".txt",
+ ".xml",
+ ".yaml",
+ ".yml",
+])
+
+const textFile = (filepath: string, mime: string) => textMime(mime) || textExtensions.has(path.extname(filepath))
+
+const dataMime = (uri: string) => uri.match(/^data:([^;,]+)[;,]/i)?.[1]?.toLowerCase()
+
+function decodeDataText(uri: string) {
+ const comma = uri.indexOf(",")
+ if (comma === -1) return ""
+ const header = uri.slice(0, comma).toLowerCase()
+ const data = uri.slice(comma + 1)
+ if (header.endsWith(";base64")) return Buffer.from(data, "base64").toString("utf8")
+ return decodeURIComponent(data)
+}
+
+function formatAttachmentText(input: { name?: string; uri: string; text: string; truncated?: boolean }) {
+ const title = input.name ?? input.uri
+ const suffix = input.truncated ? "\n[Attachment truncated]" : ""
+ return `\n${input.text}${suffix}\n`
+}
+
+function selectedLines(text: string, url: URL) {
+ const start = url.searchParams.get("start")
+ if (start === null) return { text, truncated: text.length > MAX_TEXT_ATTACHMENT_BYTES }
+ const startLine = Math.max(parseInt(start), 1)
+ const end = url.searchParams.get("end")
+ const endLine = end === null ? startLine : Math.max(parseInt(end), startLine)
+ return {
+ text: text
+ .split("\n")
+ .slice(startLine - 1, endLine)
+ .join("\n"),
+ truncated: false,
+ }
+}
+
+function truncateAttachmentText(text: string, truncated: boolean) {
+ if (text.length <= MAX_TEXT_ATTACHMENT_BYTES) return { text, truncated }
+ return { text: text.slice(0, MAX_TEXT_ATTACHMENT_BYTES), truncated: true }
+}
+
+function attachmentError(input: { name?: string; uri: string; message: string }) {
+ return formatAttachmentText({
+ name: input.name,
+ uri: input.uri,
+ text: `[Unable to read attachment: ${input.message}]`,
})
+}
+
+function resolveInlineFileAttachment(file: PromptInput.FileAttachment) {
+ const mime = dataMime(file.uri)
+ if (mime) {
+ if (textMime(mime)) {
+ return {
+ file: { ...file, mime },
+ text: formatAttachmentText({ name: file.name, uri: file.uri, text: decodeDataText(file.uri) }),
+ }
+ }
+ return { file: { ...file, mime } }
+ }
+ if (!URL.canParse(file.uri)) {
+ return {
+ file: { ...file, mime: "application/octet-stream" },
+ text: attachmentError({ name: file.name, uri: file.uri, message: "unsupported attachment URI" }),
+ }
+ }
+ const url = new URL(file.uri)
+ const target = url.pathname || file.name || file.uri
+ return { file: { ...file, mime: target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target) } }
+}
+
+function resolveInlinePrompt(input: PromptInput.Prompt) {
+ const files = (input.files ?? []).map(resolveInlineFileAttachment)
+ return Prompt.make({
+ text: [input.text, ...files.flatMap((file) => (file.text ? [file.text] : []))].filter(Boolean).join("\n\n"),
+ agents: input.agents,
+ files: files.map((file) => file.file),
+ })
+}
+
+const resolveFileAttachment = Effect.fn("Session.resolveFileAttachment")(function* (file: PromptInput.FileAttachment) {
+ const mime = dataMime(file.uri)
+ if (mime) {
+ if (textMime(mime)) {
+ return {
+ file: { ...file, mime },
+ text: formatAttachmentText({ name: file.name, uri: file.uri, text: decodeDataText(file.uri) }),
+ }
+ }
+ return { file: { ...file, mime } }
+ }
+
+ if (!URL.canParse(file.uri)) {
+ return {
+ file: { ...file, mime: "application/octet-stream" },
+ text: attachmentError({ name: file.name, uri: file.uri, message: "unsupported attachment URI" }),
+ }
+ }
+
+ const url = new URL(file.uri)
+ if (url.protocol !== "file:") {
+ const target = url.pathname || file.name || file.uri
+ return { file: { ...file, mime: target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target) } }
+ }
+
+ const location = yield* Location.Service
+ const filesystem = yield* FileSystem.Service
+ const filepath = fileURLToPath(url)
+ const relative = path.relative(location.directory, filepath)
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
+ return {
+ file: { ...file, mime: FSUtil.mimeType(filepath) },
+ text: attachmentError({
+ name: file.name ?? filepath,
+ uri: file.uri,
+ message: "file is outside the session location",
+ }),
+ }
+ }
+
+ const target = RelativePath.make(relative.split(path.sep).join("/"))
+ const listed = yield* filesystem.list({ path: target }).pipe(Effect.exit)
+ if (Exit.isSuccess(listed)) {
+ const entries = listed.value
+ const visible = entries
+ .slice(0, MAX_DIRECTORY_ENTRIES)
+ .map((entry) => `${entry.path}${entry.type === "directory" ? "/" : ""}`)
+ .join("\n")
+ return {
+ file: { ...file, mime: "application/x-directory" },
+ text: formatAttachmentText({
+ name: file.name ?? filepath,
+ uri: file.uri,
+ text: visible || "[Directory is empty]",
+ truncated: entries.length > MAX_DIRECTORY_ENTRIES,
+ }),
+ }
+ }
+
+ const read = yield* filesystem.read({ path: target }).pipe(Effect.exit)
+ if (Exit.isFailure(read)) {
+ return {
+ file: { ...file, mime: FSUtil.mimeType(filepath) },
+ text: attachmentError({ name: file.name ?? filepath, uri: file.uri, message: "file not found" }),
+ }
+ }
+ const content = read.value
+
+ if (textFile(filepath, content.mime)) {
+ const selected = selectedLines(Buffer.from(content.content).toString("utf8"), url)
+ const truncated = truncateAttachmentText(selected.text, selected.truncated)
+ return {
+ file: { ...file, mime: "text/plain" },
+ text: formatAttachmentText({ name: file.name ?? filepath, uri: file.uri, ...truncated }),
+ }
+ }
+
+ if (mediaMime(content.mime)) {
+ return {
+ file: {
+ ...file,
+ uri: `data:${content.mime};base64,${Buffer.from(content.content).toString("base64")}`,
+ mime: content.mime,
+ },
+ }
+ }
+
+ return {
+ file: { ...file, mime: content.mime },
+ text: attachmentError({
+ name: file.name ?? filepath,
+ uri: file.uri,
+ message: `unsupported file type ${content.mime}`,
+ }),
+ }
+})
+
+const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
+ const files = yield* Effect.forEach(input.files ?? [], resolveFileAttachment, { concurrency: "unbounded" })
+ return Prompt.make({
+ text: [input.text, ...files.flatMap((file) => (file.text ? [file.text] : []))].filter(Boolean).join("\n\n"),
+ agents: input.agents,
+ files: files.map((file) => file.file),
+ })
+})
export const node = makeGlobalNode({
service: Service,
diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts
index 17e13dc767..a15da4a723 100644
--- a/packages/core/src/session/runner/to-llm-message.ts
+++ b/packages/core/src/session/runner/to-llm-message.ts
@@ -10,13 +10,19 @@ import {
import { SessionMessage } from "../message"
import type { FileAttachment } from "../prompt"
-const media = (file: FileAttachment): ContentPart => ({
- type: "media",
- mediaType: file.mime,
- data: file.uri,
- filename: file.name,
- metadata: file.description === undefined ? undefined : { description: file.description },
-})
+const media = (file: FileAttachment): ContentPart[] => {
+ if (file.mime === "text/plain" || file.mime === "application/x-directory") return []
+ if (!file.mime.startsWith("image/") && file.mime !== "application/pdf") return []
+ return [
+ {
+ type: "media",
+ mediaType: file.mime,
+ data: file.uri,
+ filename: file.name,
+ metadata: file.description === undefined ? undefined : { description: file.description },
+ },
+ ]
+}
const toolInput = (tool: SessionMessage.AssistantTool) => {
if (tool.state.status !== "pending") return tool.state.input
@@ -122,7 +128,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({
id: message.id,
role: "user",
- content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
+ content: [{ type: "text", text: message.text }, ...(message.files ?? []).flatMap(media)],
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),
diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts
index 666e9d2fde..ed3f7e5207 100644
--- a/packages/core/test/session-prompt.test.ts
+++ b/packages/core/test/session-prompt.test.ts
@@ -1,4 +1,8 @@
import { describe, expect } from "bun:test"
+import { mkdir, mkdtemp, writeFile } from "fs/promises"
+import os from "os"
+import path from "path"
+import { pathToFileURL } from "url"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
@@ -88,6 +92,30 @@ const setup = Effect.gen(function* () {
.pipe(Effect.orDie)
})
+const setupSession = (input: { id: SessionV2.ID; directory: string }) =>
+ Effect.gen(function* () {
+ const { db } = yield* Database.Service
+ yield* db
+ .insert(ProjectTable)
+ .values({ id: Project.ID.global, worktree: AbsolutePath.make(input.directory), sandboxes: [] })
+ .onConflictDoNothing()
+ .run()
+ .pipe(Effect.orDie)
+ yield* db
+ .insert(SessionTable)
+ .values({
+ id: input.id,
+ project_id: Project.ID.global,
+ slug: input.id,
+ directory: input.directory,
+ title: "test",
+ version: "test",
+ })
+ .onConflictDoNothing()
+ .run()
+ .pipe(Effect.orDie)
+ })
+
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInput.find(db, id))
const admittedCount = Database.Service.use(({ db }) =>
db
@@ -114,7 +142,11 @@ const eventCount = (type: string) =>
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const assistantRow = (id: SessionMessage.ID, seq: number) => {
- const { id: _, type, ...data } = encodeMessage(
+ const {
+ id: _,
+ type,
+ ...data
+ } = encodeMessage(
SessionMessage.Assistant.make({
id,
type: "assistant",
@@ -191,6 +223,85 @@ describe("SessionV2.prompt", () => {
}),
)
+ it.effect("resolves tagged text files into durable prompt text", () =>
+ Effect.gen(function* () {
+ const dir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-prompt-")))
+ const file = path.join(dir, "src", "example.ts")
+ yield* Effect.promise(() => mkdir(path.dirname(file), { recursive: true }))
+ yield* Effect.promise(() => writeFile(file, ["one", "two", "three"].join("\n")))
+ const id = SessionV2.ID.make("ses_prompt_file")
+ yield* setupSession({ id, directory: dir })
+ const session = yield* SessionV2.Service
+
+ const message = yield* session.prompt({
+ sessionID: id,
+ prompt: {
+ text: "explain this",
+ files: [
+ {
+ uri: `${pathToFileURL(file).href}?start=2&end=3`,
+ name: "src/example.ts#2-3",
+ source: { start: 13, end: 29, text: "@src/example.ts#2-3" },
+ },
+ ],
+ },
+ resume: false,
+ })
+
+ expect(message.prompt.text).toContain("explain this")
+ expect(message.prompt.text).toContain('')
+ expect(message.prompt.text).toContain("two\nthree")
+ expect(message.prompt.files?.[0]?.mime).toBe("text/plain")
+ expect(message.prompt.files?.[0]?.source?.text).toBe("@src/example.ts#2-3")
+ }),
+ )
+
+ it.effect("resolves tagged directories into durable prompt text", () =>
+ Effect.gen(function* () {
+ const dir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-prompt-dir-")))
+ yield* Effect.promise(() => mkdir(path.join(dir, "src", "nested"), { recursive: true }))
+ yield* Effect.promise(() => writeFile(path.join(dir, "src", "index.ts"), "export {}"))
+ const id = SessionV2.ID.make("ses_prompt_directory")
+ yield* setupSession({ id, directory: dir })
+ const session = yield* SessionV2.Service
+
+ const message = yield* session.prompt({
+ sessionID: id,
+ prompt: {
+ text: "inspect directory",
+ files: [{ uri: pathToFileURL(path.join(dir, "src")).href, name: "src/" }],
+ },
+ resume: false,
+ })
+
+ expect(message.prompt.text).toContain('')
+ expect(message.prompt.text).toContain("index.ts")
+ expect(message.prompt.text).toContain("nested/")
+ expect(message.prompt.files?.[0]?.mime).toBe("application/x-directory")
+ }),
+ )
+
+ it.effect("materializes local media attachments as data urls", () =>
+ Effect.gen(function* () {
+ const dir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-prompt-image-")))
+ const file = path.join(dir, "pixel.png")
+ yield* Effect.promise(() => writeFile(file, Buffer.from("iVBORw0KGgo=", "base64")))
+ const id = SessionV2.ID.make("ses_prompt_media")
+ yield* setupSession({ id, directory: dir })
+ const session = yield* SessionV2.Service
+
+ const message = yield* session.prompt({
+ sessionID: id,
+ prompt: { text: "look", files: [{ uri: pathToFileURL(file).href, name: "pixel.png" }] },
+ resume: false,
+ })
+
+ expect(message.prompt.text).toBe("look")
+ expect(message.prompt.files?.[0]?.mime).toBe("image/png")
+ expect(message.prompt.files?.[0]?.uri.startsWith("data:image/png;base64,")).toBe(true)
+ }),
+ )
+
it.effect("commits a staged revert before admitting a new prompt", () =>
Effect.gen(function* () {
yield* setup