refactor(session): fold the session family into packages/session/
git mv the 12 packages from session-persistence/, session-projection/, session-title/, and telemetry/ into one session/ group per the regrouping RFC; merge the four group READMEs into one bilingual triplet; rewrite the group segment in tsconfig references (intra-group references shorten to ../<pkg>), tsconfig.base.json paths/globs, knip.json keys, vitest include, gate scripts, and authored doc/note citations; regenerate module graph, doc graphs, catalogs, and the lockfile importer keys. No npm names change. Full unit suite: 8779 passed; the 18 reported failures reproduce as env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify timeouts under parallel load) — each passes in isolation with NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
294
packages/session/session-title-llm/src/index.ts
Normal file
294
packages/session/session-title-llm/src/index.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* 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'
|
||||
import { createUserMessage, BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
normalizeSessionTitle,
|
||||
SessionTitleProviderId,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionTitleAutomaticMode,
|
||||
SessionTitleModelProvenance,
|
||||
SessionTitleProviderRequest,
|
||||
SessionTitleProviderResult,
|
||||
SessionTitleUserMessage,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
|
||||
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)
|
||||
const titleProvider = SessionTitleProviderId(id)
|
||||
ctx.sessionTitle.register({
|
||||
id: titleProvider,
|
||||
automatic,
|
||||
async generate(request) {
|
||||
return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages), titleProvider)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 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.',
|
||||
'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.',
|
||||
'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.
|
||||
* @param titleProvider - registered title-provider identity recorded with the request.
|
||||
* @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[],
|
||||
titleProvider: SessionTitleProviderId,
|
||||
): Promise<SessionTitleProviderResult> {
|
||||
request.signal.throwIfAborted()
|
||||
if (selectedMessages.length === 0) {
|
||||
throw new Error('session-title-llm: at least one source message is required')
|
||||
}
|
||||
const framedInput = frameMessages(selectedMessages)
|
||||
const inputBytes = Buffer.byteLength(framedInput, 'utf8')
|
||||
if (inputBytes > config.maxInputBytes) {
|
||||
throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
|
||||
}
|
||||
const route = resolveRoute(config, request)
|
||||
const messages: Message[] = [createUserMessage({
|
||||
content: [{ type: 'text', text: framedInput }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-session-title-llm' },
|
||||
})]
|
||||
const system = systemPrompt(config)
|
||||
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
|
||||
const options: GenerateOptions = deepFreeze({
|
||||
provider: route.provider,
|
||||
model: route.model,
|
||||
messages,
|
||||
system,
|
||||
maxTokens: config.maxOutputTokens,
|
||||
sessionId: request.session.id,
|
||||
purpose: 'session-title',
|
||||
signal: callDeadline.signal,
|
||||
})
|
||||
request.session.append('session/title-llm-request', {
|
||||
titleProvider,
|
||||
messageSeqs: selectedMessages.map(message => message.seq),
|
||||
route,
|
||||
system,
|
||||
messages,
|
||||
maxTokens: config.maxOutputTokens,
|
||||
})
|
||||
callDeadline.signal.throwIfAborted()
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of ctx.llm.stream(options)) {
|
||||
callDeadline.signal.throwIfAborted()
|
||||
assembler.push(chunk)
|
||||
}
|
||||
callDeadline.signal.throwIfAborted()
|
||||
const terminalError = finishError(assembler.finish)
|
||||
if (terminalError !== undefined) throw terminalError
|
||||
const blocks = assembler.blocks()
|
||||
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,
|
||||
}
|
||||
}
|
||||
30
packages/session/session-title-llm/src/invariant.ts
Normal file
30
packages/session/session-title-llm/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-llm`.
|
||||
* @module @deepseek-ai/dsh-session-title-llm/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-llm'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-title-llm-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this stateless helper validates and freezes each auxiliary request before
|
||||
* dispatch; deadline, stream, and provenance relationships are checked synchronously and by tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user