index.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { Decimal } from "decimal.js"
  2. import z from "zod/v4"
  3. import { type LanguageModelUsage, type ProviderMetadata } from "ai"
  4. import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
  5. import { Bus } from "../bus"
  6. import { Config } from "../config/config"
  7. import { Flag } from "../flag/flag"
  8. import { Identifier } from "../id/id"
  9. import { Installation } from "../installation"
  10. import type { ModelsDev } from "../provider/models"
  11. import { Share } from "../share/share"
  12. import { Storage } from "../storage/storage"
  13. import { Log } from "../util/log"
  14. import { MessageV2 } from "./message-v2"
  15. import { Project } from "../project/project"
  16. import { Instance } from "../project/instance"
  17. import { Token } from "../util/token"
  18. import { SessionPrompt } from "./prompt"
  19. export namespace Session {
  20. const log = Log.create({ service: "session" })
  21. const parentSessionTitlePrefix = "New session - "
  22. const childSessionTitlePrefix = "Child session - "
  23. function createDefaultTitle(isChild = false) {
  24. return (isChild ? childSessionTitlePrefix : parentSessionTitlePrefix) + new Date().toISOString()
  25. }
  26. export const Info = z
  27. .object({
  28. id: Identifier.schema("session"),
  29. projectID: z.string(),
  30. directory: z.string(),
  31. parentID: Identifier.schema("session").optional(),
  32. share: z
  33. .object({
  34. url: z.string(),
  35. })
  36. .optional(),
  37. title: z.string(),
  38. version: z.string(),
  39. time: z.object({
  40. created: z.number(),
  41. updated: z.number(),
  42. compacting: z.number().optional(),
  43. }),
  44. revert: z
  45. .object({
  46. messageID: z.string(),
  47. partID: z.string().optional(),
  48. snapshot: z.string().optional(),
  49. diff: z.string().optional(),
  50. })
  51. .optional(),
  52. })
  53. .meta({
  54. ref: "Session",
  55. })
  56. export type Info = z.output<typeof Info>
  57. export const ShareInfo = z
  58. .object({
  59. secret: z.string(),
  60. url: z.string(),
  61. })
  62. .meta({
  63. ref: "SessionShare",
  64. })
  65. export type ShareInfo = z.output<typeof ShareInfo>
  66. export const Event = {
  67. Updated: Bus.event(
  68. "session.updated",
  69. z.object({
  70. info: Info,
  71. }),
  72. ),
  73. Deleted: Bus.event(
  74. "session.deleted",
  75. z.object({
  76. info: Info,
  77. }),
  78. ),
  79. Error: Bus.event(
  80. "session.error",
  81. z.object({
  82. sessionID: z.string().optional(),
  83. error: MessageV2.Assistant.shape.error,
  84. }),
  85. ),
  86. }
  87. export async function create(parentID?: string, title?: string) {
  88. return createNext({
  89. parentID,
  90. directory: Instance.directory,
  91. title,
  92. })
  93. }
  94. export async function touch(sessionID: string) {
  95. await update(sessionID, (draft) => {
  96. draft.time.updated = Date.now()
  97. })
  98. }
  99. export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) {
  100. const result: Info = {
  101. id: Identifier.descending("session", input.id),
  102. version: Installation.VERSION,
  103. projectID: Instance.project.id,
  104. directory: input.directory,
  105. parentID: input.parentID,
  106. title: input.title ?? createDefaultTitle(!!input.parentID),
  107. time: {
  108. created: Date.now(),
  109. updated: Date.now(),
  110. },
  111. }
  112. log.info("created", result)
  113. await Storage.write(["session", Instance.project.id, result.id], result)
  114. const cfg = await Config.get()
  115. if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
  116. share(result.id)
  117. .then((share) => {
  118. update(result.id, (draft) => {
  119. draft.share = share
  120. })
  121. })
  122. .catch(() => {
  123. // Silently ignore sharing errors during session creation
  124. })
  125. Bus.publish(Event.Updated, {
  126. info: result,
  127. })
  128. return result
  129. }
  130. export async function get(id: string) {
  131. const read = await Storage.read<Info>(["session", Instance.project.id, id])
  132. return read as Info
  133. }
  134. export async function getShare(id: string) {
  135. return Storage.read<ShareInfo>(["share", id])
  136. }
  137. export async function share(id: string) {
  138. const cfg = await Config.get()
  139. if (cfg.share === "disabled") {
  140. throw new Error("Sharing is disabled in configuration")
  141. }
  142. const session = await get(id)
  143. if (session.share) return session.share
  144. const share = await Share.create(id)
  145. await update(id, (draft) => {
  146. draft.share = {
  147. url: share.url,
  148. }
  149. })
  150. await Storage.write(["share", id], share)
  151. await Share.sync("session/info/" + id, session)
  152. for (const msg of await messages(id)) {
  153. await Share.sync("session/message/" + id + "/" + msg.info.id, msg.info)
  154. for (const part of msg.parts) {
  155. await Share.sync("session/part/" + id + "/" + msg.info.id + "/" + part.id, part)
  156. }
  157. }
  158. return share
  159. }
  160. export async function unshare(id: string) {
  161. const share = await getShare(id)
  162. if (!share) return
  163. await Storage.remove(["share", id])
  164. await update(id, (draft) => {
  165. draft.share = undefined
  166. })
  167. await Share.remove(id, share.secret)
  168. }
  169. export async function update(id: string, editor: (session: Info) => void) {
  170. const project = Instance.project
  171. const result = await Storage.update<Info>(["session", project.id, id], (draft) => {
  172. editor(draft)
  173. draft.time.updated = Date.now()
  174. })
  175. Bus.publish(Event.Updated, {
  176. info: result,
  177. })
  178. return result
  179. }
  180. export async function messages(sessionID: string) {
  181. const result = [] as MessageV2.WithParts[]
  182. for (const p of await Storage.list(["message", sessionID])) {
  183. const read = await Storage.read<MessageV2.Info>(p)
  184. result.push({
  185. info: read,
  186. parts: await getParts(read.id),
  187. })
  188. }
  189. result.sort((a, b) => (a.info.id > b.info.id ? 1 : -1))
  190. return result
  191. }
  192. export async function getMessage(sessionID: string, messageID: string) {
  193. return {
  194. info: await Storage.read<MessageV2.Info>(["message", sessionID, messageID]),
  195. parts: await getParts(messageID),
  196. }
  197. }
  198. export async function getParts(messageID: string) {
  199. const result = [] as MessageV2.Part[]
  200. for (const item of await Storage.list(["part", messageID])) {
  201. const read = await Storage.read<MessageV2.Part>(item)
  202. result.push(read)
  203. }
  204. result.sort((a, b) => (a.id > b.id ? 1 : -1))
  205. return result
  206. }
  207. export async function* list() {
  208. const project = Instance.project
  209. for (const item of await Storage.list(["session", project.id])) {
  210. yield Storage.read<Info>(item)
  211. }
  212. }
  213. export async function children(parentID: string) {
  214. const project = Instance.project
  215. const result = [] as Session.Info[]
  216. for (const item of await Storage.list(["session", project.id])) {
  217. const session = await Storage.read<Info>(item)
  218. if (session.parentID !== parentID) continue
  219. result.push(session)
  220. }
  221. return result
  222. }
  223. export async function remove(sessionID: string, emitEvent = true) {
  224. const project = Instance.project
  225. try {
  226. const session = await get(sessionID)
  227. for (const child of await children(sessionID)) {
  228. await remove(child.id, false)
  229. }
  230. await unshare(sessionID).catch(() => {})
  231. for (const msg of await Storage.list(["message", sessionID])) {
  232. for (const part of await Storage.list(["part", msg.at(-1)!])) {
  233. await Storage.remove(part)
  234. }
  235. await Storage.remove(msg)
  236. }
  237. await Storage.remove(["session", project.id, sessionID])
  238. if (emitEvent) {
  239. Bus.publish(Event.Deleted, {
  240. info: session,
  241. })
  242. }
  243. } catch (e) {
  244. log.error(e)
  245. }
  246. }
  247. export async function updateMessage(msg: MessageV2.Info) {
  248. await Storage.write(["message", msg.sessionID, msg.id], msg)
  249. Bus.publish(MessageV2.Event.Updated, {
  250. info: msg,
  251. })
  252. return msg
  253. }
  254. export async function removeMessage(sessionID: string, messageID: string) {
  255. await Storage.remove(["message", sessionID, messageID])
  256. Bus.publish(MessageV2.Event.Removed, {
  257. sessionID,
  258. messageID,
  259. })
  260. return messageID
  261. }
  262. export async function updatePart(part: MessageV2.Part) {
  263. await Storage.write(["part", part.messageID, part.id], part)
  264. Bus.publish(MessageV2.Event.PartUpdated, {
  265. part,
  266. })
  267. return part
  268. }
  269. // goes backwards through parts until there are 40_000 tokens worth of tool
  270. // calls. then erases output of previous tool calls. idea is to throw away old
  271. // tool calls that are no longer relevant.
  272. export async function prune(input: { sessionID: string }) {
  273. const msgs = await messages(input.sessionID)
  274. let sum = 0
  275. for (let msgIndex = msgs.length - 2; msgIndex >= 0; msgIndex--) {
  276. const msg = msgs[msgIndex]
  277. if (msg.info.role === "assistant" && msg.info.summary) return
  278. for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
  279. const part = msg.parts[partIndex]
  280. if (part.type === "tool")
  281. if (part.state.status === "completed") {
  282. if (part.state.time.compacted) return
  283. sum += Token.estimate(part.state.output)
  284. if (sum > 40_000) {
  285. log.info("pruning", {
  286. sum,
  287. id: part.id,
  288. })
  289. part.state.time.compacted = Date.now()
  290. await updatePart(part)
  291. }
  292. }
  293. }
  294. }
  295. }
  296. export function getUsage(model: ModelsDev.Model, usage: LanguageModelUsage, metadata?: ProviderMetadata) {
  297. const tokens = {
  298. input: usage.inputTokens ?? 0,
  299. output: usage.outputTokens ?? 0,
  300. reasoning: usage?.reasoningTokens ?? 0,
  301. cache: {
  302. write: (metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
  303. // @ts-expect-error
  304. metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
  305. 0) as number,
  306. read: usage.cachedInputTokens ?? 0,
  307. },
  308. }
  309. return {
  310. cost: new Decimal(0)
  311. .add(new Decimal(tokens.input).mul(model.cost?.input ?? 0).div(1_000_000))
  312. .add(new Decimal(tokens.output).mul(model.cost?.output ?? 0).div(1_000_000))
  313. .add(new Decimal(tokens.cache.read).mul(model.cost?.cache_read ?? 0).div(1_000_000))
  314. .add(new Decimal(tokens.cache.write).mul(model.cost?.cache_write ?? 0).div(1_000_000))
  315. .toNumber(),
  316. tokens,
  317. }
  318. }
  319. export class BusyError extends Error {
  320. constructor(public readonly sessionID: string) {
  321. super(`Session ${sessionID} is busy`)
  322. }
  323. }
  324. export async function initialize(input: {
  325. sessionID: string
  326. modelID: string
  327. providerID: string
  328. messageID: string
  329. }) {
  330. await SessionPrompt.prompt({
  331. sessionID: input.sessionID,
  332. messageID: input.messageID,
  333. model: {
  334. providerID: input.providerID,
  335. modelID: input.modelID,
  336. },
  337. parts: [
  338. {
  339. id: Identifier.ascending("part"),
  340. type: "text",
  341. text: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
  342. },
  343. ],
  344. })
  345. await Project.setInitialized(Instance.project.id)
  346. }
  347. }