refactor(server): canonicalize service API (#31049)

This commit is contained in:
Dax 2026-06-06 23:27:28 -04:00 committed by GitHub
commit fe0c4f8c74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
388 changed files with 7075 additions and 4064 deletions

View file

@ -0,0 +1,19 @@
export function collapseToolOutput(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) {
return { output, overflow: false }
}
const preview = lines.slice(0, maxLines).join("\n")
if (Array.from(preview).length > maxChars) {
return {
output:
Array.from(preview)
.slice(0, Math.max(0, maxChars - 1))
.join("") + "…",
overflow: true,
}
}
return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true }
}

View file

@ -0,0 +1,181 @@
import { isRecord } from "./record"
type ConfigIssue = { message: string; path: string[] }
export function cliErrorMessage(input: unknown): string | undefined {
if (input instanceof Error && isRecord(input.cause) && "body" in input.cause) {
const formatted = cliErrorMessage(input.cause.body)
if (formatted) return formatted
}
if (tagged(input, "CliError")) {
if (typeof input.exitCode === "number") process.exitCode = input.exitCode
return field(input, "message") ?? ""
}
if (tagged(input, "AccountServiceError") || tagged(input, "AccountTransportError")) {
return field(input, "message") ?? ""
}
const model = configData(input, "ProviderModelNotFoundError")
if (model) {
const suggestions = Array.isArray(model.suggestions)
? model.suggestions.filter((item): item is string => typeof item === "string")
: []
return [
`Model not found: ${field(model, "providerID")}/${field(model, "modelID")}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
"Try: `opencode models` to list available models",
"Or check your config (opencode.json) provider/model names",
].join("\n")
}
const provider = configData(input, "ProviderInitError")
if (provider) return `Failed to initialize provider "${field(provider, "providerID")}". Check credentials and configuration.`
const json = configData(input, "ConfigJsonError")
if (json) {
const message = field(json, "message")
return `Config file at ${field(json, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "")
}
const directory = configData(input, "ConfigDirectoryTypoError")
if (directory) {
return `Directory "${field(directory, "dir")}" in ${field(directory, "path")} is not valid. Rename the directory to "${field(directory, "suggestion")}" or remove it. This is a common typo.`
}
const frontmatter = configData(input, "ConfigFrontmatterError")
if (frontmatter) return field(frontmatter, "message") ?? ""
const invalid = configData(input, "ConfigInvalidError")
if (invalid) {
const path = field(invalid, "path")
const message = field(invalid, "message")
const issues = Array.isArray(invalid.issues)
? invalid.issues.filter((issue): issue is ConfigIssue => {
return (
isRecord(issue) &&
typeof issue.message === "string" &&
Array.isArray(issue.path) &&
issue.path.every((item) => typeof item === "string")
)
})
: []
return [
`Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""),
...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")),
].join("\n")
}
if (tagged(input, "UICancelledError") || named(input, "UICancelledError")) return ""
if (isRecord(input) && named(input, "MCPFailed")) {
const name = isRecord(input.data) ? field(input.data, "name") : undefined
return `MCP server "${name}" failed. Note, opencode does not support MCP authentication yet.`
}
return undefined
}
function tagged(input: unknown, tag: string): input is Record<string, unknown> {
return isRecord(input) && input._tag === tag
}
function named(input: unknown, name: string) {
return isRecord(input) && (input.name === name || input._tag === name)
}
function configData(input: unknown, tag: string) {
if (!isRecord(input)) return undefined
if (input.name === tag && isRecord(input.data)) return input.data
if (input._tag === tag) return input
return undefined
}
function field(input: Record<string, unknown>, key: string) {
return typeof input[key] === "string" ? input[key] : undefined
}
export function errorFormat(error: unknown): string {
if (error instanceof Error) {
return error.stack ?? `${error.name}: ${error.message}`
}
if (typeof error === "object" && error !== null) {
try {
const json = JSON.stringify(error, null, 2)
// Plain objects whose own properties are all non-enumerable (or empty)
// serialize to "{}", which prints as a useless bare `{}` on stderr.
// Fall back to a custom toString first, then to ctor name + own prop names.
if (json === "{}") {
const str = String(error)
if (str && str !== "[object Object]") return str
const ctor = error.constructor?.name
const prefix = ctor && ctor !== "Object" ? ctor : "Error"
const names = Object.getOwnPropertyNames(error)
return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }`
}
return json
} catch {
return "Unexpected error (unserializable)"
}
}
return String(error)
}
export function errorMessage(error: unknown): string {
if (error instanceof Error) {
if (error.message) return error.message
if (error.name) return error.name
}
if (isRecord(error) && typeof error.message === "string" && error.message) {
return error.message
}
if (isRecord(error) && isRecord(error.data) && typeof error.data.message === "string" && error.data.message) {
return error.data.message
}
const text = String(error)
if (text && text !== "[object Object]") return text
const formatted = errorFormat(error)
if (formatted) return formatted
return "unknown error"
}
export function errorData(error: unknown) {
if (error instanceof Error) {
return {
type: error.name,
message: errorMessage(error),
stack: error.stack,
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
formatted: errorFormat(error),
}
}
if (!isRecord(error)) {
return {
type: typeof error,
message: errorMessage(error),
formatted: errorFormat(error),
}
}
const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
const value = error[key]
if (value === undefined) return acc
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
acc[key] = value
return acc
}
// oxlint-disable-next-line no-base-to-string -- intentional coercion of arbitrary error properties
acc[key] = value instanceof Error ? value.message : String(value)
return acc
}, {})
if (typeof data.message !== "string") data.message = errorMessage(error)
if (typeof data.type !== "string") data.type = error.constructor?.name
data.formatted = errorFormat(error)
return data
}

