index.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793
  1. /**
  2. * Log-backed session title service, deterministic fallback, and provider contract.
  3. * @module @deepseek-ai/dsh-session-title
  4. */
  5. import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis'
  6. import z from '@deepseek-ai/schemastery'
  7. import { z as zod } from 'zod'
  8. import type { Branded } from '@deepseek-ai/dsh-brand'
  9. import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
  10. import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
  11. import type {
  12. Session,
  13. SessionEvent,
  14. } from '@deepseek-ai/dsh-session'
  15. // Type-only: resolves ctx.sessionProjections for the optional unit child.
  16. import type {} from '@deepseek-ai/dsh-session-projection'
  17. // The `title` projection-key declaration lives in src/types.ts (its one home);
  18. // this re-export projects the type face onto the package root AND keeps the
  19. // module edge in the emitted index.d.ts, so aggregate programs consuming the
  20. // declarations still receive the SessionProjectionMap merge.
  21. export type * from './types.ts'
  22. import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
  23. export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
  24. /** Identifies one session-title provider registration. */
  25. export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
  26. /**
  27. * Brand a raw provider id.
  28. * @param id - stable non-empty provider identifier supplied by a plugin.
  29. * @returns the same string with the session-title provider brand.
  30. */
  31. export function SessionTitleProviderId(id: string): SessionTitleProviderId {
  32. return id as SessionTitleProviderId
  33. }
  34. /** Exact auxiliary model route that produced a title. */
  35. export interface SessionTitleModelProvenance {
  36. /** Registered LLM provider route. */
  37. readonly provider: string
  38. /** Provider model id. */
  39. readonly model: string
  40. }
  41. /** Durable ownership record for an accepted session title. */
  42. export type SessionTitleSource =
  43. | { readonly kind: 'fallback' }
  44. | {
  45. readonly kind: 'provider'
  46. readonly provider: SessionTitleProviderId
  47. readonly model?: SessionTitleModelProvenance
  48. }
  49. | {
  50. /** Explicit user rename: pins the title — automatic generation stops scheduling. */
  51. readonly kind: 'user'
  52. }
  53. /** Payload of the log-only `session/title` event. */
  54. export interface SessionTitleEventData {
  55. /** Normalized non-empty title text. */
  56. readonly title: string
  57. /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
  58. readonly messageSeqs: number[]
  59. /** Whether the built-in fallback, a registered provider, or the user supplied the title. */
  60. readonly source: SessionTitleSource
  61. }
  62. /** Latest folded title plus the title event's durable envelope facts. */
  63. export interface SessionTitleSnapshot extends SessionTitleEventData {
  64. /** Seq of the latest `session/title` event. */
  65. readonly eventSeq: number
  66. /** Timestamp of the latest `session/title` event. */
  67. readonly updatedAt: number
  68. }
  69. /** Required deterministic fallback and accepted-title limits. */
  70. export interface Config {
  71. /** Maximum whitespace-delimited words in the built-in fallback. */
  72. readonly fallbackMaxWords: number
  73. /** Maximum UTF-8 bytes in the built-in fallback. */
  74. readonly fallbackMaxBytes: number
  75. /** Maximum UTF-8 bytes in any accepted title. */
  76. readonly maxTitleBytes: number
  77. }
  78. declare module '@deepseek-ai/cordis' {
  79. interface Context {
  80. sessionTitle: SessionTitleService
  81. }
  82. }
  83. declare module '@deepseek-ai/dsh-session/types' {
  84. interface SessionEventMap {
  85. /**
  86. * Latest-wins session title snapshot. Log-only: it never enters the model
  87. * surface or derived history.
  88. */
  89. 'session/title': SessionTitleEventData
  90. }
  91. }
  92. /**
  93. * Rejection of an explicit user title whose text normalizes to empty — the
  94. * one {@link SessionTitleService.rename} failure that blames the input.
  95. * Callers translating rename failures onto a wire (`title-invalid`) narrow on
  96. * this class; liveness and disposal failures stay plain `Error`s.
  97. */
  98. export class SessionTitleInvalidError extends Error {
  99. override readonly name = 'SessionTitleInvalidError'
  100. }
  101. /** One eligible human text message exposed to title providers. */
  102. export interface SessionTitleUserMessage {
  103. /** Source `user/message` event seq. */
  104. readonly seq: number
  105. /** Exact concatenated text-block content. */
  106. readonly text: string
  107. }
  108. /** Automatic generation cadence owned by a registered provider. */
  109. export type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts'
  110. /** Immutable input supplied to one title-provider call. */
  111. export interface SessionTitleProviderRequest {
  112. /** Live session being titled. */
  113. readonly session: Session
  114. /** All eligible human messages through this generation revision. */
  115. readonly messages: readonly SessionTitleUserMessage[]
  116. /** Exact current logged main-request route, when one has been recorded. */
  117. readonly route?: SessionTitleModelProvenance
  118. /** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
  119. readonly signal: AbortSignal
  120. }
  121. /** Provider output before service-owned normalization and log acceptance. */
  122. export interface SessionTitleProviderResult {
  123. /** Proposed title text. */
  124. readonly title: string
  125. /** Exact seqs from `request.messages` used by this result. */
  126. readonly messageSeqs: readonly number[]
  127. /** Auxiliary LLM route, when generation used a model. */
  128. readonly model?: SessionTitleModelProvenance
  129. }
  130. /** One optional asynchronous title implementation registered with the service. */
  131. export interface SessionTitleProvider {
  132. /** Stable id of the provider recorded with the title. */
  133. readonly id: SessionTitleProviderId
  134. /** When new human prompts start automatic generation. */
  135. readonly automatic: SessionTitleAutomaticMode
  136. /**
  137. * Produce one title revision.
  138. * @param request - message snapshot, current route, session, and cancellation.
  139. * @returns proposed title plus exact input seqs and the optional provider/model route used to generate it.
  140. */
  141. generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
  142. }
  143. /**
  144. * Collect human text-bearing user messages in log order.
  145. * @param events - session log or persisted replay.
  146. * @param throughSeq - optional inclusive event boundary.
  147. * @returns eligible messages with exact source seqs.
  148. */
  149. export function collectSessionTitleMessages(
  150. events: readonly SessionEvent[],
  151. throughSeq?: number,
  152. ): SessionTitleUserMessage[] {
  153. const messages: SessionTitleUserMessage[] = []
  154. for (const event of events) {
  155. if (throughSeq !== undefined && event.seq > throughSeq) break
  156. if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
  157. const content = event.data.content
  158. const text = content
  159. .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text')
  160. .map(block => block.text)
  161. .join('\n')
  162. if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue
  163. messages.push({ seq: event.seq, text })
  164. }
  165. return messages
  166. }
  167. /**
  168. * Fold the latest logged title without consulting mutable metadata.
  169. * @param events - live or persisted session log.
  170. * @returns the latest immutable title snapshot, or `undefined`.
  171. */
  172. export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
  173. const event = events.findLast(item => item.type === 'session/title')
  174. if (event === undefined) return undefined
  175. return deepFreeze({
  176. title: event.data.title,
  177. messageSeqs: [...event.data.messageSeqs],
  178. source: copySessionTitleSource(event.data.source),
  179. eventSeq: event.seq,
  180. updatedAt: event.time,
  181. })
  182. }
  183. /** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
  184. function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource {
  185. switch (source.kind) {
  186. case 'fallback': return { kind: 'fallback' }
  187. case 'provider': return {
  188. kind: 'provider',
  189. provider: source.provider,
  190. ...(source.model === undefined ? {} : { model: { ...source.model } }),
  191. }
  192. case 'user': return { kind: 'user' }
  193. /* v8 ignore next -- closed-union exhaustiveness guard */
  194. default: return assertNever(source, 'SessionTitleSource')
  195. }
  196. }
  197. /** Service-owned resolved limits. */
  198. interface ResolvedConfig {
  199. readonly fallbackMaxWords: number
  200. readonly fallbackMaxBytes: number
  201. readonly maxTitleBytes: number
  202. }
  203. /** One exact provider registration generation. */
  204. interface ProviderRegistration {
  205. readonly provider: SessionTitleProvider
  206. readonly active: Set<Promise<unknown>>
  207. closing: boolean
  208. }
  209. /** Automatic work waiting for the matching main-request header. */
  210. interface PendingAutomaticWork {
  211. readonly registration: ProviderRegistration
  212. readonly revision: number
  213. readonly throughSeq: number
  214. }
  215. /** Provider call currently allowed to commit for one session. */
  216. interface ActiveProviderWork extends PendingAutomaticWork {
  217. readonly controller: AbortController
  218. readonly signal: AbortSignal
  219. }
  220. /** Mutable concurrency state scoped to one live session. */
  221. interface SessionTitleWorkState {
  222. revision: number
  223. fallback?: Promise<SessionTitleSnapshot | undefined>
  224. pending?: PendingAutomaticWork
  225. active?: ActiveProviderWork
  226. }
  227. /** Validate one positive integer configuration field. */
  228. function assertPositiveInteger(name: keyof Config, value: number): void {
  229. if (!Number.isInteger(value) || value <= 0) {
  230. throw new Error(`session-title: ${name} must be a positive integer`)
  231. }
  232. }
  233. /** Log-backed title fold plus asynchronous fallback generation. */
  234. export class SessionTitleService extends Service {
  235. static inject = ['sessions']
  236. static Config: z<Config> = z.object({
  237. fallbackMaxWords: z.number().step(1).min(1).required(),
  238. fallbackMaxBytes: z.number().step(1).min(1).required(),
  239. maxTitleBytes: z.number().step(1).min(1).required(),
  240. })
  241. private readonly config: ResolvedConfig
  242. private readonly ownerFiber: Fiber
  243. private registration: ProviderRegistration | undefined
  244. private readonly work = new Map<Session, SessionTitleWorkState>()
  245. private readonly lifetime = new AbortController()
  246. private readonly inFlight = new Set<Promise<unknown>>()
  247. constructor(ctx: Context, config: Config) {
  248. super(ctx, 'sessionTitle')
  249. this.ownerFiber = ctx.fiber
  250. const candidate: unknown = config
  251. if (candidate === null || typeof candidate !== 'object') {
  252. throw new Error('session-title: configuration is required')
  253. }
  254. const value = candidate as Config
  255. assertPositiveInteger('fallbackMaxWords', value.fallbackMaxWords)
  256. assertPositiveInteger('fallbackMaxBytes', value.fallbackMaxBytes)
  257. assertPositiveInteger('maxTitleBytes', value.maxTitleBytes)
  258. if (value.fallbackMaxBytes > value.maxTitleBytes) {
  259. throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes')
  260. }
  261. this.config = deepFreeze({ ...value })
  262. ctx.effect(() => async () => {
  263. this.lifetime.abort(new Error('session-title service disposed'))
  264. if (this.registration !== undefined) this.registration.closing = true
  265. this.registration = undefined
  266. for (const state of this.work.values()) {
  267. delete state.pending
  268. state.active?.controller.abort(new Error('session-title service disposed'))
  269. }
  270. await this.drain(this.inFlight)
  271. this.work.clear()
  272. }, 'sessionTitle lifecycle')
  273. // The title projection unit: pure last-wins fold of session/title events
  274. // (the same events foldSessionTitle consumes), serving the plain title
  275. // string clients list rows read. The unit child activates only when a
  276. // projection registry is composed (headless assemblies stay unaffected).
  277. ctx.inject(['sessionProjections'], (projectionCtx) => {
  278. const titleSchema = zod.union([zod.string().min(1), zod.null()])
  279. projectionCtx.sessionProjections.register<'title', string | null>({
  280. key: 'title',
  281. stateSchema: titleSchema,
  282. init: () => null,
  283. apply: (state, event) => (event.type === 'session/title' ? event.data.title : state),
  284. wire: { viewSchema: titleSchema, view: state => state },
  285. stateVersion: 1,
  286. })
  287. })
  288. ctx.on('session/event', (session, event) => {
  289. switch (event.type) {
  290. case 'user/message':
  291. this.onUserMessage(session, event)
  292. break
  293. case 'request/header':
  294. this.onRequestHeader(session, event)
  295. break
  296. default:
  297. break
  298. }
  299. })
  300. ctx.on('llm/stream', (options, next) => {
  301. this.onMainRequest(options)
  302. return next()
  303. }, { global: true, prepend: true })
  304. ctx.on('session/disposed', (session) => {
  305. const state = this.work.get(session)
  306. if (state === undefined) return
  307. state.active?.controller.abort(new Error('session disposed during title generation'))
  308. this.work.delete(session)
  309. })
  310. }
  311. /**
  312. * Read the latest folded title from one live or replayed session.
  313. * @param session - session whose log is the title source of truth.
  314. * @returns latest title snapshot, or `undefined` before eligible input.
  315. */
  316. get(session: Session): SessionTitleSnapshot | undefined {
  317. return foldSessionTitle(session.events)
  318. }
  319. /**
  320. * Accept an explicit user title. Appends a `session/title` event with the
  321. * `user` source, which pins the title: in-flight automatic generation is
  322. * superseded and later user messages schedule none (an explicit
  323. * {@link SessionTitleService.refresh} remains the deliberate unpin).
  324. * @param session - exact live session to rename.
  325. * @param title - raw user input; normalized before acceptance.
  326. * @returns the accepted title snapshot.
  327. * @throws {SessionTitleInvalidError} when the title normalizes to empty.
  328. * @throws {Error} when the session is not live or the service is disposed.
  329. */
  330. rename(session: Session, title: string): SessionTitleSnapshot {
  331. this.assertServiceActive()
  332. if (this.ctx.sessions.get(session.id) !== session) {
  333. throw new Error(`session "${session.id}" is not live in this store`)
  334. }
  335. const normalized = normalizeSessionTitle(title, this.config.maxTitleBytes)
  336. if (normalized.length === 0) {
  337. throw new SessionTitleInvalidError('session title must contain visible characters')
  338. }
  339. const state = this.stateFor(session)
  340. this.supersede(state, 'user rename superseded automatic title generation')
  341. session.append('session/title', {
  342. title: normalized,
  343. messageSeqs: [],
  344. source: { kind: 'user' },
  345. })
  346. const snapshot = this.get(session)
  347. /* v8 ignore next -- unreachable: the append above just committed a session/title event. */
  348. if (snapshot === undefined) throw new Error('renamed title failed to fold')
  349. return snapshot
  350. }
  351. /**
  352. * Explicitly retry the registered provider, or materialize the built-in
  353. * fallback when no provider is registered.
  354. * @param session - exact live session to refresh.
  355. * @param signal - optional caller cancellation.
  356. * @returns latest accepted title, or `undefined` when no eligible text exists.
  357. */
  358. async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined> {
  359. signal?.throwIfAborted()
  360. this.assertServiceActive()
  361. if (this.ctx.sessions.get(session.id) !== session) {
  362. throw new Error(`session "${session.id}" is not live in this store`)
  363. }
  364. const registration = this.registration
  365. const messages = collectSessionTitleMessages(session.events)
  366. const latest = messages.at(-1)
  367. if (registration === undefined || registration.closing || latest === undefined) {
  368. // Explicit refresh is the unpin even without a provider: a standing
  369. // user title must not short-circuit ensureFallback into a no-op, so
  370. // re-derive and append the fallback over it when one is derivable.
  371. const current = this.get(session)
  372. const [first] = messages
  373. if (current?.source.kind === 'user' && first !== undefined) {
  374. this.appendFallback(session, first)
  375. signal?.throwIfAborted()
  376. return this.get(session)
  377. }
  378. const fallback = await this.ensureFallback(session)
  379. signal?.throwIfAborted()
  380. return fallback
  381. }
  382. const state = this.stateFor(session)
  383. const revision = this.supersede(state, 'explicit title refresh superseded older generation')
  384. const work = this.activate({
  385. registration,
  386. revision,
  387. throughSeq: latest.seq,
  388. }, state, signal)
  389. const config = session.requestHeader()?.config
  390. const route = config === undefined ? undefined : { provider: config.provider, model: config.model }
  391. return this.startProvider(session, work, route)
  392. }
  393. /**
  394. * Register the sole optional title provider. Disposal aborts its pending and
  395. * active work before another provider may register.
  396. * @param provider - provider identity, cadence, and generation function.
  397. * @returns exact Cordis effect disposer, which settles after active calls quiesce.
  398. */
  399. register(provider: SessionTitleProvider): () => Promise<void> {
  400. this.validateProvider(provider)
  401. if (this.registration !== undefined) {
  402. throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`)
  403. }
  404. const registration: ProviderRegistration = {
  405. provider,
  406. active: new Set(),
  407. closing: false,
  408. }
  409. const dispose = this.ctx.effect(function* (this: SessionTitleService) {
  410. this.registration = registration
  411. yield async () => {
  412. registration.closing = true
  413. for (const state of this.work.values()) {
  414. if (state.pending?.registration === registration) delete state.pending
  415. if (state.active?.registration === registration) {
  416. state.active.controller.abort(new Error(`session-title provider "${provider.id}" was disposed`))
  417. }
  418. }
  419. await this.drain(registration.active)
  420. if (this.registration === registration) this.registration = undefined
  421. }
  422. }.bind(this), 'sessionTitle.register()')
  423. return dispose
  424. }
  425. /** Schedule fallback creation and any provider cadence for one eligible event. */
  426. private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
  427. if (!this.serviceActive()) return
  428. if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
  429. // A user rename pins the title: no automatic revision may override it.
  430. if (this.get(session)?.source.kind === 'user') return
  431. const registration = this.registration
  432. if (registration !== undefined && !registration.closing) {
  433. const messages = collectSessionTitleMessages(session.events, event.seq)
  434. const shouldSchedule = registration.provider.automatic === 'all-prompts'
  435. || (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
  436. if (shouldSchedule) {
  437. const state = this.stateFor(session)
  438. const revision = this.supersede(state, 'newer user message superseded title generation')
  439. state.pending = { registration, revision, throughSeq: event.seq }
  440. }
  441. }
  442. this.defer(async () => {
  443. try {
  444. await this.ensureFallback(session)
  445. } catch (error: unknown) {
  446. if (!this.serviceActive()) return
  447. this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`)
  448. }
  449. })
  450. }
  451. /** Start pending automatic work only after its exact main-request route is logged. */
  452. private onRequestHeader(session: Session, event: Extract<SessionEvent, { type: 'request/header' }>): void {
  453. if (!this.serviceActive()) return
  454. const state = this.work.get(session)
  455. const pending = state?.pending
  456. if (state === undefined || pending === undefined || pending.throughSeq >= event.seq) return
  457. const route = {
  458. provider: event.data.header.config.provider,
  459. model: event.data.header.config.model,
  460. }
  461. this.startPending(session, state, pending, route)
  462. }
  463. /** Start unchanged-route work from the marked loop request after its header fold is current. */
  464. private onMainRequest(options: GenerateOptions): void {
  465. if (!this.serviceActive() || options.sessionId === undefined || !isAgentLoopRequest(options)) return
  466. const session = this.ctx.sessions.get(options.sessionId)
  467. const state = session === undefined ? undefined : this.work.get(session)
  468. const pending = state?.pending
  469. if (session === undefined || state === undefined || pending === undefined) return
  470. const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
  471. const route = session.requestHeader()?.config
  472. if (boundary?.type !== 'step/start'
  473. || boundary.seq <= pending.throughSeq
  474. || route?.provider !== options.provider
  475. || route.model !== options.model) return
  476. this.startPending(session, state, pending, { provider: options.provider, model: options.model })
  477. }
  478. /** Consume one pending revision and schedule its non-blocking provider call. */
  479. private startPending(
  480. session: Session,
  481. state: SessionTitleWorkState,
  482. pending: PendingAutomaticWork,
  483. route: SessionTitleModelProvenance,
  484. ): void {
  485. delete state.pending
  486. this.defer(async () => {
  487. if (this.registration !== pending.registration
  488. || pending.registration.closing
  489. || this.work.get(session) !== state
  490. || state.revision !== pending.revision) return
  491. const work = this.activate(pending, state)
  492. try {
  493. await this.startProvider(session, work, route)
  494. } catch (error: unknown) {
  495. if (work.signal.aborted || !this.serviceActive()) return
  496. this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`)
  497. }
  498. })
  499. }
  500. /** Start one tracked provider call after publishing its active revision. */
  501. private startProvider(
  502. session: Session,
  503. work: ActiveProviderWork,
  504. route?: SessionTitleModelProvenance,
  505. ): Promise<SessionTitleSnapshot | undefined> {
  506. const run = Promise.resolve().then(() => this.runProvider(session, work, route))
  507. return this.track(run, work.registration)
  508. }
  509. /** Execute and accept one current provider revision. */
  510. private async runProvider(
  511. session: Session,
  512. work: ActiveProviderWork,
  513. route?: SessionTitleModelProvenance,
  514. ): Promise<SessionTitleSnapshot | undefined> {
  515. try {
  516. this.assertCurrent(session, work)
  517. await this.ensureFallback(session)
  518. this.assertCurrent(session, work)
  519. const messages = collectSessionTitleMessages(session.events, work.throughSeq)
  520. const result = await work.registration.provider.generate({
  521. session,
  522. messages,
  523. ...route === undefined ? {} : { route },
  524. signal: work.signal,
  525. })
  526. this.assertCurrent(session, work)
  527. const accepted = this.validateResult(result, messages)
  528. session.append('session/title', {
  529. title: accepted.title,
  530. messageSeqs: [...accepted.messageSeqs],
  531. source: {
  532. kind: 'provider',
  533. provider: work.registration.provider.id,
  534. ...accepted.model === undefined ? {} : { model: accepted.model },
  535. },
  536. })
  537. return this.get(session)
  538. } finally {
  539. const state = this.work.get(session)
  540. if (state?.active === work) delete state.active
  541. }
  542. }
  543. /** Validate and normalize provider output against the supplied message snapshot. */
  544. private validateResult(
  545. result: unknown,
  546. messages: readonly SessionTitleUserMessage[],
  547. ): SessionTitleProviderResult {
  548. if (result === null || typeof result !== 'object') {
  549. throw new Error('session-title provider returned an invalid result')
  550. }
  551. const candidate = result as Record<string, unknown>
  552. if (typeof candidate.title !== 'string') throw new Error('session-title provider title must be a string')
  553. const title = normalizeSessionTitle(candidate.title, this.config.maxTitleBytes)
  554. if (title.length === 0) throw new Error('session-title provider returned an empty title')
  555. if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) {
  556. throw new Error('session-title provider must identify at least one source message seq')
  557. }
  558. const messageSeqs: number[] = []
  559. const order = new Map(messages.map((message, index) => [message.seq, index]))
  560. let previous = -1
  561. for (const seq of candidate.messageSeqs as unknown[]) {
  562. if (typeof seq !== 'number') {
  563. throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
  564. }
  565. const index = order.get(seq)
  566. if (!Number.isSafeInteger(seq) || seq < 0 || index === undefined || index <= previous) {
  567. throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
  568. }
  569. messageSeqs.push(seq)
  570. previous = index
  571. }
  572. const modelCandidate = candidate.model
  573. let model: SessionTitleModelProvenance | undefined
  574. if (modelCandidate !== undefined) {
  575. if (modelCandidate === null || typeof modelCandidate !== 'object') {
  576. throw new Error('session-title provider result model must contain non-empty provider and model strings')
  577. }
  578. const record = modelCandidate as Record<string, unknown>
  579. if (typeof record.provider !== 'string' || record.provider.length === 0
  580. || typeof record.model !== 'string' || record.model.length === 0) {
  581. throw new Error('session-title provider result model must contain non-empty provider and model strings')
  582. }
  583. model = { provider: record.provider, model: record.model }
  584. }
  585. return {
  586. title,
  587. messageSeqs,
  588. ...(model === undefined ? {} : { model }),
  589. }
  590. }
  591. /** Fail a completion whose provider, revision, session, or signal is stale. */
  592. private assertCurrent(session: Session, work: ActiveProviderWork): void {
  593. this.assertServiceActive()
  594. work.signal.throwIfAborted()
  595. const state = this.work.get(session)
  596. /* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
  597. * the work signal before changing this state. */
  598. if (this.registration !== work.registration
  599. || state?.active !== work
  600. || state.revision !== work.revision
  601. || this.ctx.sessions.get(session.id) !== session) {
  602. throw new Error('session title generation state changed without cancellation')
  603. }
  604. }
  605. /** Create and publish an active provider call from one fixed revision. */
  606. private activate(
  607. pending: PendingAutomaticWork,
  608. state: SessionTitleWorkState,
  609. upstream?: AbortSignal,
  610. ): ActiveProviderWork {
  611. const controller = new AbortController()
  612. const signal = upstream === undefined
  613. ? AbortSignal.any([controller.signal, this.lifetime.signal])
  614. : AbortSignal.any([controller.signal, this.lifetime.signal, upstream])
  615. const work: ActiveProviderWork = { ...pending, controller, signal }
  616. state.active = work
  617. return work
  618. }
  619. /** Abort older active work and reserve the next session-local revision. */
  620. private supersede(state: SessionTitleWorkState, reason: string): number {
  621. state.active?.controller.abort(new Error(reason))
  622. delete state.pending
  623. state.revision += 1
  624. return state.revision
  625. }
  626. /** Return mutable work state for one session. */
  627. private stateFor(session: Session): SessionTitleWorkState {
  628. let state = this.work.get(session)
  629. if (state === undefined) {
  630. state = { revision: 0 }
  631. this.work.set(session, state)
  632. }
  633. return state
  634. }
  635. /** Queue detached service work and retain it through service disposal. */
  636. private defer(task: () => Promise<void>): void {
  637. const run = Promise.resolve().then(async () => {
  638. if (!this.serviceActive()) return
  639. await task()
  640. })
  641. void this.track(run)
  642. }
  643. /** Retain one promise until settlement for service and optional provider teardown. */
  644. private track<T>(run: Promise<T>, registration?: ProviderRegistration): Promise<T> {
  645. this.inFlight.add(run)
  646. registration?.active.add(run)
  647. const settled = (): void => {
  648. this.inFlight.delete(run)
  649. registration?.active.delete(run)
  650. }
  651. void run.then(settled, settled)
  652. return run
  653. }
  654. /** Await every current and settling promise in one lifecycle registry. */
  655. private async drain(active: Set<Promise<unknown>>): Promise<void> {
  656. while (active.size > 0) await Promise.allSettled([...active])
  657. }
  658. /** Whether the owning plugin fiber can still start or commit title work. */
  659. private serviceActive(): boolean {
  660. return !this.lifetime.signal.aborted
  661. && this.ownerFiber.uid !== null
  662. && this.ownerFiber.state === FiberState.ACTIVE
  663. }
  664. /** Reject work once the owning plugin fiber has begun unloading. */
  665. private assertServiceActive(): void {
  666. if (!this.serviceActive()) throw new Error('session-title service disposed')
  667. }
  668. /** Reject malformed provider registrations before publishing an effect. */
  669. private validateProvider(provider: unknown): asserts provider is SessionTitleProvider {
  670. if (provider === null || typeof provider !== 'object') {
  671. throw new Error('session-title provider must be an object')
  672. }
  673. const candidate = provider as Record<string, unknown>
  674. if (typeof candidate.id !== 'string' || candidate.id.length === 0) {
  675. throw new Error('session-title provider id must be a non-empty string')
  676. }
  677. if (candidate.automatic !== 'first-prompt' && candidate.automatic !== 'all-prompts') {
  678. throw new Error('session-title provider automatic mode is invalid')
  679. }
  680. if (typeof candidate.generate !== 'function') {
  681. throw new Error(`session-title provider "${candidate.id}" requires generate()`)
  682. }
  683. }
  684. /**
  685. * Derive and append the deterministic fallback title over whatever stands
  686. * (the refresh unpin path: overwriting a pinned user title is the point).
  687. * Synchronous on purpose — no await may separate derivation from append, so
  688. * it needs neither ensureFallback's in-flight dedup nor its liveness
  689. * re-check. An underivable fallback (empty after the caps) appends nothing.
  690. */
  691. private appendFallback(session: Session, first: SessionTitleUserMessage): void {
  692. const title = fallbackSessionTitle(first.text, this.config.fallbackMaxWords, this.config.fallbackMaxBytes)
  693. if (title.length === 0) return
  694. session.append('session/title', {
  695. title,
  696. messageSeqs: [first.seq],
  697. source: { kind: 'fallback' },
  698. })
  699. }
  700. /** Create the first deterministic fallback if the session still lacks a title. */
  701. private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
  702. this.assertServiceActive()
  703. const current = this.get(session)
  704. if (current !== undefined) return current
  705. const [first] = collectSessionTitleMessages(session.events)
  706. if (first === undefined) return undefined
  707. const title = fallbackSessionTitle(
  708. first.text,
  709. this.config.fallbackMaxWords,
  710. this.config.fallbackMaxBytes,
  711. )
  712. if (title.length === 0) return undefined
  713. const state = this.stateFor(session)
  714. if (state.fallback !== undefined) return state.fallback
  715. const fallback = Promise.resolve().then(() => {
  716. this.assertServiceActive()
  717. if (this.ctx.sessions.get(session.id) !== session) {
  718. throw new Error(`session "${session.id}" is not live in this store`)
  719. }
  720. const accepted = this.get(session)
  721. if (accepted !== undefined) return accepted
  722. session.append('session/title', {
  723. title,
  724. messageSeqs: [first.seq],
  725. source: { kind: 'fallback' },
  726. })
  727. return this.get(session)
  728. })
  729. state.fallback = fallback
  730. try {
  731. return await fallback
  732. } finally {
  733. delete state.fallback
  734. }
  735. }
  736. }
  737. export default SessionTitleService