| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018 |
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { PermissionV1 } from "@kirincode-ai/core/v1/permission"
- import { Slug } from "@kirincode-ai/core/util/slug"
- import { SessionV1 } from "@kirincode-ai/core/v1/session"
- import { serviceUse } from "@kirincode-ai/core/effect/service-use"
- import path from "path"
- import { BackgroundJob } from "@/background/job"
- import { Decimal } from "decimal.js"
- import type { ProviderMetadata, Usage } from "@kirincode-ai/llm"
- import { InstallationVersion } from "@kirincode-ai/core/installation/version"
- import { Database } from "@kirincode-ai/core/database/database"
- import { EventV2Bridge } from "@/event-v2-bridge"
- import { SessionV2 } from "@kirincode-ai/core/session"
- import * as SessionExecutionLocal from "@kirincode-ai/core/session/execution/local"
- import { locationServiceMapLayer } from "@kirincode-ai/core/location-services"
- 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 { sql } from "drizzle-orm"
- import { inArray } from "drizzle-orm"
- import { lt } from "drizzle-orm"
- import { or } from "drizzle-orm"
- import type { SQL } from "drizzle-orm"
- import { PartTable, SessionTable } from "@kirincode-ai/core/session/sql"
- import { ProjectTable } from "@kirincode-ai/core/project/sql"
- import { MessageV2 } from "./message-v2"
- import type { InstanceContext } from "../project/instance-context"
- import { InstanceState } from "@/effect/instance-state"
- import { Snapshot } from "@/snapshot"
- import { ProjectV2 } from "@kirincode-ai/core/project"
- import { WorkspaceV2 } from "@kirincode-ai/core/workspace"
- import { SessionID, MessageID, PartID } from "./schema"
- import type { Provider } from "@/provider/provider"
- import { Global } from "@kirincode-ai/core/global"
- import { Effect, Layer, Option, Context, Schema, Types } from "effect"
- import { NonNegativeInt, optional } from "@kirincode-ai/core/schema"
- import { RuntimeFlags } from "@/effect/runtime-flags"
- import { ProviderV2 } from "@kirincode-ai/core/provider"
- import { ModelV2 } from "@kirincode-ai/core/model"
- import { SessionMessage } from "@kirincode-ai/schema/session-message"
- const parentTitlePrefix = "New session - "
- const childTitlePrefix = "Child session - "
- 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
- ? {
- messageID: MessageID.make(row.revert.messageID),
- partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined,
- snapshot: row.revert.snapshot,
- diff: row.revert.diff,
- }
- : 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: ModelV2.ID.make(row.model.id),
- providerID: ProviderV2.ID.make(row.model.providerID),
- variant: row.model.variant,
- }
- : undefined,
- version: row.version,
- summary,
- cost: row.cost,
- tokens: {
- input: row.tokens_input,
- output: row.tokens_output,
- reasoning: row.tokens_reasoning,
- cache: {
- read: row.tokens_cache_read,
- write: row.tokens_cache_write,
- },
- },
- share,
- metadata: row.metadata ?? undefined,
- revert,
- permission: row.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,
- metadata: info.metadata,
- cost: info.cost ?? 0,
- tokens_input: (info.tokens ?? EmptyTokens).input,
- tokens_output: (info.tokens ?? EmptyTokens).output,
- tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning,
- tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read,
- tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write,
- revert: info.revert
- ? {
- messageID: SessionMessage.ID.make(info.revert.messageID),
- partID: info.revert.partID,
- snapshot: info.revert.snapshot,
- diff: info.revert.diff,
- }
- : 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: Schema.Finite,
- deletions: Schema.Finite,
- files: Schema.Finite,
- diffs: optional(Schema.Array(Snapshot.FileDiff)),
- })
- const Tokens = Schema.Struct({
- input: Schema.Finite,
- output: Schema.Finite,
- reasoning: Schema.Finite,
- cache: Schema.Struct({
- read: Schema.Finite,
- write: Schema.Finite,
- }),
- })
- const EmptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
- 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: optional(NonNegativeInt),
- archived: optional(ArchivedTimestamp),
- })
- const Revert = Schema.Struct({
- messageID: MessageID,
- partID: optional(PartID),
- snapshot: optional(Schema.String),
- diff: optional(Schema.String),
- })
- const Model = Schema.Struct({
- id: ModelV2.ID,
- providerID: ProviderV2.ID,
- variant: optional(Schema.String),
- })
- export const Metadata = Schema.Record(Schema.String, Schema.Any)
- export const Info = Schema.Struct({
- id: SessionID,
- slug: Schema.String,
- projectID: ProjectV2.ID,
- workspaceID: optional(WorkspaceV2.ID),
- directory: Schema.String,
- path: optional(Schema.String),
- parentID: optional(SessionID),
- summary: optional(Summary),
- cost: optional(Schema.Finite),
- tokens: optional(Tokens),
- share: optional(Share),
- title: Schema.String,
- agent: optional(Schema.String),
- model: optional(Model),
- version: Schema.String,
- metadata: optional(Metadata),
- time: Time,
- permission: optional(PermissionV1.Ruleset),
- revert: optional(Revert),
- }).annotate({ identifier: "Session" })
- export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
- export const ProjectInfo = Schema.Struct({
- id: ProjectV2.ID,
- name: optional(Schema.String),
- worktree: Schema.String,
- }).annotate({ identifier: "ProjectSummary" })
- export type ProjectInfo = Types.DeepMutable<Schema.Schema.Type<typeof ProjectInfo>>
- export const GlobalInfo = Schema.Struct({
- ...Info.fields,
- project: Schema.NullOr(ProjectInfo),
- }).annotate({ identifier: "GlobalSession" })
- export type GlobalInfo = Types.DeepMutable<Schema.Schema.Type<typeof GlobalInfo>>
- 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),
- metadata: Schema.optional(Metadata),
- permission: Schema.optional(PermissionV1.Ruleset),
- workspaceID: Schema.optional(WorkspaceV2.ID),
- }),
- )
- export type CreateInput = Types.DeepMutable<Schema.Schema.Type<typeof CreateInput>>
- export const ForkInput = Schema.Struct({
- sessionID: SessionID,
- messageID: Schema.optional(MessageID),
- })
- export const GetInput = SessionID
- export const ChildrenInput = SessionID
- export const RemoveInput = SessionID
- export const SetTitleInput = Schema.Struct({ sessionID: SessionID, title: Schema.String })
- export const SetArchivedInput = Schema.Struct({
- sessionID: SessionID,
- time: Schema.optional(ArchivedTimestamp),
- })
- export const SetMetadataInput = Schema.Struct({
- sessionID: SessionID,
- metadata: Metadata,
- })
- export const SetPermissionInput = Schema.Struct({
- sessionID: SessionID,
- permission: PermissionV1.Ruleset,
- })
- export const SetRevertInput = Schema.Struct({
- sessionID: SessionID,
- revert: Schema.optional(Revert),
- summary: Schema.optional(Summary),
- })
- export const MessagesInput = Schema.Struct({
- sessionID: SessionID,
- limit: Schema.optional(NonNegativeInt),
- })
- export type ListInput = {
- directory?: string
- scope?: "project"
- path?: string
- workspaceID?: WorkspaceV2.ID
- roots?: boolean
- start?: number
- search?: string
- limit?: number
- }
- export type GlobalListInput = {
- directory?: string
- roots?: boolean
- start?: number
- cursor?: number
- search?: string
- limit?: number
- archived?: boolean
- }
- export const Event = {
- Created: SessionV1.Event.Created,
- Updated: SessionV1.Event.Updated,
- Deleted: SessionV1.Event.Deleted,
- Diff: SessionV1.Event.Diff,
- Error: SessionV1.Event.Error,
- }
- export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) {
- const base = instance.project.vcs
- ? path.join(instance.worktree, ".kirincode", "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: Usage; metadata?: ProviderMetadata }) => {
- const safe = (value: number) => {
- if (!Number.isFinite(value)) return 0
- return Math.max(0, value)
- }
- const inputTokens = safe(input.usage.inputTokens ?? 0)
- const outputTokens = safe(input.usage.outputTokens ?? 0)
- const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
- const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
- const cacheWriteInputTokens = safe(
- Number(
- input.usage.cacheWriteInputTokens ??
- 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 contextTokens = inputTokens
- const costInfo =
- input.model.cost?.tiers
- ?.filter((item) => item.tier.type === "context" && contextTokens > item.tier.size)
- .sort((a, b) => b.tier.size - a.tier.size)[0] ??
- (input.model.cost?.experimentalOver200K && contextTokens > 200_000
- ? input.model.cost.experimentalOver200K
- : input.model.cost)
- const totalNanoAiu = input.metadata?.["copilot"]?.["totalNanoAiu"]
- return {
- cost:
- typeof totalNanoAiu === "number" && Number.isFinite(totalNanoAiu) && totalNanoAiu >= 0
- ? new Decimal(totalNanoAiu).div(100_000_000_000).toNumber()
- : 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 Schema.TaggedErrorClass<BusyError>()("SessionBusyError", {
- sessionID: SessionID,
- }) {}
- export type NotFound = NotFoundError
- export interface Interface {
- readonly list: (input?: ListInput) => Effect.Effect<Info[]>
- readonly listGlobal: (input?: GlobalListInput) => Effect.Effect<GlobalInfo[]>
- readonly create: (input?: {
- parentID?: SessionID
- title?: string
- agent?: string
- model?: Schema.Schema.Type<typeof Model>
- metadata?: typeof Metadata.Type
- permission?: PermissionV1.Ruleset
- workspaceID?: WorkspaceV2.ID
- }) => Effect.Effect<Info>
- readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
- readonly touch: (sessionID: SessionID) => Effect.Effect<void>
- readonly get: (id: SessionID) => Effect.Effect<Info, NotFound>
- readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
- readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
- readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect<void>
- readonly setAgentModel: (input: {
- sessionID: SessionID
- agent: string
- model: NonNullable<Info["model"]>
- time: number
- }) => Effect.Effect<void>
- readonly setPermission: (input: { sessionID: SessionID; permission: PermissionV1.Ruleset }) => Effect.Effect<void>
- readonly setRevert: (input: {
- sessionID: SessionID
- revert: Info["revert"]
- summary: Info["summary"]
- }) => Effect.Effect<void>
- readonly clearRevert: (sessionID: SessionID) => Effect.Effect<void>
- readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect<void>
- readonly setShare: (input: { sessionID: SessionID; share: Info["share"] }) => Effect.Effect<void>
- readonly setWorkspace: (input: { sessionID: SessionID; workspaceID: Info["workspaceID"] }) => Effect.Effect<void>
- readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
- readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<SessionV1.WithParts[], NotFound>
- readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
- readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
- readonly updateMessage: <T extends SessionV1.Info>(msg: T) => Effect.Effect<T>
- readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
- readonly removePart: (input: { sessionID: SessionID; messageID: MessageID; partID: PartID }) => Effect.Effect<PartID>
- readonly getPart: (input: {
- sessionID: SessionID
- messageID: MessageID
- partID: PartID
- }) => Effect.Effect<SessionV1.Part | undefined>
- readonly updatePart: <T extends SessionV1.Part>(part: T) => Effect.Effect<T>
- readonly updatePartDelta: (input: {
- sessionID: SessionID
- messageID: MessageID
- partID: PartID
- field: string
- delta: string
- }) => Effect.Effect<void>
- /** Finds the first message matching the predicate, searching newest-first. */
- readonly findMessage: (
- sessionID: SessionID,
- predicate: (msg: SessionV1.WithParts) => boolean,
- ) => Effect.Effect<Option.Option<SessionV1.WithParts>, NotFound>
- }
- export class Service extends Context.Service<Service, Interface>()("@kirincode/Session") {}
- export const use = serviceUse(Service)
- export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert" | "permission"> & {
- time?: Partial<Info["time"]>
- share?: Partial<NonNullable<Info["share"]>> | null
- summary?: Info["summary"] | null
- revert?: Info["revert"] | null
- permission?: Info["permission"] | null
- }
- const layer: Layer.Layer<
- Service,
- never,
- BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
- > = Layer.effect(
- Service,
- Effect.gen(function* () {
- const { db } = yield* Database.Service
- const database = yield* Database.Service
- const background = yield* BackgroundJob.Service
- const events = yield* EventV2Bridge.Service
- const flags = yield* RuntimeFlags.Service
- const createNext = Effect.fn("Session.createNext")(function* (input: {
- id?: SessionID
- title?: string
- agent?: string
- model?: Schema.Schema.Type<typeof Model>
- parentID?: SessionID
- workspaceID?: WorkspaceV2.ID
- directory: string
- path?: string
- metadata?: typeof Metadata.Type
- permission?: PermissionV1.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 ?? (input.parentID ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString(),
- agent: input.agent,
- model: input.model,
- metadata: input.metadata,
- permission: input.permission ? [...input.permission] : undefined,
- cost: 0,
- tokens: EmptyTokens,
- time: {
- created: Date.now(),
- updated: Date.now(),
- },
- }
- yield* Effect.logInfo("created", result)
- yield* events.publish(SessionV1.Event.Created, { sessionID: result.id, info: result })
- return result
- })
- const get = Effect.fn("Session.get")(function* (id: SessionID) {
- const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, id)).get().pipe(Effect.orDie)
- 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 yield* listByProject(db, {
- projectID: ctx.project.id,
- experimentalWorkspaces: flags.experimentalWorkspaces,
- ...input,
- })
- })
- const listGlobal = Effect.fn("Session.listGlobal")(function* (input?: GlobalListInput) {
- 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 query =
- conditions.length > 0
- ? db
- .select()
- .from(SessionTable)
- .where(and(...conditions))
- : db.select().from(SessionTable)
- const rows = yield* query
- .orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
- .limit(input?.limit ?? 100)
- .all()
- .pipe(Effect.orDie)
- const ids = [...new Set(rows.map((row) => row.project_id))]
- const projects = new Map<string, ProjectInfo>()
- if (ids.length > 0) {
- const items = yield* db
- .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
- .from(ProjectTable)
- .where(inArray(ProjectTable.id, ids))
- .all()
- .pipe(Effect.orDie)
- for (const item of items) {
- projects.set(item.id, {
- id: item.id,
- name: item.name ?? undefined,
- worktree: item.worktree,
- })
- }
- }
- return rows.map((row) => ({ ...fromRow(row), project: projects.get(row.project_id) ?? null }))
- })
- const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
- const rows = yield* db
- .select()
- .from(SessionTable)
- .where(and(eq(SessionTable.parent_id, parentID)))
- .all()
- .pipe(Effect.orDie)
- return rows.map(fromRow)
- })
- const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
- const session = yield* get(sessionID)
- try {
- // `remove` needs to work in all cases, such as broken sessions that
- // run cleanup without instance state.
- const hasInstance = yield* InstanceState.directory.pipe(
- Effect.as(true),
- Effect.catchCause(() => Effect.succeed(false)),
- )
- if (hasInstance) yield* cancelBackgroundJobs(background, sessionID)
- const kids = yield* children(sessionID)
- for (const child of kids) {
- yield* remove(child.id)
- }
- yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session })
- yield* events.remove(sessionID)
- } catch (error) {
- yield* Effect.logError("failed to remove session", { sessionID, error })
- }
- })
- const updateMessage = <T extends SessionV1.Info>(msg: T): Effect.Effect<T> =>
- Effect.gen(function* () {
- yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg })
- return msg
- }).pipe(Effect.withSpan("Session.updateMessage"))
- const updatePart = <T extends SessionV1.Part>(part: T): Effect.Effect<T> =>
- Effect.gen(function* () {
- yield* events.publish(SessionV1.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 = yield* db
- .select()
- .from(PartTable)
- .where(
- and(
- eq(PartTable.session_id, input.sessionID),
- eq(PartTable.message_id, input.messageID),
- eq(PartTable.id, input.partID),
- ),
- )
- .get()
- .pipe(Effect.orDie)
- if (!row) return
- return {
- ...row.data,
- id: row.id,
- sessionID: row.session_id,
- messageID: row.message_id,
- } as SessionV1.Part
- })
- const create = Effect.fn("Session.create")(function* (input?: {
- parentID?: SessionID
- title?: string
- agent?: string
- model?: Schema.Schema.Type<typeof Model>
- metadata?: typeof Metadata.Type
- permission?: PermissionV1.Ruleset
- workspaceID?: WorkspaceV2.ID
- }) {
- 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,
- metadata: input?.metadata,
- 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,
- metadata: structuredClone(original.metadata),
- })
- const msgs = yield* messages({ sessionID: input.sessionID })
- const idMap = new Map<string, MessageID>()
- 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: SessionV1.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) =>
- Effect.gen(function* () {
- const current = yield* get(sessionID)
- const next = {
- ...current,
- ...info,
- time: info.time ? { ...current.time, ...info.time } : current.time,
- share: info.share === null ? undefined : info.share ? { ...current.share, ...info.share } : current.share,
- summary: info.summary === null ? undefined : (info.summary ?? current.summary),
- revert: info.revert === null ? undefined : (info.revert ?? current.revert),
- permission: info.permission === null ? undefined : (info.permission ?? current.permission),
- } as Info
- yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next })
- })
- const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
- yield* patch(sessionID, { time: { updated: Date.now() } }).pipe(Effect.orDie)
- })
- const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) {
- yield* patch(input.sessionID, { title: input.title }).pipe(Effect.orDie)
- })
- const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) {
- yield* patch(input.sessionID, { time: { archived: input.time } }).pipe(Effect.orDie)
- })
- const setMetadata = Effect.fn("Session.setMetadata")(function* (input: typeof SetMetadataInput.Type) {
- yield* patch(input.sessionID, { metadata: input.metadata, time: { updated: Date.now() } }).pipe(Effect.orDie)
- })
- const setAgentModel = Effect.fn("Session.setAgentModel")(function* (input: {
- sessionID: SessionID
- agent: string
- model: NonNullable<Info["model"]>
- time: number
- }) {
- yield* patch(input.sessionID, {
- agent: input.agent,
- model: input.model,
- time: { updated: input.time },
- }).pipe(Effect.orDie)
- })
- const setPermission = Effect.fn("Session.setPermission")(function* (input: {
- sessionID: SessionID
- permission: PermissionV1.Ruleset
- }) {
- yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe(
- Effect.orDie,
- )
- })
- 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,
- }).pipe(Effect.orDie)
- })
- const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) {
- yield* patch(sessionID, { time: { updated: Date.now() }, revert: null }).pipe(Effect.orDie)
- })
- const setSummary = Effect.fn("Session.setSummary")(function* (input: {
- sessionID: SessionID
- summary: Info["summary"]
- }) {
- yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary }).pipe(Effect.orDie)
- })
- const setShare = Effect.fn("Session.setShare")(function* (input: { sessionID: SessionID; share: Info["share"] }) {
- yield* patch(input.sessionID, { share: input.share ?? null, time: { updated: Date.now() } }).pipe(Effect.orDie)
- })
- const setWorkspace = Effect.fn("Session.setWorkspace")(function* (input: {
- sessionID: SessionID
- workspaceID: Info["workspaceID"]
- }) {
- yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe(
- Effect.orDie,
- )
- })
- const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
- void sessionID
- return [] as Snapshot.FileDiff[]
- })
- const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) {
- if (input.limit) {
- return (yield* MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).pipe(
- Effect.provideService(Database.Service, database),
- )).items
- }
- const size = 50
- const result = [] as SessionV1.WithParts[]
- let before: string | undefined
- while (true) {
- const page = yield* MessageV2.page({ sessionID: input.sessionID, limit: size, before }).pipe(
- Effect.provideService(Database.Service, database),
- )
- if (page.items.length === 0) break
- for (let i = page.items.length - 1; i >= 0; i--) {
- const item = page.items[i]
- if (item) result.push(item)
- }
- if (!page.more || !page.cursor) break
- before = page.cursor
- }
- return result.reverse()
- })
- const removeMessage = Effect.fn("Session.removeMessage")(function* (input: {
- sessionID: SessionID
- messageID: MessageID
- }) {
- yield* events.publish(SessionV1.Event.MessageRemoved, {
- sessionID: input.sessionID,
- messageID: input.messageID,
- })
- return input.messageID
- })
- const removePart = Effect.fn("Session.removePart")(function* (input: {
- sessionID: SessionID
- messageID: MessageID
- partID: PartID
- }) {
- yield* events.publish(SessionV1.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* events.publish(MessageV2.Event.PartDelta, input)
- })
- /** Finds the first message matching the predicate, searching newest-first. */
- const findMessage: Interface["findMessage"] = Effect.fn("Session.findMessage")(function* (sessionID, predicate) {
- const size = 50
- let before: string | undefined
- while (true) {
- const page = yield* MessageV2.page({ sessionID, limit: size, before }).pipe(
- Effect.provideService(Database.Service, database),
- )
- if (page.items.length === 0) break
- for (let i = page.items.length - 1; i >= 0; i--) {
- const item = page.items[i]
- if (item && predicate(item)) return Option.some(item)
- }
- if (!page.more || !page.cursor) break
- before = page.cursor
- }
- return Option.none<SessionV1.WithParts>()
- })
- return Service.of({
- list,
- listGlobal,
- create,
- fork,
- touch,
- get,
- setTitle,
- setArchived,
- setMetadata,
- setAgentModel,
- setPermission,
- setRevert,
- clearRevert,
- setSummary,
- setShare,
- setWorkspace,
- diff,
- messages,
- children,
- remove,
- updateMessage,
- removeMessage,
- removePart,
- updatePart,
- getPart,
- updatePartDelta,
- findMessage,
- })
- }),
- )
- const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* (
- background: BackgroundJob.Interface,
- sessionID: SessionID,
- ) {
- const jobs = yield* background.list()
- yield* Effect.forEach(
- jobs.filter((job) => {
- if (job.status !== "running") return false
- if (job.id === sessionID) return true
- if (job.metadata?.sessionId === sessionID) return true
- return job.metadata?.parentSessionId === sessionID
- }),
- (job) => background.cancel(job.id),
- { concurrency: "unbounded", discard: true },
- )
- })
- function listByProject(
- db: Database.Interface["db"],
- input: ListInput & {
- projectID: ProjectV2.ID
- experimentalWorkspaces: boolean
- },
- ) {
- 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, sql.param(`${input.path}/%`, SessionTable.path)),
- ]
- conditions.push(
- input.directory
- ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)!
- : or(...conds)!,
- )
- }
- } else if (input.scope !== "project") {
- 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
- return db
- .select()
- .from(SessionTable)
- .where(and(...conditions))
- .orderBy(desc(SessionTable.time_updated))
- .limit(limit)
- .all()
- .pipe(
- Effect.orDie,
- Effect.map((rows) => rows.map(fromRow)),
- )
- }
- export const node = LayerNode.make({
- service: Service,
- layer: layer,
- deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node],
- })
- export * as Session from "./session"
|