diff --git a/knip.json b/knip.json index 77964077f2..3dfcf5c29f 100644 --- a/knip.json +++ b/knip.json @@ -89,6 +89,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/session-title/session-title-first-message-llm": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/context/workspace-context": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 2c73226f3c..4635cecb48 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", + "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + session titles + system-prompt + tools + skills + agents + bounded retry + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", "version": "0.0.1", "private": true, "type": "module", @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-skill-local": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -52,6 +53,7 @@ "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index 9b897d1674..22795cc520 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../core/system-prompt" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index ae2767598a..90889bebc3 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,6 +39,7 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index 7153dae8bb..c859894c4e 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../session-persistence/session-persistence" } diff --git a/packages/session-title/README.md b/packages/session-title/README.md new file mode 100644 index 0000000000..d17acc2d60 --- /dev/null +++ b/packages/session-title/README.md @@ -0,0 +1,12 @@ +# session-title/ — log-backed session-title capability family + +Durable session-title state, one optional asynchronous provider seam, and two opt-in model-backed implementations. The built-in first-message fallback is part of the service, so every composition can title a session without an auxiliary model call. + +| Package | Role | ctx key | +|---|---|---| +| [`session-title/`](session-title/README.md) | Log fold, deterministic fallback, provider registry, and refresh API | `ctx.sessionTitle` | +| [`session-title-llm/`](session-title-llm/README.md) | Shared route, prompt, timeout, stream, and validation helper | — | +| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | Optional provider using the first eligible human message | registers on `ctx.sessionTitle` | +| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | Optional provider using every eligible human message | registers on `ctx.sessionTitle` | + +Only one provider may register at a time. The shared demo spine mounts the fallback service but leaves both model providers outside default composition, so deployments choose auxiliary cost and retitling cadence explicitly. diff --git a/packages/session-title/session-title-all-messages-llm/README.md b/packages/session-title/session-title-all-messages-llm/README.md new file mode 100644 index 0000000000..553064f3a2 --- /dev/null +++ b/packages/session-title/session-title-all-messages-llm/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-session-title-all-messages-llm + +Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-user-messages` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output. + +The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If aggregate input exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title. + +## Model Experience + +### All-messages title request + +#### What the model sees + +The title model receives the shared title instruction and a JSON array of all eligible human messages through the current revision, in log order with exact seqs. Seeded history is included. + +#### Token effect + +One auxiliary request may follow every new eligible prompt, bounded per request by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may add calls. The main agent request gains zero tokens. + +#### KV Cache effect + +No main-request invalidation. Auxiliary input grows or changes after each prompt, so provider-specific cache reuse ends at the first changed JSON token. + +## Known Limitations and Deferred Work + +- Input overflow retains the prior title; this provider has no summarization-of-summaries or retention policy for very long sessions. +- It treats all eligible human messages equally and offers no weighting, filtering, or manual-title precedence. diff --git a/packages/session-title/session-title-all-messages-llm/package.json b/packages/session-title/session-title-all-messages-llm/package.json new file mode 100644 index 0000000000..0d87033b38 --- /dev/null +++ b/packages/session-title/session-title-all-messages-llm/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-session-title-all-messages-llm", + "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", + "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" + }, + "./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-session-title-llm": "^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-session-title-llm": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session-title/session-title-all-messages-llm/src/index.ts b/packages/session-title/session-title-all-messages-llm/src/index.ts new file mode 100644 index 0000000000..3e174bf8c3 --- /dev/null +++ b/packages/session-title/session-title-all-messages-llm/src/index.ts @@ -0,0 +1,36 @@ +/** All-human-messages model provider for `ctx.sessionTitle`. */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { + registerSessionTitleLlmProvider, + SessionTitleLlmConfigFields, +} from '@deepseek-ai/dsh-session-title-llm' +import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm' + +export const name = 'session-title-all-messages-llm' +export const inject = ['sessionTitle', 'llm'] + +/** Required LLM policy; this plugin adds no defaults. */ +export type Config = SessionTitleLlmConfig +/** Loader schema shared with the first-message provider. */ +/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */ +export const Config: z = z.object({ + targetWords: SessionTitleLlmConfigFields.targetWords, + targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters, + maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes, + maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens, + timeoutMs: SessionTitleLlmConfigFields.timeoutMs, + provider: SessionTitleLlmConfigFields.provider, + model: SessionTitleLlmConfigFields.model, +}) +/* jscpd:ignore-end */ + +/** + * Register the all-user-messages model provider. + * @param ctx - context exposing session-title and LLM services. + * @param config - required route, target, byte, token, and timeout policy. + */ +export function apply(ctx: Context, config: Config): void { + registerSessionTitleLlmProvider(ctx, config, name, 'all-user-messages', messages => messages) +} diff --git a/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts new file mode 100644 index 0000000000..54dee0d0ce --- /dev/null +++ b/packages/session-title/session-title-all-messages-llm/tests/provider.spec.ts @@ -0,0 +1,73 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionTitleService from '@deepseek-ai/dsh-session-title' +import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-messages-llm' + +class RecordingAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + yield { type: 'text-delta', index: 0, text: 'All messages model title' } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const +const LLM_CONFIG = { + targetWords: 5, + targetCjkCharacters: 10, + maxInputBytes: 1_000, + maxOutputTokens: 32, + timeoutMs: 1_000, +} as const + +async function settle(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('all-messages LLM title provider', () => { + it('includes seeded history and the latest prompt while inheriting the logged request route', async () => { + const seeded = new Session(SessionId('seed-source')) + seeded.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const inherited = seeded.append('user/message', { + content: [{ type: 'text', text: 'inherited prompt' }], source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + seeded.append('session/title', { + title: 'Inherited fallback', messageSeqs: [inherited.seq], source: { kind: 'fallback' }, + }) + seeded.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, TITLE_CONFIG) + const adapter = new RecordingAdapter() + ctx.llm.registerAdapter(['current-route'], adapter) + await ctx.plugin(providerPlugin, LLM_CONFIG) + const session = ctx.sessions.create(SessionId('all-plugin'), { + seed: seeded.events, + meta: { parentSession: seeded.id, seedLength: seeded.seq }, + }) + session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + const latest = session.append('user/message', { + content: [{ type: 'text', text: 'latest prompt' }], source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + await settle() + session.append('request/header', { + header: { config: { provider: 'current-route', model: 'current-model' } }, reason: 'resume', + }) + await settle() + + expect(adapter.requests[0]).toMatchObject({ provider: 'current-route', model: 'current-model' }) + const content = adapter.requests[0]?.messages[0]?.content[0] + expect(content?.type === 'text' && content.text).toContain('inherited prompt') + expect(content?.type === 'text' && content.text).toContain('latest prompt') + expect(ctx.sessionTitle.get(session)).toMatchObject({ + messageSeqs: [inherited.seq, latest.seq], + }) + }) +}) diff --git a/packages/session-title/session-title-all-messages-llm/tsconfig.json b/packages/session-title/session-title-all-messages-llm/tsconfig.json new file mode 100644 index 0000000000..2e120efa2c --- /dev/null +++ b/packages/session-title/session-title-all-messages-llm/tsconfig.json @@ -0,0 +1,13 @@ +{ + "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": "../session-title" }, + { "path": "../session-title-llm" } + ] +} diff --git a/packages/session-title/session-title-first-message-llm/README.md b/packages/session-title/session-title-first-message-llm/README.md new file mode 100644 index 0000000000..2fb083d05c --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-session-title-first-message-llm + +Optional `ctx.sessionTitle` provider that summarizes the first eligible human message through `ctx.llm`. It registers the `first-message` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`. + +The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from the current logged main request, or set both to route title generation independently. + +## Model Experience + +### First-message title request + +#### What the model sees + +The title model receives the shared title instruction and a JSON array containing only the first eligible human message. Later prompts and inherited fork history do not trigger another automatic call. + +#### Token effect + +At most one automatic auxiliary request is made for a fresh session, bounded by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may make additional calls. The main agent request gains zero tokens. + +#### KV Cache effect + +No main-request invalidation. The auxiliary request uses the configured or logged route and has provider-specific cache behavior. + +## Known Limitations and Deferred Work + +- The first message alone may cease to represent a long-running session; use the all-messages provider when later prompts should retitle it. +- A fork keeps its inherited title and never runs this provider automatically, even when its seeded first message came from the parent. diff --git a/packages/session-title/session-title-first-message-llm/package.json b/packages/session-title/session-title-first-message-llm/package.json new file mode 100644 index 0000000000..7d3c4a70fa --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-session-title-first-message-llm", + "description": "First-message LLM provider plugin for DeepSeek Harness session titles", + "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" + }, + "./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-session-title-llm": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-session-title-llm": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session-title/session-title-first-message-llm/src/index.ts b/packages/session-title/session-title-first-message-llm/src/index.ts new file mode 100644 index 0000000000..c38c3aa291 --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/src/index.ts @@ -0,0 +1,40 @@ +/** First-human-message model provider for `ctx.sessionTitle`. */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { + registerSessionTitleLlmProvider, + SessionTitleLlmConfigFields, +} from '@deepseek-ai/dsh-session-title-llm' +import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm' + +export const name = 'session-title-first-message-llm' +export const inject = ['sessionTitle', 'llm'] + +/** Required LLM policy; this plugin adds no defaults. */ +export type Config = SessionTitleLlmConfig +/** Loader schema shared with the all-messages provider. */ +/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */ +export const Config: z = z.object({ + targetWords: SessionTitleLlmConfigFields.targetWords, + targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters, + maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes, + maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens, + timeoutMs: SessionTitleLlmConfigFields.timeoutMs, + provider: SessionTitleLlmConfigFields.provider, + model: SessionTitleLlmConfigFields.model, +}) +/* jscpd:ignore-end */ + +/** + * Register the first-message model provider. + * @param ctx - context exposing session-title and LLM services. + * @param config - required route, target, byte, token, and timeout policy. + */ +export function apply(ctx: Context, config: Config): void { + registerSessionTitleLlmProvider(ctx, config, name, 'first-message', (messages) => { + const first = messages[0] + if (first === undefined) throw new Error('first-message title provider requires one human message') + return [first] + }) +} diff --git a/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts b/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..014402a147 --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/tests/loader-composition.spec.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionTitleService from '@deepseek-ai/dsh-session-title' +import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm' + +let root: string | undefined +let context: Context | undefined + +class LoaderAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + yield { type: 'text-delta', index: 0, text: 'Loader composed title' } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +async function loadComposition(): Promise { + root = await mkdtemp(join(tmpdir(), 'dsh-title-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-llm'", + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-title'", + ' config:', + ' fallbackMaxWords: 5', + ' fallbackMaxBytes: 40', + ' maxTitleBytes: 80', + "- name: '@deepseek-ai/dsh-session-title-first-message-llm'", + ' config:', + ' targetWords: 5', + ' targetCjkCharacters: 10', + ' maxInputBytes: 1000', + ' maxOutputTokens: 32', + ' timeoutMs: 1000', + " provider: 'title-route'", + " model: 'title-model'", + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-llm', LlmService], + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-title', SessionTitleService], + ['@deepseek-ai/dsh-session-title-first-message-llm', providerPlugin], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + return context +} + +describe('session-title Loader composition', () => { + it('loads the service and one model provider with required deployment policy', async () => { + const ctx = await loadComposition() + const unloaded = [...ctx.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + + const adapter = new LoaderAdapter() + ctx.llm.registerAdapter(['title-route'], adapter) + const session = ctx.sessions.create(SessionId('loader-title')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const message = session.append('user/message', { + content: [{ type: 'text', text: 'Compose a title through Loader' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + await new Promise(resolve => setTimeout(resolve, 0)) + session.append('request/header', { + header: { config: { provider: 'main-route', model: 'main-model' } }, + reason: 'initial', + }) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(adapter.requests[0]).toMatchObject({ provider: 'title-route', model: 'title-model' }) + expect(ctx.sessionTitle.get(session)).toMatchObject({ + title: 'Loader composed title', + messageSeqs: [message.seq], + source: { + kind: 'provider', + provider: 'session-title-first-message-llm', + model: { provider: 'title-route', model: 'title-model' }, + }, + }) + }) +}) diff --git a/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts b/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts new file mode 100644 index 0000000000..30873e6e80 --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/tests/provider.e2e.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionTitleService from '@deepseek-ai/dsh-session-title' +import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-message-llm' + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-message title provider with real DeepSeek API', () => { + it('replaces the fallback with a short model title', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmService) + await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, { + fallbackMaxWords: 5, + fallbackMaxBytes: 40, + maxTitleBytes: 80, + }) + await ctx.plugin(FirstMessageTitleProvider, { + targetWords: 5, + targetCjkCharacters: 10, + maxInputBytes: 4_096, + maxOutputTokens: 64, + timeoutMs: 60_000, + provider: 'deepseek', + model: 'deepseek-v4-flash', + }) + const session = ctx.sessions.create(SessionId('real-title-provider')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const message = session.append('user/message', { + content: [{ type: 'text', text: 'Explain why append-only logs make session titles durable.' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + + const title = await ctx.sessionTitle.refresh(session) + + expect(title).toMatchObject({ + messageSeqs: [message.seq], + source: { + kind: 'provider', + provider: 'session-title-first-message-llm', + model: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + }, + }) + expect(title?.title.length).toBeGreaterThan(0) + expect(Buffer.byteLength(title?.title ?? '', 'utf8')).toBeLessThanOrEqual(80) + }) +}) diff --git a/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts new file mode 100644 index 0000000000..c42b24ad13 --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/tests/provider.spec.ts @@ -0,0 +1,86 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title' +import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm' + +class RecordingAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + yield { type: 'text-delta', index: 0, text: 'First-message model title' } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const +const LLM_CONFIG = { + targetWords: 5, + targetCjkCharacters: 10, + maxInputBytes: 1_000, + maxOutputTokens: 32, + timeoutMs: 1_000, + provider: 'title-route', + model: 'title-model', +} as const + +async function settle(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('first-message LLM title provider', () => { + it('rejects an impossible empty provider request at its own boundary', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, TITLE_CONFIG) + let registered: SessionTitleProvider | undefined + vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => { + registered = provider + return () => undefined + }) + providerPlugin.apply(ctx, LLM_CONFIG) + + await expect(registered!.generate({ + session: new Session(SessionId('empty-first-provider')), + messages: [], + signal: new AbortController().signal, + })).rejects.toThrow(/requires one human message/) + }) + + it('always selects only the first eligible human message, including explicit refresh', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, TITLE_CONFIG) + const adapter = new RecordingAdapter() + ctx.llm.registerAdapter(['title-route'], adapter) + await ctx.plugin(providerPlugin, LLM_CONFIG) + const session = ctx.sessions.create(SessionId('first-plugin')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const first = session.append('user/message', { + content: [{ type: 'text', text: 'first input' }], source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + await settle() + session.append('request/header', { + header: { config: { provider: 'main', model: 'main-model' } }, reason: 'initial', + }) + await settle() + session.append('user/message', { + content: [{ type: 'text', text: 'second input must be ignored' }], source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + + await ctx.sessionTitle.refresh(session) + + expect(adapter.requests).toHaveLength(2) + for (const options of adapter.requests) { + const content = options.messages[0]?.content[0] + expect(content?.type === 'text' && content.text).toContain('first input') + expect(content?.type === 'text' && content.text).not.toContain('second input must be ignored') + } + expect(ctx.sessionTitle.get(session)).toMatchObject({ messageSeqs: [first.seq] }) + }) +}) diff --git a/packages/session-title/session-title-first-message-llm/tsconfig.json b/packages/session-title/session-title-first-message-llm/tsconfig.json new file mode 100644 index 0000000000..2e120efa2c --- /dev/null +++ b/packages/session-title/session-title-first-message-llm/tsconfig.json @@ -0,0 +1,13 @@ +{ + "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": "../session-title" }, + { "path": "../session-title-llm" } + ] +} diff --git a/packages/session-title/session-title-llm/README.md b/packages/session-title/session-title-llm/README.md new file mode 100644 index 0000000000..441d2f9215 --- /dev/null +++ b/packages/session-title/session-title-llm/README.md @@ -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. diff --git a/packages/session-title/session-title-llm/package.json b/packages/session-title/session-title-llm/package.json new file mode 100644 index 0000000000..1d81378a00 --- /dev/null +++ b/packages/session-title/session-title-llm/package.json @@ -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" + } +} diff --git a/packages/session-title/session-title-llm/src/index.ts b/packages/session-title/session-title-llm/src/index.ts new file mode 100644 index 0000000000..f09732555a --- /dev/null +++ b/packages/session-title/session-title-llm/src/index.ts @@ -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 = z.object(SessionTitleLlmConfigFields) + +/** Complete configuration key set for direct construction validation. */ +const CONFIG_KEYS: ReadonlySet = 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 { + 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, + } +} diff --git a/packages/session-title/session-title-llm/tests/llm.spec.ts b/packages/session-title/session-title-llm/tests/llm.spec.ts new file mode 100644 index 0000000000..f5195ede79 --- /dev/null +++ b/packages/session-title/session-title-llm/tests/llm.spec.ts @@ -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 { + this.requests.push(options) + yield * this.script + } +} + +class CooperativeAdapter extends LlmAdapter { + override async * stream(options: GenerateOptions): AsyncIterable { + const signal = options.signal + if (signal === undefined) throw new Error('expected title request signal') + await new Promise((_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() + } + }) +}) diff --git a/packages/session-title/session-title-llm/tsconfig.json b/packages/session-title/session-title-llm/tsconfig.json new file mode 100644 index 0000000000..f5578e6f37 --- /dev/null +++ b/packages/session-title/session-title-llm/tsconfig.json @@ -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" } + ] +} diff --git a/packages/session-title/session-title/README.md b/packages/session-title/session-title/README.md new file mode 100644 index 0000000000..96607bb1e9 --- /dev/null +++ b/packages/session-title/session-title/README.md @@ -0,0 +1,52 @@ +# @deepseek-ai/dsh-session-title + +Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp. + +Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input. + +## Service: `SessionTitleService` (ctx key: `sessionTitle`) + +- `get(session)` folds the latest accepted title from a live or replayed log. +- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject. +- `register(provider)` installs the sole optional provider and returns its Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls before another provider can register. + +Automatic work never delays the main agent response. A provider starts after the matching `request/header` records the main request's exact route; its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. + +Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt. + +## Configuration + +All limits are required; the library supplies no defaults. + +| Key | Contract | +|---|---| +| `fallbackMaxWords` | Positive maximum whitespace-delimited words in the deterministic fallback. | +| `fallbackMaxBytes` | Positive maximum UTF-8 bytes in the fallback; must not exceed `maxTitleBytes`. | +| `maxTitleBytes` | Positive maximum UTF-8 bytes accepted from any source. | + +## Provider contract + +A provider supplies a branded stable id, automatic mode (`first-message` or `all-user-messages`), and `generate(request)`. The request carries the live session, all eligible messages through one fixed revision, the current logged main-request route when available, and cancellation. The result identifies a non-empty title, unique ordered source-message seqs from that request, and optional model provenance. The service normalizes and validates the result before it becomes durable. + +See the [session-title data structures](../../../docs/core-data-structures/session-title.md) and [implemented decision](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md). + +## Model Experience + +### Session title state + +#### What the model sees + +Nothing. `session/title` is log-only and never enters the session surface, `deriveMessages()`, system prompt, tool schemas, or request prefix. + +#### Token effect + +The fallback and accepted provider revisions add zero tokens to the main agent request. An optional provider's separate auxiliary request is documented by that provider package. + +#### KV Cache effect + +None for the main request; title events do not change its reconstructed content or cache key. + +## Known Limitations and Deferred Work + +- Manual rename, title deletion, generated-versus-user precedence, search, and list indexing are outside this service. +- The provider registry deliberately accepts at most one implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence. diff --git a/packages/session-title/session-title/package.json b/packages/session-title/session-title/package.json new file mode 100644 index 0000000000..1582fc2c62 --- /dev/null +++ b/packages/session-title/session-title/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-session-title", + "description": "Log-backed session title service and provider registry for the DeepSeek Harness", + "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-brand": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts new file mode 100644 index 0000000000..bae2f4df94 --- /dev/null +++ b/packages/session-title/session-title/src/index.ts @@ -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 +} + +/** + * 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 = 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() + + 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 { + 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): 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): 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 { + 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 + 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 + 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 + 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 { + 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 diff --git a/packages/session-title/session-title/src/normalize.ts b/packages/session-title/session-title/src/normalize.ts new file mode 100644 index 0000000000..23ab790be2 --- /dev/null +++ b/packages/session-title/session-title/src/normalize.ts @@ -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() +} diff --git a/packages/session-title/session-title/tests/persistence.spec.ts b/packages/session-title/session-title/tests/persistence.spec.ts new file mode 100644 index 0000000000..9981b0f87c --- /dev/null +++ b/packages/session-title/session-title/tests/persistence.spec.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title' + +const CONFIG = { + fallbackMaxWords: 5, + fallbackMaxBytes: 40, + maxTitleBytes: 80, +} as const + +const roots: string[] = [] + +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +async function appendPersistedTitle(ctx: Context, id: ReturnType): Promise { + const session = ctx.sessions.create(id) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + session.append('user/message', { + content: [{ type: 'text', text: 'Persist this session title' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + await new Promise(resolve => setTimeout(resolve, 0)) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await ctx.parallel('session/flush', session) +} + +async function expectPersistedTitle(ctx: Context, id: ReturnType): Promise { + const loaded = await ctx.sessionPersistence.load(id) + expect(foldSessionTitle(loaded.events)).toMatchObject({ + title: 'Persist this session title', + messageSeqs: [1], + source: { kind: 'fallback' }, + eventSeq: 2, + }) + expect(loaded.events.map(event => event.type)).toEqual([ + 'turn/start', + 'user/message', + 'session/title', + 'turn/end', + ]) +} + +describe('session title persistence round trips', () => { + it('round-trips through a remounted JSONL backend', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-title-jsonl-')) + roots.push(root) + const id = SessionId('title-jsonl') + const writer = new Context() + await writer.plugin(SessionStore) + await writer.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + await writer.plugin(SessionTitleService, CONFIG) + await appendPersistedTitle(writer, id) + await writer.fiber.dispose() + + const reader = new Context() + await reader.plugin(SessionStore) + await reader.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + await expectPersistedTitle(reader, id) + await reader.fiber.dispose() + }) + + it('round-trips through a remounted SQLite backend', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-title-sqlite-')) + roots.push(root) + const path = join(root, 'sessions.db') + const id = SessionId('title-sqlite') + const writer = new Context() + await writer.plugin(SessionStore) + await writer.plugin(SessionPersistenceSqlite, { path }) + await writer.plugin(SessionTitleService, CONFIG) + await appendPersistedTitle(writer, id) + await writer.fiber.dispose() + + const reader = new Context() + await reader.plugin(SessionStore) + await reader.plugin(SessionPersistenceSqlite, { path }) + await expectPersistedTitle(reader, id) + await reader.fiber.dispose() + }) +}) diff --git a/packages/session-title/session-title/tests/provider.spec.ts b/packages/session-title/session-title/tests/provider.spec.ts new file mode 100644 index 0000000000..cb298461da --- /dev/null +++ b/packages/session-title/session-title/tests/provider.spec.ts @@ -0,0 +1,289 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionTitleService, { + SessionTitleProviderId, + type SessionTitleProvider, + type SessionTitleProviderRequest, + type SessionTitleProviderResult, +} from '@deepseek-ai/dsh-session-title' + +const CONFIG = { + fallbackMaxWords: 5, + fallbackMaxBytes: 24, + maxTitleBytes: 24, +} as const + +function deferred(): { + promise: Promise + resolve(value: T): void + reject(error: unknown): void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + +async function settle(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +function appendHumanPrompt(session: ReturnType, text: string) { + return session.append('user/message', { + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) +} + +function appendRoute(session: ReturnType, reason: 'initial' | 'change' = 'initial'): void { + session.append('request/header', { + header: { config: { provider: 'main-route', model: 'chat-model' } }, + reason, + }) +} + +describe('SessionTitleService provider lifecycle', () => { + it('inherits title events across forks, skips first-message retitling, and lets all-messages update later', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const parent = ctx.sessions.create(SessionId('title-parent')) + parent.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const inheritedMessage = appendHumanPrompt(parent, 'Inherited title prompt') + await settle() + parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const child = ctx.sessions.fork(parent, undefined, SessionId('title-child')) + expect(ctx.sessionTitle.get(child)).toEqual(ctx.sessionTitle.get(parent)) + expect(child.events.find(event => event.type === 'session/title')) + .toEqual(parent.events.find(event => event.type === 'session/title')) + + const firstGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({ + title: 'Should not run', + messageSeqs: [request.messages[0]!.seq], + })) + const disposeFirst = ctx.sessionTitle.register({ + id: SessionTitleProviderId('fork-first'), + automatic: 'first-message', + generate: firstGenerate, + }) + child.append('turn/start', { + turn: 2, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const childMessage = appendHumanPrompt(child, 'Child follow-up prompt') + await settle() + appendRoute(child) + await settle() + child.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + expect(firstGenerate).not.toHaveBeenCalled() + disposeFirst() + + const allGenerate = vi.fn(async (request: SessionTitleProviderRequest) => ({ + title: 'Fork all prompts', + messageSeqs: request.messages.map(message => message.seq), + })) + ctx.sessionTitle.register({ + id: SessionTitleProviderId('fork-all'), + automatic: 'all-user-messages', + generate: allGenerate, + }) + child.append('turn/start', { + turn: 3, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const latestMessage = appendHumanPrompt(child, 'Retitle the fork now') + await settle() + appendRoute(child, 'change') + await settle() + child.append('turn/end', { turn: 3, reason: { kind: 'completed' } }) + + expect(allGenerate).toHaveBeenCalledOnce() + expect(ctx.sessionTitle.get(child)).toMatchObject({ + title: 'Fork all prompts', + messageSeqs: [inheritedMessage.seq, childMessage.seq, latestMessage.seq], + source: { kind: 'provider', provider: SessionTitleProviderId('fork-all') }, + }) + expect(ctx.sessionTitle.get(parent)?.title).toBe('Inherited title prompt') + }) + + it('runs a first-message provider once after the routed request and retries only through refresh', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const requests: SessionTitleProviderRequest[] = [] + const provider: SessionTitleProvider = { + id: SessionTitleProviderId('first-model'), + automatic: 'first-message', + async generate(request) { + requests.push(request) + return { + title: '\u001B[31m A model-generated title that is too long ', + messageSeqs: [request.messages[0]!.seq], + model: { provider: 'aux-route', model: 'title-model' }, + } + }, + } + ctx.sessionTitle.register(provider) + const session = ctx.sessions.create(SessionId('first-provider')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const first = appendHumanPrompt(session, 'Explain asynchronous title generation') + await settle() + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') + + appendRoute(session) + await settle() + + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + session, + messages: [{ seq: first.seq, text: 'Explain asynchronous title generation' }], + route: { provider: 'main-route', model: 'chat-model' }, + }) + expect(ctx.sessionTitle.get(session)).toMatchObject({ + title: 'A model-generated title', + messageSeqs: [first.seq], + source: { + kind: 'provider', + provider: SessionTitleProviderId('first-model'), + model: { provider: 'aux-route', model: 'title-model' }, + }, + }) + + const second = appendHumanPrompt(session, 'A later prompt') + appendRoute(session, 'change') + await settle() + expect(requests).toHaveLength(1) + + await ctx.sessionTitle.refresh(session) + expect(requests).toHaveLength(2) + expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq]) + }) + + it('rejects a second provider and aborts stale work when the winner is disposed', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const pending = deferred() + let observedSignal: AbortSignal | undefined + const first: SessionTitleProvider = { + id: SessionTitleProviderId('winner'), + automatic: 'all-user-messages', + generate(request) { + observedSignal = request.signal + return pending.promise + }, + } + const dispose = ctx.sessionTitle.register(first) + expect(() => ctx.sessionTitle.register({ + id: SessionTitleProviderId('duplicate'), + automatic: 'first-message', + generate: async () => ({ title: 'duplicate', messageSeqs: [0] }), + })).toThrow(/already registered/) + + const session = ctx.sessions.create(SessionId('dispose-provider')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const message = appendHumanPrompt(session, 'Generate this title') + await settle() + appendRoute(session) + await settle() + expect(observedSignal?.aborted).toBe(false) + + dispose() + expect(observedSignal?.aborted).toBe(true) + pending.resolve({ title: 'stale provider result', messageSeqs: [message.seq] }) + await settle() + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') + + const replacement: SessionTitleProvider = { + id: SessionTitleProviderId('replacement'), + automatic: 'first-message', + generate: async () => ({ title: 'replacement', messageSeqs: [message.seq] }), + } + const disposeReplacement = ctx.sessionTitle.register(replacement) + disposeReplacement() + }) + + it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const firstResult = deferred() + const requests: SessionTitleProviderRequest[] = [] + const provider: SessionTitleProvider = { + id: SessionTitleProviderId('all-model'), + automatic: 'all-user-messages', + generate(request) { + requests.push(request) + if (requests.length === 1) return firstResult.promise + return Promise.resolve({ + title: 'Newest complete title', + messageSeqs: request.messages.map(message => message.seq), + }) + }, + } + ctx.sessionTitle.register(provider) + const session = ctx.sessions.create(SessionId('supersede')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const first = appendHumanPrompt(session, 'First prompt') + await settle() + appendRoute(session) + await settle() + + const second = appendHumanPrompt(session, 'Second prompt') + expect(requests[0]?.signal.aborted).toBe(true) + appendRoute(session, 'change') + await settle() + expect(ctx.sessionTitle.get(session)).toMatchObject({ + title: 'Newest complete title', + messageSeqs: [first.seq, second.seq], + }) + + firstResult.resolve({ title: 'Old ignored result', messageSeqs: [first.seq] }) + await settle() + expect(ctx.sessionTitle.get(session)?.title).toBe('Newest complete title') + }) + + it('contains automatic failures but lets explicit refresh reject', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const provider: SessionTitleProvider = { + id: SessionTitleProviderId('failing'), + automatic: 'all-user-messages', + generate: async () => { throw new Error('title backend failed') }, + } + ctx.sessionTitle.register(provider) + const session = ctx.sessions.create(SessionId('failure')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + appendHumanPrompt(session, 'Keep a fallback') + await settle() + appendRoute(session) + await settle() + + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('automatic title generation failed')) + await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow('title backend failed') + warn.mockRestore() + }) +}) diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts new file mode 100644 index 0000000000..adf1358c97 --- /dev/null +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -0,0 +1,287 @@ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionTitleService, { + SessionTitleProviderId, + type Config, + type SessionTitleProvider, + type SessionTitleProviderRequest, + type SessionTitleProviderResult, +} from '@deepseek-ai/dsh-session-title' + +const CONFIG = { + fallbackMaxWords: 5, + fallbackMaxBytes: 40, + maxTitleBytes: 80, +} as const + +function deferred(): { promise: Promise; resolve(value: T): void } { + let resolve!: (value: T) => void + const promise = new Promise((accept) => { resolve = accept }) + return { promise, resolve } +} + +async function settle(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +async function setup(config: Config = CONFIG): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, config) + return ctx +} + +function startSession(ctx: Context, id: string): ReturnType { + const session = ctx.sessions.create(SessionId(id)) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + return session +} + +function appendPrompt(session: ReturnType, text: string) { + return session.append('user/message', { + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) +} + +describe('SessionTitleService configuration and refresh boundaries', () => { + it('requires explicit positive limits with a fallback cap no larger than the accepted-title cap', () => { + expect(() => new SessionTitleService(new Context(), undefined as never)) + .toThrow('configuration is required') + expect(() => new SessionTitleService(new Context(), null as never)) + .toThrow('configuration is required') + expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 0 })) + .toThrow(/fallbackMaxWords must be a positive integer/) + expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxWords: 1.5 })) + .toThrow(/fallbackMaxWords must be a positive integer/) + expect(() => new SessionTitleService(new Context(), { ...CONFIG, fallbackMaxBytes: 81 })) + .toThrow(/fallbackMaxBytes must not exceed maxTitleBytes/) + }) + + it('returns no title for empty input with or without a provider, and rejects detached or pre-aborted refreshes', async () => { + const fallbackOnly = await setup() + const empty = fallbackOnly.sessions.create(SessionId('empty-fallback')) + await expect(fallbackOnly.sessionTitle.refresh(empty)).resolves.toBeUndefined() + + const withProvider = await setup() + const generate = vi.fn(async (): Promise => ({ + title: 'unused', + messageSeqs: [0], + })) + withProvider.sessionTitle.register({ + id: SessionTitleProviderId('empty-provider'), + automatic: 'first-message', + generate, + }) + const providerEmpty = withProvider.sessions.create(SessionId('empty-provider')) + await expect(withProvider.sessionTitle.refresh(providerEmpty)).resolves.toBeUndefined() + expect(generate).not.toHaveBeenCalled() + + await expect(withProvider.sessionTitle.refresh(new Session(SessionId('detached')))) + .rejects.toThrow(/not live in this store/) + const controller = new AbortController() + controller.abort(new Error('already cancelled')) + await expect(withProvider.sessionTitle.refresh(providerEmpty, controller.signal)) + .rejects.toThrow('already cancelled') + }) + + it('passes an absent route and caller cancellation into explicit generation', async () => { + const ctx = await setup() + let observed: SessionTitleProviderRequest | undefined + ctx.sessionTitle.register({ + id: SessionTitleProviderId('explicit-no-route'), + automatic: 'first-message', + async generate(request) { + observed = request + return { title: 'Explicit title', messageSeqs: [request.messages[0]!.seq] } + }, + }) + const session = startSession(ctx, 'explicit-no-route') + appendPrompt(session, 'Refresh before any request header') + await settle() + const controller = new AbortController() + + await expect(ctx.sessionTitle.refresh(session, controller.signal)) + .resolves.toMatchObject({ title: 'Explicit title' }) + expect(observed?.route).toBeUndefined() + expect(observed?.signal.aborted).toBe(false) + }) + + it('propagates explicit cancellation and session disposal to active work', async () => { + const callerCtx = await setup() + const callerPending = deferred() + let callerSignal: AbortSignal | undefined + callerCtx.sessionTitle.register({ + id: SessionTitleProviderId('caller-cancel'), + automatic: 'first-message', + generate(request) { + callerSignal = request.signal + return callerPending.promise + }, + }) + const callerSession = startSession(callerCtx, 'caller-cancel') + const callerMessage = appendPrompt(callerSession, 'Cancel this refresh') + await settle() + const controller = new AbortController() + const refresh = callerCtx.sessionTitle.refresh(callerSession, controller.signal) + await settle() + controller.abort(new Error('caller cancelled')) + callerPending.resolve({ title: 'ignored', messageSeqs: [callerMessage.seq] }) + await expect(refresh).rejects.toThrow('caller cancelled') + expect(callerSignal?.aborted).toBe(true) + + const disposeCtx = await setup() + const disposePending = deferred() + let disposeSignal: AbortSignal | undefined + disposeCtx.sessionTitle.register({ + id: SessionTitleProviderId('session-dispose'), + automatic: 'first-message', + generate(request) { + disposeSignal = request.signal + return disposePending.promise + }, + }) + const disposed = disposeCtx.sessions.prepare(SessionId('session-dispose')) + const detach = disposeCtx.sessions.enter(disposed) + disposeCtx.sessions.announce(disposed) + disposed.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const disposedMessage = appendPrompt(disposed, 'Dispose this session') + await settle() + const disposedRefresh = disposeCtx.sessionTitle.refresh(disposed) + await settle() + detach() + disposePending.resolve({ title: 'ignored', messageSeqs: [disposedMessage.seq] }) + await expect(disposedRefresh).rejects.toThrow(/session disposed/) + expect(disposeSignal?.aborted).toBe(true) + }) + + it('warns when a detached session prevents queued fallback publication', async () => { + const ctx = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const session = ctx.sessions.prepare(SessionId('fallback-detach')) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + ctx.on('session/event', (subject, event) => { + if (subject === session && event.type === 'user/message') detach() + }) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + appendPrompt(session, 'Detach before the fallback microtask') + await settle() + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('fallback title update failed')) + expect(ctx.sessionTitle.get(session)).toBeUndefined() + }) + + it('leaves a title absent when the byte cap cannot hold the first code point', async () => { + const ctx = await setup({ fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 2 }) + const session = startSession(ctx, 'no-code-point') + appendPrompt(session, '😀') + await settle() + expect(ctx.sessionTitle.get(session)).toBeUndefined() + await expect(ctx.sessionTitle.refresh(session)).resolves.toBeUndefined() + }) +}) + +describe('SessionTitleService provider validation and stale scheduling', () => { + it('rejects malformed provider registrations before publishing them', async () => { + const ctx = await setup() + const generate = async (): Promise => ({ title: 'title', messageSeqs: [0] }) + expect(() => ctx.sessionTitle.register(null as never)).toThrow(/must be an object/) + expect(() => ctx.sessionTitle.register('provider' as never)).toThrow(/must be an object/) + expect(() => ctx.sessionTitle.register({ + id: 1, + automatic: 'first-message', + generate, + } as unknown as SessionTitleProvider)).toThrow(/id must be a non-empty string/) + expect(() => ctx.sessionTitle.register({ + id: SessionTitleProviderId(''), + automatic: 'first-message', + generate, + })).toThrow(/id must be a non-empty string/) + expect(() => ctx.sessionTitle.register({ + id: SessionTitleProviderId('bad-mode'), + automatic: 'sometimes' as never, + generate, + })).toThrow(/automatic mode is invalid/) + expect(() => ctx.sessionTitle.register({ + id: SessionTitleProviderId('missing-generate'), + automatic: 'first-message', + generate: undefined, + } as unknown as SessionTitleProvider)).toThrow(/requires generate/) + }) + + it('drops automatic work when its provider is disposed before the queued start', async () => { + const ctx = await setup() + const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ + title: 'too late', + messageSeqs: [request.messages[0]!.seq], + })) + const dispose = ctx.sessionTitle.register({ + id: SessionTitleProviderId('queued-dispose'), + automatic: 'all-user-messages', + generate, + }) + const session = startSession(ctx, 'queued-dispose') + appendPrompt(session, 'Queue provider work') + await settle() + session.append('request/header', { + header: { config: { provider: 'main', model: 'main' } }, + reason: 'initial', + }) + dispose() + await settle() + expect(generate).not.toHaveBeenCalled() + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') + }) + + it('rejects malformed provider results without replacing the fallback', async () => { + const ctx = await setup() + let result: unknown + ctx.sessionTitle.register({ + id: SessionTitleProviderId('invalid-results'), + automatic: 'first-message', + generate: async () => result as SessionTitleProviderResult, + }) + const session = startSession(ctx, 'invalid-results') + const first = appendPrompt(session, 'First source') + await settle() + const second = appendPrompt(session, 'Second source') + await settle() + + const cases: Array<{ value: unknown; error: RegExp }> = [ + { value: null, error: /invalid result/ }, + { value: 1, error: /invalid result/ }, + { value: { title: 1, messageSeqs: [first.seq] }, error: /title must be a string/ }, + { value: { title: '\u001B[31m', messageSeqs: [first.seq] }, error: /empty title/ }, + { value: { title: 'valid', messageSeqs: undefined }, error: /at least one source message/ }, + { value: { title: 'valid', messageSeqs: [] }, error: /at least one source message/ }, + { value: { title: 'valid', messageSeqs: ['not-a-seq'] }, error: /unique, ordered seqs/ }, + { value: { title: 'valid', messageSeqs: [1.5] }, error: /unique, ordered seqs/ }, + { value: { title: 'valid', messageSeqs: [-1] }, error: /unique, ordered seqs/ }, + { value: { title: 'valid', messageSeqs: [999] }, error: /unique, ordered seqs/ }, + { value: { title: 'valid', messageSeqs: [first.seq, first.seq] }, error: /unique, ordered seqs/ }, + { value: { title: 'valid', messageSeqs: [second.seq, first.seq] }, error: /unique, ordered seqs/ }, + { value: { title: 'valid', messageSeqs: [first.seq], model: null }, error: /model provenance/ }, + { value: { title: 'valid', messageSeqs: [first.seq], model: 'route' }, error: /model provenance/ }, + { value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 1, model: 'm' } }, error: /model provenance/ }, + { value: { title: 'valid', messageSeqs: [first.seq], model: { provider: '', model: 'm' } }, error: /model provenance/ }, + { value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: 1 } }, error: /model provenance/ }, + { value: { title: 'valid', messageSeqs: [first.seq], model: { provider: 'p', model: '' } }, error: /model provenance/ }, + ] + for (const item of cases) { + result = item.value + await expect(ctx.sessionTitle.refresh(session)).rejects.toThrow(item.error) + expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback') + } + }) +}) diff --git a/packages/session-title/session-title/tests/session-title.spec.ts b/packages/session-title/session-title/tests/session-title.spec.ts new file mode 100644 index 0000000000..d33ad791d2 --- /dev/null +++ b/packages/session-title/session-title/tests/session-title.spec.ts @@ -0,0 +1,145 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionTitleService, { + SessionTitleProviderId, + fallbackSessionTitle, + foldSessionTitle, + normalizeSessionTitle, + truncateTitleUtf8, +} from '@deepseek-ai/dsh-session-title' + +const CONFIG = { + fallbackMaxWords: 5, + fallbackMaxBytes: 40, + maxTitleBytes: 80, +} as const + +async function settleTitles(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('session title normalization', () => { + it('removes terminal controls, collapses whitespace, and applies word and UTF-8 byte caps', () => { + expect(normalizeSessionTitle('\u001B]0;stolen\u0007 Hello\t brave\nnew world ', 80)) + .toBe('Hello brave new world') + expect(fallbackSessionTitle('one two three four', 3, 80)).toBe('one two three') + expect(fallbackSessionTitle('你好世界', 5, 7)).toBe('你好') + expect(Buffer.byteLength(fallbackSessionTitle('😀😀', 5, 5), 'utf8')).toBe(4) + }) + + it('rejects non-positive and fractional public limits', () => { + expect(() => truncateTitleUtf8('title', 0)).toThrow(/maxBytes must be a positive integer/) + expect(() => fallbackSessionTitle('title', 1.5, 10)).toThrow(/maxWords must be a positive integer/) + }) +}) + +describe('SessionTitleService', () => { + it('logs and folds an immediate fallback after the first eligible human text message', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const session = ctx.sessions.create(SessionId('fresh')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const message = session.append('user/message', { + content: [{ type: 'text', text: ' Build\nlog-backed session titles please ' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + + await settleTitles() + + const titleEvent = session.events.findLast(event => event.type === 'session/title') + expect(titleEvent).toMatchObject({ + type: 'session/title', + seq: 2, + data: { + title: 'Build log-backed session titles please', + messageSeqs: [message.seq], + source: { kind: 'fallback' }, + }, + }) + expect(ctx.sessionTitle.get(session)).toEqual({ + title: 'Build log-backed session titles please', + messageSeqs: [message.seq], + source: { kind: 'fallback' }, + eventSeq: 2, + updatedAt: titleEvent?.time, + }) + expect(session.deriveMessages()).toHaveLength(1) + expect(session.surface.nodes).toEqual([message.seq]) + }) + + it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionTitleService, CONFIG) + const session = ctx.sessions.create(SessionId('eligibility')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + session.append('user/message', { + content: [{ type: 'text', text: 'plugin text' }], + source: { kind: 'plugin', plugin: 'seed' }, + }, { surfaceOp: 'append' }) + session.append('user/message', { + content: [{ type: 'reasoning', text: 'not visible text' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + session.append('user/message', { + content: [{ type: 'text', text: ' \n\t ' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + await settleTitles() + expect(ctx.sessionTitle.get(session)).toBeUndefined() + + const eligible = session.append('user/message', { + content: [{ type: 'text', text: 'first real prompt' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + await settleTitles() + const first = ctx.sessionTitle.get(session) + session.append('user/message', { + content: [{ type: 'text', text: 'later prompt' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + await settleTitles() + + expect(first?.messageSeqs).toEqual([eligible.seq]) + expect(ctx.sessionTitle.get(session)).toEqual(first) + expect(session.events.filter(event => event.type === 'session/title')).toHaveLength(1) + }) + + it('folds the latest title event during replay', () => { + const seed = new Session(SessionId('source')) + seed.append('session/title', { + title: 'Earlier', + messageSeqs: [1], + source: { kind: 'fallback' }, + }) + seed.append('session/title', { + title: 'Later', + messageSeqs: [1, 4], + source: { + kind: 'provider', + provider: SessionTitleProviderId('test-provider'), + model: { provider: 'mock', model: 'title-model' }, + }, + }) + + expect(foldSessionTitle(seed.events)).toEqual({ + title: 'Later', + messageSeqs: [1, 4], + source: { + kind: 'provider', + provider: SessionTitleProviderId('test-provider'), + model: { provider: 'mock', model: 'title-model' }, + }, + eventSeq: 1, + updatedAt: seed.events[1]?.time, + }) + }) +}) diff --git a/packages/session-title/session-title/tsconfig.json b/packages/session-title/session-title/tsconfig.json new file mode 100644 index 0000000000..8671e6c654 --- /dev/null +++ b/packages/session-title/session-title/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 94cdd10743..6c9f8d744c 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-permission": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index de4ccd3650..c6fb666a8b 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../core/agent" }, diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 5e7c8d060c..53c5574bce 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -27,6 +27,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -42,6 +43,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 0c9e89e9e5..d35cac98df 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-title/session-title" + }, { "path": "../../llm/llm" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0381eeda0a..4a0d83a6d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -768,6 +768,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill @@ -1518,6 +1521,109 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-title/session-title: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-sqlite + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-title/session-title-all-messages-llm: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../session-title + '@deepseek-ai/dsh-session-title-llm': + specifier: workspace:^ + version: link:../session-title-llm + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-title/session-title-first-message-llm: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../session-title + '@deepseek-ai/dsh-session-title-llm': + specifier: workspace:^ + version: link:../session-title-llm + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + + packages/session-title/session-title-llm: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../session-title + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -2104,6 +2210,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2254,6 +2363,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -2683,6 +2795,9 @@ importers: '@deepseek-ai/dsh-session-persistence-sqlite': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../packages/session-title/session-title '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index a9405ddd80..94406cb4f9 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/tsconfig.base.json b/tsconfig.base.json index 53f69b2cf5..1a79a5f784 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -62,6 +62,7 @@ "./packages/hooks/*/src", "./packages/session-persistence/*/src", "./packages/session-query/*/src", + "./packages/session-title/*/src", "./packages/sdk/*/src", "./packages/ui/*/src", "./packages/examples/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 0504806166..f708fad56d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -23,6 +23,10 @@ { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, + { "path": "./packages/session-title/session-title" }, + { "path": "./packages/session-title/session-title-llm" }, + { "path": "./packages/session-title/session-title-first-message-llm" }, + { "path": "./packages/session-title/session-title-all-messages-llm" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/context/time-context" }, diff --git a/tsconfig.json b/tsconfig.json index 1bf732aff2..5ea9ae6b1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,10 @@ { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, + { "path": "./packages/session-title/session-title" }, + { "path": "./packages/session-title/session-title-llm" }, + { "path": "./packages/session-title/session-title-first-message-llm" }, + { "path": "./packages/session-title/session-title-all-messages-llm" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/context/time-context" },