View file

@ -0,0 +1,130 @@
import path from "node:path"
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".abap": "abap",
".bat": "bat",
".bib": "bibtex",
".bibtex": "bibtex",
".clj": "clojure",
".cljs": "clojure",
".cljc": "clojure",
".edn": "clojure",
".coffee": "coffeescript",
".c": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".c++": "cpp",
".cs": "csharp",
".csx": "csharp",
".css": "css",
".d": "d",
".pas": "pascal",
".pascal": "pascal",
".diff": "diff",
".patch": "diff",
".dart": "dart",
".dockerfile": "dockerfile",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".ets": "typescript",
".hrl": "erlang",
".fs": "fsharp",
".fsi": "fsharp",
".fsx": "fsharp",
".fsscript": "fsharp",
".gitcommit": "git-commit",
".gitrebase": "git-rebase",
".go": "go",
".groovy": "groovy",
".gleam": "gleam",
".hbs": "handlebars",
".handlebars": "handlebars",
".hs": "haskell",
".lhs": "haskell",
".html": "html",
".htm": "html",
".ini": "ini",
".java": "java",
".jl": "julia",
".js": "javascript",
".kt": "kotlin",
".kts": "kotlin",
".jsx": "javascriptreact",
".json": "json",
".tex": "latex",
".latex": "latex",
".less": "less",
".lua": "lua",
".makefile": "makefile",
makefile: "makefile",
".md": "markdown",
".markdown": "markdown",
".m": "objective-c",
".mm": "objective-cpp",
".pl": "perl",
".pm": "perl",
".pm6": "perl6",
".php": "php",
".ps1": "powershell",
".psm1": "powershell",
".pug": "jade",
".jade": "jade",
".py": "python",
".r": "r",
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rake": "ruby",
".gemspec": "ruby",
".ru": "ruby",
".erb": "erb",
".html.erb": "erb",
".js.erb": "erb",
".css.erb": "erb",
".json.erb": "erb",
".rs": "rust",
".scss": "scss",
".sass": "sass",
".scala": "scala",
".shader": "shaderlab",
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".ksh": "shellscript",
".sql": "sql",
".svelte": "svelte",
".swift": "swift",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".mtsx": "typescriptreact",
".ctsx": "typescriptreact",
".xml": "xml",
".xsl": "xsl",
".yaml": "yaml",
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
".vue": "vue",
".zig": "zig",
".zon": "zig",
".astro": "astro",
".ml": "ocaml",
".mli": "ocaml",
".tf": "terraform",
".tfvars": "terraform-vars",
".hcl": "hcl",
".nix": "nix",
".typ": "typst",
".typc": "typst",
}
export function filetype(input?: string) {
if (!input) return "none"
const language = LANGUAGE_EXTENSIONS[path.extname(input)]
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
return language
}

View file

