feat(session-title): add fallback and model providers

This commit is contained in:
Tianyi Cui
2026-07-21 01:53:24 +08:00
parent 9a6914d845
commit 58dc5f94de
41 files changed
+2900 -1

No files matched your search

@@ -0,0 +1,556 @@
/**
* Log-backed session title service, deterministic fallback, and provider seam.
* @module @deepseek-ai/dsh-session-title
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
/** Identifies one session-title provider registration. */
export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
/**
* Brand a raw provider id.
* @param id - stable non-empty provider identifier supplied by a plugin.
* @returns the same string with the session-title provider brand.
*/
export function SessionTitleProviderId(id: string): SessionTitleProviderId {
return id as SessionTitleProviderId
}
/** Exact auxiliary model route that produced a title. */
export interface SessionTitleModelProvenance {
/** Registered LLM provider route. */
readonly provider: string
/** Provider model id. */
readonly model: string
}
/** Durable ownership record for an accepted session title. */
export type SessionTitleSource =
| { readonly kind: 'fallback' }
| {
readonly kind: 'provider'
readonly provider: SessionTitleProviderId
readonly model?: SessionTitleModelProvenance
}
/** Payload of the log-only `session/title` event. */
export interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title. */
readonly messageSeqs: number[]
/** Built-in fallback or registered-provider provenance. */
readonly source: SessionTitleSource
}
/** Latest folded title plus the title event's durable envelope facts. */
export interface SessionTitleSnapshot extends SessionTitleEventData {
/** Seq of the latest `session/title` event. */
readonly eventSeq: number
/** Timestamp of the latest `session/title` event. */
readonly updatedAt: number
}
/** Required deterministic fallback and accepted-title limits. */
export interface Config {
/** Maximum whitespace-delimited words in the built-in fallback. */
readonly fallbackMaxWords: number
/** Maximum UTF-8 bytes in the built-in fallback. */
readonly fallbackMaxBytes: number
/** Maximum UTF-8 bytes in any accepted title. */
readonly maxTitleBytes: number
}
declare module 'cordis' {
interface Context {
sessionTitle: SessionTitleService
}
}
declare module '@deepseek-ai/dsh-session' {
interface TurnTriggerMap {
/** Zero-step turn opened only to durably append a late title update. */
'session-title': { kind: 'session-title' }
}
interface SessionEventMap {
/**
* Latest-wins session title snapshot. Log-only: it never enters the model
* surface or derived history.
*/
'session/title': SessionTitleEventData
}
interface OutOfBandSessionEventMap {
'session/title': true
}
}
/** One eligible human text message exposed to title providers. */
export interface SessionTitleUserMessage {
/** Source `user/message` event seq. */
readonly seq: number
/** Exact concatenated text-block content. */
readonly text: string
}
/** Automatic generation cadence owned by a registered provider. */
export type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages'
/** Immutable input supplied to one title-provider call. */
export interface SessionTitleProviderRequest {
/** Live session being titled. */
readonly session: Session
/** All eligible human messages through this generation revision. */
readonly messages: readonly SessionTitleUserMessage[]
/** Exact current logged main-request route, when one has been recorded. */
readonly route?: SessionTitleModelProvenance
/** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
readonly signal: AbortSignal
}
/** Provider output before service-owned normalization and durable acceptance. */
export interface SessionTitleProviderResult {
/** Proposed title text. */
readonly title: string
/** Exact seqs from `request.messages` used by this result. */
readonly messageSeqs: readonly number[]
/** Auxiliary LLM route, when generation used a model. */
readonly model?: SessionTitleModelProvenance
}
/** One optional asynchronous title implementation registered with the service. */
export interface SessionTitleProvider {
/** Stable provider identity recorded in title provenance. */
readonly id: SessionTitleProviderId
/** When new human prompts start automatic generation. */
readonly automatic: SessionTitleAutomaticMode
/**
* Produce one title revision.
* @param request - message snapshot, current route, session, and cancellation.
* @returns proposed title plus exact input seqs and optional model provenance.
*/
generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
}
/**
* Collect human text-bearing user messages in log order.
* @param events - session log or persisted replay.
* @param throughSeq - optional inclusive event boundary.
* @returns eligible messages with exact source seqs.
*/
export function collectSessionTitleMessages(
events: readonly SessionEvent[],
throughSeq?: number,
): SessionTitleUserMessage[] {
const messages: SessionTitleUserMessage[] = []
for (const event of events) {
if (throughSeq !== undefined && event.seq > throughSeq) break
if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
const text = event.data.content
.filter((block): block is Extract<(typeof event.data.content)[number], { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('\n')
if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue
messages.push({ seq: event.seq, text })
}
return messages
}
/**
* Fold the latest logged title without consulting mutable metadata.
* @param events - live or persisted session log.
* @returns the latest immutable title snapshot, or `undefined`.
*/
export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
const event = events.findLast(item => item.type === 'session/title')
if (event === undefined) return undefined
return deepFreeze({
title: event.data.title,
messageSeqs: [...event.data.messageSeqs],
source: event.data.source.kind === 'fallback'
? { kind: 'fallback' }
: {
kind: 'provider',
provider: event.data.source.provider,
...(event.data.source.model === undefined
? {}
: { model: { ...event.data.source.model } }),
},
eventSeq: event.seq,
updatedAt: event.time,
})
}
/** Service-owned resolved limits. */
interface ResolvedConfig {
readonly fallbackMaxWords: number
readonly fallbackMaxBytes: number
readonly maxTitleBytes: number
}
/** One exact provider registration generation. */
interface ProviderRegistration {
readonly provider: SessionTitleProvider
}
/** Automatic work waiting for the matching main-request header. */
interface PendingAutomaticWork {
readonly registration: ProviderRegistration
readonly revision: number
readonly throughSeq: number
}
/** Provider call currently allowed to commit for one session. */
interface ActiveProviderWork extends PendingAutomaticWork {
readonly controller: AbortController
readonly signal: AbortSignal
}
/** Mutable concurrency state scoped to one live session. */
interface SessionTitleWorkState {
revision: number
pending?: PendingAutomaticWork
active?: ActiveProviderWork
}
/** Validate one positive integer configuration field. */
function assertPositiveInteger(name: keyof Config, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`session-title: ${name} must be a positive integer`)
}
}
/** Log-backed title fold plus asynchronous fallback generation. */
export class SessionTitleService extends Service {
static inject = ['sessions']
static Config: z<Config> = z.object({
fallbackMaxWords: z.number().step(1).min(1).required(),
fallbackMaxBytes: z.number().step(1).min(1).required(),
maxTitleBytes: z.number().step(1).min(1).required(),
})
private readonly config: ResolvedConfig
private registration: ProviderRegistration | undefined
private readonly work = new Map<Session, SessionTitleWorkState>()
constructor(ctx: Context, config: Config) {
super(ctx, 'sessionTitle')
const candidate: unknown = config
if (candidate === null || typeof candidate !== 'object') {
throw new Error('session-title: configuration is required')
}
const value = candidate as Config
assertPositiveInteger('fallbackMaxWords', value.fallbackMaxWords)
assertPositiveInteger('fallbackMaxBytes', value.fallbackMaxBytes)
assertPositiveInteger('maxTitleBytes', value.maxTitleBytes)
if (value.fallbackMaxBytes > value.maxTitleBytes) {
throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes')
}
this.config = deepFreeze({ ...value })
ctx.on('session/event', (session, event) => {
switch (event.type) {
case 'user/message':
this.onUserMessage(session, event)
break
case 'request/header':
this.onRequestHeader(session, event)
break
default:
break
}
})
ctx.on('session/disposed', (session) => {
const state = this.work.get(session)
if (state === undefined) return
state.active?.controller.abort(new Error('session disposed during title generation'))
this.work.delete(session)
})
}
/**
* Read the latest folded title from one live or replayed session.
* @param session - session whose log is the title source of truth.
* @returns latest title snapshot, or `undefined` before eligible input.
*/
get(session: Session): SessionTitleSnapshot | undefined {
return foldSessionTitle(session.events)
}
/**
* Explicitly retry the registered provider, or materialize the built-in
* fallback when no provider is registered.
* @param session - exact live session to refresh.
* @param signal - optional caller cancellation.
* @returns latest accepted title, or `undefined` when no eligible text exists.
*/
async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined> {
signal?.throwIfAborted()
if (this.ctx.sessions.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const fallback = await this.ensureFallback(session)
const registration = this.registration
if (registration === undefined) return fallback
const messages = collectSessionTitleMessages(session.events)
const latest = messages.at(-1)
if (latest === undefined) return fallback
const state = this.stateFor(session)
const revision = this.supersede(state, 'explicit title refresh superseded older generation')
const work = this.activate({
registration,
revision,
throughSeq: latest.seq,
}, state, signal)
const config = session.requestHeader()?.config
const route = config === undefined ? undefined : { provider: config.provider, model: config.model }
return this.runProvider(session, work, route)
}
/**
* Register the sole optional title provider. Disposal aborts its pending and
* active work before another provider may register.
* @param provider - provider identity, cadence, and generation function.
* @returns exact Cordis effect disposer for HMR-safe unregistration.
*/
register(provider: SessionTitleProvider): () => void {
this.validateProvider(provider)
if (this.registration !== undefined) {
throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`)
}
const registration: ProviderRegistration = {
provider,
}
const dispose = this.ctx.effect(function* (this: SessionTitleService) {
this.registration = registration
yield () => {
this.registration = undefined
for (const state of this.work.values()) {
delete state.pending
state.active?.controller.abort(new Error(`session-title provider "${provider.id}" was disposed`))
}
}
}.bind(this), 'sessionTitle.register()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact effect disposer preserves owner teardown ordering
return dispose
}
/** Schedule fallback creation and any provider cadence for one eligible event. */
private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
const registration = this.registration
if (registration !== undefined) {
const messages = collectSessionTitleMessages(session.events, event.seq)
const shouldSchedule = registration.provider.automatic === 'all-user-messages'
|| (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
if (shouldSchedule) {
const state = this.stateFor(session)
const revision = this.supersede(state, 'newer user message superseded title generation')
state.pending = { registration, revision, throughSeq: event.seq }
}
}
queueMicrotask(() => {
void this.ensureFallback(session).catch((error: unknown) => {
this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`)
})
})
}
/** Start pending automatic work only after its exact main-request route is logged. */
private onRequestHeader(session: Session, event: Extract<SessionEvent, { type: 'request/header' }>): void {
const state = this.work.get(session)
const pending = state?.pending
if (state === undefined || pending === undefined || pending.throughSeq >= event.seq) return
delete state.pending
const route = {
provider: event.data.header.config.provider,
model: event.data.header.config.model,
}
queueMicrotask(() => {
if (this.registration !== pending.registration || state.revision !== pending.revision) return
const work = this.activate(pending, state)
void this.runProvider(session, work, route).catch((error: unknown) => {
if (work.signal.aborted) return
this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`)
})
})
}
/** Execute and durably accept one current provider revision. */
private async runProvider(
session: Session,
work: ActiveProviderWork,
route?: SessionTitleModelProvenance,
): Promise<SessionTitleSnapshot | undefined> {
try {
await this.ensureFallback(session)
this.assertCurrent(session, work)
const messages = collectSessionTitleMessages(session.events, work.throughSeq)
const result = await work.registration.provider.generate({
session,
messages,
...route === undefined ? {} : { route },
signal: work.signal,
})
this.assertCurrent(session, work)
const accepted = this.validateResult(result, messages)
await this.ctx.sessions.appendOutOfBand(session, 'session/title', {
title: accepted.title,
messageSeqs: [...accepted.messageSeqs],
source: {
kind: 'provider',
provider: work.registration.provider.id,
...accepted.model === undefined ? {} : { model: accepted.model },
},
}, { kind: 'session-title' })
return this.get(session)
} finally {
const state = this.work.get(session)
if (state?.active === work) delete state.active
}
}
/** Validate and normalize provider output against the supplied message snapshot. */
private validateResult(
result: unknown,
messages: readonly SessionTitleUserMessage[],
): SessionTitleProviderResult {
if (result === null || typeof result !== 'object') {
throw new Error('session-title provider returned an invalid result')
}
const candidate = result as Record<string, unknown>
if (typeof candidate.title !== 'string') throw new Error('session-title provider title must be a string')
const title = normalizeSessionTitle(candidate.title, this.config.maxTitleBytes)
if (title.length === 0) throw new Error('session-title provider returned an empty title')
if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) {
throw new Error('session-title provider must identify at least one source message seq')
}
const messageSeqs: number[] = []
const order = new Map(messages.map((message, index) => [message.seq, index]))
let previous = -1
for (const seq of candidate.messageSeqs as unknown[]) {
if (typeof seq !== 'number') {
throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
}
const index = order.get(seq)
if (!Number.isSafeInteger(seq) || seq < 0 || index === undefined || index <= previous) {
throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
}
messageSeqs.push(seq)
previous = index
}
const modelCandidate = candidate.model
let model: SessionTitleModelProvenance | undefined
if (modelCandidate !== undefined) {
if (modelCandidate === null || typeof modelCandidate !== 'object') {
throw new Error('session-title provider model provenance requires non-empty provider and model')
}
const record = modelCandidate as Record<string, unknown>
if (typeof record.provider !== 'string' || record.provider.length === 0
|| typeof record.model !== 'string' || record.model.length === 0) {
throw new Error('session-title provider model provenance requires non-empty provider and model')
}
model = { provider: record.provider, model: record.model }
}
return {
title,
messageSeqs,
...(model === undefined ? {} : { model }),
}
}
/** Fail a completion whose provider, revision, session, or signal is stale. */
private assertCurrent(session: Session, work: ActiveProviderWork): void {
work.signal.throwIfAborted()
const state = this.work.get(session)
/* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
* the work signal before changing this state. */
if (this.registration !== work.registration
|| state?.active !== work
|| state.revision !== work.revision
|| this.ctx.sessions.get(session.id) !== session) {
throw new Error('session title generation state changed without cancellation')
}
}
/** Create and publish an active provider call from one fixed revision. */
private activate(
pending: PendingAutomaticWork,
state: SessionTitleWorkState,
upstream?: AbortSignal,
): ActiveProviderWork {
const controller = new AbortController()
const signal = upstream === undefined
? controller.signal
: AbortSignal.any([controller.signal, upstream])
const work: ActiveProviderWork = { ...pending, controller, signal }
state.active = work
return work
}
/** Abort older active work and reserve the next session-local revision. */
private supersede(state: SessionTitleWorkState, reason: string): number {
state.active?.controller.abort(new Error(reason))
delete state.pending
state.revision += 1
return state.revision
}
/** Return mutable work state for one session. */
private stateFor(session: Session): SessionTitleWorkState {
let state = this.work.get(session)
if (state === undefined) {
state = { revision: 0 }
this.work.set(session, state)
}
return state
}
/** Reject malformed provider registrations before publishing an effect. */
private validateProvider(provider: unknown): asserts provider is SessionTitleProvider {
if (provider === null || typeof provider !== 'object') {
throw new Error('session-title provider must be an object')
}
const candidate = provider as Record<string, unknown>
if (typeof candidate.id !== 'string' || candidate.id.length === 0) {
throw new Error('session-title provider id must be a non-empty string')
}
if (candidate.automatic !== 'first-message' && candidate.automatic !== 'all-user-messages') {
throw new Error('session-title provider automatic mode is invalid')
}
if (typeof candidate.generate !== 'function') {
throw new Error(`session-title provider "${candidate.id}" requires generate()`)
}
}
/** Create the first deterministic fallback if the session still lacks a title. */
private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
const current = this.get(session)
if (current !== undefined) return current
const [first] = collectSessionTitleMessages(session.events)
if (first === undefined) return undefined
const title = fallbackSessionTitle(
first.text,
this.config.fallbackMaxWords,
this.config.fallbackMaxBytes,
)
if (title.length === 0) return undefined
await this.ctx.sessions.appendOutOfBand(session, 'session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
return this.get(session)
}
}
export default SessionTitleService
@@ -0,0 +1,74 @@
/** Title text normalization and UTF-8-safe truncation. */
/** Operating-system-command escape sequences, including unterminated tails. */
const OSC_SEQUENCE = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
/** Control-sequence-introducer escapes such as SGR color codes. */
const CSI_SEQUENCE = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
/** Remaining two-byte ESC control sequences. */
const ESC_SEQUENCE = /\u001B[@-_]/gu
/** Non-whitespace C0/C1 control characters. */
const CONTROL_CHARACTER = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu
/** Directional and invisible controls that can make a displayed title deceptive. */
const DIRECTIONAL_CONTROL = /[\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/gu
/** Reject an invalid public text limit. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`)
}
}
/** Remove controls and produce one trimmed, whitespace-normalized line. */
function cleanTitleText(input: string): string {
return input
.replace(OSC_SEQUENCE, '')
.replace(CSI_SEQUENCE, '')
.replace(ESC_SEQUENCE, '')
.replace(CONTROL_CHARACTER, '')
.replace(DIRECTIONAL_CONTROL, '')
.replace(/\s+/gu, ' ')
.trim()
}
/**
* Truncate a string to a UTF-8 byte budget without splitting a Unicode code point.
* @param input - normalized title text.
* @param maxBytes - positive UTF-8 byte budget.
* @returns the longest leading code-point prefix within the budget.
*/
export function truncateTitleUtf8(input: string, maxBytes: number): string {
assertPositiveInteger('maxBytes', maxBytes)
if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input
let used = 0
let output = ''
for (const character of input) {
const bytes = Buffer.byteLength(character, 'utf8')
if (used + bytes > maxBytes) break
output += character
used += bytes
}
return output
}
/**
* Normalize one accepted session title and enforce its UTF-8 byte budget.
* @param input - untrusted title text.
* @param maxBytes - positive maximum encoded size.
* @returns a terminal-safe one-line title, possibly empty after sanitization.
*/
export function normalizeSessionTitle(input: string, maxBytes: number): string {
return truncateTitleUtf8(cleanTitleText(input), maxBytes).trimEnd()
}
/**
* Derive the deterministic first-message fallback.
* @param input - text from the first eligible human message.
* @param maxWords - positive whitespace-delimited word cap.
* @param maxBytes - positive UTF-8 byte cap.
* @returns the normalized leading words within both limits.
*/
export function fallbackSessionTitle(input: string, maxWords: number, maxBytes: number): string {
assertPositiveInteger('maxWords', maxWords)
const words = cleanTitleText(input).split(' ').filter(Boolean).slice(0, maxWords)
return truncateTitleUtf8(words.join(' '), maxBytes).trimEnd()
}