export * as SessionV2 from "./session" export * from "./session/schema" import { DateTime, Effect, Layer, Schema, Context } from "effect" import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "drizzle-orm" import { Project } from "./project" import { WorkspaceV2 } from "./workspace" import { ModelV2 } from "./model" import { Location } from "./location" import { SessionMessage } from "./session/message" import type { Prompt } from "./session/prompt" import { EventV2 } from "./event" import { ProviderV2 } from "./provider" import { Database } from "./database/database" import { SessionMessageTable, SessionTable } from "./session/sql" import { SessionSchema } from "./session/schema" // get project -> project.locations // // get all sessions // // - by project // - by subpath // - by workspace (home is special) type Cursor = { id: SessionSchema.ID time: number direction: "previous" | "next" } type ListInput = { workspaceID?: WorkspaceV2.ID projectID?: Project.ID path?: string roots?: boolean start?: number search?: string cursor?: Cursor limit?: number order?: "asc" | "desc" directory?: string } type CreateInput = { id?: SessionSchema.ID agent?: string model?: ModelV2.Ref location?: Location.Ref parentID?: SessionSchema.ID workspaceID?: WorkspaceV2.ID } type MoveInput = { sessionID: SessionSchema.ID location: Location.Ref } type CompactInput = { sessionID: SessionSchema.ID prompt?: Prompt } export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { sessionID: SessionSchema.ID, }) {} export class OperationUnavailableError extends Schema.TaggedErrorClass()( "Session.OperationUnavailableError", { operation: Schema.Literals(["prompt", "compact", "wait"]), }, ) {} export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.MessageDecodeError", { sessionID: SessionSchema.ID, messageID: SessionMessage.ID, }) {} export type Error = NotFoundError | OperationUnavailableError | MessageDecodeError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect readonly move: (input: MoveInput) => Effect.Effect readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly messages: (input: { sessionID: SessionSchema.ID limit?: number order?: "asc" | "desc" cursor?: { id: SessionMessage.ID time: number direction: "previous" | "next" } }) => Effect.Effect readonly context: (sessionID: SessionSchema.ID) => Effect.Effect readonly subagent: (input: { id?: EventV2.ID parentID: SessionSchema.ID prompt: Prompt agent: string model?: ModelV2.Ref resume?: boolean }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect readonly prompt: (input: { id?: EventV2.ID sessionID: SessionSchema.ID prompt: Prompt delivery?: SessionSchema.Delivery resume?: boolean }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID sessionID: SessionSchema.ID command: string delivery?: SessionSchema.Delivery resume?: boolean }) => Effect.Effect readonly skill: (input: { id?: EventV2.ID sessionID: SessionSchema.ID skill: string delivery?: SessionSchema.Delivery resume?: boolean }) => Effect.Effect readonly compact: (input: CompactInput | SessionSchema.ID) => Effect.Effect readonly wait: (id: SessionSchema.ID) => Effect.Effect readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Session") {} function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { return new SessionSchema.Info({ id: SessionSchema.ID.make(row.id), projectID: Project.ID.make(row.project_id), workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined, title: row.title, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, path: row.path ?? "", agent: row.agent ?? undefined, model: row.model ? { id: ModelV2.ID.make(row.model.id), providerID: ProviderV2.ID.make(row.model.providerID), variant: ModelV2.VariantID.make(row.model.variant ?? "default"), } : undefined, cost: row.cost, tokens: { input: row.tokens_input, output: row.tokens_output, reasoning: row.tokens_reasoning, cache: { read: row.tokens_cache_read, write: row.tokens_cache_write, }, }, time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, }, }) } export const layer = Layer.effect( Service, Effect.gen(function* () { const db = yield* Database.Service const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( Effect.mapError( () => new MessageDecodeError({ sessionID: SessionSchema.ID.make(row.session_id), messageID: SessionMessage.ID.make(row.id), }), ), ) const result = Service.of({ create: Effect.fn("V2Session.create")(function* () { return {} as SessionSchema.Info }), get: Effect.fn("V2Session.get")(function* (sessionID) { const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie) if (!row) return yield* new NotFoundError({ sessionID }) return fromRow(row) }), list: Effect.fn("V2Session.list")(function* (input = {}) { const direction = input.cursor?.direction ?? "next" const requestedOrder = input.order ?? "desc" const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder const sortColumn = SessionTable.time_updated const conditions: SQL[] = [] if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) if (input.path) conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if (input.projectID) conditions.push(eq(SessionTable.project_id, input.projectID)) if (input.roots) conditions.push(isNull(SessionTable.parent_id)) if (input.start) conditions.push(gte(sortColumn, input.start)) if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) if (input.cursor) { conditions.push( order === "asc" ? or(gt(sortColumn, input.cursor.time), and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)))! : or(lt(sortColumn, input.cursor.time), and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)))!, ) } const query = db .select() .from(SessionTable) .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy( order === "asc" ? asc(sortColumn) : desc(sortColumn), order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), ) const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(Effect.orDie) return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) }), messages: Effect.fn("V2Session.messages")(function* (input) { yield* result.get(input.sessionID) const direction = input.cursor?.direction ?? "next" const requestedOrder = input.order ?? "desc" const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder const boundary = input.cursor ? order === "asc" ? or( gt(SessionMessageTable.time_created, input.cursor.time), and(eq(SessionMessageTable.time_created, input.cursor.time), gt(SessionMessageTable.id, input.cursor.id)), ) : or( lt(SessionMessageTable.time_created, input.cursor.time), and(eq(SessionMessageTable.time_created, input.cursor.time), lt(SessionMessageTable.id, input.cursor.id)), ) : undefined const where = boundary ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) : eq(SessionMessageTable.session_id, input.sessionID) const query = db .select() .from(SessionMessageTable) .where(where) .orderBy( order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), ) const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(Effect.orDie) return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, (row) => decode(row)) }), context: Effect.fn("V2Session.context")(function* (sessionID) { yield* result.get(sessionID) const compaction = yield* db .select() .from(SessionMessageTable) .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) .limit(1) .get() .pipe(Effect.orDie) const rows = yield* db .select() .from(SessionMessageTable) .where( and( eq(SessionMessageTable.session_id, sessionID), compaction ? or( gt(SessionMessageTable.time_created, compaction.time_created), and(eq(SessionMessageTable.time_created, compaction.time_created), gte(SessionMessageTable.id, compaction.id)), ) : undefined, ), ) .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) .all() .pipe(Effect.orDie) return yield* Effect.forEach(rows, (row) => decode(row)) }), prompt: Effect.fn("V2Session.prompt")(function* (input) { yield* result.get(input.sessionID) return yield* new OperationUnavailableError({ operation: "prompt" }) }), shell: Effect.fn("V2Session.shell")(function* () {}), skill: Effect.fn("V2Session.skill")(function* () {}), switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}), switchModel: Effect.fn("V2Session.switchModel")(function* () {}), subagent: Effect.fn("V2Session.subagent")(function* (input) { yield* result.get(input.parentID) return yield* new OperationUnavailableError({ operation: "prompt" }) }), compact: Effect.fn("V2Session.compact")(function* (input) { const sessionID = typeof input === "string" ? input : input.sessionID yield* result.get(sessionID) return yield* new OperationUnavailableError({ operation: "compact" }) }), wait: Effect.fn("V2Session.wait")(function* (sessionID) { yield* result.get(sessionID) return yield* new OperationUnavailableError({ operation: "wait" }) }), resume: Effect.fn("V2Session.resume")(function* () {}), move: Effect.fn("V2Session.move")(function* () {}), }) return result }), ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.orDie)