@ -0,0 +1,20 @@
export function formatDuration(secs: number) {
if (secs <= 0) return ""
if (secs < 60) return `${secs}s`
if (secs < 3600) {
const mins = Math.floor(secs / 60)
const remaining = secs % 60
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
}
if (secs < 86400) {
const hours = Math.floor(secs / 3600)
const remaining = Math.floor((secs % 3600) / 60)
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
}
if (secs < 604800) {
const days = Math.floor(secs / 86400)
return days === 1 ? "~1 day" : `~${days} days`
}
const weeks = Math.floor(secs / 604800)
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
}

View file

@ -0,0 +1,25 @@
import type { BaseRenderable, BoxRenderable } from "@opentui/core"
const previousByParent = new WeakMap<
BaseRenderable,
{ frameID: number; previous: WeakMap<BaseRenderable, BaseRenderable | undefined> }
>()
export function setPreLayoutSiblingMargin(el: BoxRenderable, margin: (previous?: BaseRenderable) => number) {
// Run before Yoga layout so scroll geometry matches the rendered frame.
el.onLifecyclePass = () => {
const parent = el.parent
if (!parent) return
const cached = previousByParent.get(parent)
const previous = cached?.frameID === el.ctx.frameId ? cached.previous : previousSiblings(parent, el.ctx.frameId)
const value = margin(previous.get(el))
if (el.marginTop !== value) el.marginTop = value
}
}
function previousSiblings(parent: BaseRenderable, frameID: number) {
const previous = new WeakMap<BaseRenderable, BaseRenderable | undefined>()
parent.getChildren().forEach((child, index, children) => previous.set(child, children[index - 1]))
previousByParent.set(parent, { frameID, previous })
return previous
}

View file

@ -0,0 +1,86 @@
export function titlecase(str: string) {
return str.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function time(input: number): string {
const date = new Date(input)
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
}
export function datetime(input: number): string {
const date = new Date(input)
const localTime = time(input)
const localDate = date.toLocaleDateString()
return `${localTime} · ${localDate}`
}
export function todayTimeOrDateTime(input: number): string {
const date = new Date(input)
const now = new Date()
const isToday =
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) {
return time(input)
} else {
return datetime(input)
}
}
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + "K"
}
return num.toString()
}
export function duration(input: number) {
if (input < 1000) {
return `${input}ms`
}
if (input < 60000) {
return `${(input / 1000).toFixed(1)}s`
}
if (input < 3600000) {
const minutes = Math.floor(input / 60000)
const seconds = Math.floor((input % 60000) / 1000)
return `${minutes}m ${seconds}s`
}
if (input < 86400000) {
const hours = Math.floor(input / 3600000)
const minutes = Math.floor((input % 3600000) / 60000)
return `${hours}h ${minutes}m`
}
const hours = Math.floor(input / 3600000)
const days = Math.floor((input % 3600000) / 86400000)
return `${days}d ${hours}h`
}
export function truncate(str: string, len: number): string {
if (str.length <= len) return str
return str.slice(0, len - 1) + "…"
}
export function truncateLeft(str: string, len: number): string {
if (str.length <= len) return str
return "…" + str.slice(-(len - 1))
}
export function truncateMiddle(str: string, maxLength: number = 35): string {
if (str.length <= maxLength) return str
const ellipsis = "…"
const keepStart = Math.ceil((maxLength - ellipsis.length) / 2)
const keepEnd = Math.floor((maxLength - ellipsis.length) / 2)
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
}
export function pluralize(count: number, singular: string, plural: string): string {
const template = count === 1 ? singular : plural
return template.replace("{}", count.toString())
}
export * as Locale from "./locale"

View file

@ -0,0 +1,28 @@
import type { Provider } from "@opencode-ai/sdk/v2"
export function parse(value: string) {
const [providerID, ...modelID] = value.split("/")
return { providerID, modelID: modelID.join("/") }
}
export function index(list: Provider[] | undefined) {
return new Map((list ?? []).map((item) => [item.id, item] as const))
}
export function get(list: Provider[] | ReadonlyMap<string, Provider> | undefined, providerID: string, modelID: string) {
const provider =
list instanceof Map
? list.get(providerID)
: Array.isArray(list)
? list.find((item) => item.id === providerID)
: undefined
return provider?.models[modelID]
}
export function name(
list: Provider[] | ReadonlyMap<string, Provider> | undefined,
providerID: string,
modelID: string,
) {
return get(list, providerID, modelID)?.name ?? modelID
}

View file

