Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158export * as SessionTitle from "./title.js"
import { isDeepStrictEqual } from "node:util"import { LLMClient, LLMEvent, Message, SystemPart } from "@opencode/ai"import type { Agent } from "@opencode/schema/agent"import { Context, DateTime, Effect, Layer, Stream } from "effect"import { Database } from "../database/database.js"import { Bus } from "../bus.js"import { makeLocationNode } from "@opencode/util/effect/app-node"import { isExactRootFallback } from "@opencode/util/session-title-fallback"import { llmClient } from "../effect/app-node-platform.js"import { SessionContext } from "./context.js"import { SessionEvent } from "./event.js"import { SessionHistory } from "./history.js"import type { SessionRunnerModel } from "./runner/model.js"import { SessionSchema } from "./schema.js"import { SessionUsage } from "./usage.js"import { SessionStore } from "./store.js"
const MAX_LENGTH = 100const MAX_CONTEXT_LENGTH = 8_000const MAX_FIRST_MESSAGE_LENGTH = 2_000const titleChanged = Symbol("Session title changed")
export interface Interface { /** Generates an initial title or regenerates one from bounded conversation history. */ readonly generate: (sessionID: SessionSchema.ID) => Effect.Effect<void>}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)export const isUntitled = (session: SessionSchema.Info) => isExactRootFallback({ title: session.title, time: { created: DateTime.toEpochMillis(session.time.created) }, })
export const layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service const llm = yield* LLMClient.Service const context = yield* SessionContext.Service const store = yield* SessionStore.Service const db = (yield* Database.Service).db
const attempt = Effect.fn("SessionTitle.attempt")(function* (input: { readonly session: SessionSchema.Info readonly agent: Agent.Info readonly text: string readonly model: SessionRunnerModel.Resolved }) { const chunks: string[] = [] let failed = false let usage: SessionUsage.Recorded | undefined const recordUsage = Effect.suspend(() => usage ? bus.publish(SessionEvent.UsageRecorded, { sessionID: input.session.id, source: "title", ...usage, }) : Effect.void, ) const prepared = yield* context.request.title({ session: input.session, agent: input.agent.id, model: input.model, system: input.agent.system ? [SystemPart.make(input.agent.system)] : [], messages: [Message.user(input.text)], }) if (prepared.event.result !== undefined) return prepared.event.result yield* llm.stream(prepared.request, prepared.options).pipe( Stream.runForEach((event) => { if (LLMEvent.is.providerError(event)) failed = true if (LLMEvent.is.textDelta(event)) chunks.push(event.text) if (LLMEvent.is.stepFinish(event)) { const step = SessionUsage.record(event.usage, input.model.cost) usage = usage ? SessionUsage.add(usage, step) : step } return Effect.void }), Effect.catchTag("AI.Error", () => Effect.sync(() => { failed = true }), ), Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)), ) yield* recordUsage if (failed) return return chunks .join("") .split("\n") .map((line) => line.trim()) .find((line) => line.length > 0) })
const generate = Effect.fn("SessionTitle.generate")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) if (!session) return const firstUser = yield* SessionHistory.firstUserMessage(db, session.id) if (!firstUser) return const text = !isUntitled(session) ? yield* store.context(session.id).pipe( Effect.map((messages) => { const original = `Original request:\n${firstUser.text.slice(0, MAX_FIRST_MESSAGE_LENGTH)}` const recent = messages .flatMap((message) => { if (message.type === "user" && message.id !== firstUser.id) return [`User: ${message.text.trim()}`] if (message.type !== "assistant") return [] const text = message.content .flatMap((part) => (part.type === "text" ? [part.text.trim()] : [])) .filter(Boolean) .join("\n") return text ? [`Assistant: ${text}`] : [] }) .join("\n\n") if (!recent) return original const prefix = `${original}\n\nRecent conversation:\n` return `${prefix}${recent.slice(-(MAX_CONTEXT_LENGTH - prefix.length))}` }), Effect.orElseSucceed(() => firstUser.text), ) : firstUser.text const selection = yield* context.selectTitle(session) if (!selection) return const title = (yield* attempt({ session, agent: selection.agent, text, model: selection.selected })) ?? (selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref) ? yield* attempt({ session, agent: selection.agent, text, model: selection.primary }) : undefined) if (!title) return const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1 const current = yield* store.get(sessionID) if (!current || current.title !== session.title || current.title === truncate(title)) return yield* bus .publish( SessionEvent.Renamed, { sessionID: session.id, title: truncate(title), }, { commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) }, ) .pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect)))) }) return Service.of({ generate }) }),)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],})