chore: update merge branch with latest v2
This commit is contained in:
commit
7da1598830
45 changed files with 1062 additions and 731 deletions
34
packages/core/src/mime.ts
Normal file
34
packages/core/src/mime.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export * as Mime from "./mime.js"
|
||||
|
||||
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"
|
||||
if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp"
|
||||
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
|
||||
return "image/webp"
|
||||
if (
|
||||
startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) &&
|
||||
(startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) ||
|
||||
startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73]))
|
||||
)
|
||||
return "image/avif"
|
||||
return isText(bytes) ? "text/plain" : "application/octet-stream"
|
||||
}
|
||||
|
||||
function startsWith(bytes: Uint8Array, prefix: number[]) {
|
||||
return prefix.every((value, index) => bytes[index] === value)
|
||||
}
|
||||
|
||||
function isText(bytes: Uint8Array) {
|
||||
if (bytes.length === 0) return true
|
||||
if (bytes.includes(0)) return false
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const controls = bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0)
|
||||
return controls / bytes.length <= 0.3
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -37,6 +37,7 @@ import { SessionCompaction } from "./session/compaction"
|
|||
import { SessionRevert } from "./session/revert"
|
||||
import { Revert } from "@opencode-ai/schema/revert"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Mime } from "./mime"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { Job } from "./job"
|
||||
|
|
@ -44,6 +45,7 @@ import { CommandV2 } from "./command"
|
|||
import { Shell } from "./shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
export const RevertState = Revert.State
|
||||
export type RevertState = Revert.State
|
||||
|
|
@ -119,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,
|
||||
}) {}
|
||||
|
|
@ -133,6 +139,7 @@ export type Error =
|
|||
| MessageDecodeError
|
||||
| OperationUnavailableError
|
||||
| PromptConflictError
|
||||
| AttachmentError
|
||||
| BusyError
|
||||
| SkillNotFoundError
|
||||
| CommandV2.NotFoundError
|
||||
|
|
@ -187,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
|
||||
|
|
@ -201,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
|
||||
|
|
@ -251,6 +258,7 @@ const layer = Layer.effect(
|
|||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
|
|
@ -456,7 +464,7 @@ 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 prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs))
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
|
||||
|
|
@ -713,19 +721,110 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
|
|||
}
|
||||
}
|
||||
|
||||
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 resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const files = input.files
|
||||
? yield* Effect.forEach(
|
||||
input.files,
|
||||
(file) => materializeAttachment(fs, file),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
: undefined
|
||||
return Prompt.make({ text: input.text, agents: input.agents, files })
|
||||
})
|
||||
|
||||
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 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 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) {
|
||||
if (value === null) return
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
// Mirrors the shell tool's in-memory preview safety limit.
|
||||
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
|
@ -742,5 +841,6 @@ export const node = makeGlobalNode({
|
|||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -94,7 +94,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") {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { AgentAttachment, FileAttachment, Prompt, Source } from "@opencode-ai/schema/prompt"
|
||||
|
|
@ -9,16 +9,38 @@ 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 },
|
||||
})
|
||||
|
||||
const textAttachment = (file: FileAttachment) =>
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [
|
||||
`Attached file: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
"",
|
||||
Buffer.from(file.data, "base64").toString("utf8"),
|
||||
]
|
||||
.filter((line): line is string => line !== undefined)
|
||||
.join("\n"),
|
||||
metadata: {
|
||||
attachment: {
|
||||
source: file.source,
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||
|
|
@ -117,11 +139,18 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||
case "model-switched":
|
||||
return []
|
||||
case "user":
|
||||
const files = message.files ?? []
|
||||
return [
|
||||
...files
|
||||
.filter((file) => file.mime === "text/plain")
|
||||
.map(textAttachment),
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: [{ type: "text", text: message.text }, ...(message.files ?? []).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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue