import { Schema } from "effect" import { SessionEvent } from "./session-event" import { produce } from "immer" export const ID = SessionEvent.ID export type ID = Schema.Schema.Type const Base = { id: SessionEvent.ID, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), time: Schema.Struct({ created: Schema.DateTimeUtc, }), } export class User extends Schema.Class("Session.Entry.User")({ ...Base, text: SessionEvent.Prompt.fields.text, files: SessionEvent.Prompt.fields.files, agents: SessionEvent.Prompt.fields.agents, type: Schema.Literal("user"), time: Schema.Struct({ created: Schema.DateTimeUtc, }), }) { static fromEvent(event: SessionEvent.Prompt) { return new User({ id: event.id, type: "user", metadata: event.metadata, text: event.text, files: event.files, agents: event.agents, time: { created: event.timestamp }, }) } } export class Synthetic extends Schema.Class("Session.Entry.Synthetic")({ ...SessionEvent.Synthetic.fields, ...Base, type: Schema.Literal("synthetic"), }) {} export class ToolStatePending extends Schema.Class("Session.Entry.ToolState.Pending")({ status: Schema.Literal("pending"), input: Schema.String, }) {} export class ToolStateRunning extends Schema.Class("Session.Entry.ToolState.Running")({ status: Schema.Literal("running"), input: Schema.Record(Schema.String, Schema.Unknown), title: Schema.String.pipe(Schema.optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} export class ToolStateCompleted extends Schema.Class("Session.Entry.ToolState.Completed")({ status: Schema.Literal("completed"), input: Schema.Record(Schema.String, Schema.Unknown), output: Schema.String, title: Schema.String, metadata: Schema.Record(Schema.String, Schema.Unknown), attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), }) {} export class ToolStateError extends Schema.Class("Session.Entry.ToolState.Error")({ status: Schema.Literal("error"), input: Schema.Record(Schema.String, Schema.Unknown), error: Schema.String, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) export type ToolState = Schema.Schema.Type export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ type: Schema.Literal("tool"), callID: Schema.String, name: Schema.String, state: ToolState, time: Schema.Struct({ created: Schema.DateTimeUtc, ran: Schema.DateTimeUtc.pipe(Schema.optional), completed: Schema.DateTimeUtc.pipe(Schema.optional), pruned: Schema.DateTimeUtc.pipe(Schema.optional), }), }) {} export class AssistantText extends Schema.Class("Session.Entry.Assistant.Text")({ type: Schema.Literal("text"), text: Schema.String, }) {} export class AssistantReasoning extends Schema.Class("Session.Entry.Assistant.Reasoning")({ type: Schema.Literal("reasoning"), text: Schema.String, }) {} export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]) export type AssistantContent = Schema.Schema.Type export class Assistant extends Schema.Class("Session.Entry.Assistant")({ ...Base, type: Schema.Literal("assistant"), content: AssistantContent.pipe(Schema.Array), cost: Schema.Number.pipe(Schema.optional), tokens: Schema.Struct({ input: Schema.Number, output: Schema.Number, reasoning: Schema.Number, cache: Schema.Struct({ read: Schema.Number, write: Schema.Number, }), }).pipe(Schema.optional), error: Schema.String.pipe(Schema.optional), time: Schema.Struct({ created: Schema.DateTimeUtc, completed: Schema.DateTimeUtc.pipe(Schema.optional), }), }) {} export class Compaction extends Schema.Class("Session.Entry.Compaction")({ ...SessionEvent.Compacted.fields, type: Schema.Literal("compaction"), ...Base, }) {} export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]) export type Entry = Schema.Schema.Type export type Type = Entry["type"] export type History = { entries: Entry[] pending: Entry[] } export function step(old: History, event: SessionEvent.Event): History { return produce(old, (draft) => { const lastAssistant = draft.entries.findLast((x) => x.type === "assistant") const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined switch (event.type) { case "prompt": { if (pendingAssistant) { // @ts-expect-error draft.pending.push(User.fromEvent(event)) break } // @ts-expect-error draft.entries.push(User.fromEvent(event)) break } case "step.started": { if (pendingAssistant) pendingAssistant.time.completed = event.timestamp draft.entries.push({ id: event.id, type: "assistant", time: { created: event.timestamp, }, content: [], }) break } case "text.started": { if (!pendingAssistant) break pendingAssistant.content.push({ type: "text", text: "", }) break } case "text.delta": { if (!pendingAssistant) break const match = pendingAssistant.content.findLast((x) => x.type === "text") if (match) match.text += event.delta break } case "text.ended": { break } case "tool.input.started": { if (!pendingAssistant) break pendingAssistant.content.push({ type: "tool", callID: event.callID, name: event.name, time: { created: event.timestamp, }, state: { status: "pending", input: "", }, }) break } case "tool.input.delta": { if (!pendingAssistant) break const match = pendingAssistant.content.findLast((x) => x.type === "tool") // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) if (match) match.state.input += event.delta break } case "tool.input.ended": { break } case "tool.called": { if (!pendingAssistant) break const match = pendingAssistant.content.findLast((x) => x.type === "tool") if (match) { match.time.ran = event.timestamp match.state = { status: "running", input: event.input, } } break } case "tool.success": { if (!pendingAssistant) break const match = pendingAssistant.content.findLast((x) => x.type === "tool") if (match && match.state.status === "running") { match.state = { status: "completed", input: match.state.input, output: event.output ?? "", title: event.title, metadata: event.metadata ?? {}, // @ts-expect-error attachments: event.attachments ?? [], } } break } case "tool.error": { if (!pendingAssistant) break const match = pendingAssistant.content.findLast((x) => x.type === "tool") if (match && match.state.status === "running") { match.state = { status: "error", error: event.error, input: match.state.input, metadata: event.metadata ?? {}, } } break } case "reasoning.started": { if (!pendingAssistant) break pendingAssistant.content.push({ type: "reasoning", text: "", }) break } case "reasoning.delta": { if (!pendingAssistant) break const match = pendingAssistant.content.findLast((x) => x.type === "reasoning") if (match) match.text += event.delta break } case "reasoning.ended": { if (!pendingAssistant) break const match = pendingAssistant.content.findLast((x) => x.type === "reasoning") if (match) match.text = event.text break } case "step.ended": { if (!pendingAssistant) break pendingAssistant.time.completed = event.timestamp pendingAssistant.cost = event.cost pendingAssistant.tokens = event.tokens break } } }) } /* export interface Interface { readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry readonly fromSession: (sessionID: SessionID) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionEntry") {} export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const decodeEntry = Schema.decodeUnknownSync(Entry) const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type }) const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) { return Database.use((db) => db .select() .from(SessionEntryTable) .where(eq(SessionEntryTable.session_id, sessionID)) .orderBy(SessionEntryTable.id) .all() .map((row) => decode(row)), ) }) return Service.of({ decode, fromSession, }) }), ) */ export * as SessionEntry from "./session-entry"