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