dsh-session-title-gen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-09-06
9
+
10
+ ### Added
11
+
12
+ - 会话标题自动生成:监听会话首条人类消息,LLM 生成结构化标题(`[工作区名] 简要描述`),经核心 `session/title` 事件写入(重启保留)
13
+ - 与核心标题机制协作:核心 fallback/provider 非结构化标题触发重新生成;生成失败静默回退;用户手动标题不被覆盖
14
+ - 可配置:开关(`enabled`)、格式模板(`template`)、LLM 路由覆盖(`provider`/`model`)、字节/token/超时上限
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bsfeng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # dsh-session-title-gen
2
+
3
+ [![插件生态](https://img.shields.io/badge/插件生态-topic%20dsh-4d6bfe)](https://github.com/topics/dsh)
4
+
5
+ <div align="center">
6
+ <img alt="会话列表:新会话首条消息后,标题被自动改写为 LLM 生成的简短描述" src="./assets/session-list.png" width="720" />
7
+ </div>
8
+
9
+ **DSH 会话标题自动生成插件**:监听会话首条人类消息,用 LLM 生成类似 git commit 的**结构化标题**——先标明当前会话属于哪个工作区(项目),再写一段简要描述,如 `[my-dsh-plugins] 修复记忆页签崩溃`。会话列表一眼可辨归属与主题。
10
+
11
+ ## 功能
12
+
13
+ - **结构化标题自动生成**:首条人类消息后生成 `[工作区名] 简要描述`;工作区名 = 会话工作目录的项目根 basename,描述由 LLM 生成(system prompt 强制单行格式,语言跟随消息)。
14
+ - **重启后标题保留**:经 DSH 核心 `session/title` 事件(log-backed)写入,DSH 原生会话列表与 observability 面板均显示新标题。
15
+ - **失败静默回退**:LLM 不可用 / 超时 / 输出为空时不阻塞会话,核心标题机制照常工作。
16
+ - **与核心标题机制协作**:不注册标题 provider,直接 append `session/title` 事件覆盖;核心生成的非结构化标题(fallback / provider)会触发本插件重新生成;用户手动重命名的标题(source: user)不被覆盖。
17
+
18
+ ## 配置
19
+
20
+ | 配置项 | 默认值 | 说明 |
21
+ | ----------------------------------- | ----------------------------- | ----------------------------------------- |
22
+ | `enabled` | `true` | 总开关(`false` 完全关闭,不监听事件) |
23
+ | `template` | `[{workspace}] {description}` | 标题格式模板(两个占位符) |
24
+ | `provider` / `model` | 缺省 | LLM provider / model 覆盖 |
25
+ | `maxTitleBytes` | `80` | 标题 UTF-8 字节上限 |
26
+ | `maxInputBytes` / `maxOutputTokens` | `4096` / `64` | 送入 LLM 的消息字节上限 / 输出 token 上限 |
27
+ | `timeoutMs` | `30000` | LLM 调用超时(超时静默回退) |
28
+
29
+ 配置写在 profile 的 `cordis.patch.yml`:
30
+
31
+ ```yaml
32
+ - id: session-title-gen
33
+ config:
34
+ enabled: true
35
+ template: '[{workspace}] {description}'
36
+ ```
37
+
38
+ ## 安装
39
+
40
+ ```bash
41
+ # npm 安装(推荐)
42
+ dsh plugin --profile web add dsh-session-title-gen --trust-lockfile
43
+
44
+ # 本地 link(本仓库开发者)
45
+ git clone https://github.com/baosfeng/my-dsh-plugins.git
46
+ dsh plugin --profile web add link:<仓库路径>/plugins/dsh-session-title-gen
47
+ ```
48
+
49
+ ## 相关文档
50
+
51
+ → [会话标题自动生成概述](../../docs/会话标题自动生成/概述.md)
52
+
53
+ ## License
54
+
55
+ MIT
Binary file
@@ -0,0 +1,9 @@
1
+ # dsh-session-title-gen bundle patch: inserts the plugin row into the profile
2
+ # roster. The row mounts the server half (session/event listener + LLM title
3
+ # generation + session/title append). Install with:
4
+ #
5
+ # git clone https://github.com/baosfeng/my-dsh-plugins.git
6
+ # dsh plugin --profile web add link:<仓库路径>/plugins/dsh-session-title-gen
7
+ - insert:
8
+ - id: session-title-gen
9
+ name: 'dsh-session-title-gen'
@@ -0,0 +1,86 @@
1
+ /**
2
+ * dsh-session-title-gen — 单会话标题生成流程(issue #232 拆分:控制 apply 体量)。
3
+ *
4
+ * 纯逻辑 + 注入依赖:宿主 ctx 不再参与,便于测试与审查。
5
+ */
6
+ import { workspaceNameOf } from './workspace.js';
7
+ import { foldTitle, generateTitle, isStructuredTitle, sessionEvents } from './title.js';
8
+ /** 本插件写入标题的 source.provider 标识。 */
9
+ const PROVIDER_ID = 'dsh-session-title-gen';
10
+ /**
11
+ * Generate and append the structured title for one session.
12
+ *
13
+ * 任何失败都不 append(核心标题保留、不阻塞会话),但会经 `warn` 留下痕迹。
14
+ * @param input - { session, llm, warn, config }.
15
+ */
16
+ export async function generateSessionTitle({ session, llm, warn, config }) {
17
+ try {
18
+ const cwd = session.header?.cwd;
19
+ const workspace = await workspaceNameOf(cwd);
20
+ if (workspace === '') {
21
+ // 不接受"静默无前缀"(issue #232):取不到工作区名时把事实写进日志,
22
+ // 让"标题缺 [工作区] 前缀"可被发现、可排查,而不是无声降级。
23
+ warn(`session "${session.id}": no workspace name resolved (session header cwd=${typeof cwd === 'string' ? JSON.stringify(cwd) : String(cwd)}); the generated title carries no [workspace] prefix`);
24
+ }
25
+ const messages = collectMessages(session);
26
+ if (messages.length === 0)
27
+ return;
28
+ const route = routeOf(session);
29
+ const signal = AbortSignal.any([AbortSignal.timeout(config.timeoutMs)]);
30
+ const result = await generateTitle({ llm }, { session, workspace, messages, route, signal, config });
31
+ session.append('session/title', {
32
+ title: result.title,
33
+ messageSeqs: messages.map((message) => message.seq),
34
+ source: { kind: 'provider', provider: PROVIDER_ID, model: result.model },
35
+ });
36
+ }
37
+ catch (error) {
38
+ warn(`session "${session.id}": structured title generation failed: ${String(error)}`);
39
+ }
40
+ }
41
+ /** 是否跳过生成:已有我们生成的 / 用户手动 / 结构化标题。 */
42
+ export function shouldSkip(session) {
43
+ const current = foldTitle(session);
44
+ if (current === undefined)
45
+ return false;
46
+ if (current.source?.kind === 'user')
47
+ return true;
48
+ if (current.source?.provider === PROVIDER_ID)
49
+ return true;
50
+ return isStructuredTitle(current.title);
51
+ }
52
+ /** 是否为人类用户消息(过滤插件注入)。 */
53
+ export function isUserMessage(event) {
54
+ return event?.data?.source?.kind === 'user';
55
+ }
56
+ /** 收集会话中人类用户消息(文本 + seq)。 */
57
+ function collectMessages(session) {
58
+ const messages = [];
59
+ for (const event of sessionEvents(session)) {
60
+ if (!isUserMessage(event))
61
+ continue;
62
+ const text = textOf(event);
63
+ if (text === '')
64
+ continue;
65
+ messages.push({ seq: event.seq, text });
66
+ }
67
+ return messages;
68
+ }
69
+ /** 提取 user/message 事件的文本内容(text blocks 拼接)。 */
70
+ function textOf(event) {
71
+ const blocks = event.data?.content;
72
+ if (!Array.isArray(blocks))
73
+ return '';
74
+ return blocks
75
+ .filter((block) => block?.type === 'text')
76
+ .map((block) => block.text ?? '')
77
+ .join('\n')
78
+ .trim();
79
+ }
80
+ /** 会话请求路由(provider/model),无则 undefined。 */
81
+ function routeOf(session) {
82
+ const config = session.requestHeader?.()?.config;
83
+ if (config === null || typeof config !== 'object')
84
+ return undefined;
85
+ return { provider: config.provider, model: config.model };
86
+ }
package/lib/host.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * dsh-session-title-gen — 宿主契约适配(issue #232)。
3
+ *
4
+ * DSH 0.1.5-rc.1 下 profile 插件与宿主 root 存在三处可见性差异,任何一处踩错
5
+ * 都会让插件**静默失效**(不报错、标题退回核心机制、缺 `[工作区]` 前缀):
6
+ * 1. 事件实例隔离:`ctx.events !== ctx.root.events`,会话事件只在 root 派发;
7
+ * 2. 插件 ctx 在 apply 返回后 inactive,动态取服务抛
8
+ * `cannot get required service "llm" in inactive context`;
9
+ * 3. loader 会回收插件 fiber 上的 effect,root 监听器不能用 `ctx.effect` 托管。
10
+ *
11
+ * 本模块把这些适配收敛在一处,供 index.ts 保持薄 apply。
12
+ */
13
+ /**
14
+ * root 上的 session/event 监听器注册表(每个 root ctx 一份)。
15
+ *
16
+ * 以 root ctx 为键保存 disposer:插件重载时先移除上一个监听器,避免同一事件
17
+ * 被多个残留监听器重复处理(root 注册不随插件 fiber 卸载自动清理)。
18
+ */
19
+ const rootListeners = new WeakMap();
20
+ /**
21
+ * Register the session/event listener where the host actually dispatches it.
22
+ * @param listenCtx - the context to register on (root when available).
23
+ * @param handler - listener receiving (session, event).
24
+ * @returns the registered context (for diagnostics/tests).
25
+ */
26
+ export function listenSessionEvents(listenCtx, handler) {
27
+ rootListeners.get(listenCtx)?.();
28
+ // { global: true } 跳过 cordis 的 scope 过滤(@deepseek-ai/dsh-scope 的 carrier
29
+ // filter);缺了它,打有 scope tag 的监听器会被静默排除。
30
+ rootListeners.set(listenCtx, listenCtx.on('session/event', handler, { global: true }));
31
+ }
32
+ /**
33
+ * Resolve the llm service from a listener-safe source.
34
+ * @param primary - root context, whose services stay reachable for the app lifetime.
35
+ * @param fallback - instance captured while the plugin ctx was still active.
36
+ * @returns a usable llm service.
37
+ */
38
+ export function selectLlm(primary, fallback) {
39
+ try {
40
+ const fromRoot = primary.llm;
41
+ if (fromRoot !== undefined)
42
+ return fromRoot;
43
+ }
44
+ catch {
45
+ // root 不暴露该服务时回退到 apply 期捕获的实例
46
+ }
47
+ return fallback;
48
+ }
49
+ /**
50
+ * Build a logging function that can never break title generation.
51
+ * @param logger - plugin logger captured while the ctx was still active.
52
+ * @returns a warn sink that swallows its own failures.
53
+ */
54
+ export function createWarn(logger) {
55
+ return (message) => {
56
+ try {
57
+ logger?.warn?.(message);
58
+ }
59
+ catch {
60
+ // 日志失败不影响标题生成
61
+ }
62
+ };
63
+ }
package/lib/index.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * dsh-session-title-gen — structured session title generation (issue #160).
3
+ *
4
+ * 监听会话首条人类消息,用 LLM 生成类似 git commit 的结构化标题
5
+ * (先归属后描述,如 `[my-dsh-plugins] 修复 #143 记忆页签崩溃`),经核心
6
+ * `session/title` 事件写入(log-backed,重启保留)。
7
+ *
8
+ * 与核心 dsh-session-title 的协作:
9
+ * - 核心 bundle 已注册唯一 provider(session-title-first-prompt-llm),
10
+ * 本插件不注册 provider,直接 append session/title 事件覆盖;
11
+ * - 核心 fallback/provider 生成的非结构化标题会触发本插件重新生成
12
+ * (监听 session/title 事件兜底竞态);
13
+ * - 生成失败不 append(核心标题保留,不阻塞会话)。
14
+ *
15
+ * 宿主契约适配见 host.ts(issue #232:profile 插件的 events 实例隔离、
16
+ * Session.events 私有化、插件 ctx 在监听器执行时已 inactive),
17
+ * 单会话生成流程见 generate.ts。
18
+ */
19
+ import { generateSessionTitle, isUserMessage, shouldSkip } from './generate.js';
20
+ import { createWarn, listenSessionEvents, selectLlm } from './host.js';
21
+ import { DEFAULT_TEMPLATE } from './title.js';
22
+ export const name = 'dsh-session-title-gen';
23
+ export const inject = ['llm'];
24
+ /** 默认配置(可被 cordis.patch.yml config 覆盖)。 */
25
+ const DEFAULTS = {
26
+ enabled: true,
27
+ template: DEFAULT_TEMPLATE,
28
+ provider: undefined,
29
+ model: undefined,
30
+ maxTitleBytes: 80,
31
+ maxInputBytes: 4096,
32
+ maxOutputTokens: 64,
33
+ timeoutMs: 30000,
34
+ };
35
+ export function apply(ctx, config) {
36
+ const cfg = resolveConfig(config);
37
+ if (!cfg.enabled)
38
+ return;
39
+ const state = new Map();
40
+ // 注册点与服务都必须按宿主 root 规则取(详见 host.ts):会话事件只在 root 的
41
+ // events 实例上派发,而插件 ctx 在监听器执行时已 inactive,动态取服务会抛错。
42
+ const listenCtx = ctx.root ?? ctx;
43
+ const llm = selectLlm(listenCtx, ctx.llm);
44
+ const warn = createWarn(ctx.logger);
45
+ listenSessionEvents(listenCtx, onSessionEvent);
46
+ ctx.effect(() => () => {
47
+ state.clear();
48
+ }, 'dsh-session-title-gen: state lifecycle');
49
+ /** session/event 监听器:人类消息或标题事件触发结构化标题生成。 */
50
+ function onSessionEvent(session, event) {
51
+ const sess = session;
52
+ const evt = event;
53
+ if (evt?.type === 'user/message') {
54
+ if (!isUserMessage(evt))
55
+ return;
56
+ return maybeGenerate(sess);
57
+ }
58
+ if (evt?.type === 'session/title')
59
+ return maybeGenerate(sess);
60
+ return undefined;
61
+ }
62
+ /** 生成结构化标题(fire-and-forget;同一会话同时只跑一次)。 */
63
+ async function maybeGenerate(session) {
64
+ if (session === null || typeof session !== 'object' || typeof session.id !== 'string')
65
+ return;
66
+ if (shouldSkip(session))
67
+ return;
68
+ if (state.get(session.id)?.generating)
69
+ return;
70
+ const promise = generateSessionTitle({ session, llm, warn, config: cfg });
71
+ state.set(session.id, { generating: promise });
72
+ try {
73
+ await promise;
74
+ }
75
+ finally {
76
+ state.delete(session.id);
77
+ }
78
+ }
79
+ }
80
+ /** 配置解析:缺省值 + 类型护栏。 */
81
+ function resolveConfig(config) {
82
+ const candidate = config ?? {};
83
+ return {
84
+ enabled: candidate.enabled !== false,
85
+ template: nonEmptyString(candidate.template, DEFAULTS.template),
86
+ provider: nonEmptyString(candidate.provider, DEFAULTS.provider),
87
+ model: nonEmptyString(candidate.model, DEFAULTS.model),
88
+ maxTitleBytes: positiveInt(candidate.maxTitleBytes, DEFAULTS.maxTitleBytes),
89
+ maxInputBytes: positiveInt(candidate.maxInputBytes, DEFAULTS.maxInputBytes),
90
+ maxOutputTokens: positiveInt(candidate.maxOutputTokens, DEFAULTS.maxOutputTokens),
91
+ timeoutMs: positiveInt(candidate.timeoutMs, DEFAULTS.timeoutMs),
92
+ };
93
+ }
94
+ function nonEmptyString(value, fallback) {
95
+ return typeof value === 'string' && value !== '' ? value : fallback;
96
+ }
97
+ function positiveInt(value, fallback) {
98
+ return Number.isInteger(value) && value > 0 ? value : fallback;
99
+ }
package/lib/title.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * dsh-session-title-gen — title generation core.
3
+ *
4
+ * 纯函数 + LLM 生成:模板格式化、结构化判断、轻量流组装(只收集
5
+ * text-delta 与 finish reason,不依赖 @deepseek-ai/dsh-llm 的
6
+ * BlockAssembler)、标题折叠读取、UTF-8 截断。
7
+ */
8
+ /** 默认格式模板:先归属后描述(类似 git commit 的 type(scope))。 */
9
+ export const DEFAULT_TEMPLATE = '[{workspace}] {description}';
10
+ /**
11
+ * Format a title from the template, replacing {workspace} and {description}.
12
+ * @param template - format template with {workspace}/{description} placeholders.
13
+ * @param workspace - workspace name (may be '').
14
+ * @param description - LLM-generated description.
15
+ * @returns the formatted one-line title, or '' when description is empty.
16
+ */
17
+ export function formatTitle(template, workspace, description) {
18
+ const desc = String(description ?? '').trim();
19
+ if (desc === '')
20
+ return '';
21
+ const ws = String(workspace ?? '').trim();
22
+ let tpl = String(template ?? DEFAULT_TEMPLATE);
23
+ if (ws === '') {
24
+ // 无工作区时移除 `[{workspace}]` 占位(split/join 无正则回溯,避免
25
+ // 用户配置的 template 含大量空白时触发 ReDoS;`\s+` 规范化是线性匹配)
26
+ tpl = tpl.split('[{workspace}]').join(' ');
27
+ }
28
+ const title = tpl.replaceAll('{workspace}', ws).replaceAll('{description}', desc);
29
+ return title.replace(/\s+/g, ' ').trim();
30
+ }
31
+ /**
32
+ * Heuristic: whether a title already looks structured (bracket-prefixed,
33
+ * the default template shape `[workspace] description`).
34
+ * @param title - title text to inspect.
35
+ * @returns true when the title starts with a `[...]` prefix.
36
+ */
37
+ export function isStructuredTitle(title) {
38
+ return typeof title === 'string' && /^\[[^\]]+\]/.test(title);
39
+ }
40
+ /**
41
+ * Lightweight stream assembly: collect text-delta text and the finish
42
+ * reason kind from an llm.stream async iterable.
43
+ * @param chunks - async iterable of stream chunks.
44
+ * @returns { text, finish } — joined text and finish kind ('stop'/'error'/…).
45
+ */
46
+ export async function collectStreamText(chunks) {
47
+ let text = '';
48
+ let finish = undefined;
49
+ let failure = undefined;
50
+ for await (const chunk of chunks) {
51
+ if (chunk === null || typeof chunk !== 'object')
52
+ continue;
53
+ if (chunk.type === 'text-delta')
54
+ text += chunk.text ?? '';
55
+ else if (chunk.type === 'finish') {
56
+ finish = chunk.reason?.kind;
57
+ failure = chunk.reason?.failure;
58
+ }
59
+ }
60
+ return { text, finish, failure };
61
+ }
62
+ /**
63
+ * Read a session's committed events.
64
+ *
65
+ * DSH 0.1.5-rc.1 起 `Session.events` 变为 private(宿主类型只剩
66
+ * `private eventsSnapshot`),公开读取入口是 `snapshotEvents()`;直接读
67
+ * `session.events` 得到 undefined,会让消息收集与标题折叠**静默**返回空
68
+ * (issue #232 实测:插件执行了却从不 append 标题)。这里优先用公开 API,
69
+ * 并为旧宿主保留 `events` 数组回退。
70
+ *
71
+ * @param session - live session (or mock) carrying either accessor.
72
+ * @returns committed events in log order, or an empty array.
73
+ */
74
+ export function sessionEvents(session) {
75
+ if (session === null || typeof session !== 'object')
76
+ return [];
77
+ const candidate = session;
78
+ if (typeof candidate.snapshotEvents === 'function') {
79
+ try {
80
+ const events = candidate.snapshotEvents();
81
+ if (Array.isArray(events))
82
+ return events;
83
+ }
84
+ catch {
85
+ // 公开 API 不可用时回退到 events 数组(旧宿主)
86
+ }
87
+ }
88
+ return Array.isArray(candidate.events) ? candidate.events : [];
89
+ }
90
+ /**
91
+ * Fold the latest session/title event data from a session log.
92
+ * @param session - live session (or mock) with committed events.
93
+ * @returns the latest title event data, or undefined.
94
+ */
95
+ export function foldTitle(session) {
96
+ const events = sessionEvents(session);
97
+ for (let i = events.length - 1; i >= 0; i--) {
98
+ const event = events[i];
99
+ if (event?.type === 'session/title')
100
+ return event.data;
101
+ }
102
+ return undefined;
103
+ }
104
+ /**
105
+ * Truncate a string to a UTF-8 byte budget without splitting a code point.
106
+ * @param input - title text.
107
+ * @param maxBytes - positive byte budget.
108
+ * @returns the longest leading code-point prefix within the budget.
109
+ */
110
+ function truncateUtf8(input, maxBytes) {
111
+ if (Buffer.byteLength(input, 'utf8') <= maxBytes)
112
+ return input;
113
+ let used = 0;
114
+ let output = '';
115
+ for (const character of input) {
116
+ const bytes = Buffer.byteLength(character, 'utf8');
117
+ if (used + bytes > maxBytes)
118
+ break;
119
+ output += character;
120
+ used += bytes;
121
+ }
122
+ return output;
123
+ }
124
+ /**
125
+ * Generate a structured title through the llm service.
126
+ * @param services - object exposing the llm service (a `DshContext` qualifies).
127
+ * @param options - { session, workspace, messages, route, signal, config }.
128
+ * @returns { title, model } — formatted title and the used model route.
129
+ * @throws when the LLM call fails, produces no text, or no route is available.
130
+ */
131
+ export async function generateTitle(services, { session, workspace, messages, route, signal, config }) {
132
+ const framed = frameMessages(messages);
133
+ assertInputSize(framed, config.maxInputBytes);
134
+ const resolved = resolveRoute(config, route);
135
+ const options = buildOptions({ session, framed, route: resolved, signal, config });
136
+ const { text, finish, failure } = await collectStreamText(services.llm.stream(options));
137
+ assertFinish(finish, failure);
138
+ const description = String(text ?? '')
139
+ .replace(/\s+/g, ' ')
140
+ .trim();
141
+ const title = formatTitle(config.template, workspace, description);
142
+ return { title: finalizeTitle(title, config.maxTitleBytes), model: resolved };
143
+ }
144
+ /** Frame exact messages as JSON so user text cannot break structural delimiters. */
145
+ function frameMessages(messages) {
146
+ return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`;
147
+ }
148
+ /** Reject oversized input before it reaches the model. */
149
+ function assertInputSize(framed, maxInputBytes) {
150
+ const inputBytes = Buffer.byteLength(framed, 'utf8');
151
+ if (inputBytes > maxInputBytes) {
152
+ throw new Error(`session-title-gen: input is ${inputBytes} bytes, exceeding maxInputBytes ${maxInputBytes}`);
153
+ }
154
+ }
155
+ /** Resolve the explicit route pair or the session request route. */
156
+ function resolveRoute(config, route) {
157
+ const provider = config.provider ?? route?.provider;
158
+ const model = config.model ?? route?.model;
159
+ if (provider === undefined || model === undefined) {
160
+ throw new Error('session-title-gen: no LLM route available; configure provider and model together');
161
+ }
162
+ return { provider, model };
163
+ }
164
+ /** Build the llm.stream options for one title request. */
165
+ function buildOptions({ session, framed, route, signal, config, }) {
166
+ return {
167
+ provider: route.provider,
168
+ model: route.model,
169
+ messages: [{ role: 'user', content: [{ type: 'text', text: framed }] }],
170
+ system: systemPrompt(),
171
+ maxTokens: config.maxOutputTokens,
172
+ sessionId: session.id,
173
+ purpose: 'session-title',
174
+ signal,
175
+ };
176
+ }
177
+ /** Translate a terminal error finish into a thrown failure. */
178
+ function assertFinish(finish, failure) {
179
+ if (finish === 'error') {
180
+ throw new Error(`session-title-gen: LLM call failed: ${failure?.message ?? 'unknown error'}`);
181
+ }
182
+ }
183
+ /** Normalize and truncate the formatted title; reject empty results. */
184
+ function finalizeTitle(title, maxBytes) {
185
+ if (title === '')
186
+ throw new Error('session-title-gen: title model produced an empty title');
187
+ const truncated = truncateUtf8(title, maxBytes);
188
+ if (truncated === '')
189
+ throw new Error('session-title-gen: title empty after truncation');
190
+ return truncated;
191
+ }
192
+ /** Stable system instruction: structured title, workspace first. */
193
+ function systemPrompt() {
194
+ return [
195
+ 'Create a concise title for an AI coding-assistant session from the supplied human messages.',
196
+ 'The title MUST start with the workspace name in square brackets, then a brief description, like a git commit subject: [workspace] description.',
197
+ 'Return only the title on one line, in plain text of natural language, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.',
198
+ 'Use the language of the messages.',
199
+ 'Aim for about 8 words in non-CJK languages or 16 CJK characters for the description part.',
200
+ ].join('\n');
201
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * dsh-session-title-gen — workspace-name resolution.
3
+ *
4
+ * 工作区名 = 会话 cwd 的项目根 basename:findProjectRoot 向上找最近
5
+ * .git 祖先(dsh-shared),取目录名作为归属标识(如 `[my-dsh-plugins]`)。
6
+ */
7
+ import { basename } from 'node:path';
8
+ import { findProjectRoot } from 'dsh-shared';
9
+ /**
10
+ * Resolve the workspace name for a session cwd.
11
+ * @param cwd - session working directory (absolute path), or empty.
12
+ * @returns the project-root basename, or '' when cwd is missing.
13
+ */
14
+ export async function workspaceNameOf(cwd) {
15
+ if (typeof cwd !== 'string' || cwd === '')
16
+ return '';
17
+ const root = await findProjectRoot(cwd);
18
+ return basename(root);
19
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "dsh-session-title-gen",
3
+ "version": "0.1.0",
4
+ "description": "DSH 会话标题自动生成插件:监听会话首条人类消息,用 LLM 生成类似 git commit 的结构化标题(先标明归属工作区,再写简要描述),经核心 session/title 事件写入(重启保留),失败自动回退核心标题机制。DSH web plugin: structured session titles like git commit messages (workspace first, then description), written through the core session/title event.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/baosfeng/my-dsh-plugins"
10
+ },
11
+ "engines": {
12
+ "node": ">=22"
13
+ },
14
+ "scripts": {
15
+ "build": "npx tsc -p tsconfig.json",
16
+ "typecheck": "npx tsc --noEmit -p tsconfig.json",
17
+ "test": "npx vitest run --coverage && cucumber-js test/features --import \"test/features/steps/*.mjs\""
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "default": "./lib/index.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "lib",
27
+ "cordis.patch.yml",
28
+ "README.md",
29
+ "CHANGELOG.md",
30
+ "LICENSE",
31
+ "assets"
32
+ ],
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ }
37
+ },
38
+ "peerDependencies": {
39
+ "cordis": "^4.0.0-rc.10",
40
+ "dsh-shared": "^0.1.4"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "cordis": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "license": "MIT"
48
+ }