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 changed files with 2900 additions and 1 deletions

View File

@@ -0,0 +1,43 @@
# @deepseek-ai/dsh-session-title-llm
Shared implementation policy for model-backed session-title providers. It resolves the auxiliary route, frames exact selected human messages as JSON, applies a language-aware title instruction, enforces input and output budgets, composes timeout and caller cancellation, assembles the stream, and returns normalized text with exact source seqs and model provenance.
This package is a library, not a Cordis plugin. The provider plugins call `registerSessionTitleLlmProvider()` with their cadence and message selector; it validates shared config and delegates each revision to `generateSessionTitleWithLlm()`, so registration, route, prompt, cancellation, and validation behavior cannot drift between them.
## Route and failure contract
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. Input exceeding `maxInputBytes` rejects instead of being truncated. Timeout, cancellation, malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure.
## Configuration
Every field is required except the paired route override; there are no library defaults.
| Key | Contract |
|---|---|
| `targetWords` | Positive target word count for non-CJK titles. |
| `targetCjkCharacters` | Positive target character count for Chinese, Japanese, or Korean titles. |
| `maxInputBytes` | Positive aggregate UTF-8 byte ceiling across selected messages. |
| `maxOutputTokens` | Positive auxiliary generation token cap. |
| `timeoutMs` | Positive end-to-end deadline within the runtime timer limit. |
| `provider`, `model` | Optional explicit route; both or neither. |
## Model Experience
### Auxiliary title request
#### What the model sees
The title model receives a fixed system instruction to return one concise unadorned title in the input language, including the configured word and CJK-character targets. Its one user message contains a JSON array of the exact selected human messages and their seqs.
#### Token effect
The auxiliary request consumes tokens according to selected input size and `maxOutputTokens`. It is separate from the main agent request and does not add title text or framing to agent history.
#### KV Cache effect
No main-request invalidation. Auxiliary cache reuse is provider-specific; the fixed instruction is reusable while the JSON message array changes with each revision.
## Known Limitations and Deferred Work
- The helper accepts text output only and rejects tool calls; structured-output adapters and provider-specific prompt variants are not exposed.
- It enforces a byte ceiling for the whole selected input rather than clipping individual messages or applying a retention policy.

View File

