import { Decimal } from "decimal.js" import z from "zod/v4" import { type LanguageModelUsage, type ProviderMetadata } from "ai" import PROMPT_INITIALIZE from "../session/prompt/initialize.txt" import { Bus } from "../bus" import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" import type { ModelsDev } from "../provider/models" import { Share } from "../share/share" import { Storage } from "../storage/storage" import { Log } from "../util/log" import { MessageV2 } from "./message-v2" import { Project } from "../project/project" import { Instance } from "../project/instance" import { Token } from "../util/token" import { SessionPrompt } from "./prompt" export namespace Session { const log = Log.create({ service: "session" }) const parentSessionTitlePrefix = "New session - " const childSessionTitlePrefix = "Child session - " function createDefaultTitle(isChild = false) { return (isChild ? childSessionTitlePrefix : parentSessionTitlePrefix) + new Date().toISOString() } export const Info = z .object({ id: Identifier.schema("session"), projectID: z.string(), directory: z.string(), parentID: Identifier.schema("session").optional(), share: z .object({ url: z.string(), }) .optional(), title: z.string(), version: z.string(), time: z.object({ created: z.number(), updated: z.number(), compacting: z.number().optional(), }), revert: z .object({ messageID: z.string(), partID: z.string().optional(), snapshot: z.string().optional(), diff: z.string().optional(), }) .optional(), }) .meta({ ref: "Session", }) export type Info = z.output export const ShareInfo = z .object({ secret: z.string(), url: z.string(), }) .meta({ ref: "SessionShare", }) export type ShareInfo = z.output export const Event = { Updated: Bus.event( "session.updated", z.object({ info: Info, }), ), Deleted: Bus.event( "session.deleted", z.object({ info: Info, }), ), Error: Bus.event( "session.error", z.object({ sessionID: z.string().optional(), error: MessageV2.Assistant.shape.error, }), ), } export async function create(parentID?: string, title?: string) { return createNext({ parentID, directory: Instance.directory, title, }) } export async function touch(sessionID: string) { await update(sessionID, (draft) => { draft.time.updated = Date.now() }) } export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) { const result: Info = { id: Identifier.descending("session", input.id), version: Installation.VERSION, projectID: Instance.project.id, directory: input.directory, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), time: { created: Date.now(), updated: Date.now(), }, } log.info("created", result) await Storage.write(["session", Instance.project.id, result.id], result) const cfg = await Config.get() if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) share(result.id) .then((share) => { update(result.id, (draft) => { draft.share = share }) }) .catch(() => { // Silently ignore sharing errors during session creation }) Bus.publish(Event.Updated, { info: result, }) return result } export async function get(id: string) { const read = await Storage.read(["session", Instance.project.id, id]) return read as Info } export async function getShare(id: string) { return Storage.read(["share", id]) } export async function share(id: string) { const cfg = await Config.get() if (cfg.share === "disabled") { throw new Error("Sharing is disabled in configuration") } const session = await get(id) if (session.share) return session.share const share = await Share.create(id) await update(id, (draft) => { draft.share = { url: share.url, } }) await Storage.write(["share", id], share) await Share.sync("session/info/" + id, session) for (const msg of await messages(id)) { await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info) for (const part of msg.parts) { await Share.sync("session/part/" + id + "/" + msg.info.id + "/" + part.id, part) } } return share } export async function unshare(id: string) { const share = await getShare(id) if (!share) return await Storage.remove(["share", id]) await update(id, (draft) => { draft.share = undefined }) await Share.remove(id, share.secret) } export async function update(id: string, editor: (session: Info) => void) { const project = Instance.project const result = await Storage.update(["session", project.id, id], (draft) => { editor(draft) draft.time.updated = Date.now() }) Bus.publish(Event.Updated, { info: result, }) return result } export async function messages(sessionID: string) { const result = [] as MessageV2.WithParts[] for (const p of await Storage.list(["message", sessionID])) { const read = await Storage.read(p) result.push({ info: read, parts: await getParts(read.id), }) } result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1)) return result } export async function getMessage(sessionID: string, messageID: string) { return { info: await Storage.read(["message", sessionID, messageID]), parts: await getParts(messageID), } } export async function getParts(messageID: string) { const result = [] as MessageV2.Part[] for (const item of await Storage.list(["part", messageID])) { const read = await Storage.read(item) result.push(read) } result.sort((a, b) => (a.id > b.id ? 1 : -1)) return result } export async function* list() { const project = Instance.project for (const item of await Storage.list(["session", project.id])) { yield Storage.read(item) } } export async function children(parentID: string) { const project = Instance.project const result = [] as Session.Info[] for (const item of await Storage.list(["session", project.id])) { const session = await Storage.read(item) if (session.parentID !== parentID) continue result.push(session) } return result } export async function remove(sessionID: string, emitEvent = true) { const project = Instance.project try { const session = await get(sessionID) for (const child of await children(sessionID)) { await remove(child.id, false) } await unshare(sessionID).catch(() => {}) for (const msg of await Storage.list(["message", sessionID])) { for (const part of await Storage.list(["part", msg.at(-1)!])) { await Storage.remove(part) } await Storage.remove(msg) } await Storage.remove(["session", project.id, sessionID]) if (emitEvent) { Bus.publish(Event.Deleted, { info: session, }) } } catch (e) { log.error(e) } } export async function updateMessage(msg: MessageV2.Info) { await Storage.write(["message", msg.sessionID, msg.id], msg) Bus.publish(MessageV2.Event.Updated, { info: msg, }) return msg } export async function removeMessage(sessionID: string, messageID: string) { await Storage.remove(["message", sessionID, messageID]) Bus.publish(MessageV2.Event.Removed, { sessionID, messageID, }) return messageID } export async function updatePart(part: MessageV2.Part) { await Storage.write(["part", part.messageID, part.id], part) Bus.publish(MessageV2.Event.PartUpdated, { part, }) return part } // goes backwards through parts until there are 40_000 tokens worth of tool // calls. then erases output of previous tool calls. idea is to throw away old // tool calls that are no longer relevant. export async function prune(input: { sessionID: string }) { const msgs = await messages(input.sessionID) let sum = 0 for (let msgIndex = msgs.length - 2; msgIndex >= 0; msgIndex--) { const msg = msgs[msgIndex] if (msg.info.role === "assistant" && msg.info.summary) return for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { const part = msg.parts[partIndex] if (part.type === "tool") if (part.state.status === "completed") { if (part.state.time.compacted) return sum += Token.estimate(part.state.output) if (sum > 40_000) { log.info("pruning", { sum, id: part.id, }) part.state.time.compacted = Date.now() await updatePart(part) } } } } } export function getUsage(model: ModelsDev.Model, usage: LanguageModelUsage, metadata?: ProviderMetadata) { const tokens = { input: usage.inputTokens ?? 0, output: usage.outputTokens ?? 0, reasoning: usage?.reasoningTokens ?? 0, cache: { write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? // @ts-expect-error metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? 0) as number, read: usage.cachedInputTokens ?? 0, }, } return { cost: new Decimal(0) .add(new Decimal(tokens.input).mul(model.cost?.input ?? 0).div(1_000_000)) .add(new Decimal(tokens.output).mul(model.cost?.output ?? 0).div(1_000_000)) .add(new Decimal(tokens.cache.read).mul(model.cost?.cache_read ?? 0).div(1_000_000)) .add(new Decimal(tokens.cache.write).mul(model.cost?.cache_write ?? 0).div(1_000_000)) .toNumber(), tokens, } } export class BusyError extends Error { constructor(public readonly sessionID: string) { super(`Session ${sessionID} is busy`) } } export async function initialize(input: { sessionID: string modelID: string providerID: string messageID: string }) { await SessionPrompt.prompt({ sessionID: input.sessionID, messageID: input.messageID, model: { providerID: input.providerID, modelID: input.modelID, }, parts: [ { id: Identifier.ascending("part"), type: "text", text: PROMPT_INITIALIZE.replace("${path}", Instance.worktree), }, ], }) await Project.setInitialized(Instance.project.id) } }