From ffaff1505225d9b16481fd57b23ba8808ac6e692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?= <108566281+Blueteemo@users.noreply.github.com> Date: Sun, 10 May 2026 04:28:21 +0800 Subject: [PATCH 1/2] fix: normalize Windows desktop session paths --- packages/app/src/utils/path-key.ts | 42 +- packages/opencode/src/session/session.ts | 1880 +++++++++++----------- 2 files changed, 962 insertions(+), 960 deletions(-) diff --git a/packages/app/src/utils/path-key.ts b/packages/app/src/utils/path-key.ts index 68d53e91d863..482de05e1cac 100644 --- a/packages/app/src/utils/path-key.ts +++ b/packages/app/src/utils/path-key.ts @@ -1,24 +1,18 @@ -export type PathKey = string & { _brand: "PathKey" } - -const isDrive = (value: string) => { - if (value.length !== 2) return false - const code = value.charCodeAt(0) - return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) -} - -const trimTrailingSlashes = (value: string) => { - for (let i = value.length - 1; i >= 0; i--) { - if (value[i] !== "/") return value.slice(0, i + 1) - } - return "" -} - -const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\") - -export const pathKey = (path: string) => { - const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path - const trimmed = trimTrailingSlashes(value) - if (!trimmed && value.startsWith("/")) return "/" as PathKey - if (isDrive(trimmed)) return `${trimmed}/` as PathKey - return trimmed as PathKey -} +export type PathKey = string & { _brand: "PathKey" } + +const isDrive = (value: string) => { + if (value.length !== 2) return false + const code = value.charCodeAt(0) + return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) +} + +const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\") + +export const pathKey = (path: string) => { + let value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path + if (/^\/+$/.test(value)) return "/" as PathKey + if (isDrive(value)) return `${value}/` as PathKey + const trimmed = value.replace(/\/+$/, "") + if (!trimmed) return "/" as PathKey + return trimmed as PathKey +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 510d82a7313e..2b7b87e4b97d 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,936 +1,944 @@ -import { Slug } from "@opencode-ai/core/util/slug" -import path from "path" -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { Decimal } from "decimal.js" -import { type ProviderMetadata, type LanguageModelUsage } from "ai" -import { Flag } from "@opencode-ai/core/flag/flag" -import { InstallationVersion } from "@opencode-ai/core/installation/version" - -import { Database } from "@/storage/db" -import { NotFoundError } from "@/storage/storage" -import { eq } from "drizzle-orm" -import { and } from "drizzle-orm" -import { gte } from "drizzle-orm" -import { isNull } from "drizzle-orm" -import { desc } from "drizzle-orm" -import { like } from "drizzle-orm" -import { inArray } from "drizzle-orm" -import { lt } from "drizzle-orm" -import { or } from "drizzle-orm" -import { SyncEvent } from "../sync" -import type { SQL } from "drizzle-orm" -import { PartTable, SessionTable } from "./session.sql" -import { ProjectTable } from "../project/project.sql" -import { Storage } from "@/storage/storage" -import * as Log from "@opencode-ai/core/util/log" -import { MessageV2 } from "./message-v2" -import type { InstanceContext } from "../project/instance" -import { InstanceState } from "@/effect/instance-state" -import { Snapshot } from "@/snapshot" -import { ProjectID } from "../project/schema" -import { WorkspaceID } from "../control-plane/schema" -import { SessionID, MessageID, PartID } from "./schema" -import { ModelID, ProviderID } from "@/provider/schema" - -import type { Provider } from "@/provider/provider" -import { Permission } from "@/permission" -import { Global } from "@opencode-ai/core/global" -import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { zod } from "@opencode-ai/core/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" - -const log = Log.create({ service: "session" }) - -const parentTitlePrefix = "New session - " -const childTitlePrefix = "Child session - " - -function createDefaultTitle(isChild = false) { - return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() -} - -export function isDefaultTitle(title: string) { - return new RegExp( - `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, - ).test(title) -} - -type SessionRow = typeof SessionTable.$inferSelect - -export function fromRow(row: SessionRow): Info { - const summary = - row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null - ? { - additions: row.summary_additions ?? 0, - deletions: row.summary_deletions ?? 0, - files: row.summary_files ?? 0, - diffs: row.summary_diffs ?? undefined, - } - : undefined - const share = row.share_url ? { url: row.share_url } : undefined - const revert = row.revert ?? undefined - return { - id: row.id, - slug: row.slug, - projectID: row.project_id, - workspaceID: row.workspace_id ?? undefined, - directory: row.directory, - path: row.path ?? undefined, - parentID: row.parent_id ?? undefined, - title: row.title, - agent: row.agent ?? undefined, - model: row.model - ? { - id: ModelID.make(row.model.id), - providerID: ProviderID.make(row.model.providerID), - variant: row.model.variant, - } - : undefined, - version: row.version, - summary, - share, - revert, - permission: row.permission ?? undefined, - time: { - created: row.time_created, - updated: row.time_updated, - compacting: row.time_compacting ?? undefined, - archived: row.time_archived ?? undefined, - }, - } -} - -export function toRow(info: Info) { - return { - id: info.id, - project_id: info.projectID, - workspace_id: info.workspaceID, - parent_id: info.parentID, - slug: info.slug, - directory: info.directory, - path: info.path, - title: info.title, - agent: info.agent, - model: info.model, - version: info.version, - share_url: info.share?.url, - summary_additions: info.summary?.additions, - summary_deletions: info.summary?.deletions, - summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs, - revert: info.revert ?? null, - permission: info.permission, - time_created: info.time.created, - time_updated: info.time.updated, - time_compacting: info.time.compacting, - time_archived: info.time.archived, - } -} - -function getForkedTitle(title: string): string { - const match = title.match(/^(.+) \(fork #(\d+)\)$/) - if (match) { - const base = match[1] - const num = parseInt(match[2], 10) - return `${base} (fork #${num + 1})` - } - return `${title} (fork #1)` -} - -function sessionPath(worktree: string, cwd: string) { - return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/") -} - -const Summary = Schema.Struct({ - additions: NonNegativeInt, - deletions: NonNegativeInt, - files: NonNegativeInt, - diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), -}) - -const Share = Schema.Struct({ - url: Schema.String, -}) - -// Legacy HTTP accepted negative values here. Keep archive timestamps permissive -// while excluding non-finite values that cannot round-trip through JSON. -export const ArchivedTimestamp = Schema.Finite - -const Time = Schema.Struct({ - created: NonNegativeInt, - updated: NonNegativeInt, - compacting: optionalOmitUndefined(NonNegativeInt), - archived: optionalOmitUndefined(ArchivedTimestamp), -}) - -const Revert = Schema.Struct({ - messageID: MessageID, - partID: optionalOmitUndefined(PartID), - snapshot: optionalOmitUndefined(Schema.String), - diff: optionalOmitUndefined(Schema.String), -}) - -const Model = Schema.Struct({ - id: ModelID, - providerID: ProviderID, - variant: optionalOmitUndefined(Schema.String), -}) - -export const Info = Schema.Struct({ - id: SessionID, - slug: Schema.String, - projectID: ProjectID, - workspaceID: optionalOmitUndefined(WorkspaceID), - directory: Schema.String, - path: optionalOmitUndefined(Schema.String), - parentID: optionalOmitUndefined(SessionID), - summary: optionalOmitUndefined(Summary), - share: optionalOmitUndefined(Share), - title: Schema.String, - agent: optionalOmitUndefined(Schema.String), - model: optionalOmitUndefined(Model), - version: Schema.String, - time: Time, - permission: optionalOmitUndefined(Permission.Ruleset), - revert: optionalOmitUndefined(Revert), -}) - .annotate({ identifier: "Session" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type Info = Types.DeepMutable> - -export const ProjectInfo = Schema.Struct({ - id: ProjectID, - name: optionalOmitUndefined(Schema.String), - worktree: Schema.String, -}) - .annotate({ identifier: "ProjectSummary" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type ProjectInfo = Types.DeepMutable> - -export const GlobalInfo = Schema.Struct({ - ...Info.fields, - project: Schema.NullOr(ProjectInfo), -}) - .annotate({ identifier: "GlobalSession" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type GlobalInfo = Types.DeepMutable> - -export const CreateInput = Schema.optional( - Schema.Struct({ - parentID: Schema.optional(SessionID), - title: Schema.optional(Schema.String), - agent: Schema.optional(Schema.String), - model: Schema.optional(Model), - permission: Schema.optional(Permission.Ruleset), - workspaceID: Schema.optional(WorkspaceID), - }), -).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type CreateInput = Types.DeepMutable> - -export const ForkInput = Schema.Struct({ - sessionID: SessionID, - messageID: Schema.optional(MessageID), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const GetInput = SessionID -export const ChildrenInput = SessionID -export const RemoveInput = SessionID -export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe( - withStatics((s) => ({ zod: zod(s) })), -) -export const SetArchivedInput = Schema.Struct({ - sessionID: SessionID, - time: Schema.optional(ArchivedTimestamp), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const SetPermissionInput = Schema.Struct({ - sessionID: SessionID, - permission: Permission.Ruleset, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const SetRevertInput = Schema.Struct({ - sessionID: SessionID, - revert: Schema.optional(Revert), - summary: Schema.optional(Summary), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const MessagesInput = Schema.Struct({ - sessionID: SessionID, - limit: Schema.optional(NonNegativeInt), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type ListInput = { - directory?: string - scope?: "project" - path?: string - workspaceID?: WorkspaceID - roots?: boolean - start?: number - search?: string - limit?: number -} - -const CreatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: Info, -}) - -const UpdatedShare = Schema.Struct({ - url: Schema.optional(Schema.NullOr(Schema.String)), -}) - -const UpdatedTime = Schema.Struct({ - created: Schema.optional(Schema.NullOr(NonNegativeInt)), - updated: Schema.optional(Schema.NullOr(NonNegativeInt)), - compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), - archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)), -}) - -const UpdatedInfo = Schema.Struct({ - id: Schema.optional(Schema.NullOr(SessionID)), - slug: Schema.optional(Schema.NullOr(Schema.String)), - projectID: Schema.optional(Schema.NullOr(ProjectID)), - workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)), - directory: Schema.optional(Schema.NullOr(Schema.String)), - path: Schema.optional(Schema.NullOr(Schema.String)), - parentID: Schema.optional(Schema.NullOr(SessionID)), - summary: Schema.optional(Schema.NullOr(Summary)), - share: Schema.optional(UpdatedShare), - title: Schema.optional(Schema.NullOr(Schema.String)), - agent: Schema.optional(Schema.NullOr(Schema.String)), - model: Schema.optional(Schema.NullOr(Model)), - version: Schema.optional(Schema.NullOr(Schema.String)), - time: Schema.optional(UpdatedTime), - permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), - revert: Schema.optional(Schema.NullOr(Revert)), -}) - -const UpdatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: UpdatedInfo, -}) - -export const Event = { - Created: SyncEvent.define({ - type: "session.created", - version: 1, - aggregate: "sessionID", - schema: CreatedEventSchema, - }), - Updated: SyncEvent.define({ - type: "session.updated", - version: 1, - aggregate: "sessionID", - schema: UpdatedEventSchema, - busSchema: CreatedEventSchema, - }), - Deleted: SyncEvent.define({ - type: "session.deleted", - version: 1, - aggregate: "sessionID", - schema: CreatedEventSchema, - }), - Diff: BusEvent.define( - "session.diff", - Schema.Struct({ - sessionID: SessionID, - diff: Schema.Array(Snapshot.FileDiff), - }), - ), - Error: BusEvent.define( - "session.error", - Schema.Struct({ - sessionID: Schema.optional(SessionID), - // Reuses MessageV2.Assistant.fields.error (already Schema.optional) so - // the derived zod keeps the same discriminated-union shape on the bus. - error: MessageV2.Assistant.fields.error, - }), - ), -} - -export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { - const base = instance.project.vcs - ? path.join(instance.worktree, ".opencode", "plans") - : path.join(Global.Path.data, "plans") - return path.join(base, [input.time.created, input.slug].join("-") + ".md") -} - -export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { - const safe = (value: number) => { - if (!Number.isFinite(value)) return 0 - return value - } - const inputTokens = safe(input.usage.inputTokens ?? 0) - const outputTokens = safe(input.usage.outputTokens ?? 0) - const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) - - const cacheReadInputTokens = safe( - input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, - ) - const cacheWriteInputTokens = safe( - Number( - input.usage.inputTokenDetails?.cacheWriteTokens ?? - input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? - // google-vertex-anthropic returns metadata under "vertex" key - // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') - input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? - // @ts-expect-error - input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? - // @ts-expect-error - input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? - 0, - ), - ) - - // AI SDK v6 normalized inputTokens to include cached tokens across all providers - // (including Anthropic/Bedrock which previously excluded them). Always subtract cache - // tokens to get the non-cached input count for separate cost calculation. - const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens) - - const total = input.usage.totalTokens - - const tokens = { - total, - input: adjustedInputTokens, - output: safe(outputTokens - reasoningTokens), - reasoning: reasoningTokens, - cache: { - write: cacheWriteInputTokens, - read: cacheReadInputTokens, - }, - } - - const costInfo = - input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 - ? input.model.cost.experimentalOver200K - : input.model.cost - return { - cost: safe( - new Decimal(0) - .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)) - .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)) - .add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000)) - .add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000)) - // TODO: update models.dev to have better pricing model, for now: - // charge reasoning tokens at the same rate as output tokens - .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000)) - .toNumber(), - ), - tokens, - } -} - -export class BusyError extends Error { - constructor(public readonly sessionID: string) { - super(`Session ${sessionID} is busy`) - } -} - -export type NotFound = InstanceType - -export interface Interface { - readonly list: (input?: ListInput) => Effect.Effect - readonly create: (input?: { - parentID?: SessionID - title?: string - agent?: string - model?: Schema.Schema.Type - permission?: Permission.Ruleset - workspaceID?: WorkspaceID - }) => Effect.Effect - readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect - readonly touch: (sessionID: SessionID) => Effect.Effect - readonly get: (id: SessionID) => Effect.Effect - readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect - readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect - readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect - readonly setRevert: (input: { - sessionID: SessionID - revert: Info["revert"] - summary: Info["summary"] - }) => Effect.Effect - readonly clearRevert: (sessionID: SessionID) => Effect.Effect - readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect - readonly diff: (sessionID: SessionID) => Effect.Effect - readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect - readonly children: (parentID: SessionID) => Effect.Effect - readonly remove: (sessionID: SessionID) => Effect.Effect - readonly updateMessage: (msg: T) => Effect.Effect - readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect - readonly getPart: (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - }) => Effect.Effect - readonly updatePart: (part: T) => Effect.Effect - readonly updatePartDelta: (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - field: string - delta: string - }) => Effect.Effect - /** Finds the first message matching the predicate, searching newest-first. */ - readonly findMessage: ( - sessionID: SessionID, - predicate: (msg: MessageV2.WithParts) => boolean, - ) => Effect.Effect> -} - -export class Service extends Context.Service()("@opencode/Session") {} - -export type Patch = Types.DeepMutable["data"]["info"]> - -const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const storage = yield* Storage.Service - const sync = yield* SyncEvent.Service - - const createNext = Effect.fn("Session.createNext")(function* (input: { - id?: SessionID - title?: string - agent?: string - model?: Schema.Schema.Type - parentID?: SessionID - workspaceID?: WorkspaceID - directory: string - path?: string - permission?: Permission.Ruleset - }) { - const ctx = yield* InstanceState.context - const result: Info = { - id: SessionID.descending(input.id), - slug: Slug.create(), - version: InstallationVersion, - projectID: ctx.project.id, - directory: input.directory, - path: input.path, - workspaceID: input.workspaceID, - parentID: input.parentID, - title: input.title ?? createDefaultTitle(!!input.parentID), - agent: input.agent, - model: input.model, - permission: input.permission, - time: { - created: Date.now(), - updated: Date.now(), - }, - } - log.info("created", result) - - yield* sync.run(Event.Created, { sessionID: result.id, info: result }) - - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // This only exist for backwards compatibility. We should not be - // manually publishing this event; it is a sync event now - yield* bus.publish(Event.Updated, { - sessionID: result.id, - info: result, - }) - } - - return result - }) - - const get = Effect.fn("Session.get")(function* (id: SessionID) { - const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` })) - return fromRow(row) - }) - - const list = Effect.fn("Session.list")(function* (input?: ListInput) { - const ctx = yield* InstanceState.context - return Array.from(listByProject({ projectID: ctx.project.id, ...input })) - }) - - const children = Effect.fn("Session.children")(function* (parentID: SessionID) { - const rows = yield* db((d) => - d - .select() - .from(SessionTable) - .where(and(eq(SessionTable.parent_id, parentID))) - .all(), - ) - return rows.map(fromRow) - }) - - const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { - const session = yield* get(sessionID) - try { - const kids = yield* children(sessionID) - for (const child of kids) { - yield* remove(child.id) - } - - // `remove` needs to work in all cases, such as a broken - // sessions that run cleanup. In certain cases these will - // run without any instance state, so we need to turn off - // publishing of events in that case - const hasInstance = yield* InstanceState.directory.pipe( - Effect.as(true), - Effect.catchCause(() => Effect.succeed(false)), - ) - - yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) - yield* sync.remove(sessionID) - } catch (e) { - log.error(e) - } - }) - - const updateMessage = (msg: T): Effect.Effect => - Effect.gen(function* () { - yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }) - return msg - }).pipe(Effect.withSpan("Session.updateMessage")) - - const updatePart = (part: T): Effect.Effect => - Effect.gen(function* () { - yield* sync.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), - time: Date.now(), - }) - return part - }).pipe(Effect.withSpan("Session.updatePart")) - - const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { - const row = Database.use((db) => - db - .select() - .from(PartTable) - .where( - and( - eq(PartTable.session_id, input.sessionID), - eq(PartTable.message_id, input.messageID), - eq(PartTable.id, input.partID), - ), - ) - .get(), - ) - if (!row) return - return { - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - } as MessageV2.Part - }) - - const create = Effect.fn("Session.create")(function* (input?: { - parentID?: SessionID - title?: string - agent?: string - model?: Schema.Schema.Type - permission?: Permission.Ruleset - workspaceID?: WorkspaceID - }) { - const ctx = yield* InstanceState.context - const workspace = yield* InstanceState.workspaceID - return yield* createNext({ - parentID: input?.parentID, - directory: ctx.directory, - path: sessionPath(ctx.worktree, ctx.directory), - title: input?.title, - agent: input?.agent, - model: input?.model, - permission: input?.permission, - workspaceID: input?.workspaceID ?? workspace, - }) - }) - - const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { - const ctx = yield* InstanceState.context - const original = yield* get(input.sessionID) - const title = getForkedTitle(original.title) - const session = yield* createNext({ - directory: ctx.directory, - path: sessionPath(ctx.worktree, ctx.directory), - workspaceID: original.workspaceID, - title, - }) - const msgs = yield* messages({ sessionID: input.sessionID }) - const idMap = new Map() - - for (const msg of msgs) { - if (input.messageID && msg.info.id >= input.messageID) break - const newID = MessageID.ascending() - idMap.set(msg.info.id, newID) - - const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined - const cloned = yield* updateMessage({ - ...msg.info, - sessionID: session.id, - id: newID, - ...(parentID && { parentID }), - }) - - for (const part of msg.parts) { - const p: MessageV2.Part = { - ...part, - id: PartID.ascending(), - messageID: cloned.id, - sessionID: session.id, - } - if (p.type === "compaction" && p.tail_start_id) { - p.tail_start_id = idMap.get(p.tail_start_id) - } - yield* updatePart(p) - } - } - return session - }) - - const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info }) - - const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() } }) - }) - - const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) { - yield* patch(input.sessionID, { title: input.title }) - }) - - const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { - yield* patch(input.sessionID, { time: { archived: input.time } }) - }) - - const setPermission = Effect.fn("Session.setPermission")(function* (input: { - sessionID: SessionID - permission: Permission.Ruleset - }) { - yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } }) - }) - - const setRevert = Effect.fn("Session.setRevert")(function* (input: { - sessionID: SessionID - revert: Info["revert"] - summary: Info["summary"] - }) { - yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }) - }) - - const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }) - }) - - const setSummary = Effect.fn("Session.setSummary")(function* (input: { - sessionID: SessionID - summary: Info["summary"] - }) { - yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }) - }) - - const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { - return yield* storage - .read(["session_diff", sessionID]) - .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) - }) - - const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { - if (input.limit) { - return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items - } - return Array.from(MessageV2.stream(input.sessionID)).reverse() - }) - - const removeMessage = Effect.fn("Session.removeMessage")(function* (input: { - sessionID: SessionID - messageID: MessageID - }) { - yield* sync.run(MessageV2.Event.Removed, { - sessionID: input.sessionID, - messageID: input.messageID, - }) - return input.messageID - }) - - const removePart = Effect.fn("Session.removePart")(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - }) { - yield* sync.run(MessageV2.Event.PartRemoved, { - sessionID: input.sessionID, - messageID: input.messageID, - partID: input.partID, - }) - return input.partID - }) - - const updatePartDelta = Effect.fnUntraced(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - field: string - delta: string - }) { - yield* bus.publish(MessageV2.Event.PartDelta, input) - }) - - /** Finds the first message matching the predicate, searching newest-first. */ - const findMessage = Effect.fn("Session.findMessage")(function* ( - sessionID: SessionID, - predicate: (msg: MessageV2.WithParts) => boolean, - ) { - for (const item of MessageV2.stream(sessionID)) { - if (predicate(item)) return Option.some(item) - } - return Option.none() - }) - - return Service.of({ - list, - create, - fork, - touch, - get, - setTitle, - setArchived, - setPermission, - setRevert, - clearRevert, - setSummary, - diff, - messages, - children, - remove, - updateMessage, - removeMessage, - removePart, - updatePart, - getPart, - updatePartDelta, - findMessage, - }) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), - Layer.provide(Storage.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), -) - -function* listByProject( - input: ListInput & { - projectID: ProjectID - }, -) { - const conditions = [eq(SessionTable.project_id, input.projectID)] - - if (input.workspaceID) { - conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) - } - if (input.path !== undefined) { - if (input.path) { - const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] - - conditions.push( - input.directory - ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! - : or(...conds)!, - ) - } - } else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - if (input.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } - } - if (input.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - - const limit = input.limit ?? 100 - - const rows = Database.use((db) => - db - .select() - .from(SessionTable) - .where(and(...conditions)) - .orderBy(desc(SessionTable.time_updated)) - .limit(limit) - .all(), - ) - for (const row of rows) { - yield fromRow(row) - } -} - -export function* listGlobal(input?: { - directory?: string - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean -}) { - const conditions: SQL[] = [] - - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } - if (input?.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input?.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input?.cursor) { - conditions.push(lt(SessionTable.time_updated, input.cursor)) - } - if (input?.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - if (!input?.archived) { - conditions.push(isNull(SessionTable.time_archived)) - } - - const limit = input?.limit ?? 100 - - const rows = Database.use((db) => { - const query = - conditions.length > 0 - ? db - .select() - .from(SessionTable) - .where(and(...conditions)) - : db.select().from(SessionTable) - return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() - }) - - const ids = [...new Set(rows.map((row) => row.project_id))] - const projects = new Map() - - if (ids.length > 0) { - const items = Database.use((db) => - db - .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) - .from(ProjectTable) - .where(inArray(ProjectTable.id, ids)) - .all(), - ) - for (const item of items) { - projects.set(item.id, { - id: item.id, - name: item.name ?? undefined, - worktree: item.worktree, - }) - } - } - - for (const row of rows) { - const project = projects.get(row.project_id) ?? null - yield { ...fromRow(row), project } - } -} - -export * as Session from "./session" +import { Slug } from "@opencode-ai/core/util/slug" +import path from "path" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Decimal } from "decimal.js" +import { type ProviderMetadata, type LanguageModelUsage } from "ai" +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" + +import { Database } from "@/storage/db" +import { NotFoundError } from "@/storage/storage" +import { eq } from "drizzle-orm" +import { and } from "drizzle-orm" +import { gte } from "drizzle-orm" +import { isNull } from "drizzle-orm" +import { desc } from "drizzle-orm" +import { like } from "drizzle-orm" +import { inArray } from "drizzle-orm" +import { lt } from "drizzle-orm" +import { or } from "drizzle-orm" +import { SyncEvent } from "../sync" +import type { SQL } from "drizzle-orm" +import { PartTable, SessionTable } from "./session.sql" +import { ProjectTable } from "../project/project.sql" +import { Storage } from "@/storage/storage" +import * as Log from "@opencode-ai/core/util/log" +import { MessageV2 } from "./message-v2" +import type { InstanceContext } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" +import { Snapshot } from "@/snapshot" +import { ProjectID } from "../project/schema" +import { WorkspaceID } from "../control-plane/schema" +import { SessionID, MessageID, PartID } from "./schema" +import { ModelID, ProviderID } from "@/provider/schema" + +import type { Provider } from "@/provider/provider" +import { Permission } from "@/permission" +import { Global } from "@opencode-ai/core/global" +import { Effect, Layer, Option, Context, Schema, Types } from "effect" +import { zod } from "@/util/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" + +const log = Log.create({ service: "session" }) + +const parentTitlePrefix = "New session - " +const childTitlePrefix = "Child session - " + +function createDefaultTitle(isChild = false) { + return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() +} + +export function isDefaultTitle(title: string) { + return new RegExp( + `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, + ).test(title) +} + +type SessionRow = typeof SessionTable.$inferSelect + +export function fromRow(row: SessionRow): Info { + const summary = + row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null + ? { + additions: row.summary_additions ?? 0, + deletions: row.summary_deletions ?? 0, + files: row.summary_files ?? 0, + diffs: row.summary_diffs ?? undefined, + } + : undefined + const share = row.share_url ? { url: row.share_url } : undefined + const revert = row.revert ?? undefined + return { + id: row.id, + slug: row.slug, + projectID: row.project_id, + workspaceID: row.workspace_id ?? undefined, + directory: row.directory, + path: row.path ?? undefined, + parentID: row.parent_id ?? undefined, + title: row.title, + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } + : undefined, + version: row.version, + summary, + share, + revert, + permission: row.permission ?? undefined, + time: { + created: row.time_created, + updated: row.time_updated, + compacting: row.time_compacting ?? undefined, + archived: row.time_archived ?? undefined, + }, + } +} + +export function toRow(info: Info) { + return { + id: info.id, + project_id: info.projectID, + workspace_id: info.workspaceID, + parent_id: info.parentID, + slug: info.slug, + directory: info.directory, + path: info.path, + title: info.title, + agent: info.agent, + model: info.model, + version: info.version, + share_url: info.share?.url, + summary_additions: info.summary?.additions, + summary_deletions: info.summary?.deletions, + summary_files: info.summary?.files, + summary_diffs: info.summary?.diffs, + revert: info.revert ?? null, + permission: info.permission, + time_created: info.time.created, + time_updated: info.time.updated, + time_compacting: info.time.compacting, + time_archived: info.time.archived, + } +} + +function getForkedTitle(title: string): string { + const match = title.match(/^(.+) \(fork #(\d+)\)$/) + if (match) { + const base = match[1] + const num = parseInt(match[2], 10) + return `${base} (fork #${num + 1})` + } + return `${title} (fork #1)` +} + +function sessionPath(worktree: string, cwd: string) { + return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/") +} + +const Summary = Schema.Struct({ + additions: NonNegativeInt, + deletions: NonNegativeInt, + files: NonNegativeInt, + diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), +}) + +const Share = Schema.Struct({ + url: Schema.String, +}) + +// Legacy HTTP accepted negative values here. Keep archive timestamps permissive +// while excluding non-finite values that cannot round-trip through JSON. +export const ArchivedTimestamp = Schema.Finite + +const Time = Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(ArchivedTimestamp), +}) + +const Revert = Schema.Struct({ + messageID: MessageID, + partID: optionalOmitUndefined(PartID), + snapshot: optionalOmitUndefined(Schema.String), + diff: optionalOmitUndefined(Schema.String), +}) + +const Model = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + variant: optionalOmitUndefined(Schema.String), +}) + +export const Info = Schema.Struct({ + id: SessionID, + slug: Schema.String, + projectID: ProjectID, + workspaceID: optionalOmitUndefined(WorkspaceID), + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(Summary), + share: optionalOmitUndefined(Share), + title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(Model), + version: Schema.String, + time: Time, + permission: optionalOmitUndefined(Permission.Ruleset), + revert: optionalOmitUndefined(Revert), +}) + .annotate({ identifier: "Session" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Types.DeepMutable> + +export const ProjectInfo = Schema.Struct({ + id: ProjectID, + name: optionalOmitUndefined(Schema.String), + worktree: Schema.String, +}) + .annotate({ identifier: "ProjectSummary" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ProjectInfo = Types.DeepMutable> + +export const GlobalInfo = Schema.Struct({ + ...Info.fields, + project: Schema.NullOr(ProjectInfo), +}) + .annotate({ identifier: "GlobalSession" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type GlobalInfo = Types.DeepMutable> + +export const CreateInput = Schema.optional( + Schema.Struct({ + parentID: Schema.optional(SessionID), + title: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Model), + permission: Schema.optional(Permission.Ruleset), + workspaceID: Schema.optional(WorkspaceID), + }), +).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type CreateInput = Types.DeepMutable> + +export const ForkInput = Schema.Struct({ + sessionID: SessionID, + messageID: Schema.optional(MessageID), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const GetInput = SessionID +export const ChildrenInput = SessionID +export const RemoveInput = SessionID +export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe( + withStatics((s) => ({ zod: zod(s) })), +) +export const SetArchivedInput = Schema.Struct({ + sessionID: SessionID, + time: Schema.optional(ArchivedTimestamp), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const SetPermissionInput = Schema.Struct({ + sessionID: SessionID, + permission: Permission.Ruleset, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const SetRevertInput = Schema.Struct({ + sessionID: SessionID, + revert: Schema.optional(Revert), + summary: Schema.optional(Summary), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const MessagesInput = Schema.Struct({ + sessionID: SessionID, + limit: Schema.optional(NonNegativeInt), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ListInput = { + directory?: string + scope?: "project" + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + limit?: number +} + +const CreatedEventSchema = Schema.Struct({ + sessionID: SessionID, + info: Info, +}) + +const UpdatedShare = Schema.Struct({ + url: Schema.optional(Schema.NullOr(Schema.String)), +}) + +const UpdatedTime = Schema.Struct({ + created: Schema.optional(Schema.NullOr(NonNegativeInt)), + updated: Schema.optional(Schema.NullOr(NonNegativeInt)), + compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), + archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)), +}) + +const UpdatedInfo = Schema.Struct({ + id: Schema.optional(Schema.NullOr(SessionID)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + projectID: Schema.optional(Schema.NullOr(ProjectID)), + workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)), + directory: Schema.optional(Schema.NullOr(Schema.String)), + path: Schema.optional(Schema.NullOr(Schema.String)), + parentID: Schema.optional(Schema.NullOr(SessionID)), + summary: Schema.optional(Schema.NullOr(Summary)), + share: Schema.optional(UpdatedShare), + title: Schema.optional(Schema.NullOr(Schema.String)), + agent: Schema.optional(Schema.NullOr(Schema.String)), + model: Schema.optional(Schema.NullOr(Model)), + version: Schema.optional(Schema.NullOr(Schema.String)), + time: Schema.optional(UpdatedTime), + permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), + revert: Schema.optional(Schema.NullOr(Revert)), +}) + +const UpdatedEventSchema = Schema.Struct({ + sessionID: SessionID, + info: UpdatedInfo, +}) + +export const Event = { + Created: SyncEvent.define({ + type: "session.created", + version: 1, + aggregate: "sessionID", + schema: CreatedEventSchema, + }), + Updated: SyncEvent.define({ + type: "session.updated", + version: 1, + aggregate: "sessionID", + schema: UpdatedEventSchema, + busSchema: CreatedEventSchema, + }), + Deleted: SyncEvent.define({ + type: "session.deleted", + version: 1, + aggregate: "sessionID", + schema: CreatedEventSchema, + }), + Diff: BusEvent.define( + "session.diff", + Schema.Struct({ + sessionID: SessionID, + diff: Schema.Array(Snapshot.FileDiff), + }), + ), + Error: BusEvent.define( + "session.error", + Schema.Struct({ + sessionID: Schema.optional(SessionID), + // Reuses MessageV2.Assistant.fields.error (already Schema.optional) so + // the derived zod keeps the same discriminated-union shape on the bus. + error: MessageV2.Assistant.fields.error, + }), + ), +} + +export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { + const base = instance.project.vcs + ? path.join(instance.worktree, ".opencode", "plans") + : path.join(Global.Path.data, "plans") + return path.join(base, [input.time.created, input.slug].join("-") + ".md") +} + +export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { + const safe = (value: number) => { + if (!Number.isFinite(value)) return 0 + return value + } + const inputTokens = safe(input.usage.inputTokens ?? 0) + const outputTokens = safe(input.usage.outputTokens ?? 0) + const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) + + const cacheReadInputTokens = safe( + input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, + ) + const cacheWriteInputTokens = safe( + Number( + input.usage.inputTokenDetails?.cacheWriteTokens ?? + input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? + // google-vertex-anthropic returns metadata under "vertex" key + // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') + input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? + // @ts-expect-error + input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? + // @ts-expect-error + input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? + 0, + ), + ) + + // AI SDK v6 normalized inputTokens to include cached tokens across all providers + // (including Anthropic/Bedrock which previously excluded them). Always subtract cache + // tokens to get the non-cached input count for separate cost calculation. + const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens) + + const total = input.usage.totalTokens + + const tokens = { + total, + input: adjustedInputTokens, + output: safe(outputTokens - reasoningTokens), + reasoning: reasoningTokens, + cache: { + write: cacheWriteInputTokens, + read: cacheReadInputTokens, + }, + } + + const costInfo = + input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 + ? input.model.cost.experimentalOver200K + : input.model.cost + return { + cost: safe( + new Decimal(0) + .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)) + .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)) + .add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000)) + .add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000)) + // TODO: update models.dev to have better pricing model, for now: + // charge reasoning tokens at the same rate as output tokens + .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000)) + .toNumber(), + ), + tokens, + } +} + +export class BusyError extends Error { + constructor(public readonly sessionID: string) { + super(`Session ${sessionID} is busy`) + } +} + +export type NotFound = InstanceType + +export interface Interface { + readonly list: (input?: ListInput) => Effect.Effect + readonly create: (input?: { + parentID?: SessionID + title?: string + agent?: string + model?: Schema.Schema.Type + permission?: Permission.Ruleset + workspaceID?: WorkspaceID + }) => Effect.Effect + readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly touch: (sessionID: SessionID) => Effect.Effect + readonly get: (id: SessionID) => Effect.Effect + readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect + readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect + readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect + readonly setRevert: (input: { + sessionID: SessionID + revert: Info["revert"] + summary: Info["summary"] + }) => Effect.Effect + readonly clearRevert: (sessionID: SessionID) => Effect.Effect + readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect + readonly diff: (sessionID: SessionID) => Effect.Effect + readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect + readonly children: (parentID: SessionID) => Effect.Effect + readonly remove: (sessionID: SessionID) => Effect.Effect + readonly updateMessage: (msg: T) => Effect.Effect + readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect + readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect + readonly getPart: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) => Effect.Effect + readonly updatePart: (part: T) => Effect.Effect + readonly updatePartDelta: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + field: string + delta: string + }) => Effect.Effect + /** Finds the first message matching the predicate, searching newest-first. */ + readonly findMessage: ( + sessionID: SessionID, + predicate: (msg: MessageV2.WithParts) => boolean, + ) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/Session") {} + +export type Patch = Types.DeepMutable["data"]["info"]> + +const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const storage = yield* Storage.Service + const sync = yield* SyncEvent.Service + + const normalizeDirectory = (directory: string) => { + const normalized = directory.replaceAll("\\", "/") + if (/^\/+$/.test(normalized)) return normalized + const driveMatch = normalized.match(/^([A-Za-z]:)\/+$/) + if (driveMatch) return `${driveMatch[1]}/` + return normalized.replace(/\/+$/, "") + } + + const createNext = Effect.fn("Session.createNext")(function* (input: { + id?: SessionID + title?: string + agent?: string + model?: Schema.Schema.Type + parentID?: SessionID + workspaceID?: WorkspaceID + directory: string + path?: string + permission?: Permission.Ruleset + }) { + const ctx = yield* InstanceState.context + const result: Info = { + id: SessionID.descending(input.id), + slug: Slug.create(), + version: InstallationVersion, + projectID: ctx.project.id, + directory: normalizeDirectory(input.directory), + path: input.path, + workspaceID: input.workspaceID, + parentID: input.parentID, + title: input.title ?? createDefaultTitle(!!input.parentID), + agent: input.agent, + model: input.model, + permission: input.permission, + time: { + created: Date.now(), + updated: Date.now(), + }, + } + log.info("created", result) + + yield* sync.run(Event.Created, { sessionID: result.id, info: result }) + + if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + // This only exist for backwards compatibility. We should not be + // manually publishing this event; it is a sync event now + yield* bus.publish(Event.Updated, { + sessionID: result.id, + info: result, + }) + } + + return result + }) + + const get = Effect.fn("Session.get")(function* (id: SessionID) { + const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` })) + return fromRow(row) + }) + + const list = Effect.fn("Session.list")(function* (input?: ListInput) { + const ctx = yield* InstanceState.context + return Array.from(listByProject({ projectID: ctx.project.id, ...input })) + }) + + const children = Effect.fn("Session.children")(function* (parentID: SessionID) { + const rows = yield* db((d) => + d + .select() + .from(SessionTable) + .where(and(eq(SessionTable.parent_id, parentID))) + .all(), + ) + return rows.map(fromRow) + }) + + const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { + const session = yield* get(sessionID) + try { + const kids = yield* children(sessionID) + for (const child of kids) { + yield* remove(child.id) + } + + // `remove` needs to work in all cases, such as a broken + // sessions that run cleanup. In certain cases these will + // run without any instance state, so we need to turn off + // publishing of events in that case + const hasInstance = yield* InstanceState.directory.pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ) + + yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) + yield* sync.remove(sessionID) + } catch (e) { + log.error(e) + } + }) + + const updateMessage = (msg: T): Effect.Effect => + Effect.gen(function* () { + yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }) + return msg + }).pipe(Effect.withSpan("Session.updateMessage")) + + const updatePart = (part: T): Effect.Effect => + Effect.gen(function* () { + yield* sync.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + part: structuredClone(part), + time: Date.now(), + }) + return part + }).pipe(Effect.withSpan("Session.updatePart")) + + const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { + const row = Database.use((db) => + db + .select() + .from(PartTable) + .where( + and( + eq(PartTable.session_id, input.sessionID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.id, input.partID), + ), + ) + .get(), + ) + if (!row) return + return { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + }) + + const create = Effect.fn("Session.create")(function* (input?: { + parentID?: SessionID + title?: string + agent?: string + model?: Schema.Schema.Type + permission?: Permission.Ruleset + workspaceID?: WorkspaceID + }) { + const ctx = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID + return yield* createNext({ + parentID: input?.parentID, + directory: ctx.directory, + path: sessionPath(ctx.worktree, ctx.directory), + title: input?.title, + agent: input?.agent, + model: input?.model, + permission: input?.permission, + workspaceID: input?.workspaceID ?? workspace, + }) + }) + + const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + const ctx = yield* InstanceState.context + const original = yield* get(input.sessionID) + const title = getForkedTitle(original.title) + const session = yield* createNext({ + directory: ctx.directory, + path: sessionPath(ctx.worktree, ctx.directory), + workspaceID: original.workspaceID, + title, + }) + const msgs = yield* messages({ sessionID: input.sessionID }) + const idMap = new Map() + + for (const msg of msgs) { + if (input.messageID && msg.info.id >= input.messageID) break + const newID = MessageID.ascending() + idMap.set(msg.info.id, newID) + + const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined + const cloned = yield* updateMessage({ + ...msg.info, + sessionID: session.id, + id: newID, + ...(parentID && { parentID }), + }) + + for (const part of msg.parts) { + const p: MessageV2.Part = { + ...part, + id: PartID.ascending(), + messageID: cloned.id, + sessionID: session.id, + } + if (p.type === "compaction" && p.tail_start_id) { + p.tail_start_id = idMap.get(p.tail_start_id) + } + yield* updatePart(p) + } + } + return session + }) + + const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info }) + + const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { + yield* patch(sessionID, { time: { updated: Date.now() } }) + }) + + const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) { + yield* patch(input.sessionID, { title: input.title }) + }) + + const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { + yield* patch(input.sessionID, { time: { archived: input.time } }) + }) + + const setPermission = Effect.fn("Session.setPermission")(function* (input: { + sessionID: SessionID + permission: Permission.Ruleset + }) { + yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } }) + }) + + const setRevert = Effect.fn("Session.setRevert")(function* (input: { + sessionID: SessionID + revert: Info["revert"] + summary: Info["summary"] + }) { + yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }) + }) + + const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) { + yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }) + }) + + const setSummary = Effect.fn("Session.setSummary")(function* (input: { + sessionID: SessionID + summary: Info["summary"] + }) { + yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }) + }) + + const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { + return yield* storage + .read(["session_diff", sessionID]) + .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) + }) + + const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { + if (input.limit) { + return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items + } + return Array.from(MessageV2.stream(input.sessionID)).reverse() + }) + + const removeMessage = Effect.fn("Session.removeMessage")(function* (input: { + sessionID: SessionID + messageID: MessageID + }) { + yield* sync.run(MessageV2.Event.Removed, { + sessionID: input.sessionID, + messageID: input.messageID, + }) + return input.messageID + }) + + const removePart = Effect.fn("Session.removePart")(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) { + yield* sync.run(MessageV2.Event.PartRemoved, { + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }) + return input.partID + }) + + const updatePartDelta = Effect.fnUntraced(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + field: string + delta: string + }) { + yield* bus.publish(MessageV2.Event.PartDelta, input) + }) + + /** Finds the first message matching the predicate, searching newest-first. */ + const findMessage = Effect.fn("Session.findMessage")(function* ( + sessionID: SessionID, + predicate: (msg: MessageV2.WithParts) => boolean, + ) { + for (const item of MessageV2.stream(sessionID)) { + if (predicate(item)) return Option.some(item) + } + return Option.none() + }) + + return Service.of({ + list, + create, + fork, + touch, + get, + setTitle, + setArchived, + setPermission, + setRevert, + clearRevert, + setSummary, + diff, + messages, + children, + remove, + updateMessage, + removeMessage, + removePart, + updatePart, + getPart, + updatePartDelta, + findMessage, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), +) + +function* listByProject( + input: ListInput & { + projectID: ProjectID + }, +) { + const conditions = [eq(SessionTable.project_id, input.projectID)] + + if (input.workspaceID) { + conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + } + if (input.path !== undefined) { + if (input.path) { + const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] + + conditions.push( + input.directory + ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! + : or(...conds)!, + ) + } + } else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (input.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } + } + if (input.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + + const limit = input.limit ?? 100 + + const rows = Database.use((db) => + db + .select() + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated)) + .limit(limit) + .all(), + ) + for (const row of rows) { + yield fromRow(row) + } +} + +export function* listGlobal(input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean +}) { + const conditions: SQL[] = [] + + if (input?.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } + if (input?.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input?.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input?.cursor) { + conditions.push(lt(SessionTable.time_updated, input.cursor)) + } + if (input?.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + if (!input?.archived) { + conditions.push(isNull(SessionTable.time_archived)) + } + + const limit = input?.limit ?? 100 + + const rows = Database.use((db) => { + const query = + conditions.length > 0 + ? db + .select() + .from(SessionTable) + .where(and(...conditions)) + : db.select().from(SessionTable) + return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + }) + + const ids = [...new Set(rows.map((row) => row.project_id))] + const projects = new Map() + + if (ids.length > 0) { + const items = Database.use((db) => + db + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) { + projects.set(item.id, { + id: item.id, + name: item.name ?? undefined, + worktree: item.worktree, + }) + } + } + + for (const row of rows) { + const project = projects.get(row.project_id) ?? null + yield { ...fromRow(row), project } + } +} + +export * as Session from "./session" From f0723ad3dc27ace139dfb628393df03a3fe0c65c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 13 May 2026 10:53:07 +0800 Subject: [PATCH 2/2] formatting --- packages/app/src/utils/path-key.ts | 36 +- packages/opencode/src/session/session.ts | 1888 +++++++++++----------- 2 files changed, 962 insertions(+), 962 deletions(-) diff --git a/packages/app/src/utils/path-key.ts b/packages/app/src/utils/path-key.ts index 482de05e1cac..671d2dec319d 100644 --- a/packages/app/src/utils/path-key.ts +++ b/packages/app/src/utils/path-key.ts @@ -1,18 +1,18 @@ -export type PathKey = string & { _brand: "PathKey" } - -const isDrive = (value: string) => { - if (value.length !== 2) return false - const code = value.charCodeAt(0) - return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) -} - -const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\") - -export const pathKey = (path: string) => { - let value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path - if (/^\/+$/.test(value)) return "/" as PathKey - if (isDrive(value)) return `${value}/` as PathKey - const trimmed = value.replace(/\/+$/, "") - if (!trimmed) return "/" as PathKey - return trimmed as PathKey -} +export type PathKey = string & { _brand: "PathKey" } + +const isDrive = (value: string) => { + if (value.length !== 2) return false + const code = value.charCodeAt(0) + return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) +} + +const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\") + +export const pathKey = (path: string) => { + let value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path + if (/^\/+$/.test(value)) return "/" as PathKey + if (isDrive(value)) return `${value}/` as PathKey + const trimmed = value.replace(/\/+$/, "") + if (!trimmed) return "/" as PathKey + return trimmed as PathKey +} diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 2b7b87e4b97d..2de5e3488bff 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,944 +1,944 @@ -import { Slug } from "@opencode-ai/core/util/slug" -import path from "path" -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { Decimal } from "decimal.js" -import { type ProviderMetadata, type LanguageModelUsage } from "ai" -import { Flag } from "@opencode-ai/core/flag/flag" -import { InstallationVersion } from "@opencode-ai/core/installation/version" - -import { Database } from "@/storage/db" -import { NotFoundError } from "@/storage/storage" -import { eq } from "drizzle-orm" -import { and } from "drizzle-orm" -import { gte } from "drizzle-orm" -import { isNull } from "drizzle-orm" -import { desc } from "drizzle-orm" -import { like } from "drizzle-orm" -import { inArray } from "drizzle-orm" -import { lt } from "drizzle-orm" -import { or } from "drizzle-orm" -import { SyncEvent } from "../sync" -import type { SQL } from "drizzle-orm" -import { PartTable, SessionTable } from "./session.sql" -import { ProjectTable } from "../project/project.sql" -import { Storage } from "@/storage/storage" -import * as Log from "@opencode-ai/core/util/log" -import { MessageV2 } from "./message-v2" -import type { InstanceContext } from "../project/instance" -import { InstanceState } from "@/effect/instance-state" -import { Snapshot } from "@/snapshot" -import { ProjectID } from "../project/schema" -import { WorkspaceID } from "../control-plane/schema" -import { SessionID, MessageID, PartID } from "./schema" -import { ModelID, ProviderID } from "@/provider/schema" - -import type { Provider } from "@/provider/provider" -import { Permission } from "@/permission" -import { Global } from "@opencode-ai/core/global" -import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" - -const log = Log.create({ service: "session" }) - -const parentTitlePrefix = "New session - " -const childTitlePrefix = "Child session - " - -function createDefaultTitle(isChild = false) { - return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() -} - -export function isDefaultTitle(title: string) { - return new RegExp( - `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, - ).test(title) -} - -type SessionRow = typeof SessionTable.$inferSelect - -export function fromRow(row: SessionRow): Info { - const summary = - row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null - ? { - additions: row.summary_additions ?? 0, - deletions: row.summary_deletions ?? 0, - files: row.summary_files ?? 0, - diffs: row.summary_diffs ?? undefined, - } - : undefined - const share = row.share_url ? { url: row.share_url } : undefined - const revert = row.revert ?? undefined - return { - id: row.id, - slug: row.slug, - projectID: row.project_id, - workspaceID: row.workspace_id ?? undefined, - directory: row.directory, - path: row.path ?? undefined, - parentID: row.parent_id ?? undefined, - title: row.title, - agent: row.agent ?? undefined, - model: row.model - ? { - id: ModelID.make(row.model.id), - providerID: ProviderID.make(row.model.providerID), - variant: row.model.variant, - } - : undefined, - version: row.version, - summary, - share, - revert, - permission: row.permission ?? undefined, - time: { - created: row.time_created, - updated: row.time_updated, - compacting: row.time_compacting ?? undefined, - archived: row.time_archived ?? undefined, - }, - } -} - -export function toRow(info: Info) { - return { - id: info.id, - project_id: info.projectID, - workspace_id: info.workspaceID, - parent_id: info.parentID, - slug: info.slug, - directory: info.directory, - path: info.path, - title: info.title, - agent: info.agent, - model: info.model, - version: info.version, - share_url: info.share?.url, - summary_additions: info.summary?.additions, - summary_deletions: info.summary?.deletions, - summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs, - revert: info.revert ?? null, - permission: info.permission, - time_created: info.time.created, - time_updated: info.time.updated, - time_compacting: info.time.compacting, - time_archived: info.time.archived, - } -} - -function getForkedTitle(title: string): string { - const match = title.match(/^(.+) \(fork #(\d+)\)$/) - if (match) { - const base = match[1] - const num = parseInt(match[2], 10) - return `${base} (fork #${num + 1})` - } - return `${title} (fork #1)` -} - -function sessionPath(worktree: string, cwd: string) { - return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/") -} - -const Summary = Schema.Struct({ - additions: NonNegativeInt, - deletions: NonNegativeInt, - files: NonNegativeInt, - diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), -}) - -const Share = Schema.Struct({ - url: Schema.String, -}) - -// Legacy HTTP accepted negative values here. Keep archive timestamps permissive -// while excluding non-finite values that cannot round-trip through JSON. -export const ArchivedTimestamp = Schema.Finite - -const Time = Schema.Struct({ - created: NonNegativeInt, - updated: NonNegativeInt, - compacting: optionalOmitUndefined(NonNegativeInt), - archived: optionalOmitUndefined(ArchivedTimestamp), -}) - -const Revert = Schema.Struct({ - messageID: MessageID, - partID: optionalOmitUndefined(PartID), - snapshot: optionalOmitUndefined(Schema.String), - diff: optionalOmitUndefined(Schema.String), -}) - -const Model = Schema.Struct({ - id: ModelID, - providerID: ProviderID, - variant: optionalOmitUndefined(Schema.String), -}) - -export const Info = Schema.Struct({ - id: SessionID, - slug: Schema.String, - projectID: ProjectID, - workspaceID: optionalOmitUndefined(WorkspaceID), - directory: Schema.String, - path: optionalOmitUndefined(Schema.String), - parentID: optionalOmitUndefined(SessionID), - summary: optionalOmitUndefined(Summary), - share: optionalOmitUndefined(Share), - title: Schema.String, - agent: optionalOmitUndefined(Schema.String), - model: optionalOmitUndefined(Model), - version: Schema.String, - time: Time, - permission: optionalOmitUndefined(Permission.Ruleset), - revert: optionalOmitUndefined(Revert), -}) - .annotate({ identifier: "Session" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type Info = Types.DeepMutable> - -export const ProjectInfo = Schema.Struct({ - id: ProjectID, - name: optionalOmitUndefined(Schema.String), - worktree: Schema.String, -}) - .annotate({ identifier: "ProjectSummary" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type ProjectInfo = Types.DeepMutable> - -export const GlobalInfo = Schema.Struct({ - ...Info.fields, - project: Schema.NullOr(ProjectInfo), -}) - .annotate({ identifier: "GlobalSession" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) -export type GlobalInfo = Types.DeepMutable> - -export const CreateInput = Schema.optional( - Schema.Struct({ - parentID: Schema.optional(SessionID), - title: Schema.optional(Schema.String), - agent: Schema.optional(Schema.String), - model: Schema.optional(Model), - permission: Schema.optional(Permission.Ruleset), - workspaceID: Schema.optional(WorkspaceID), - }), -).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type CreateInput = Types.DeepMutable> - -export const ForkInput = Schema.Struct({ - sessionID: SessionID, - messageID: Schema.optional(MessageID), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const GetInput = SessionID -export const ChildrenInput = SessionID -export const RemoveInput = SessionID -export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe( - withStatics((s) => ({ zod: zod(s) })), -) -export const SetArchivedInput = Schema.Struct({ - sessionID: SessionID, - time: Schema.optional(ArchivedTimestamp), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const SetPermissionInput = Schema.Struct({ - sessionID: SessionID, - permission: Permission.Ruleset, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const SetRevertInput = Schema.Struct({ - sessionID: SessionID, - revert: Schema.optional(Revert), - summary: Schema.optional(Summary), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export const MessagesInput = Schema.Struct({ - sessionID: SessionID, - limit: Schema.optional(NonNegativeInt), -}).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type ListInput = { - directory?: string - scope?: "project" - path?: string - workspaceID?: WorkspaceID - roots?: boolean - start?: number - search?: string - limit?: number -} - -const CreatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: Info, -}) - -const UpdatedShare = Schema.Struct({ - url: Schema.optional(Schema.NullOr(Schema.String)), -}) - -const UpdatedTime = Schema.Struct({ - created: Schema.optional(Schema.NullOr(NonNegativeInt)), - updated: Schema.optional(Schema.NullOr(NonNegativeInt)), - compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), - archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)), -}) - -const UpdatedInfo = Schema.Struct({ - id: Schema.optional(Schema.NullOr(SessionID)), - slug: Schema.optional(Schema.NullOr(Schema.String)), - projectID: Schema.optional(Schema.NullOr(ProjectID)), - workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)), - directory: Schema.optional(Schema.NullOr(Schema.String)), - path: Schema.optional(Schema.NullOr(Schema.String)), - parentID: Schema.optional(Schema.NullOr(SessionID)), - summary: Schema.optional(Schema.NullOr(Summary)), - share: Schema.optional(UpdatedShare), - title: Schema.optional(Schema.NullOr(Schema.String)), - agent: Schema.optional(Schema.NullOr(Schema.String)), - model: Schema.optional(Schema.NullOr(Model)), - version: Schema.optional(Schema.NullOr(Schema.String)), - time: Schema.optional(UpdatedTime), - permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), - revert: Schema.optional(Schema.NullOr(Revert)), -}) - -const UpdatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: UpdatedInfo, -}) - -export const Event = { - Created: SyncEvent.define({ - type: "session.created", - version: 1, - aggregate: "sessionID", - schema: CreatedEventSchema, - }), - Updated: SyncEvent.define({ - type: "session.updated", - version: 1, - aggregate: "sessionID", - schema: UpdatedEventSchema, - busSchema: CreatedEventSchema, - }), - Deleted: SyncEvent.define({ - type: "session.deleted", - version: 1, - aggregate: "sessionID", - schema: CreatedEventSchema, - }), - Diff: BusEvent.define( - "session.diff", - Schema.Struct({ - sessionID: SessionID, - diff: Schema.Array(Snapshot.FileDiff), - }), - ), - Error: BusEvent.define( - "session.error", - Schema.Struct({ - sessionID: Schema.optional(SessionID), - // Reuses MessageV2.Assistant.fields.error (already Schema.optional) so - // the derived zod keeps the same discriminated-union shape on the bus. - error: MessageV2.Assistant.fields.error, - }), - ), -} - -export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { - const base = instance.project.vcs - ? path.join(instance.worktree, ".opencode", "plans") - : path.join(Global.Path.data, "plans") - return path.join(base, [input.time.created, input.slug].join("-") + ".md") -} - -export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { - const safe = (value: number) => { - if (!Number.isFinite(value)) return 0 - return value - } - const inputTokens = safe(input.usage.inputTokens ?? 0) - const outputTokens = safe(input.usage.outputTokens ?? 0) - const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) - - const cacheReadInputTokens = safe( - input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, - ) - const cacheWriteInputTokens = safe( - Number( - input.usage.inputTokenDetails?.cacheWriteTokens ?? - input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? - // google-vertex-anthropic returns metadata under "vertex" key - // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') - input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? - // @ts-expect-error - input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? - // @ts-expect-error - input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? - 0, - ), - ) - - // AI SDK v6 normalized inputTokens to include cached tokens across all providers - // (including Anthropic/Bedrock which previously excluded them). Always subtract cache - // tokens to get the non-cached input count for separate cost calculation. - const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens) - - const total = input.usage.totalTokens - - const tokens = { - total, - input: adjustedInputTokens, - output: safe(outputTokens - reasoningTokens), - reasoning: reasoningTokens, - cache: { - write: cacheWriteInputTokens, - read: cacheReadInputTokens, - }, - } - - const costInfo = - input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 - ? input.model.cost.experimentalOver200K - : input.model.cost - return { - cost: safe( - new Decimal(0) - .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)) - .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)) - .add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000)) - .add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000)) - // TODO: update models.dev to have better pricing model, for now: - // charge reasoning tokens at the same rate as output tokens - .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000)) - .toNumber(), - ), - tokens, - } -} - -export class BusyError extends Error { - constructor(public readonly sessionID: string) { - super(`Session ${sessionID} is busy`) - } -} - -export type NotFound = InstanceType - -export interface Interface { - readonly list: (input?: ListInput) => Effect.Effect - readonly create: (input?: { - parentID?: SessionID - title?: string - agent?: string - model?: Schema.Schema.Type - permission?: Permission.Ruleset - workspaceID?: WorkspaceID - }) => Effect.Effect - readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect - readonly touch: (sessionID: SessionID) => Effect.Effect - readonly get: (id: SessionID) => Effect.Effect - readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect - readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect - readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect - readonly setRevert: (input: { - sessionID: SessionID - revert: Info["revert"] - summary: Info["summary"] - }) => Effect.Effect - readonly clearRevert: (sessionID: SessionID) => Effect.Effect - readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect - readonly diff: (sessionID: SessionID) => Effect.Effect - readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect - readonly children: (parentID: SessionID) => Effect.Effect - readonly remove: (sessionID: SessionID) => Effect.Effect - readonly updateMessage: (msg: T) => Effect.Effect - readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect - readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect - readonly getPart: (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - }) => Effect.Effect - readonly updatePart: (part: T) => Effect.Effect - readonly updatePartDelta: (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - field: string - delta: string - }) => Effect.Effect - /** Finds the first message matching the predicate, searching newest-first. */ - readonly findMessage: ( - sessionID: SessionID, - predicate: (msg: MessageV2.WithParts) => boolean, - ) => Effect.Effect> -} - -export class Service extends Context.Service()("@opencode/Session") {} - -export type Patch = Types.DeepMutable["data"]["info"]> - -const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => - Effect.sync(() => Database.use(fn)) - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const storage = yield* Storage.Service - const sync = yield* SyncEvent.Service - - const normalizeDirectory = (directory: string) => { - const normalized = directory.replaceAll("\\", "/") - if (/^\/+$/.test(normalized)) return normalized - const driveMatch = normalized.match(/^([A-Za-z]:)\/+$/) - if (driveMatch) return `${driveMatch[1]}/` - return normalized.replace(/\/+$/, "") - } - - const createNext = Effect.fn("Session.createNext")(function* (input: { - id?: SessionID - title?: string - agent?: string - model?: Schema.Schema.Type - parentID?: SessionID - workspaceID?: WorkspaceID - directory: string - path?: string - permission?: Permission.Ruleset - }) { - const ctx = yield* InstanceState.context - const result: Info = { - id: SessionID.descending(input.id), - slug: Slug.create(), - version: InstallationVersion, - projectID: ctx.project.id, - directory: normalizeDirectory(input.directory), - path: input.path, - workspaceID: input.workspaceID, - parentID: input.parentID, - title: input.title ?? createDefaultTitle(!!input.parentID), - agent: input.agent, - model: input.model, - permission: input.permission, - time: { - created: Date.now(), - updated: Date.now(), - }, - } - log.info("created", result) - - yield* sync.run(Event.Created, { sessionID: result.id, info: result }) - - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - // This only exist for backwards compatibility. We should not be - // manually publishing this event; it is a sync event now - yield* bus.publish(Event.Updated, { - sessionID: result.id, - info: result, - }) - } - - return result - }) - - const get = Effect.fn("Session.get")(function* (id: SessionID) { - const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) - if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` })) - return fromRow(row) - }) - - const list = Effect.fn("Session.list")(function* (input?: ListInput) { - const ctx = yield* InstanceState.context - return Array.from(listByProject({ projectID: ctx.project.id, ...input })) - }) - - const children = Effect.fn("Session.children")(function* (parentID: SessionID) { - const rows = yield* db((d) => - d - .select() - .from(SessionTable) - .where(and(eq(SessionTable.parent_id, parentID))) - .all(), - ) - return rows.map(fromRow) - }) - - const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { - const session = yield* get(sessionID) - try { - const kids = yield* children(sessionID) - for (const child of kids) { - yield* remove(child.id) - } - - // `remove` needs to work in all cases, such as a broken - // sessions that run cleanup. In certain cases these will - // run without any instance state, so we need to turn off - // publishing of events in that case - const hasInstance = yield* InstanceState.directory.pipe( - Effect.as(true), - Effect.catchCause(() => Effect.succeed(false)), - ) - - yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) - yield* sync.remove(sessionID) - } catch (e) { - log.error(e) - } - }) - - const updateMessage = (msg: T): Effect.Effect => - Effect.gen(function* () { - yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }) - return msg - }).pipe(Effect.withSpan("Session.updateMessage")) - - const updatePart = (part: T): Effect.Effect => - Effect.gen(function* () { - yield* sync.run(MessageV2.Event.PartUpdated, { - sessionID: part.sessionID, - part: structuredClone(part), - time: Date.now(), - }) - return part - }).pipe(Effect.withSpan("Session.updatePart")) - - const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { - const row = Database.use((db) => - db - .select() - .from(PartTable) - .where( - and( - eq(PartTable.session_id, input.sessionID), - eq(PartTable.message_id, input.messageID), - eq(PartTable.id, input.partID), - ), - ) - .get(), - ) - if (!row) return - return { - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - } as MessageV2.Part - }) - - const create = Effect.fn("Session.create")(function* (input?: { - parentID?: SessionID - title?: string - agent?: string - model?: Schema.Schema.Type - permission?: Permission.Ruleset - workspaceID?: WorkspaceID - }) { - const ctx = yield* InstanceState.context - const workspace = yield* InstanceState.workspaceID - return yield* createNext({ - parentID: input?.parentID, - directory: ctx.directory, - path: sessionPath(ctx.worktree, ctx.directory), - title: input?.title, - agent: input?.agent, - model: input?.model, - permission: input?.permission, - workspaceID: input?.workspaceID ?? workspace, - }) - }) - - const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { - const ctx = yield* InstanceState.context - const original = yield* get(input.sessionID) - const title = getForkedTitle(original.title) - const session = yield* createNext({ - directory: ctx.directory, - path: sessionPath(ctx.worktree, ctx.directory), - workspaceID: original.workspaceID, - title, - }) - const msgs = yield* messages({ sessionID: input.sessionID }) - const idMap = new Map() - - for (const msg of msgs) { - if (input.messageID && msg.info.id >= input.messageID) break - const newID = MessageID.ascending() - idMap.set(msg.info.id, newID) - - const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined - const cloned = yield* updateMessage({ - ...msg.info, - sessionID: session.id, - id: newID, - ...(parentID && { parentID }), - }) - - for (const part of msg.parts) { - const p: MessageV2.Part = { - ...part, - id: PartID.ascending(), - messageID: cloned.id, - sessionID: session.id, - } - if (p.type === "compaction" && p.tail_start_id) { - p.tail_start_id = idMap.get(p.tail_start_id) - } - yield* updatePart(p) - } - } - return session - }) - - const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info }) - - const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() } }) - }) - - const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) { - yield* patch(input.sessionID, { title: input.title }) - }) - - const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { - yield* patch(input.sessionID, { time: { archived: input.time } }) - }) - - const setPermission = Effect.fn("Session.setPermission")(function* (input: { - sessionID: SessionID - permission: Permission.Ruleset - }) { - yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } }) - }) - - const setRevert = Effect.fn("Session.setRevert")(function* (input: { - sessionID: SessionID - revert: Info["revert"] - summary: Info["summary"] - }) { - yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }) - }) - - const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) { - yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }) - }) - - const setSummary = Effect.fn("Session.setSummary")(function* (input: { - sessionID: SessionID - summary: Info["summary"] - }) { - yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }) - }) - - const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { - return yield* storage - .read(["session_diff", sessionID]) - .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) - }) - - const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { - if (input.limit) { - return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items - } - return Array.from(MessageV2.stream(input.sessionID)).reverse() - }) - - const removeMessage = Effect.fn("Session.removeMessage")(function* (input: { - sessionID: SessionID - messageID: MessageID - }) { - yield* sync.run(MessageV2.Event.Removed, { - sessionID: input.sessionID, - messageID: input.messageID, - }) - return input.messageID - }) - - const removePart = Effect.fn("Session.removePart")(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - }) { - yield* sync.run(MessageV2.Event.PartRemoved, { - sessionID: input.sessionID, - messageID: input.messageID, - partID: input.partID, - }) - return input.partID - }) - - const updatePartDelta = Effect.fnUntraced(function* (input: { - sessionID: SessionID - messageID: MessageID - partID: PartID - field: string - delta: string - }) { - yield* bus.publish(MessageV2.Event.PartDelta, input) - }) - - /** Finds the first message matching the predicate, searching newest-first. */ - const findMessage = Effect.fn("Session.findMessage")(function* ( - sessionID: SessionID, - predicate: (msg: MessageV2.WithParts) => boolean, - ) { - for (const item of MessageV2.stream(sessionID)) { - if (predicate(item)) return Option.some(item) - } - return Option.none() - }) - - return Service.of({ - list, - create, - fork, - touch, - get, - setTitle, - setArchived, - setPermission, - setRevert, - clearRevert, - setSummary, - diff, - messages, - children, - remove, - updateMessage, - removeMessage, - removePart, - updatePart, - getPart, - updatePartDelta, - findMessage, - }) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(Bus.layer), - Layer.provide(Storage.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), -) - -function* listByProject( - input: ListInput & { - projectID: ProjectID - }, -) { - const conditions = [eq(SessionTable.project_id, input.projectID)] - - if (input.workspaceID) { - conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) - } - if (input.path !== undefined) { - if (input.path) { - const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] - - conditions.push( - input.directory - ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! - : or(...conds)!, - ) - } - } else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { - if (input.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } - } - if (input.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - - const limit = input.limit ?? 100 - - const rows = Database.use((db) => - db - .select() - .from(SessionTable) - .where(and(...conditions)) - .orderBy(desc(SessionTable.time_updated)) - .limit(limit) - .all(), - ) - for (const row of rows) { - yield fromRow(row) - } -} - -export function* listGlobal(input?: { - directory?: string - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean -}) { - const conditions: SQL[] = [] - - if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) - } - if (input?.roots) { - conditions.push(isNull(SessionTable.parent_id)) - } - if (input?.start) { - conditions.push(gte(SessionTable.time_updated, input.start)) - } - if (input?.cursor) { - conditions.push(lt(SessionTable.time_updated, input.cursor)) - } - if (input?.search) { - conditions.push(like(SessionTable.title, `%${input.search}%`)) - } - if (!input?.archived) { - conditions.push(isNull(SessionTable.time_archived)) - } - - const limit = input?.limit ?? 100 - - const rows = Database.use((db) => { - const query = - conditions.length > 0 - ? db - .select() - .from(SessionTable) - .where(and(...conditions)) - : db.select().from(SessionTable) - return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() - }) - - const ids = [...new Set(rows.map((row) => row.project_id))] - const projects = new Map() - - if (ids.length > 0) { - const items = Database.use((db) => - db - .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) - .from(ProjectTable) - .where(inArray(ProjectTable.id, ids)) - .all(), - ) - for (const item of items) { - projects.set(item.id, { - id: item.id, - name: item.name ?? undefined, - worktree: item.worktree, - }) - } - } - - for (const row of rows) { - const project = projects.get(row.project_id) ?? null - yield { ...fromRow(row), project } - } -} - -export * as Session from "./session" +import { Slug } from "@opencode-ai/core/util/slug" +import path from "path" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Decimal } from "decimal.js" +import { type ProviderMetadata, type LanguageModelUsage } from "ai" +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" + +import { Database } from "@/storage/db" +import { NotFoundError } from "@/storage/storage" +import { eq } from "drizzle-orm" +import { and } from "drizzle-orm" +import { gte } from "drizzle-orm" +import { isNull } from "drizzle-orm" +import { desc } from "drizzle-orm" +import { like } from "drizzle-orm" +import { inArray } from "drizzle-orm" +import { lt } from "drizzle-orm" +import { or } from "drizzle-orm" +import { SyncEvent } from "../sync" +import type { SQL } from "drizzle-orm" +import { PartTable, SessionTable } from "./session.sql" +import { ProjectTable } from "../project/project.sql" +import { Storage } from "@/storage/storage" +import * as Log from "@opencode-ai/core/util/log" +import { MessageV2 } from "./message-v2" +import type { InstanceContext } from "../project/instance" +import { InstanceState } from "@/effect/instance-state" +import { Snapshot } from "@/snapshot" +import { ProjectID } from "../project/schema" +import { WorkspaceID } from "../control-plane/schema" +import { SessionID, MessageID, PartID } from "./schema" +import { ModelID, ProviderID } from "@/provider/schema" + +import type { Provider } from "@/provider/provider" +import { Permission } from "@/permission" +import { Global } from "@opencode-ai/core/global" +import { Effect, Layer, Option, Context, Schema, Types } from "effect" +import { zod } from "@/util/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" + +const log = Log.create({ service: "session" }) + +const parentTitlePrefix = "New session - " +const childTitlePrefix = "Child session - " + +function createDefaultTitle(isChild = false) { + return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() +} + +export function isDefaultTitle(title: string) { + return new RegExp( + `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`, + ).test(title) +} + +type SessionRow = typeof SessionTable.$inferSelect + +export function fromRow(row: SessionRow): Info { + const summary = + row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null + ? { + additions: row.summary_additions ?? 0, + deletions: row.summary_deletions ?? 0, + files: row.summary_files ?? 0, + diffs: row.summary_diffs ?? undefined, + } + : undefined + const share = row.share_url ? { url: row.share_url } : undefined + const revert = row.revert ?? undefined + return { + id: row.id, + slug: row.slug, + projectID: row.project_id, + workspaceID: row.workspace_id ?? undefined, + directory: row.directory, + path: row.path ?? undefined, + parentID: row.parent_id ?? undefined, + title: row.title, + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } + : undefined, + version: row.version, + summary, + share, + revert, + permission: row.permission ?? undefined, + time: { + created: row.time_created, + updated: row.time_updated, + compacting: row.time_compacting ?? undefined, + archived: row.time_archived ?? undefined, + }, + } +} + +export function toRow(info: Info) { + return { + id: info.id, + project_id: info.projectID, + workspace_id: info.workspaceID, + parent_id: info.parentID, + slug: info.slug, + directory: info.directory, + path: info.path, + title: info.title, + agent: info.agent, + model: info.model, + version: info.version, + share_url: info.share?.url, + summary_additions: info.summary?.additions, + summary_deletions: info.summary?.deletions, + summary_files: info.summary?.files, + summary_diffs: info.summary?.diffs, + revert: info.revert ?? null, + permission: info.permission, + time_created: info.time.created, + time_updated: info.time.updated, + time_compacting: info.time.compacting, + time_archived: info.time.archived, + } +} + +function getForkedTitle(title: string): string { + const match = title.match(/^(.+) \(fork #(\d+)\)$/) + if (match) { + const base = match[1] + const num = parseInt(match[2], 10) + return `${base} (fork #${num + 1})` + } + return `${title} (fork #1)` +} + +function sessionPath(worktree: string, cwd: string) { + return path.relative(path.resolve(worktree), cwd).replaceAll("\\", "/") +} + +const Summary = Schema.Struct({ + additions: NonNegativeInt, + deletions: NonNegativeInt, + files: NonNegativeInt, + diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), +}) + +const Share = Schema.Struct({ + url: Schema.String, +}) + +// Legacy HTTP accepted negative values here. Keep archive timestamps permissive +// while excluding non-finite values that cannot round-trip through JSON. +export const ArchivedTimestamp = Schema.Finite + +const Time = Schema.Struct({ + created: NonNegativeInt, + updated: NonNegativeInt, + compacting: optionalOmitUndefined(NonNegativeInt), + archived: optionalOmitUndefined(ArchivedTimestamp), +}) + +const Revert = Schema.Struct({ + messageID: MessageID, + partID: optionalOmitUndefined(PartID), + snapshot: optionalOmitUndefined(Schema.String), + diff: optionalOmitUndefined(Schema.String), +}) + +const Model = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + variant: optionalOmitUndefined(Schema.String), +}) + +export const Info = Schema.Struct({ + id: SessionID, + slug: Schema.String, + projectID: ProjectID, + workspaceID: optionalOmitUndefined(WorkspaceID), + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(Summary), + share: optionalOmitUndefined(Share), + title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(Model), + version: Schema.String, + time: Time, + permission: optionalOmitUndefined(Permission.Ruleset), + revert: optionalOmitUndefined(Revert), +}) + .annotate({ identifier: "Session" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Types.DeepMutable> + +export const ProjectInfo = Schema.Struct({ + id: ProjectID, + name: optionalOmitUndefined(Schema.String), + worktree: Schema.String, +}) + .annotate({ identifier: "ProjectSummary" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ProjectInfo = Types.DeepMutable> + +export const GlobalInfo = Schema.Struct({ + ...Info.fields, + project: Schema.NullOr(ProjectInfo), +}) + .annotate({ identifier: "GlobalSession" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type GlobalInfo = Types.DeepMutable> + +export const CreateInput = Schema.optional( + Schema.Struct({ + parentID: Schema.optional(SessionID), + title: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Model), + permission: Schema.optional(Permission.Ruleset), + workspaceID: Schema.optional(WorkspaceID), + }), +).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type CreateInput = Types.DeepMutable> + +export const ForkInput = Schema.Struct({ + sessionID: SessionID, + messageID: Schema.optional(MessageID), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const GetInput = SessionID +export const ChildrenInput = SessionID +export const RemoveInput = SessionID +export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String }).pipe( + withStatics((s) => ({ zod: zod(s) })), +) +export const SetArchivedInput = Schema.Struct({ + sessionID: SessionID, + time: Schema.optional(ArchivedTimestamp), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const SetPermissionInput = Schema.Struct({ + sessionID: SessionID, + permission: Permission.Ruleset, +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const SetRevertInput = Schema.Struct({ + sessionID: SessionID, + revert: Schema.optional(Revert), + summary: Schema.optional(Summary), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export const MessagesInput = Schema.Struct({ + sessionID: SessionID, + limit: Schema.optional(NonNegativeInt), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ListInput = { + directory?: string + scope?: "project" + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + limit?: number +} + +const CreatedEventSchema = Schema.Struct({ + sessionID: SessionID, + info: Info, +}) + +const UpdatedShare = Schema.Struct({ + url: Schema.optional(Schema.NullOr(Schema.String)), +}) + +const UpdatedTime = Schema.Struct({ + created: Schema.optional(Schema.NullOr(NonNegativeInt)), + updated: Schema.optional(Schema.NullOr(NonNegativeInt)), + compacting: Schema.optional(Schema.NullOr(NonNegativeInt)), + archived: Schema.optional(Schema.NullOr(ArchivedTimestamp)), +}) + +const UpdatedInfo = Schema.Struct({ + id: Schema.optional(Schema.NullOr(SessionID)), + slug: Schema.optional(Schema.NullOr(Schema.String)), + projectID: Schema.optional(Schema.NullOr(ProjectID)), + workspaceID: Schema.optional(Schema.NullOr(WorkspaceID)), + directory: Schema.optional(Schema.NullOr(Schema.String)), + path: Schema.optional(Schema.NullOr(Schema.String)), + parentID: Schema.optional(Schema.NullOr(SessionID)), + summary: Schema.optional(Schema.NullOr(Summary)), + share: Schema.optional(UpdatedShare), + title: Schema.optional(Schema.NullOr(Schema.String)), + agent: Schema.optional(Schema.NullOr(Schema.String)), + model: Schema.optional(Schema.NullOr(Model)), + version: Schema.optional(Schema.NullOr(Schema.String)), + time: Schema.optional(UpdatedTime), + permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), + revert: Schema.optional(Schema.NullOr(Revert)), +}) + +const UpdatedEventSchema = Schema.Struct({ + sessionID: SessionID, + info: UpdatedInfo, +}) + +export const Event = { + Created: SyncEvent.define({ + type: "session.created", + version: 1, + aggregate: "sessionID", + schema: CreatedEventSchema, + }), + Updated: SyncEvent.define({ + type: "session.updated", + version: 1, + aggregate: "sessionID", + schema: UpdatedEventSchema, + busSchema: CreatedEventSchema, + }), + Deleted: SyncEvent.define({ + type: "session.deleted", + version: 1, + aggregate: "sessionID", + schema: CreatedEventSchema, + }), + Diff: BusEvent.define( + "session.diff", + Schema.Struct({ + sessionID: SessionID, + diff: Schema.Array(Snapshot.FileDiff), + }), + ), + Error: BusEvent.define( + "session.error", + Schema.Struct({ + sessionID: Schema.optional(SessionID), + // Reuses MessageV2.Assistant.fields.error (already Schema.optional) so + // the derived zod keeps the same discriminated-union shape on the bus. + error: MessageV2.Assistant.fields.error, + }), + ), +} + +export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) { + const base = instance.project.vcs + ? path.join(instance.worktree, ".opencode", "plans") + : path.join(Global.Path.data, "plans") + return path.join(base, [input.time.created, input.slug].join("-") + ".md") +} + +export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { + const safe = (value: number) => { + if (!Number.isFinite(value)) return 0 + return value + } + const inputTokens = safe(input.usage.inputTokens ?? 0) + const outputTokens = safe(input.usage.outputTokens ?? 0) + const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) + + const cacheReadInputTokens = safe( + input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, + ) + const cacheWriteInputTokens = safe( + Number( + input.usage.inputTokenDetails?.cacheWriteTokens ?? + input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? + // google-vertex-anthropic returns metadata under "vertex" key + // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') + input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ?? + // @ts-expect-error + input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? + // @ts-expect-error + input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? + 0, + ), + ) + + // AI SDK v6 normalized inputTokens to include cached tokens across all providers + // (including Anthropic/Bedrock which previously excluded them). Always subtract cache + // tokens to get the non-cached input count for separate cost calculation. + const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens) + + const total = input.usage.totalTokens + + const tokens = { + total, + input: adjustedInputTokens, + output: safe(outputTokens - reasoningTokens), + reasoning: reasoningTokens, + cache: { + write: cacheWriteInputTokens, + read: cacheReadInputTokens, + }, + } + + const costInfo = + input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000 + ? input.model.cost.experimentalOver200K + : input.model.cost + return { + cost: safe( + new Decimal(0) + .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000)) + .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000)) + .add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000)) + .add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000)) + // TODO: update models.dev to have better pricing model, for now: + // charge reasoning tokens at the same rate as output tokens + .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000)) + .toNumber(), + ), + tokens, + } +} + +export class BusyError extends Error { + constructor(public readonly sessionID: string) { + super(`Session ${sessionID} is busy`) + } +} + +export type NotFound = InstanceType + +export interface Interface { + readonly list: (input?: ListInput) => Effect.Effect + readonly create: (input?: { + parentID?: SessionID + title?: string + agent?: string + model?: Schema.Schema.Type + permission?: Permission.Ruleset + workspaceID?: WorkspaceID + }) => Effect.Effect + readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly touch: (sessionID: SessionID) => Effect.Effect + readonly get: (id: SessionID) => Effect.Effect + readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect + readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect + readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect + readonly setRevert: (input: { + sessionID: SessionID + revert: Info["revert"] + summary: Info["summary"] + }) => Effect.Effect + readonly clearRevert: (sessionID: SessionID) => Effect.Effect + readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect + readonly diff: (sessionID: SessionID) => Effect.Effect + readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect + readonly children: (parentID: SessionID) => Effect.Effect + readonly remove: (sessionID: SessionID) => Effect.Effect + readonly updateMessage: (msg: T) => Effect.Effect + readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect + readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect + readonly getPart: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) => Effect.Effect + readonly updatePart: (part: T) => Effect.Effect + readonly updatePartDelta: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + field: string + delta: string + }) => Effect.Effect + /** Finds the first message matching the predicate, searching newest-first. */ + readonly findMessage: ( + sessionID: SessionID, + predicate: (msg: MessageV2.WithParts) => boolean, + ) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/Session") {} + +export type Patch = Types.DeepMutable["data"]["info"]> + +const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const storage = yield* Storage.Service + const sync = yield* SyncEvent.Service + + const normalizeDirectory = (directory: string) => { + const normalized = directory.replaceAll("\\", "/") + if (/^\/+$/.test(normalized)) return normalized + const driveMatch = normalized.match(/^([A-Za-z]:)\/+$/) + if (driveMatch) return `${driveMatch[1]}/` + return normalized.replace(/\/+$/, "") + } + + const createNext = Effect.fn("Session.createNext")(function* (input: { + id?: SessionID + title?: string + agent?: string + model?: Schema.Schema.Type + parentID?: SessionID + workspaceID?: WorkspaceID + directory: string + path?: string + permission?: Permission.Ruleset + }) { + const ctx = yield* InstanceState.context + const result: Info = { + id: SessionID.descending(input.id), + slug: Slug.create(), + version: InstallationVersion, + projectID: ctx.project.id, + directory: normalizeDirectory(input.directory), + path: input.path, + workspaceID: input.workspaceID, + parentID: input.parentID, + title: input.title ?? createDefaultTitle(!!input.parentID), + agent: input.agent, + model: input.model, + permission: input.permission, + time: { + created: Date.now(), + updated: Date.now(), + }, + } + log.info("created", result) + + yield* sync.run(Event.Created, { sessionID: result.id, info: result }) + + if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + // This only exist for backwards compatibility. We should not be + // manually publishing this event; it is a sync event now + yield* bus.publish(Event.Updated, { + sessionID: result.id, + info: result, + }) + } + + return result + }) + + const get = Effect.fn("Session.get")(function* (id: SessionID) { + const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get()) + if (!row) return yield* Effect.fail(new NotFoundError({ message: `Session not found: ${id}` })) + return fromRow(row) + }) + + const list = Effect.fn("Session.list")(function* (input?: ListInput) { + const ctx = yield* InstanceState.context + return Array.from(listByProject({ projectID: ctx.project.id, ...input })) + }) + + const children = Effect.fn("Session.children")(function* (parentID: SessionID) { + const rows = yield* db((d) => + d + .select() + .from(SessionTable) + .where(and(eq(SessionTable.parent_id, parentID))) + .all(), + ) + return rows.map(fromRow) + }) + + const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) { + const session = yield* get(sessionID) + try { + const kids = yield* children(sessionID) + for (const child of kids) { + yield* remove(child.id) + } + + // `remove` needs to work in all cases, such as a broken + // sessions that run cleanup. In certain cases these will + // run without any instance state, so we need to turn off + // publishing of events in that case + const hasInstance = yield* InstanceState.directory.pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ) + + yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance }) + yield* sync.remove(sessionID) + } catch (e) { + log.error(e) + } + }) + + const updateMessage = (msg: T): Effect.Effect => + Effect.gen(function* () { + yield* sync.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }) + return msg + }).pipe(Effect.withSpan("Session.updateMessage")) + + const updatePart = (part: T): Effect.Effect => + Effect.gen(function* () { + yield* sync.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + part: structuredClone(part), + time: Date.now(), + }) + return part + }).pipe(Effect.withSpan("Session.updatePart")) + + const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { + const row = Database.use((db) => + db + .select() + .from(PartTable) + .where( + and( + eq(PartTable.session_id, input.sessionID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.id, input.partID), + ), + ) + .get(), + ) + if (!row) return + return { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + }) + + const create = Effect.fn("Session.create")(function* (input?: { + parentID?: SessionID + title?: string + agent?: string + model?: Schema.Schema.Type + permission?: Permission.Ruleset + workspaceID?: WorkspaceID + }) { + const ctx = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID + return yield* createNext({ + parentID: input?.parentID, + directory: ctx.directory, + path: sessionPath(ctx.worktree, ctx.directory), + title: input?.title, + agent: input?.agent, + model: input?.model, + permission: input?.permission, + workspaceID: input?.workspaceID ?? workspace, + }) + }) + + const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { + const ctx = yield* InstanceState.context + const original = yield* get(input.sessionID) + const title = getForkedTitle(original.title) + const session = yield* createNext({ + directory: ctx.directory, + path: sessionPath(ctx.worktree, ctx.directory), + workspaceID: original.workspaceID, + title, + }) + const msgs = yield* messages({ sessionID: input.sessionID }) + const idMap = new Map() + + for (const msg of msgs) { + if (input.messageID && msg.info.id >= input.messageID) break + const newID = MessageID.ascending() + idMap.set(msg.info.id, newID) + + const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined + const cloned = yield* updateMessage({ + ...msg.info, + sessionID: session.id, + id: newID, + ...(parentID && { parentID }), + }) + + for (const part of msg.parts) { + const p: MessageV2.Part = { + ...part, + id: PartID.ascending(), + messageID: cloned.id, + sessionID: session.id, + } + if (p.type === "compaction" && p.tail_start_id) { + p.tail_start_id = idMap.get(p.tail_start_id) + } + yield* updatePart(p) + } + } + return session + }) + + const patch = (sessionID: SessionID, info: Patch) => sync.run(Event.Updated, { sessionID, info }) + + const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) { + yield* patch(sessionID, { time: { updated: Date.now() } }) + }) + + const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) { + yield* patch(input.sessionID, { title: input.title }) + }) + + const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { + yield* patch(input.sessionID, { time: { archived: input.time } }) + }) + + const setPermission = Effect.fn("Session.setPermission")(function* (input: { + sessionID: SessionID + permission: Permission.Ruleset + }) { + yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } }) + }) + + const setRevert = Effect.fn("Session.setRevert")(function* (input: { + sessionID: SessionID + revert: Info["revert"] + summary: Info["summary"] + }) { + yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }) + }) + + const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) { + yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }) + }) + + const setSummary = Effect.fn("Session.setSummary")(function* (input: { + sessionID: SessionID + summary: Info["summary"] + }) { + yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }) + }) + + const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { + return yield* storage + .read(["session_diff", sessionID]) + .pipe(Effect.orElseSucceed((): Snapshot.FileDiff[] => [])) + }) + + const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { + if (input.limit) { + return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items + } + return Array.from(MessageV2.stream(input.sessionID)).reverse() + }) + + const removeMessage = Effect.fn("Session.removeMessage")(function* (input: { + sessionID: SessionID + messageID: MessageID + }) { + yield* sync.run(MessageV2.Event.Removed, { + sessionID: input.sessionID, + messageID: input.messageID, + }) + return input.messageID + }) + + const removePart = Effect.fn("Session.removePart")(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) { + yield* sync.run(MessageV2.Event.PartRemoved, { + sessionID: input.sessionID, + messageID: input.messageID, + partID: input.partID, + }) + return input.partID + }) + + const updatePartDelta = Effect.fnUntraced(function* (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + field: string + delta: string + }) { + yield* bus.publish(MessageV2.Event.PartDelta, input) + }) + + /** Finds the first message matching the predicate, searching newest-first. */ + const findMessage = Effect.fn("Session.findMessage")(function* ( + sessionID: SessionID, + predicate: (msg: MessageV2.WithParts) => boolean, + ) { + for (const item of MessageV2.stream(sessionID)) { + if (predicate(item)) return Option.some(item) + } + return Option.none() + }) + + return Service.of({ + list, + create, + fork, + touch, + get, + setTitle, + setArchived, + setPermission, + setRevert, + clearRevert, + setSummary, + diff, + messages, + children, + remove, + updateMessage, + removeMessage, + removePart, + updatePart, + getPart, + updatePartDelta, + findMessage, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Storage.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), +) + +function* listByProject( + input: ListInput & { + projectID: ProjectID + }, +) { + const conditions = [eq(SessionTable.project_id, input.projectID)] + + if (input.workspaceID) { + conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + } + if (input.path !== undefined) { + if (input.path) { + const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] + + conditions.push( + input.directory + ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! + : or(...conds)!, + ) + } + } else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (input.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } + } + if (input.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + + const limit = input.limit ?? 100 + + const rows = Database.use((db) => + db + .select() + .from(SessionTable) + .where(and(...conditions)) + .orderBy(desc(SessionTable.time_updated)) + .limit(limit) + .all(), + ) + for (const row of rows) { + yield fromRow(row) + } +} + +export function* listGlobal(input?: { + directory?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean +}) { + const conditions: SQL[] = [] + + if (input?.directory) { + conditions.push(eq(SessionTable.directory, input.directory)) + } + if (input?.roots) { + conditions.push(isNull(SessionTable.parent_id)) + } + if (input?.start) { + conditions.push(gte(SessionTable.time_updated, input.start)) + } + if (input?.cursor) { + conditions.push(lt(SessionTable.time_updated, input.cursor)) + } + if (input?.search) { + conditions.push(like(SessionTable.title, `%${input.search}%`)) + } + if (!input?.archived) { + conditions.push(isNull(SessionTable.time_archived)) + } + + const limit = input?.limit ?? 100 + + const rows = Database.use((db) => { + const query = + conditions.length > 0 + ? db + .select() + .from(SessionTable) + .where(and(...conditions)) + : db.select().from(SessionTable) + return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all() + }) + + const ids = [...new Set(rows.map((row) => row.project_id))] + const projects = new Map() + + if (ids.length > 0) { + const items = Database.use((db) => + db + .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) + .from(ProjectTable) + .where(inArray(ProjectTable.id, ids)) + .all(), + ) + for (const item of items) { + projects.set(item.id, { + id: item.id, + name: item.name ?? undefined, + worktree: item.worktree, + }) + } + } + + for (const row of rows) { + const project = projects.get(row.project_id) ?? null + yield { ...fromRow(row), project } + } +} + +export * as Session from "./session"