@@ -0,0 +1,40 @@
{
"name": "@deepseek-ai/dsh-session-title-llm",
"description": "Shared LLM generation policy for DeepSeek Harness session-title providers",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,245 @@
/**
* 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 { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions } 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'
/** 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 total UTF-8 bytes across selected source-message text. */
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)
ctx.sessionTitle.register({
id: SessionTitleProviderId(id),
automatic,
async generate(request) {
return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages))
},
})
}
/** 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, with no quotes, prefix, explanation, Markdown, or terminal control codes.',
'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.
* @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[],
): Promise<SessionTitleProviderResult> {
request.signal.throwIfAborted()
if (selectedMessages.length === 0) {
throw new Error('session-title-llm: at least one source message is required')
}
const inputBytes = selectedMessages.reduce((total, message) => total + Buffer.byteLength(message.text, 'utf8'), 0)
if (inputBytes > config.maxInputBytes) {
throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
}
const route = resolveRoute(config, request)
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
const options: GenerateOptions = {
provider: route.provider,
model: route.model,
messages: [{ role: 'user', content: [{ type: 'text', text: frameMessages(selectedMessages) }] }],
system: systemPrompt(config),
maxTokens: config.maxOutputTokens,
sessionId: request.session.id,
signal: callDeadline.signal,
}
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
const terminalError = finishError(assembler.finish)
if (terminalError !== undefined) throw terminalError
const blocks = assembler.message().content
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,
}
}

View File

@@ -0,0 +1,266 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
generateSessionTitleWithLlm,
resolveSessionTitleLlmConfig,
SESSION_TITLE_TIMEOUT_CODE,
} from '@deepseek-ai/dsh-session-title-llm'
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private readonly script: readonly StreamChunk[]) {
super()
}
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
yield * this.script
}
}
class CooperativeAdapter extends LlmAdapter {
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const signal = options.signal
if (signal === undefined) throw new Error('expected title request signal')
await new Promise<never>((_resolve, reject) => {
const rejectAbort = (): void => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation
reject(signal.reason)
}
if (signal.aborted) {
rejectAbort()
return
}
signal.addEventListener('abort', rejectAbort, { once: true })
})
}
}
const SCRIPT: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: ' 五个字标题 ' },
{ type: 'finish', reason: { kind: 'stop' } },
]
const CONFIG = {
targetWords: 5,
targetCjkCharacters: 10,
maxInputBytes: 1_000,
maxOutputTokens: 32,
timeoutMs: 1_000,
} as const
function request(signal = new AbortController().signal): SessionTitleProviderRequest {
return {
session: new Session(SessionId('title-call')),
messages: [
{ seq: 2, text: 'first prompt' },
{ seq: 9, text: '第二个问题' },
],
route: { provider: 'current-route', model: 'current-model' },
signal,
}
}
function requestWithoutRoute(signal = new AbortController().signal): SessionTitleProviderRequest {
return {
session: new Session(SessionId('title-call-no-route')),
messages: [{ seq: 2, text: 'first prompt' }],
signal,
}
}
async function withScript(script: readonly StreamChunk[]): Promise<{
ctx: Context
adapter: RecordingAdapter
}> {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(script)
ctx.llm.registerAdapter(['current-route'], adapter)
return { ctx, adapter }
}
describe('generateSessionTitleWithLlm', () => {
it('uses the exact logged route, language targets, full framed input, and output token cap', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['current-route'], adapter)
const result = await generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
)
expect(result).toEqual({
title: '五个字标题',
messageSeqs: [2, 9],
model: { provider: 'current-route', model: 'current-model' },
})
expect(adapter.requests).toHaveLength(1)
const options = adapter.requests[0]!
expect(options).toMatchObject({
provider: 'current-route',
model: 'current-model',
maxTokens: 32,
sessionId: SessionId('title-call'),
})
expect(options.system).toContain('5 words')
expect(options.system).toContain('10 CJK characters')
const prompt = options.messages[0]?.content[0]
expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
})
it('uses paired explicit overrides and rejects an oversized input without calling the model', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['explicit-route'], adapter)
const config = resolveSessionTitleLlmConfig({
...CONFIG,
provider: 'explicit-route',
model: 'explicit-model',
maxInputBytes: 4,
})
await expect(generateSessionTitleWithLlm(ctx, config, request(), request().messages))
.rejects.toThrow(/input.*bytes.*maxInputBytes/i)
expect(adapter.requests).toEqual([])
const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 })
await generateSessionTitleWithLlm(ctx, withinLimit, request(), [request().messages[0]!])
expect(adapter.requests[0]).toMatchObject({
provider: 'explicit-route',
model: 'explicit-model',
})
})
it('requires every deployment limit and a complete optional route pair', () => {
expect(() => resolveSessionTitleLlmConfig(undefined as never)).toThrow(/configuration is required/)
expect(() => resolveSessionTitleLlmConfig(null as never)).toThrow(/configuration is required/)
expect(() => resolveSessionTitleLlmConfig('invalid' as never)).toThrow(/configuration is required/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, extra: true } as SessionTitleLlmConfig))
.toThrow(/unknown config key "extra"/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 0 }))
.toThrow(/targetWords.*positive integer/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 1.5 }))
.toThrow(/targetWords.*positive integer/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'only-provider' }))
.toThrow(/provider and model must be supplied together/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, model: 'only-model' }))
.toThrow(/provider and model must be supplied together/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: '', model: 'model' }))
.toThrow(/overrides must be non-empty strings/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: '' }))
.toThrow(/overrides must be non-empty strings/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 1, model: 'model' } as never))
.toThrow(/overrides must be non-empty strings/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: 1 } as never))
.toThrow(/overrides must be non-empty strings/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
.toThrow(/timeoutMs must not exceed/)
expect(() => resolveSessionTitleLlmConfig(CONFIG)).not.toThrow()
})
it('rejects an absent route, empty selection, and pre-aborted caller before model dispatch', async () => {
const { ctx, adapter } = await withScript(SCRIPT)
const config = resolveSessionTitleLlmConfig(CONFIG)
await expect(generateSessionTitleWithLlm(ctx, config, requestWithoutRoute(), requestWithoutRoute().messages))
.rejects.toThrow(/no logged request route/)
await expect(generateSessionTitleWithLlm(ctx, config, request(), []))
.rejects.toThrow(/at least one source message/)
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
await expect(generateSessionTitleWithLlm(ctx, config, request(controller.signal), request().messages))
.rejects.toThrow('caller stopped')
expect(adapter.requests).toEqual([])
})
it.each([
[{ kind: 'error', failure: { message: 'provider failed', code: 'SERVER' } }, 'provider failed', 'SERVER'],
[{ kind: 'aborted', failure: { message: 'provider aborted', code: 'ABORTED' } }, 'provider aborted', 'ABORTED'],
] satisfies Array<[FinishReason, string, string]>)('preserves %s terminal failure details', async (reason, message, code) => {
const { ctx } = await withScript([{ type: 'finish', reason }])
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
)).rejects.toMatchObject({ message, code })
})
it.each([
[{ kind: 'max-tokens' }, /reached maxOutputTokens/],
[{ kind: 'tool-calls' }, /unexpectedly requested a tool/],
[{ kind: 'future-finish' } as never, /unsupported finish reason "future-finish"/],
] satisfies Array<[FinishReason, RegExp]>)('rejects the terminal finish reason %s', async (reason, error) => {
const { ctx } = await withScript([{ type: 'finish', reason }])
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
)).rejects.toThrow(error)
})
it('rejects tool-call blocks and a successful response with no text', async () => {
const toolScript: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
{ type: 'finish', reason: { kind: 'stop' } },
]
const tool = await withScript(toolScript)
await expect(generateSessionTitleWithLlm(
tool.ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
)).rejects.toThrow(/output must contain text only/)
const reasoning = await withScript([
{ type: 'block-start', index: 0, blockType: 'reasoning' },
{ type: 'reasoning-delta', index: 0, text: 'no final title' },
{ type: 'finish', reason: { kind: 'stop' } },
])
await expect(generateSessionTitleWithLlm(
reasoning.ctx,
resolveSessionTitleLlmConfig(CONFIG),
request(),
request().messages,
)).rejects.toThrow(/produced no text/)
})
it('aborts a cooperative model stream at the configured deadline', async () => {
vi.useFakeTimers()
try {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
const pending = generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
request(),
request().messages,
)
const rejected = expect(pending).rejects.toMatchObject({
code: SESSION_TITLE_TIMEOUT_CODE,
timeoutMs: 10,
})
await vi.advanceTimersByTimeAsync(10)
await rejected
} finally {
vi.useRealTimers()
}
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../llm/llm" },
{ "path": "../../util/timeout" },
{ "path": "../session-title" }
]
}