124 lines
4.3 KiB
TypeScript
124 lines
4.3 KiB
TypeScript
export * as SessionTitle from "./title"
|
|
|
|
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
|
import { Context, Effect, Layer, Stream } from "effect"
|
|
import { AgentV2 } from "../agent"
|
|
import { Database } from "../database/database"
|
|
import { EventV2 } from "../event"
|
|
import { makeLocationNode } from "../effect/app-node"
|
|
import { llmClient } from "../effect/app-node-platform"
|
|
import { SessionEvent } from "./event"
|
|
import { SessionHistory } from "./history"
|
|
import { SessionModelHeaders } from "./model-headers"
|
|
import { SessionRunnerModel } from "./runner/model"
|
|
import { SessionSchema } from "./schema"
|
|
import { SessionUsage } from "./usage"
|
|
|
|
const MAX_LENGTH = 100
|
|
|
|
type Dependencies = {
|
|
readonly events: EventV2.Interface
|
|
readonly llm: {
|
|
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
|
|
}
|
|
readonly agents: AgentV2.Interface
|
|
readonly models: SessionRunnerModel.Interface
|
|
}
|
|
|
|
export interface Interface {
|
|
/** Generates a title from the session's first user message and renames the session. Runs at most once per session. */
|
|
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionTitle") {}
|
|
|
|
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
|
|
|
|
const make = (dependencies: Dependencies) => {
|
|
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
|
|
db: Database.Interface["db"],
|
|
session: SessionSchema.Info,
|
|
) {
|
|
if (session.parentID) return
|
|
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
|
|
if (!firstUser) return
|
|
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
|
|
if (!agent) return
|
|
const resolved = yield* (
|
|
agent.model
|
|
? dependencies.models.resolve({ ...session, model: agent.model })
|
|
: dependencies.models.resolve(session)
|
|
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
if (!resolved) return
|
|
const chunks: string[] = []
|
|
let failed = false
|
|
let usage: SessionUsage.Recorded | undefined
|
|
const recordUsage = Effect.suspend(() =>
|
|
usage
|
|
? dependencies.events.publish(SessionEvent.UsageRecorded, {
|
|
sessionID: session.id,
|
|
source: "title",
|
|
...usage,
|
|
})
|
|
: Effect.void,
|
|
)
|
|
const streamed = yield* dependencies.llm
|
|
.stream(
|
|
LLM.request({
|
|
model: resolved.model,
|
|
http: { headers: SessionModelHeaders.make(session) },
|
|
system: agent.system,
|
|
messages: [Message.user(firstUser.text)],
|
|
tools: [],
|
|
}),
|
|
)
|
|
.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, resolved.cost)
|
|
usage = usage ? SessionUsage.add(usage, step) : step
|
|
}
|
|
return Effect.void
|
|
}),
|
|
Effect.as(true),
|
|
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
|
|
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
|
)
|
|
yield* recordUsage
|
|
if (!streamed || failed) return
|
|
const title = chunks
|
|
.join("")
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.find((line) => line.length > 0)
|
|
if (!title) return
|
|
yield* dependencies.events.publish(SessionEvent.Renamed, {
|
|
sessionID: session.id,
|
|
title: truncate(title),
|
|
})
|
|
})
|
|
return { generateForFirstPrompt }
|
|
}
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const events = yield* EventV2.Service
|
|
const llm = yield* LLMClient.Service
|
|
const agents = yield* AgentV2.Service
|
|
const models = yield* SessionRunnerModel.Service
|
|
const database = yield* Database.Service
|
|
const title = make({ events, llm, agents, models })
|
|
return Service.of({
|
|
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const node = makeLocationNode({
|
|
service: Service,
|
|
layer,
|
|
deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node],
|
|
})
|