index.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892
  1. import { Slug } from "@opencode-ai/util/slug"
  2. import path from "path"
  3. import { BusEvent } from "@/bus/bus-event"
  4. import { Bus } from "@/bus"
  5. import { Decimal } from "decimal.js"
  6. import z from "zod"
  7. import { type ProviderMetadata } from "ai"
  8. import { Config } from "../config/config"
  9. import { Flag } from "../flag/flag"
  10. import { Installation } from "../installation"
  11. import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
  12. import { SyncEvent } from "../sync"
  13. import type { SQL } from "../storage/db"
  14. import { SessionTable } from "./session.sql"
  15. import { ProjectTable } from "../project/project.sql"
  16. import { Storage } from "@/storage/storage"
  17. import { Log } from "../util/log"
  18. import { updateSchema } from "../util/update-schema"
  19. import { MessageV2 } from "./message-v2"
  20. import { Instance } from "../project/instance"
  21. import { InstanceState } from "@/effect/instance-state"
  22. import { SessionPrompt } from "./prompt"
  23. import { fn } from "@/util/fn"
  24. import { Command } from "../command"
  25. import { Snapshot } from "@/snapshot"
  26. import { ProjectID } from "../project/schema"
  27. import { WorkspaceID } from "../control-plane/schema"
  28. import { SessionID, MessageID, PartID } from "./schema"
  29. import type { Provider } from "@/provider/provider"
  30. import { ModelID, ProviderID } from "@/provider/schema"
  31. import { Permission } from "@/permission"
  32. import { Global } from "@/global"
  33. import type { LanguageModelV2Usage } from "@ai-sdk/provider"
  34. import { Effect, Layer, Scope, ServiceMap } from "effect"
  35. import { makeRuntime } from "@/effect/run-service"
  36. export namespace Session {
  37. const log = Log.create({ service: "session" })
  38. const parentTitlePrefix = "New session - "
  39. const childTitlePrefix = "Child session - "
  40. function createDefaultTitle(isChild = false) {
  41. return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString()
  42. }
  43. export function isDefaultTitle(title: string) {
  44. return new RegExp(
  45. `^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`,
  46. ).test(title)
  47. }
  48. type SessionRow = typeof SessionTable.$inferSelect
  49. export function fromRow(row: SessionRow): Info {
  50. const summary =
  51. row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null
  52. ? {
  53. additions: row.summary_additions ?? 0,
  54. deletions: row.summary_deletions ?? 0,
  55. files: row.summary_files ?? 0,
  56. diffs: row.summary_diffs ?? undefined,
  57. }
  58. : undefined
  59. const share = row.share_url ? { url: row.share_url } : undefined
  60. const revert = row.revert ?? undefined
  61. return {
  62. id: row.id,
  63. slug: row.slug,
  64. projectID: row.project_id,
  65. workspaceID: row.workspace_id ?? undefined,
  66. directory: row.directory,
  67. parentID: row.parent_id ?? undefined,
  68. title: row.title,
  69. version: row.version,
  70. summary,
  71. share,
  72. revert,
  73. permission: row.permission ?? undefined,
  74. time: {
  75. created: row.time_created,
  76. updated: row.time_updated,
  77. compacting: row.time_compacting ?? undefined,
  78. archived: row.time_archived ?? undefined,
  79. },
  80. }
  81. }
  82. export function toRow(info: Info) {
  83. return {
  84. id: info.id,
  85. project_id: info.projectID,
  86. workspace_id: info.workspaceID,
  87. parent_id: info.parentID,
  88. slug: info.slug,
  89. directory: info.directory,
  90. title: info.title,
  91. version: info.version,
  92. share_url: info.share?.url,
  93. summary_additions: info.summary?.additions,
  94. summary_deletions: info.summary?.deletions,
  95. summary_files: info.summary?.files,
  96. summary_diffs: info.summary?.diffs,
  97. revert: info.revert ?? null,
  98. permission: info.permission,
  99. time_created: info.time.created,
  100. time_updated: info.time.updated,
  101. time_compacting: info.time.compacting,
  102. time_archived: info.time.archived,
  103. }
  104. }
  105. function getForkedTitle(title: string): string {
  106. const match = title.match(/^(.+) \(fork #(\d+)\)$/)
  107. if (match) {
  108. const base = match[1]
  109. const num = parseInt(match[2], 10)
  110. return `${base} (fork #${num + 1})`
  111. }
  112. return `${title} (fork #1)`
  113. }
  114. export const Info = z
  115. .object({
  116. id: SessionID.zod,
  117. slug: z.string(),
  118. projectID: ProjectID.zod,
  119. workspaceID: WorkspaceID.zod.optional(),
  120. directory: z.string(),
  121. parentID: SessionID.zod.optional(),
  122. summary: z
  123. .object({
  124. additions: z.number(),
  125. deletions: z.number(),
  126. files: z.number(),
  127. diffs: Snapshot.FileDiff.array().optional(),
  128. })
  129. .optional(),
  130. share: z
  131. .object({
  132. url: z.string(),
  133. })
  134. .optional(),
  135. title: z.string(),
  136. version: z.string(),
  137. time: z.object({
  138. created: z.number(),
  139. updated: z.number(),
  140. compacting: z.number().optional(),
  141. archived: z.number().optional(),
  142. }),
  143. permission: Permission.Ruleset.optional(),
  144. revert: z
  145. .object({
  146. messageID: MessageID.zod,
  147. partID: PartID.zod.optional(),
  148. snapshot: z.string().optional(),
  149. diff: z.string().optional(),
  150. })
  151. .optional(),
  152. })
  153. .meta({
  154. ref: "Session",
  155. })
  156. export type Info = z.output<typeof Info>
  157. export const ProjectInfo = z
  158. .object({
  159. id: ProjectID.zod,
  160. name: z.string().optional(),
  161. worktree: z.string(),
  162. })
  163. .meta({
  164. ref: "ProjectSummary",
  165. })
  166. export type ProjectInfo = z.output<typeof ProjectInfo>
  167. export const GlobalInfo = Info.extend({
  168. project: ProjectInfo.nullable(),
  169. }).meta({
  170. ref: "GlobalSession",
  171. })
  172. export type GlobalInfo = z.output<typeof GlobalInfo>
  173. export const Event = {
  174. Created: SyncEvent.define({
  175. type: "session.created",
  176. version: 1,
  177. aggregate: "sessionID",
  178. schema: z.object({
  179. sessionID: SessionID.zod,
  180. info: Info,
  181. }),
  182. }),
  183. Updated: SyncEvent.define({
  184. type: "session.updated",
  185. version: 1,
  186. aggregate: "sessionID",
  187. schema: z.object({
  188. sessionID: SessionID.zod,
  189. info: updateSchema(Info).extend({
  190. share: updateSchema(Info.shape.share.unwrap()).optional(),
  191. time: updateSchema(Info.shape.time).optional(),
  192. }),
  193. }),
  194. busSchema: z.object({
  195. sessionID: SessionID.zod,
  196. info: Info,
  197. }),
  198. }),
  199. Deleted: SyncEvent.define({
  200. type: "session.deleted",
  201. version: 1,
  202. aggregate: "sessionID",
  203. schema: z.object({
  204. sessionID: SessionID.zod,
  205. info: Info,
  206. }),
  207. }),
  208. Diff: BusEvent.define(
  209. "session.diff",
  210. z.object({
  211. sessionID: SessionID.zod,
  212. diff: Snapshot.FileDiff.array(),
  213. }),
  214. ),
  215. Error: BusEvent.define(
  216. "session.error",
  217. z.object({
  218. sessionID: SessionID.zod.optional(),
  219. error: MessageV2.Assistant.shape.error,
  220. }),
  221. ),
  222. }
  223. export function plan(input: { slug: string; time: { created: number } }) {
  224. const base = Instance.project.vcs
  225. ? path.join(Instance.worktree, ".opencode", "plans")
  226. : path.join(Global.Path.data, "plans")
  227. return path.join(base, [input.time.created, input.slug].join("-") + ".md")
  228. }
  229. export const getUsage = (input: {
  230. model: Provider.Model
  231. usage: LanguageModelV2Usage
  232. metadata?: ProviderMetadata
  233. }) => {
  234. const safe = (value: number) => {
  235. if (!Number.isFinite(value)) return 0
  236. return value
  237. }
  238. const inputTokens = safe(input.usage.inputTokens ?? 0)
  239. const outputTokens = safe(input.usage.outputTokens ?? 0)
  240. const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
  241. const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
  242. const cacheWriteInputTokens = safe(
  243. (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
  244. // google-vertex-anthropic returns metadata under "vertex" key
  245. // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
  246. input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
  247. // @ts-expect-error
  248. input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
  249. // @ts-expect-error
  250. input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
  251. 0) as number,
  252. )
  253. // AI SDK v6 normalized inputTokens to include cached tokens across all providers
  254. // (including Anthropic/Bedrock which previously excluded them). Always subtract cache
  255. // tokens to get the non-cached input count for separate cost calculation.
  256. const adjustedInputTokens = safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens)
  257. const total = input.usage.totalTokens
  258. const tokens = {
  259. total,
  260. input: adjustedInputTokens,
  261. output: outputTokens,
  262. reasoning: reasoningTokens,
  263. cache: {
  264. write: cacheWriteInputTokens,
  265. read: cacheReadInputTokens,
  266. },
  267. }
  268. const costInfo =
  269. input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000
  270. ? input.model.cost.experimentalOver200K
  271. : input.model.cost
  272. return {
  273. cost: safe(
  274. new Decimal(0)
  275. .add(new Decimal(tokens.input).mul(costInfo?.input ?? 0).div(1_000_000))
  276. .add(new Decimal(tokens.output).mul(costInfo?.output ?? 0).div(1_000_000))
  277. .add(new Decimal(tokens.cache.read).mul(costInfo?.cache?.read ?? 0).div(1_000_000))
  278. .add(new Decimal(tokens.cache.write).mul(costInfo?.cache?.write ?? 0).div(1_000_000))
  279. // TODO: update models.dev to have better pricing model, for now:
  280. // charge reasoning tokens at the same rate as output tokens
  281. .add(new Decimal(tokens.reasoning).mul(costInfo?.output ?? 0).div(1_000_000))
  282. .toNumber(),
  283. ),
  284. tokens,
  285. }
  286. }
  287. export class BusyError extends Error {
  288. constructor(public readonly sessionID: string) {
  289. super(`Session ${sessionID} is busy`)
  290. }
  291. }
  292. export interface Interface {
  293. readonly create: (input?: {
  294. parentID?: SessionID
  295. title?: string
  296. permission?: Permission.Ruleset
  297. workspaceID?: WorkspaceID
  298. }) => Effect.Effect<Info>
  299. readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
  300. readonly touch: (sessionID: SessionID) => Effect.Effect<void>
  301. readonly get: (id: SessionID) => Effect.Effect<Info>
  302. readonly share: (id: SessionID) => Effect.Effect<{ url: string }>
  303. readonly unshare: (id: SessionID) => Effect.Effect<void>
  304. readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
  305. readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
  306. readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
  307. readonly setRevert: (input: {
  308. sessionID: SessionID
  309. revert: Info["revert"]
  310. summary: Info["summary"]
  311. }) => Effect.Effect<void>
  312. readonly clearRevert: (sessionID: SessionID) => Effect.Effect<void>
  313. readonly setSummary: (input: { sessionID: SessionID; summary: Info["summary"] }) => Effect.Effect<void>
  314. readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
  315. readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<MessageV2.WithParts[]>
  316. readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
  317. readonly remove: (sessionID: SessionID) => Effect.Effect<void>
  318. readonly updateMessage: <T extends MessageV2.Info>(msg: T) => Effect.Effect<T>
  319. readonly removeMessage: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<MessageID>
  320. readonly removePart: (input: {
  321. sessionID: SessionID
  322. messageID: MessageID
  323. partID: PartID
  324. }) => Effect.Effect<PartID>
  325. readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
  326. readonly updatePartDelta: (input: {
  327. sessionID: SessionID
  328. messageID: MessageID
  329. partID: PartID
  330. field: string
  331. delta: string
  332. }) => Effect.Effect<void>
  333. readonly initialize: (input: {
  334. sessionID: SessionID
  335. modelID: ModelID
  336. providerID: ProviderID
  337. messageID: MessageID
  338. }) => Effect.Effect<void>
  339. }
  340. export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
  341. type Patch = z.infer<typeof Event.Updated.schema>["info"]
  342. const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
  343. Effect.sync(() => Database.use(fn))
  344. export const layer: Layer.Layer<Service, never, Bus.Service | Config.Service> = Layer.effect(
  345. Service,
  346. Effect.gen(function* () {
  347. const bus = yield* Bus.Service
  348. const config = yield* Config.Service
  349. const scope = yield* Scope.Scope
  350. const createNext = Effect.fn("Session.createNext")(function* (input: {
  351. id?: SessionID
  352. title?: string
  353. parentID?: SessionID
  354. workspaceID?: WorkspaceID
  355. directory: string
  356. permission?: Permission.Ruleset
  357. }) {
  358. const ctx = yield* InstanceState.context
  359. const result: Info = {
  360. id: SessionID.descending(input.id),
  361. slug: Slug.create(),
  362. version: Installation.VERSION,
  363. projectID: ctx.project.id,
  364. directory: input.directory,
  365. workspaceID: input.workspaceID,
  366. parentID: input.parentID,
  367. title: input.title ?? createDefaultTitle(!!input.parentID),
  368. permission: input.permission,
  369. time: {
  370. created: Date.now(),
  371. updated: Date.now(),
  372. },
  373. }
  374. log.info("created", result)
  375. yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
  376. const cfg = yield* config.get()
  377. if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
  378. yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
  379. }
  380. if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
  381. // This only exist for backwards compatibility. We should not be
  382. // manually publishing this event; it is a sync event now
  383. yield* bus.publish(Event.Updated, {
  384. sessionID: result.id,
  385. info: result,
  386. })
  387. }
  388. return result
  389. })
  390. const get = Effect.fn("Session.get")(function* (id: SessionID) {
  391. const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
  392. if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
  393. return fromRow(row)
  394. })
  395. const share = Effect.fn("Session.share")(function* (id: SessionID) {
  396. const cfg = yield* config.get()
  397. if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration")
  398. const result = yield* Effect.promise(async () => {
  399. const { ShareNext } = await import("@/share/share-next")
  400. return ShareNext.create(id)
  401. })
  402. yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } }))
  403. return result
  404. })
  405. const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) {
  406. yield* Effect.promise(async () => {
  407. const { ShareNext } = await import("@/share/share-next")
  408. await ShareNext.remove(id)
  409. })
  410. yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } }))
  411. })
  412. const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
  413. const ctx = yield* InstanceState.context
  414. const rows = yield* db((d) =>
  415. d
  416. .select()
  417. .from(SessionTable)
  418. .where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
  419. .all(),
  420. )
  421. return rows.map(fromRow)
  422. })
  423. const remove: (sessionID: SessionID) => Effect.Effect<void> = Effect.fnUntraced(function* (sessionID: SessionID) {
  424. try {
  425. const session = yield* get(sessionID)
  426. const kids = yield* children(sessionID)
  427. for (const child of kids) {
  428. yield* remove(child.id)
  429. }
  430. yield* unshare(sessionID).pipe(Effect.ignore)
  431. yield* Effect.sync(() => {
  432. SyncEvent.run(Event.Deleted, { sessionID, info: session })
  433. SyncEvent.remove(sessionID)
  434. })
  435. } catch (e) {
  436. log.error(e)
  437. }
  438. })
  439. const updateMessage = <T extends MessageV2.Info>(msg: T): Effect.Effect<T> =>
  440. Effect.gen(function* () {
  441. yield* Effect.sync(() => SyncEvent.run(MessageV2.Event.Updated, { sessionID: msg.sessionID, info: msg }))
  442. return msg
  443. }).pipe(Effect.withSpan("Session.updateMessage"))
  444. const updatePart = <T extends MessageV2.Part>(part: T): Effect.Effect<T> =>
  445. Effect.gen(function* () {
  446. yield* Effect.sync(() =>
  447. SyncEvent.run(MessageV2.Event.PartUpdated, {
  448. sessionID: part.sessionID,
  449. part: structuredClone(part),
  450. time: Date.now(),
  451. }),
  452. )
  453. return part
  454. }).pipe(Effect.withSpan("Session.updatePart"))
  455. const create = Effect.fn("Session.create")(function* (input?: {
  456. parentID?: SessionID
  457. title?: string
  458. permission?: Permission.Ruleset
  459. workspaceID?: WorkspaceID
  460. }) {
  461. const directory = yield* InstanceState.directory
  462. return yield* createNext({
  463. parentID: input?.parentID,
  464. directory,
  465. title: input?.title,
  466. permission: input?.permission,
  467. workspaceID: input?.workspaceID,
  468. })
  469. })
  470. const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
  471. const directory = yield* InstanceState.directory
  472. const original = yield* get(input.sessionID)
  473. const title = getForkedTitle(original.title)
  474. const session = yield* createNext({
  475. directory,
  476. workspaceID: original.workspaceID,
  477. title,
  478. })
  479. const msgs = yield* messages({ sessionID: input.sessionID })
  480. const idMap = new Map<string, MessageID>()
  481. for (const msg of msgs) {
  482. if (input.messageID && msg.info.id >= input.messageID) break
  483. const newID = MessageID.ascending()
  484. idMap.set(msg.info.id, newID)
  485. const parentID = msg.info.role === "assistant" && msg.info.parentID ? idMap.get(msg.info.parentID) : undefined
  486. const cloned = yield* updateMessage({
  487. ...msg.info,
  488. sessionID: session.id,
  489. id: newID,
  490. ...(parentID && { parentID }),
  491. })
  492. for (const part of msg.parts) {
  493. yield* updatePart({
  494. ...part,
  495. id: PartID.ascending(),
  496. messageID: cloned.id,
  497. sessionID: session.id,
  498. })
  499. }
  500. }
  501. return session
  502. })
  503. const patch = (sessionID: SessionID, info: Patch) =>
  504. Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID, info }))
  505. const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
  506. yield* patch(sessionID, { time: { updated: Date.now() } })
  507. })
  508. const setTitle = Effect.fn("Session.setTitle")(function* (input: { sessionID: SessionID; title: string }) {
  509. yield* patch(input.sessionID, { title: input.title })
  510. })
  511. const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) {
  512. yield* patch(input.sessionID, { time: { archived: input.time } })
  513. })
  514. const setPermission = Effect.fn("Session.setPermission")(function* (input: {
  515. sessionID: SessionID
  516. permission: Permission.Ruleset
  517. }) {
  518. yield* patch(input.sessionID, { permission: input.permission, time: { updated: Date.now() } })
  519. })
  520. const setRevert = Effect.fn("Session.setRevert")(function* (input: {
  521. sessionID: SessionID
  522. revert: Info["revert"]
  523. summary: Info["summary"]
  524. }) {
  525. yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert })
  526. })
  527. const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) {
  528. yield* patch(sessionID, { time: { updated: Date.now() }, revert: null })
  529. })
  530. const setSummary = Effect.fn("Session.setSummary")(function* (input: {
  531. sessionID: SessionID
  532. summary: Info["summary"]
  533. }) {
  534. yield* patch(input.sessionID, { time: { updated: Date.now() }, summary: input.summary })
  535. })
  536. const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
  537. return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
  538. Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]),
  539. )
  540. })
  541. const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) {
  542. return yield* Effect.promise(async () => {
  543. const result = [] as MessageV2.WithParts[]
  544. for await (const msg of MessageV2.stream(input.sessionID)) {
  545. if (input.limit && result.length >= input.limit) break
  546. result.push(msg)
  547. }
  548. result.reverse()
  549. return result
  550. })
  551. })
  552. const removeMessage = Effect.fn("Session.removeMessage")(function* (input: {
  553. sessionID: SessionID
  554. messageID: MessageID
  555. }) {
  556. yield* Effect.sync(() =>
  557. SyncEvent.run(MessageV2.Event.Removed, {
  558. sessionID: input.sessionID,
  559. messageID: input.messageID,
  560. }),
  561. )
  562. return input.messageID
  563. })
  564. const removePart = Effect.fn("Session.removePart")(function* (input: {
  565. sessionID: SessionID
  566. messageID: MessageID
  567. partID: PartID
  568. }) {
  569. yield* Effect.sync(() =>
  570. SyncEvent.run(MessageV2.Event.PartRemoved, {
  571. sessionID: input.sessionID,
  572. messageID: input.messageID,
  573. partID: input.partID,
  574. }),
  575. )
  576. return input.partID
  577. })
  578. const updatePartDelta = Effect.fn("Session.updatePartDelta")(function* (input: {
  579. sessionID: SessionID
  580. messageID: MessageID
  581. partID: PartID
  582. field: string
  583. delta: string
  584. }) {
  585. yield* bus.publish(MessageV2.Event.PartDelta, input)
  586. })
  587. const initialize = Effect.fn("Session.initialize")(function* (input: {
  588. sessionID: SessionID
  589. modelID: ModelID
  590. providerID: ProviderID
  591. messageID: MessageID
  592. }) {
  593. yield* Effect.promise(() =>
  594. SessionPrompt.command({
  595. sessionID: input.sessionID,
  596. messageID: input.messageID,
  597. model: input.providerID + "/" + input.modelID,
  598. command: Command.Default.INIT,
  599. arguments: "",
  600. }),
  601. )
  602. })
  603. return Service.of({
  604. create,
  605. fork,
  606. touch,
  607. get,
  608. share,
  609. unshare,
  610. setTitle,
  611. setArchived,
  612. setPermission,
  613. setRevert,
  614. clearRevert,
  615. setSummary,
  616. diff,
  617. messages,
  618. children,
  619. remove,
  620. updateMessage,
  621. removeMessage,
  622. removePart,
  623. updatePart,
  624. updatePartDelta,
  625. initialize,
  626. })
  627. }),
  628. )
  629. export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
  630. const { runPromise } = makeRuntime(Service, defaultLayer)
  631. export const create = fn(
  632. z
  633. .object({
  634. parentID: SessionID.zod.optional(),
  635. title: z.string().optional(),
  636. permission: Info.shape.permission,
  637. workspaceID: WorkspaceID.zod.optional(),
  638. })
  639. .optional(),
  640. (input) => runPromise((svc) => svc.create(input)),
  641. )
  642. export const fork = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), (input) =>
  643. runPromise((svc) => svc.fork(input)),
  644. )
  645. export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id)))
  646. export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
  647. export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id)))
  648. export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id)))
  649. export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
  650. runPromise((svc) => svc.setTitle(input)),
  651. )
  652. export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) =>
  653. runPromise((svc) => svc.setArchived(input)),
  654. )
  655. export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
  656. runPromise((svc) => svc.setPermission(input)),
  657. )
  658. export const setRevert = fn(
  659. z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
  660. (input) =>
  661. runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
  662. )
  663. export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id)))
  664. export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) =>
  665. runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })),
  666. )
  667. export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id)))
  668. export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
  669. runPromise((svc) => svc.messages(input)),
  670. )
  671. export function* list(input?: {
  672. directory?: string
  673. workspaceID?: WorkspaceID
  674. roots?: boolean
  675. start?: number
  676. search?: string
  677. limit?: number
  678. }) {
  679. const project = Instance.project
  680. const conditions = [eq(SessionTable.project_id, project.id)]
  681. if (input?.workspaceID) {
  682. conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
  683. }
  684. if (input?.directory) {
  685. conditions.push(eq(SessionTable.directory, input.directory))
  686. }
  687. if (input?.roots) {
  688. conditions.push(isNull(SessionTable.parent_id))
  689. }
  690. if (input?.start) {
  691. conditions.push(gte(SessionTable.time_updated, input.start))
  692. }
  693. if (input?.search) {
  694. conditions.push(like(SessionTable.title, `%${input.search}%`))
  695. }
  696. const limit = input?.limit ?? 100
  697. const rows = Database.use((db) =>
  698. db
  699. .select()
  700. .from(SessionTable)
  701. .where(and(...conditions))
  702. .orderBy(desc(SessionTable.time_updated))
  703. .limit(limit)
  704. .all(),
  705. )
  706. for (const row of rows) {
  707. yield fromRow(row)
  708. }
  709. }
  710. export function* listGlobal(input?: {
  711. directory?: string
  712. roots?: boolean
  713. start?: number
  714. cursor?: number
  715. search?: string
  716. limit?: number
  717. archived?: boolean
  718. }) {
  719. const conditions: SQL[] = []
  720. if (input?.directory) {
  721. conditions.push(eq(SessionTable.directory, input.directory))
  722. }
  723. if (input?.roots) {
  724. conditions.push(isNull(SessionTable.parent_id))
  725. }
  726. if (input?.start) {
  727. conditions.push(gte(SessionTable.time_updated, input.start))
  728. }
  729. if (input?.cursor) {
  730. conditions.push(lt(SessionTable.time_updated, input.cursor))
  731. }
  732. if (input?.search) {
  733. conditions.push(like(SessionTable.title, `%${input.search}%`))
  734. }
  735. if (!input?.archived) {
  736. conditions.push(isNull(SessionTable.time_archived))
  737. }
  738. const limit = input?.limit ?? 100
  739. const rows = Database.use((db) => {
  740. const query =
  741. conditions.length > 0
  742. ? db
  743. .select()
  744. .from(SessionTable)
  745. .where(and(...conditions))
  746. : db.select().from(SessionTable)
  747. return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
  748. })
  749. const ids = [...new Set(rows.map((row) => row.project_id))]
  750. const projects = new Map<string, ProjectInfo>()
  751. if (ids.length > 0) {
  752. const items = Database.use((db) =>
  753. db
  754. .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
  755. .from(ProjectTable)
  756. .where(inArray(ProjectTable.id, ids))
  757. .all(),
  758. )
  759. for (const item of items) {
  760. projects.set(item.id, {
  761. id: item.id,
  762. name: item.name ?? undefined,
  763. worktree: item.worktree,
  764. })
  765. }
  766. }
  767. for (const row of rows) {
  768. const project = projects.get(row.project_id) ?? null
  769. yield { ...fromRow(row), project }
  770. }
  771. }
  772. export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id)))
  773. export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id)))
  774. export async function updateMessage<T extends MessageV2.Info>(msg: T): Promise<T> {
  775. MessageV2.Info.parse(msg)
  776. return runPromise((svc) => svc.updateMessage(msg))
  777. }
  778. export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) =>
  779. runPromise((svc) => svc.removeMessage(input)),
  780. )
  781. export const removePart = fn(
  782. z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod }),
  783. (input) => runPromise((svc) => svc.removePart(input)),
  784. )
  785. export async function updatePart<T extends MessageV2.Part>(part: T): Promise<T> {
  786. MessageV2.Part.parse(part)
  787. return runPromise((svc) => svc.updatePart(part))
  788. }
  789. export const updatePartDelta = fn(
  790. z.object({
  791. sessionID: SessionID.zod,
  792. messageID: MessageID.zod,
  793. partID: PartID.zod,
  794. field: z.string(),
  795. delta: z.string(),
  796. }),
  797. (input) => runPromise((svc) => svc.updatePartDelta(input)),
  798. )
  799. export const initialize = fn(
  800. z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }),
  801. (input) => runPromise((svc) => svc.initialize(input)),
  802. )
  803. }