2026-07-21 01:53:24 +08:00
/**
* Shared route, framing, timeout, assembly, and validation policy for
* model-backed session-title providers.
* @module @deepseek-ai/dsh-session-title-llm
*/
import type { Context } from 'cordis'
import z from 'schemastery'
2026-07-28 13:55:59 +08:00
import { createUserMessage , BlockAssembler , deepFreeze } from '@deepseek-ai/dsh-llm'
2026-07-21 12:08:00 +08:00
import type { FinishReason , GenerateOptions , Message } from '@deepseek-ai/dsh-llm'
2026-07-21 01:53:24 +08:00
import { deadline , MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
2026-07-21 14:39:09 +08:00
import {
normalizeSessionTitle ,
SessionTitleProviderId ,
} from '@deepseek-ai/dsh-session-title'
2026-07-21 01:53:24 +08:00
import type {
SessionTitleAutomaticMode ,
SessionTitleModelProvenance ,
SessionTitleProviderRequest ,
SessionTitleProviderResult ,
SessionTitleUserMessage ,
} from '@deepseek-ai/dsh-session-title'
2026-07-21 12:08:00 +08:00
/** Exact model-visible request recorded before one auxiliary title dispatch. */
export interface SessionTitleLlmRequestEventData {
/** Registered title-provider identity responsible for the request. */
readonly titleProvider : SessionTitleProviderId
/** Exact human `user/message` seqs represented in `messages`. */
readonly messageSeqs : number [ ]
/** Exact auxiliary LLM route. */
readonly route : SessionTitleModelProvenance
/** Exact auxiliary system prompt. */
readonly system : string
/** Exact auxiliary message list. */
readonly messages : Message [ ]
/** Exact auxiliary output-token cap. */
readonly maxTokens : number
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Log-only pre-dispatch record of one session-title model request. */
'session/title-llm-request' : SessionTitleLlmRequestEventData
}
}
2026-07-21 01:53:24 +08:00
/** Capability-owned timeout reason code for auxiliary title requests. */
export const SESSION_TITLE_TIMEOUT_CODE = 'SESSION_TITLE_TIMEOUT'
/** Required deployment policy for one model-backed title plugin. */
export interface SessionTitleLlmConfig {
/** Target word count for non-CJK titles. */
readonly targetWords : number
/** Target character count for Chinese, Japanese, or Korean titles. */
readonly targetCjkCharacters : number
2026-07-21 15:00:15 +08:00
/** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
2026-07-21 01:53:24 +08:00
readonly maxInputBytes : number
/** Auxiliary generation output-token cap. */
readonly maxOutputTokens : number
/** End-to-end auxiliary request deadline in milliseconds. */
readonly timeoutMs : number
/** Optional explicit provider route; must be paired with `model`. */
readonly provider? : string
/** Optional explicit model id; must be paired with `provider`. */
readonly model? : string
}
/** Validated immutable model-provider policy. */
export interface ResolvedSessionTitleLlmConfig extends SessionTitleLlmConfig { }
/** Shared Loader field schemas with no library defaults. */
export const SessionTitleLlmConfigFields = {
targetWords : z.number ( ) . step ( 1 ) . min ( 1 ) . required ( ) ,
targetCjkCharacters : z.number ( ) . step ( 1 ) . min ( 1 ) . required ( ) ,
maxInputBytes : z.number ( ) . step ( 1 ) . min ( 1 ) . required ( ) ,
maxOutputTokens : z.number ( ) . step ( 1 ) . min ( 1 ) . required ( ) ,
timeoutMs : z.number ( ) . step ( 1 ) . min ( 1 ) . max ( MAX_TIMER_DELAY_MS ) . required ( ) ,
provider : z.string ( ) ,
model : z.string ( ) ,
}
/** Shared Loader schema with no library defaults. */
export const SessionTitleLlmConfigSchema : z < SessionTitleLlmConfig > = z . object ( SessionTitleLlmConfigFields )
/** Complete configuration key set for direct construction validation. */
const CONFIG_KEYS : ReadonlySet < string > = new Set ( [
'targetWords' ,
'targetCjkCharacters' ,
'maxInputBytes' ,
'maxOutputTokens' ,
'timeoutMs' ,
'provider' ,
'model' ,
] )
/** Validate one positive integer limit. */
function assertPositiveInteger ( name : string , value : number ) : void {
if ( ! Number . isInteger ( value ) || value <= 0 ) {
throw new Error ( ` session-title-llm: ${ name } must be a positive integer ` )
}
}
/**
* Validate and detach required model-provider configuration.
* @param config - untrusted plugin configuration.
* @returns immutable policy with optional route absence preserved.
*/
export function resolveSessionTitleLlmConfig (
config : SessionTitleLlmConfig ,
) : ResolvedSessionTitleLlmConfig {
const candidate : unknown = config
if ( candidate === null || typeof candidate !== 'object' ) {
throw new Error ( 'session-title-llm: configuration is required' )
}
const value = candidate as SessionTitleLlmConfig
for ( const key of Object . keys ( value ) ) {
if ( ! CONFIG_KEYS . has ( key ) ) throw new Error ( ` session-title-llm: unknown config key " ${ key } " ` )
}
assertPositiveInteger ( 'targetWords' , value . targetWords )
assertPositiveInteger ( 'targetCjkCharacters' , value . targetCjkCharacters )
assertPositiveInteger ( 'maxInputBytes' , value . maxInputBytes )
assertPositiveInteger ( 'maxOutputTokens' , value . maxOutputTokens )
assertPositiveInteger ( 'timeoutMs' , value . timeoutMs )
if ( value . timeoutMs > MAX_TIMER_DELAY_MS ) {
throw new Error ( ` session-title-llm: timeoutMs must not exceed ${ MAX_TIMER_DELAY_MS } ` )
}
const hasProvider = value . provider !== undefined
const hasModel = value . model !== undefined
if ( hasProvider !== hasModel ) {
throw new Error ( 'session-title-llm: provider and model must be supplied together' )
}
if ( hasProvider
&& ( typeof value . provider !== 'string' || value . provider . length === 0
|| typeof value . model !== 'string' || value . model . length === 0 ) ) {
throw new Error ( 'session-title-llm: provider and model overrides must be non-empty strings' )
}
return deepFreeze ( { . . . value } )
}
/** Select the provider-owned message subset from one fixed service revision. */
export type SessionTitleLlmMessageSelector = (
messages : readonly SessionTitleUserMessage [ ] ,
) = > readonly SessionTitleUserMessage [ ]
/**
* Register one model-backed provider through the shared configuration and call policy.
* @param ctx - context exposing the title and LLM services.
* @param config - untrusted required deployment policy.
* @param id - stable plugin identity recorded in title provenance.
* @param automatic - provider-owned automatic generation cadence.
* @param selectMessages - exact source-message selection for one revision.
*/
export function registerSessionTitleLlmProvider (
ctx : Context ,
config : SessionTitleLlmConfig ,
id : string ,
automatic : SessionTitleAutomaticMode ,
selectMessages : SessionTitleLlmMessageSelector ,
) : void {
const resolved = resolveSessionTitleLlmConfig ( config )
2026-07-21 12:08:00 +08:00
const titleProvider = SessionTitleProviderId ( id )
2026-07-21 01:53:24 +08:00
ctx . sessionTitle . register ( {
2026-07-21 12:08:00 +08:00
id : titleProvider ,
2026-07-21 01:53:24 +08:00
automatic ,
async generate ( request ) {
2026-07-21 12:08:00 +08:00
return generateSessionTitleWithLlm ( ctx , resolved , request , selectMessages ( request . messages ) , titleProvider )
2026-07-21 01:53:24 +08:00
} ,
} )
}
/** Resolve the explicit pair or the exact route captured from `request/header`. */
function resolveRoute (
config : ResolvedSessionTitleLlmConfig ,
request : SessionTitleProviderRequest ,
) : SessionTitleModelProvenance {
if ( config . provider !== undefined && config . model !== undefined ) {
return { provider : config.provider , model : config.model }
}
if ( request . route === undefined ) {
throw new Error ( 'session-title-llm: no logged request route is available; configure provider and model together' )
}
return request . route
}
/** Stable language-aware system instruction shared by both provider plugins. */
function systemPrompt ( config : ResolvedSessionTitleLlmConfig ) : string {
return [
'Create a concise title for an AI coding-assistant session from the supplied human messages.' ,
2026-07-27 17:47:48 +08:00
'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.' ,
2026-07-21 01:53:24 +08:00
'Use the language of the messages.' ,
` Aim for about ${ config . targetWords } words in non-CJK languages or ${ config . targetCjkCharacters } CJK characters. ` ,
] . join ( '\n' )
}
/** Frame exact messages as JSON so user text cannot break structural delimiters. */
function frameMessages ( messages : readonly SessionTitleUserMessage [ ] ) : string {
return ` Generate the session title from this JSON array of human messages: \ n ${ JSON . stringify ( messages ) } `
}
/** Translate terminal finish reasons into an auxiliary-call failure. */
function finishError ( finish : FinishReason ) : Error | undefined {
switch ( finish . kind ) {
case 'stop' :
return undefined
case 'error' :
case 'aborted' : {
const error = new Error ( finish . failure . message ) as Error & { code? : string }
error . code = finish . failure . code
return error
}
case 'max-tokens' :
return new Error ( 'session-title-llm: title output reached maxOutputTokens' )
case 'tool-calls' :
return new Error ( 'session-title-llm: title model unexpectedly requested a tool' )
default :
return new Error ( ` session-title-llm: unsupported finish reason " ${ String ( ( finish as { kind? : unknown } ).kind)}" ` )
}
}
/**
* Generate one title through the shared auxiliary LLM call.
* @param ctx - context exposing the registered LLM service.
* @param config - validated model-provider policy.
* @param request - service-owned session, route, message snapshot, and cancellation.
* @param selectedMessages - exact provider-selected subset to frame and attribute.
2026-07-21 12:08:00 +08:00
* @param titleProvider - registered title-provider identity recorded with the request.
2026-07-21 01:53:24 +08:00
* @returns normalized non-empty title, exact source seqs, and used model route.
*/
export async function generateSessionTitleWithLlm (
ctx : Context ,
config : ResolvedSessionTitleLlmConfig ,
request : SessionTitleProviderRequest ,
selectedMessages : readonly SessionTitleUserMessage [ ] ,
2026-07-21 12:08:00 +08:00
titleProvider : SessionTitleProviderId ,
2026-07-21 01:53:24 +08:00
) : Promise < SessionTitleProviderResult > {
request . signal . throwIfAborted ( )
if ( selectedMessages . length === 0 ) {
throw new Error ( 'session-title-llm: at least one source message is required' )
}
2026-07-21 15:00:15 +08:00
const framedInput = frameMessages ( selectedMessages )
const inputBytes = Buffer . byteLength ( framedInput , 'utf8' )
2026-07-21 01:53:24 +08:00
if ( inputBytes > config . maxInputBytes ) {
throw new Error ( ` session-title-llm: input is ${ inputBytes } bytes, exceeding maxInputBytes ${ config . maxInputBytes } ` )
}
const route = resolveRoute ( config , request )
2026-07-28 13:55:59 +08:00
const messages : Message [ ] = [ createUserMessage ( {
2026-07-21 15:00:15 +08:00
content : [ { type : 'text' , text : framedInput } ] ,
2026-07-28 13:55:59 +08:00
source : { kind : 'plugin' , plugin : 'dsh-session-title-llm' } ,
} ) ]
2026-07-21 12:08:00 +08:00
const system = systemPrompt ( config )
2026-07-21 01:53:24 +08:00
using callDeadline = deadline ( request . signal , config . timeoutMs , SESSION_TITLE_TIMEOUT_CODE )
2026-07-21 12:08:00 +08:00
const options : GenerateOptions = deepFreeze ( {
2026-07-21 01:53:24 +08:00
provider : route.provider ,
model : route.model ,
2026-07-21 12:08:00 +08:00
messages ,
system ,
2026-07-21 01:53:24 +08:00
maxTokens : config.maxOutputTokens ,
sessionId : request.session.id ,
2026-07-23 18:20:17 +08:00
purpose : 'session-title' ,
2026-07-21 01:53:24 +08:00
signal : callDeadline.signal ,
2026-07-21 12:08:00 +08:00
} )
2026-07-28 14:41:51 +08:00
request . session . append ( 'session/title-llm-request' , {
2026-07-21 12:08:00 +08:00
titleProvider ,
messageSeqs : selectedMessages.map ( message = > message . seq ) ,
route ,
system ,
messages ,
maxTokens : config.maxOutputTokens ,
2026-07-28 14:41:51 +08:00
} )
2026-07-21 12:08:00 +08:00
callDeadline . signal . throwIfAborted ( )
2026-07-21 01:53:24 +08:00
const assembler = new BlockAssembler ( )
2026-07-21 15:00:15 +08:00
for await ( const chunk of ctx . llm . stream ( options ) ) {
callDeadline . signal . throwIfAborted ( )
assembler . push ( chunk )
}
callDeadline . signal . throwIfAborted ( )
2026-07-21 01:53:24 +08:00
const terminalError = finishError ( assembler . finish )
if ( terminalError !== undefined ) throw terminalError
2026-07-28 13:55:59 +08:00
const blocks = assembler . blocks ( )
2026-07-21 01:53:24 +08:00
if ( blocks . some ( block = > block . type === 'tool-call' ) ) {
throw new Error ( 'session-title-llm: title output must contain text only' )
}
const text = blocks
. filter ( ( block ) : block is Extract < ( typeof blocks ) [ number ] , { type : 'text' } > = > block . type === 'text' )
. map ( block = > block . text )
. join ( ' ' )
const title = normalizeSessionTitle ( text , Number . MAX_SAFE_INTEGER )
if ( title . length === 0 ) throw new Error ( 'session-title-llm: title model produced no text' )
return {
title ,
messageSeqs : selectedMessages.map ( message = > message . seq ) ,
model : route ,
}
}