// Session message extraction and prompt history. // // Fetches session messages from the SDK and extracts user turn text for // the prompt history ring. Also finds the most recently used variant for // the current model so the footer can pre-select it. import { promptCopy, promptSame } from "./prompt.shared" import type { Message, Part } from "@opencode-ai/sdk/v2" import type { RunInput, RunPrompt } from "./types" const LIMIT = 200 export type SessionMessages = Array<{ info: Message; parts: Part[] }> type Turn = { prompt: RunPrompt provider: string | undefined model: string | undefined variant: string | undefined } export type RunSession = { first: boolean turns: Turn[] model?: NonNullable variant?: string } function fileName(url: string, filename?: string) { if (filename) { return filename } try { const next = new URL(url) if (next.protocol !== "file:") { return url } const name = next.pathname.split("/").at(-1) if (name) { return decodeURIComponent(name) } } catch {} return url } function fileSource( part: Extract, text: { start: number; end: number; value: string }, ) { if (part.source) { return { ...structuredClone(part.source), text, } } return { type: "file" as const, path: part.filename ?? part.url, text, } } export function messagePrompt(msg: SessionMessages[number]): RunPrompt { const parts: RunPrompt["parts"] = [] let text = msg.parts .filter((part): part is Extract => { return part.type === "text" && !part.synthetic }) .map((part) => part.text) .join("") let cursor = Bun.stringWidth(text) const used: Array<{ start: number; end: number }> = [] const take = (value: string): { start: number; end: number; value: string } | undefined => { let from = 0 while (true) { const idx = text.indexOf(value, from) if (idx === -1) { return undefined } const start = Bun.stringWidth(text.slice(0, idx)) const end = start + Bun.stringWidth(value) if (!used.some((item) => item.start < end && start < item.end)) { return { start, end, value } } from = idx + value.length } } const add = (value: string) => { const gap = text ? " " : "" const start = cursor + Bun.stringWidth(gap) text += gap + value const end = start + Bun.stringWidth(value) cursor = end return { start, end, value } } for (const part of msg.parts) { if (part.type === "file") { const next = part.source?.text ? structuredClone(part.source.text) : take("@" + fileName(part.url, part.filename)) const span = next ?? add("@" + fileName(part.url, part.filename)) used.push({ start: span.start, end: span.end }) parts.push({ type: "file", mime: part.mime, filename: part.filename, url: part.url, source: fileSource(part, span), }) continue } if (part.type !== "agent") { continue } const span = part.source ? structuredClone(part.source) : (take("@" + part.name) ?? add("@" + part.name)) used.push({ start: span.start, end: span.end }) parts.push({ type: "agent", name: part.name, source: span, }) } return { text, parts } } function turn(msg: SessionMessages[number]): Turn | undefined { if (msg.info.role !== "user") { return undefined } return { prompt: messagePrompt(msg), provider: msg.info.model.providerID, model: msg.info.model.modelID, variant: msg.info.model.variant, } } export function createSession(messages: SessionMessages): RunSession { return { first: messages.length === 0, turns: messages.flatMap((msg) => { const item = turn(msg) return item ? [item] : [] }), } } export async function resolveCurrentSession( sdk: RunInput["sdk"], sessionID: string, limit = LIMIT, ): Promise { const [response, session] = await Promise.all([ sdk.message.list({ sessionID, limit, order: "desc" }), sdk.session.get({ sessionID }), ]) const messages = response.data.toReversed() return { first: messages.length === 0, turns: messages.flatMap((message) => { if (message.type !== "user") return [] return [ { prompt: { text: message.text, parts: [ ...(message.files ?? []).map((file) => ({ type: "file" as const, url: file.uri, mime: file.mime, filename: file.name, source: file.source ? { type: "file" as const, path: file.name ?? file.uri, text: { start: file.source.start, end: file.source.end, value: file.source.text }, } : undefined, })), ...(message.agents ?? []).map((agent) => ({ type: "agent" as const, name: agent.name, source: agent.source ? { start: agent.source.start, end: agent.source.end, value: agent.source.text } : undefined, })), ], }, provider: session.model?.providerID, model: session.model?.id, variant: session.model?.variant, }, ] }), ...(session.model && { model: { providerID: session.model.providerID, modelID: session.model.id, }, variant: session.model.variant, }), } } export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] { const out: RunPrompt[] = [] for (const turn of session.turns) { if (!turn.prompt.text.trim()) { continue } if (out[out.length - 1] && promptSame(out[out.length - 1], turn.prompt)) { continue } out.push(promptCopy(turn.prompt)) } return out.slice(-limit) } export function sessionVariant(session: RunSession, model: RunInput["model"]): string | undefined { if (!model) { return undefined } if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) { return session.variant } for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) { const turn = session.turns[idx] if (turn.provider !== model.providerID || turn.model !== model.modelID) { continue } return turn.variant } return undefined }