@ -0,0 +1,12 @@
import { realpathSync } from "node:fs"
import { win32 } from "node:path"
export function normalizePath(input: string, platform: string) {
if (platform !== "win32") return input
const resolved = win32.normalize(win32.resolve(input.replaceAll("/", "\\")))
try {
return realpathSync.native(resolved)
} catch {
return resolved
}
}

View file

@ -0,0 +1,33 @@
import path from "path"
import { appendFile, mkdir, rename, rm } from "fs/promises"
export function readText(filePath: string) {
return Bun.file(filePath).text()
}
export function readJson<T>(filePath: string) {
return Bun.file(filePath).json() as Promise<T>
}
export async function writeText(filePath: string, content: string) {
await mkdir(path.dirname(filePath), { recursive: true })
await Bun.write(filePath, content)
}
export async function appendText(filePath: string, content: string) {
await mkdir(path.dirname(filePath), { recursive: true })
await appendFile(filePath, content)
}
export async function writeJsonAtomic(filePath: string, value: unknown) {
await mkdir(path.dirname(filePath), { recursive: true })
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`
await Bun.write(temporary, JSON.stringify(value)).catch(async (error) => {
await rm(temporary, { force: true }).catch(() => undefined)
throw error
})
await rename(temporary, filePath).catch(async (error) => {
await rm(temporary, { force: true }).catch(() => undefined)
throw error
})
}

View file

@ -0,0 +1,38 @@
const logo = {
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
}
const reset = "\x1b[0m"
const bold = "\x1b[1m"
const dim = "\x1b[90m"
function wordmark(pad = "") {
const draw = (line: string, fg: string, shadow: string, bg: string) =>
[...line]
.map((char) => {
if (char === "_") return `${bg} ${reset}`
if (char === "^") return `${fg}${bg}${reset}`
if (char === "~") return `${shadow}${reset}`
if (char === " ") return " "
return `${fg}${char}${reset}`
})
.join("")
return logo.left.map((line, index) => {
const left = draw(line, dim, "\x1b[38;5;235m", "\x1b[48;5;235m")
const right = draw(logo.right[index] ?? "", reset, "\x1b[38;5;238m", "\x1b[48;5;238m")
return `${pad}${left} ${right}`
})
}
export function sessionExitSummary(input: { title: string; sessionID?: string }) {
const weak = (text: string) => `${dim}${text.padEnd(10, " ")}${reset}`
return [
...wordmark(" "),
"",
` ${weak("Session")}${bold}${input.title}${reset}`,
` ${weak("Continue")}${bold}opencode -s ${input.sessionID}${reset}`,
"",
].join("\n")
}

View file

@ -0,0 +1,7 @@
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
Array.isArray(consoleManagedProviders)
? consoleManagedProviders.includes(providerID)
: consoleManagedProviders.has(providerID)
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
contains(consoleManagedProviders, providerID)

View file

@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}

View file

@ -0,0 +1,18 @@
import { parsePatch } from "diff"
export function getRevertDiffFiles(diffText: string) {
if (!diffText) return []
try {
return parsePatch(diffText).map((patch) => {
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
return {
filename: filename.replace(/^[ab]\//, ""),
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
}
})
} catch {
return []
}
}

View file

@ -0,0 +1,27 @@
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
export type ScrollConfig = {
scroll_acceleration?: { enabled?: boolean }
scroll_speed?: number
}
export class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
tick(_now?: number): number {
return this.speed
}
reset(): void {}
}
export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAcceleration {
if (tuiConfig?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
if (tuiConfig?.scroll_speed !== undefined) {
return new CustomSpeedScroll(tuiConfig.scroll_speed)
}
return new CustomSpeedScroll(3)
}

View file

@ -0,0 +1,79 @@
import type { TuiPlatform } from "../platform"
type Toast = {
show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void
error: (err: unknown) => void
}
type FocusableSelectionTarget = {
hasSelection: () => boolean
getClipboardText?: (text: string) => string
}
type Renderer = {
getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null
clearSelection: () => void
currentFocusedRenderable?: FocusableSelectionTarget | null
}
type SelectionKeyEvent = {
ctrl?: boolean
name: string
preventDefault: () => void
stopPropagation: () => void
}
export function copy(renderer: Renderer, toast: Toast, clipboard: TuiPlatform["clipboard"]): boolean {
const selection = renderer.getSelection()
if (!selection) return false
const text = selection.getSelectedText()
if (!text) return false
const focus = renderer.currentFocusedRenderable
const clipboardText =
focus?.getClipboardText && selection.selectedRenderables.includes(focus) ? focus.getClipboardText(text) : text
clipboard
?.write?.(clipboardText)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
return true
}
export function handleSelectionKey(
renderer: Renderer,
toast: Toast,
event: SelectionKeyEvent,
clipboard: TuiPlatform["clipboard"],
) {
const selection = renderer.getSelection()
if (!selection) return
if (event.ctrl && event.name === "c") {
if (!copy(renderer, toast, clipboard)) {
renderer.clearSelection()
return
}
event.preventDefault()
event.stopPropagation()
return
}
if (event.name === "escape") {
renderer.clearSelection()
event.preventDefault()
event.stopPropagation()
return
}
const focus = renderer.currentFocusedRenderable
if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return
renderer.clearSelection()
}
export * as Selection from "./selection"

View file

@ -0,0 +1,3 @@
export function isDefaultTitle(title: string) {
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
}

View file

@ -0,0 +1,41 @@
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { debounce, type Scheduled } from "@solid-primitives/scheduled"
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
const [get, set] = createSignal(value)
return [get, debounce((v: T) => set(() => v), ms)]
}
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
let revealed = show()
createEffect(
on([show, enabled], ([visible, animate]) => {
if (!visible) {
setAlpha(0)
return
}
if (!animate || revealed) {
revealed = true
setAlpha(1)
return
}
const start = performance.now()
revealed = true
setAlpha(0)
const timer = setInterval(() => {
const progress = Math.min((performance.now() - start) / 160, 1)
setAlpha(progress * progress * (3 - 2 * progress))
if (progress >= 1) clearInterval(timer)
}, 16)
onCleanup(() => clearInterval(timer))
}),
)
return alpha
}

View file

@ -0,0 +1,13 @@
export function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
return "Web Search"
}
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
if (!state || typeof state !== "object" || Array.isArray(state)) return {}
if (!("status" in state) || state.status === "pending") return {}
if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {}
if (Array.isArray(state.structured)) return {}
return state.structured as Record<string, unknown>
}

View file

@ -0,0 +1,112 @@
import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "./locale"
import * as Model from "./model"
export type TranscriptOptions = {
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
providers?: Provider[]
}
export type SessionInfo = {
id: string
title: string
time: {
created: number
updated: number
}
}
export type MessageWithParts = {
info: UserMessage | AssistantMessage
parts: Part[]
}
export function formatTranscript(
session: SessionInfo,
messages: MessageWithParts[],
options: TranscriptOptions,
): string {
const providers = Model.index(options.providers)
let transcript = `# ${session.title}\n\n`
transcript += `**Session ID:** ${session.id}\n`
transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
transcript += `---\n\n`
for (const msg of messages) {
transcript += formatMessage(msg.info, msg.parts, options, providers)
transcript += `---\n\n`
}
return transcript
}
export function formatMessage(
msg: UserMessage | AssistantMessage,
parts: Part[],
options: TranscriptOptions,
providers?: Provider[] | ReadonlyMap<string, Provider>,
): string {
let result = ""
if (msg.role === "user") {
result += `## User\n\n`
} else {
result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers)
}
for (const part of parts) {
result += formatPart(part, options)
}
return result
}
export function formatAssistantHeader(
msg: AssistantMessage,
includeMetadata: boolean,
providers?: Provider[] | ReadonlyMap<string, Provider>,
): string {
if (!includeMetadata) {
return `## Assistant\n\n`
}
const duration =
msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : ""
const modelName = Model.name(providers, msg.providerID, msg.modelID)
return `## Assistant (${Locale.titlecase(msg.agent)} · ${modelName}${duration ? ` · ${duration}` : ""})\n\n`
}
export function formatPart(part: Part, options: TranscriptOptions): string {
if (part.type === "text" && !part.synthetic) {
return `${part.text}\n\n`
}
if (part.type === "reasoning") {
if (options.thinking) {
return `_Thinking:_\n\n${part.text}\n\n`
}
return ""
}
if (part.type === "tool") {
let result = `**Tool: ${part.tool}**\n`
if (options.toolDetails && part.state.input) {
result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n`
}
if (options.toolDetails && part.state.status === "completed" && part.state.output) {
result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n`
}
if (options.toolDetails && part.state.status === "error" && part.state.error) {
result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n`
}
result += `\n`
return result
}
return ""
}