feat(tui): use canonical prompt attachments · argszero/opencode@c13f06c · GitHub
Skip to content

Commit c13f06c

Browse files
committed
feat(tui): use canonical prompt attachments
1 parent 91f1815 commit c13f06c

24 files changed

Lines changed: 611 additions & 466 deletions

File tree

packages/client/src/promise/generated/types.ts

Lines changed: 10 additions & 0 deletions

packages/core/src/mime.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
export * as Mime from "./mime.js"
2+
3+
import { Effect, FileSystem, Option } from "effect"
4+
import { fileURLToPath } from "url"
5+
import { FSUtil } from "./fs-util"
6+
7+
const SAMPLE_BYTES = 8192
8+
9+
export const resolve = Effect.fn("Mime.resolve")(function* (uri: string) {
10+
const data = dataSample(uri)
11+
if (data) return detect(data)
12+
13+
const target = yield* Effect.try({
14+
try: () => localPath(uri),
15+
catch: () => new Error("Invalid file URI"),
16+
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
17+
if (!target) return "application/octet-stream"
18+
19+
const fs = yield* FSUtil.Service
20+
const local = yield* Effect.scoped(
21+
Effect.gen(function* () {
22+
const info = yield* fs.stat(target)
23+
if (info.type === "Directory") return { type: "directory" as const }
24+
if (info.type !== "File") return
25+
const file = yield* fs.open(target)
26+
return {
27+
type: "file" as const,
28+
sample: Option.getOrElse(yield* file.readAlloc(FileSystem.Size(SAMPLE_BYTES)), () => new Uint8Array()),
29+
}
30+
}),
31+
).pipe(Effect.catch(() => Effect.succeed(undefined)))
32+
33+
if (local?.type === "directory") return "application/x-directory"
34+
if (local?.type === "file") return detect(local.sample)
35+
return "application/octet-stream"
36+
})
37+
38+
function detect(bytes: Uint8Array) {
39+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
40+
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
41+
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
42+
if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp"
43+
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
44+
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
45+
return "image/webp"
46+
if (
47+
startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) &&
48+
(startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) ||
49+
startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73]))
50+
)
51+
return "image/avif"
52+
return isText(bytes) ? "text/plain" : "application/octet-stream"
53+
}
54+
55+
function dataSample(uri: string) {
56+
if (!uri.startsWith("data:")) return
57+
const comma = uri.indexOf(",")
58+
if (comma === -1) return new Uint8Array()
59+
const metadata = uri.slice(5, comma)
60+
const payload = uri.slice(comma + 1)
61+
if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) {
62+
return Buffer.from(payload.slice(0, Math.ceil((SAMPLE_BYTES * 4) / 3) + 4), "base64").subarray(0, SAMPLE_BYTES)
63+
}
64+
return new TextEncoder().encode(payload.slice(0, SAMPLE_BYTES))
65+
}
66+
67+
function localPath(uri: string) {
68+
if (!URL.canParse(uri)) return
69+
const url = new URL(uri)
70+
if (url.protocol !== "file:") return
71+
return fileURLToPath(url)
72+
}
73+
74+
function startsWith(bytes: Uint8Array, prefix: number[]) {
75+
return prefix.every((value, index) => bytes[index] === value)
76+
}
77+
78+
function isText(bytes: Uint8Array) {
79+
if (bytes.length === 0) return true
80+
if (bytes.includes(0)) return false
81+
try {
82+
new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true })
83+
} catch {
84+
return false
85+
}
86+
const controls = bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0)
87+
return controls / bytes.length <= 0.3
88+
}

