session.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. export * as SessionV2 from "./session"
  2. export * from "./session/schema"
  3. import { DateTime, Effect, Layer, Schema, Context } from "effect"
  4. import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm"
  5. import { ProjectV2 } from "./project"
  6. import { WorkspaceV2 } from "./workspace"
  7. import { ModelV2 } from "./model"
  8. import { Location } from "./location"
  9. import { SessionMessage } from "./session/message"
  10. import type { Prompt } from "./session/prompt"
  11. import { EventV2 } from "./event"
  12. import { ProviderV2 } from "./provider"
  13. import { Database } from "./database/database"
  14. import { SessionProjector } from "./session/projector"
  15. import { SessionMessageTable, SessionTable } from "./session/sql"
  16. import { SessionSchema } from "./session/schema"
  17. import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
  18. import { AgentV2 } from "./agent"
  19. // get project -> project.locations
  20. //
  21. // get all sessions
  22. //
  23. // - by project
  24. // - by subpath
  25. // - by workspace (home is special)
  26. export const ListAnchor = Schema.Struct({
  27. id: SessionSchema.ID,
  28. time: Schema.Finite,
  29. direction: Schema.Literals(["previous", "next"]),
  30. })
  31. export type ListAnchor = typeof ListAnchor.Type
  32. const ListInputBase = {
  33. workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
  34. search: Schema.String.pipe(Schema.optional),
  35. limit: PositiveInt.pipe(Schema.optional),
  36. order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
  37. anchor: ListAnchor.pipe(Schema.optional),
  38. }
  39. const ListDirectoryInput = Schema.Struct({
  40. ...ListInputBase,
  41. directory: AbsolutePath,
  42. })
  43. const ListProjectInput = Schema.Struct({
  44. ...ListInputBase,
  45. project: ProjectV2.ID,
  46. subpath: RelativePath.pipe(Schema.optional),
  47. })
  48. const ListAllInput = Schema.Struct(ListInputBase)
  49. export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
  50. export type ListInput = typeof ListInput.Type
  51. type CreateInput = {
  52. id?: SessionSchema.ID
  53. agent?: string
  54. model?: ModelV2.Ref
  55. location: Location.Ref
  56. }
  57. type MoveInput = {
  58. sessionID: SessionSchema.ID
  59. location: Location.Ref
  60. }
  61. type CompactInput = {
  62. sessionID: SessionSchema.ID
  63. prompt?: Prompt
  64. }
  65. export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
  66. sessionID: SessionSchema.ID,
  67. }) {}
  68. export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
  69. "Session.OperationUnavailableError",
  70. {
  71. operation: Schema.Literals(["prompt", "compact", "wait"]),
  72. },
  73. ) {}
  74. export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
  75. sessionID: SessionSchema.ID,
  76. messageID: SessionMessage.ID,
  77. }) {}
  78. export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError
  79. export interface Interface {
  80. readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
  81. readonly create: (input?: CreateInput) => Effect.Effect<SessionSchema.Info>
  82. readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
  83. readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
  84. readonly messages: (input: {
  85. sessionID: SessionSchema.ID
  86. limit?: number
  87. order?: "asc" | "desc"
  88. cursor?: {
  89. id: SessionMessage.ID
  90. time: number
  91. direction: "previous" | "next"
  92. }
  93. }) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
  94. readonly context: (
  95. sessionID: SessionSchema.ID,
  96. ) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
  97. readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, never>
  98. readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
  99. readonly prompt: (input: {
  100. id?: EventV2.ID
  101. sessionID: SessionSchema.ID
  102. prompt: Prompt
  103. delivery?: SessionSchema.Delivery
  104. resume?: boolean
  105. }) => Effect.Effect<SessionMessage.User, NotFoundError | OperationUnavailableError>
  106. readonly shell: (input: {
  107. id?: EventV2.ID
  108. sessionID: SessionSchema.ID
  109. command: string
  110. delivery?: SessionSchema.Delivery
  111. resume?: boolean
  112. }) => Effect.Effect<void, never>
  113. readonly skill: (input: {
  114. id?: EventV2.ID
  115. sessionID: SessionSchema.ID
  116. skill: string
  117. delivery?: SessionSchema.Delivery
  118. resume?: boolean
  119. }) => Effect.Effect<void, never>
  120. readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
  121. readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
  122. readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void>
  123. }
  124. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
  125. function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
  126. return SessionSchema.Info.make({
  127. id: SessionSchema.ID.make(row.id),
  128. projectID: ProjectV2.ID.make(row.project_id),
  129. title: row.title,
  130. parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
  131. agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
  132. model: row.model
  133. ? {
  134. id: ModelV2.ID.make(row.model.id),
  135. providerID: ProviderV2.ID.make(row.model.providerID),
  136. variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
  137. }
  138. : undefined,
  139. cost: row.cost,
  140. tokens: {
  141. input: row.tokens_input,
  142. output: row.tokens_output,
  143. reasoning: row.tokens_reasoning,
  144. cache: {
  145. read: row.tokens_cache_read,
  146. write: row.tokens_cache_write,
  147. },
  148. },
  149. location: Location.Ref.make({
  150. directory: AbsolutePath.make(row.directory),
  151. workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
  152. }),
  153. subpath: row.path ? RelativePath.make(row.path) : undefined,
  154. time: {
  155. created: DateTime.makeUnsafe(row.time_created),
  156. updated: DateTime.makeUnsafe(row.time_updated),
  157. archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
  158. },
  159. })
  160. }
  161. export const layer = Layer.effect(
  162. Service,
  163. Effect.gen(function* () {
  164. const db = (yield* Database.Service).db
  165. const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
  166. const decode = (row: typeof SessionMessageTable.$inferSelect) =>
  167. decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
  168. Effect.mapError(
  169. () =>
  170. new MessageDecodeError({
  171. sessionID: SessionSchema.ID.make(row.session_id),
  172. messageID: SessionMessage.ID.make(row.id),
  173. }),
  174. ),
  175. )
  176. const result = Service.of({
  177. create: Effect.fn("V2Session.create")(function* () {
  178. return {} as SessionSchema.Info
  179. }),
  180. get: Effect.fn("V2Session.get")(function* (sessionID) {
  181. const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
  182. if (!row) return yield* new NotFoundError({ sessionID })
  183. return fromRow(row)
  184. }),
  185. list: Effect.fn("V2Session.list")(function* (input = {}) {
  186. const direction = input.anchor?.direction ?? "next"
  187. const requestedOrder = input.order ?? "desc"
  188. const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
  189. const sortColumn = SessionTable.time_created
  190. const conditions: SQL[] = []
  191. if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
  192. if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
  193. if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
  194. if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
  195. if (input.anchor) {
  196. conditions.push(
  197. order === "asc"
  198. ? or(
  199. gt(sortColumn, input.anchor.time),
  200. and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
  201. )!
  202. : or(
  203. lt(sortColumn, input.anchor.time),
  204. and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
  205. )!,
  206. )
  207. }
  208. const query = db
  209. .select()
  210. .from(SessionTable)
  211. .where(conditions.length > 0 ? and(...conditions) : undefined)
  212. .orderBy(
  213. order === "asc" ? asc(sortColumn) : desc(sortColumn),
  214. order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
  215. )
  216. const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
  217. Effect.orDie,
  218. )
  219. return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
  220. }),
  221. messages: Effect.fn("V2Session.messages")(function* (input) {
  222. yield* result.get(input.sessionID)
  223. const direction = input.cursor?.direction ?? "next"
  224. const requestedOrder = input.order ?? "desc"
  225. const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
  226. const boundary = input.cursor
  227. ? order === "asc"
  228. ? or(
  229. gt(SessionMessageTable.time_created, input.cursor.time),
  230. and(
  231. eq(SessionMessageTable.time_created, input.cursor.time),
  232. gt(SessionMessageTable.id, input.cursor.id),
  233. ),
  234. )
  235. : or(
  236. lt(SessionMessageTable.time_created, input.cursor.time),
  237. and(
  238. eq(SessionMessageTable.time_created, input.cursor.time),
  239. lt(SessionMessageTable.id, input.cursor.id),
  240. ),
  241. )
  242. : undefined
  243. const where = boundary
  244. ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
  245. : eq(SessionMessageTable.session_id, input.sessionID)
  246. const query = db
  247. .select()
  248. .from(SessionMessageTable)
  249. .where(where)
  250. .orderBy(
  251. order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
  252. order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
  253. )
  254. const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
  255. Effect.orDie,
  256. )
  257. return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
  258. }),
  259. context: Effect.fn("V2Session.context")(function* (sessionID) {
  260. yield* result.get(sessionID)
  261. const compaction = yield* db
  262. .select()
  263. .from(SessionMessageTable)
  264. .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
  265. .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
  266. .limit(1)
  267. .get()
  268. .pipe(Effect.orDie)
  269. const rows = yield* db
  270. .select()
  271. .from(SessionMessageTable)
  272. .where(
  273. and(
  274. eq(SessionMessageTable.session_id, sessionID),
  275. compaction
  276. ? or(
  277. gt(SessionMessageTable.time_created, compaction.time_created),
  278. and(
  279. eq(SessionMessageTable.time_created, compaction.time_created),
  280. gte(SessionMessageTable.id, compaction.id),
  281. ),
  282. )
  283. : undefined,
  284. ),
  285. )
  286. .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
  287. .all()
  288. .pipe(Effect.orDie)
  289. return yield* Effect.forEach(rows, decode)
  290. }),
  291. prompt: Effect.fn("V2Session.prompt")(function* (input) {
  292. yield* result.get(input.sessionID)
  293. return yield* Effect.fail(new OperationUnavailableError({ operation: "prompt" }))
  294. }),
  295. shell: Effect.fn("V2Session.shell")(function* () {}),
  296. skill: Effect.fn("V2Session.skill")(function* () {}),
  297. switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}),
  298. switchModel: Effect.fn("V2Session.switchModel")(function* () {}),
  299. compact: Effect.fn("V2Session.compact")(function* (input) {
  300. yield* result.get(input.sessionID)
  301. return yield* new OperationUnavailableError({ operation: "compact" })
  302. }),
  303. wait: Effect.fn("V2Session.wait")(function* (sessionID) {
  304. yield* result.get(sessionID)
  305. return yield* new OperationUnavailableError({ operation: "wait" })
  306. }),
  307. resume: Effect.fn("V2Session.resume")(function* () {}),
  308. move: Effect.fn("V2Session.move")(function* () {}),
  309. })
  310. return result
  311. }),
  312. )
  313. export const defaultLayer = layer.pipe(
  314. Layer.provide(SessionProjector.defaultLayer),
  315. Layer.provide(Database.defaultLayer),
  316. Layer.orDie,
  317. )