packages/core/src/session.ts

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ import { SessionCompaction } from "./session/compaction"
3737
import { SessionRevert } from "./session/revert"
3838
import { Revert } from "@opencode-ai/schema/revert"
3939
import { FSUtil } from "./fs-util"
40+
import { Mime } from "./mime"
4041
import type { EventLog } from "@opencode-ai/schema/event-log"
4142
import { SkillV2 } from "./skill"
4243
import { Job } from "./job"
4344
import { CommandV2 } from "./command"
4445
import { Shell } from "./shell"
4546
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
4647
import { KeyedMutex } from "./effect/keyed-mutex"
48+
import { fileURLToPath } from "url"
4749

4850
export const RevertState = Revert.State
4951
export type RevertState = Revert.State
@@ -251,6 +253,7 @@ const layer = Layer.effect(
251253
const execution = yield* SessionExecution.Service
252254
const store = yield* SessionStore.Service
253255
const locations = yield* LocationServiceMap.Service
256+
const fs = yield* FSUtil.Service
254257
const jobs = yield* Job.Service
255258
const scope = yield* Scope.Scope
256259
const activeShells = new Set<SessionSchema.ID>()
@@ -456,7 +459,7 @@ const layer = Layer.effect(
456459
// continues from the reverted boundary rather than stale post-boundary history.
457460
if (session.revert)
458461
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
459-
const prompt = resolvePrompt(input.prompt)
462+
const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs))
460463
const messageID = input.id ?? SessionMessage.ID.create()
461464
const delivery = input.delivery ?? "steer"
462465
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
@@ -713,19 +716,52 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
713716
}
714717
}
715718

716-
const resolvePrompt = (input: PromptInput.Prompt) =>
717-
Prompt.make({
718-
text: input.text,
719-
agents: input.agents,
720-
files: input.files?.map((file) => {
721-
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
722-
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
723-
return {
724-
...file,
725-
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
726-
}
719+
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
720+
const fs = yield* FSUtil.Service
721+
const files = input.files
722+
? yield* Effect.forEach(
723+
input.files,
724+
(file) =>
725+
Effect.gen(function* () {
726+
const mime = yield* Mime.resolve(file.uri)
727+
const content = mime === "text/plain" ? yield* readTextAttachment(fs, file.uri) : undefined
728+
return { ...file, mime, ...(content === undefined ? {} : { content }) }
729+
}),
730+
{ concurrency: 8 },
731+
)
732+
: undefined
733+
return Prompt.make({ text: input.text, agents: input.agents, files })
734+
})
735+
736+
function readTextAttachment(fs: FSUtil.Interface, uri: string) {
737+
if (uri.startsWith("data:")) return Effect.succeed(undefined)
738+
return Effect.try({
739+
try: () => new URL(uri),
740+
catch: () => new Error("Invalid attachment URI"),
741+
}).pipe(
742+
Effect.flatMap((url) => {
743+
if (url.protocol !== "file:") return Effect.succeed(undefined)
744+
const start = positiveInt(url.searchParams.get("start"))
745+
const end = positiveInt(url.searchParams.get("end"))
746+
url.search = ""
747+
url.hash = ""
748+
return Effect.try({
749+
try: () => fileURLToPath(url),
750+
catch: () => new Error("Invalid file URI"),
751+
}).pipe(
752+
Effect.flatMap((target) => fs.readFileString(target)),
753+
Effect.map((content) => (start === undefined ? content : content.split("\n").slice(start - 1, end).join("\n"))),
754+
)
727755
}),
728-
})
756+
Effect.catch(() => Effect.succeed(undefined)),
757+
)
758+
}
759+
760+
function positiveInt(value: string | null) {
761+
if (value === null) return
762+
const parsed = Number(value)
763+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
764+
}
729765

730766
// Mirrors the shell tool's in-memory preview safety limit.
731767
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
@@ -742,5 +778,6 @@ export const node = makeGlobalNode({
742778
SessionStore.node,
743779
LocationServiceMap.node,
744780
SessionProjector.node,
781+
FSUtil.node,
745782
],
746783
})

packages/core/src/session/runner/to-llm-message.ts

Lines changed: 40 additions & 1 deletion

0 commit comments

Comments
 (0)