@shellraining/dsh-session-title-layout 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +23 -2
- package/README.zh.md +99 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +6 -0
- package/lib/format.d.ts +103 -0
- package/lib/format.js +203 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +72 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shellRaining
|
|
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
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-session-title-layout
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
会话标题版式提供者:`<type>:<摘要>`(如 `feat:重命名侧边栏会话标题`),分隔符、摘要语言、触发方式均可配。
|
|
4
|
+
|
|
5
|
+
在 profile 或 home 的 cordis.patch.yml 覆盖配置:
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
- id: session-title-layout
|
|
9
|
+
config:
|
|
10
|
+
preset: type-summary
|
|
11
|
+
separator: ":"
|
|
12
|
+
summaryLanguage: 中文
|
|
13
|
+
automatic: all-prompts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
dsh plugin --profile web add @shellraining/dsh-session-title-layout
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 许可
|
|
23
|
+
|
|
24
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# dsh-session-title-layout
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的会话标题 provider。它让模型只生成标题中有意义的部分,再按你选的版式拼起来,例如:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
feat: add dark mode to settings
|
|
9
|
+
feat:给设置页增加深色模式
|
|
10
|
+
feat/261022/add dark mode to settings
|
|
11
|
+
给设置页增加深色模式
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
标题最多由三段组成:
|
|
15
|
+
|
|
16
|
+
| 段 | 由谁生成 | 说明 |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `type` | 模型 | 从允许列表中选一个(`feat`、`fix` 等)。 |
|
|
19
|
+
| `date` | 本插件 | 本地格式化,模型看不到。 |
|
|
20
|
+
| `summary` | 模型 | 普通文本,不做 slug 化,语言可指定。 |
|
|
21
|
+
|
|
22
|
+
只会向模型要求你用到的那几段。
|
|
23
|
+
|
|
24
|
+
## 要求
|
|
25
|
+
|
|
26
|
+
- dsh `>=0.2.1-rc.2 <0.3.0`
|
|
27
|
+
- Node.js 20 及以上
|
|
28
|
+
|
|
29
|
+
dsh 同一时间只允许一个异步标题 provider。本包的 bundle 会禁用自带的 `session-title-llm`,不要同时启用别的 provider(比如 all-prompts 那个 bundle)。
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
在 Plugins 页面安装,或让 agent 调用 `plugin_manager` 的 `install_bundle`,目标为:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
git+https://github.com/shellRaining/dsh-session-title-layout.git
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
安装 bundle 会插入一行默认配置(`conventional` 版式)。要自定义,在 profile 的 `cordis.patch.yml` 里覆盖这一行:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
- id: session-title-layout
|
|
43
|
+
config:
|
|
44
|
+
preset: type-summary
|
|
45
|
+
separator: ":"
|
|
46
|
+
summaryLanguage: 中文
|
|
47
|
+
automatic: all-prompts
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
如果你是按路径加载插件而不是安装 bundle,就自己插入这一行,`name` 指向 `lib/index.js`。两种方式不要同时用,否则行 id 会冲突。
|
|
51
|
+
|
|
52
|
+
已安装包的代码更新后需要重启 dsh;配置变更按 profile 的 patch 重载模式生效。
|
|
53
|
+
|
|
54
|
+
## 配置
|
|
55
|
+
|
|
56
|
+
所有字段都可省略。
|
|
57
|
+
|
|
58
|
+
| 字段 | 默认值 | 含义 |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `preset` | `conventional` | `conventional` 为 `type/date/summary`,以 `/` 连接;`type-summary` 为 `type/summary`,以 `/` 连接;`summary` 只有摘要。 |
|
|
61
|
+
| `segments` | 取自预设 | 有序列表,元素为 `type`、`date`、`summary`。必须含 `summary`,不可重复。 |
|
|
62
|
+
| `separator` | 取自预设 | 段与段之间的文本,如 `": "` 或 `":"`。 |
|
|
63
|
+
| `types` | `feat, fix, refactor, docs, chore, style, test, perf, build, ci, revert` | `type` 的允许值,比较时不区分大小写。 |
|
|
64
|
+
| `dateFormat` | `YYMMDD` | 支持 `YYYY`、`YY`、`MM`、`DD`,使用本地时间。 |
|
|
65
|
+
| `dateSource` | `session-created` | `session-created` 使重新生成标题时日期保持不变;`now` 用生成时刻。 |
|
|
66
|
+
| `summaryMaxLength` | `40` | 摘要最多字符数,既写进 prompt 也对结果强制截断。 |
|
|
67
|
+
| `summaryLanguage` | 跟随用户语言 | 摘要语言,如 `中文`、`English`。 |
|
|
68
|
+
| `automatic` | `first-prompt` | `first-prompt` 只生成一次;`all-prompts` 随新消息重新生成。 |
|
|
69
|
+
| `provider`、`model` | 会话主路由 | 生成标题用的辅助模型,要么都设要么都不设。 |
|
|
70
|
+
| `maxInputChars` | `8000` | 发给模型的用户文本预算:第一条消息加能放下的最新几条,每条最多取 2000 字符。 |
|
|
71
|
+
| `maxOutputTokens` | `2048` | 标题请求的输出 token 上限。 |
|
|
72
|
+
| `timeoutMs` | `60000` | 标题请求的超时。 |
|
|
73
|
+
|
|
74
|
+
配置无效时插件在激活阶段报错,并指出字段名。
|
|
75
|
+
|
|
76
|
+
## 行为
|
|
77
|
+
|
|
78
|
+
- 模型回复不可用(没有 JSON、没有 summary,或 `type` 不在允许列表)时本次调用失败,会话保留当前标题,不会猜一个。
|
|
79
|
+
- 版式变更只影响之后生成的标题。已有标题以纯文本存于会话日志,不会被改写。
|
|
80
|
+
- 标题仍会经过 dsh 自身的规范化和字节上限。
|
|
81
|
+
|
|
82
|
+
## 隐私
|
|
83
|
+
|
|
84
|
+
标题生成会把你的提示词发给所配置的模型路由。使用 `all-prompts` 时,每条新消息都会触发一次。请把 `provider` 和 `model` 指向你放心发送提示词的路由。
|
|
85
|
+
|
|
86
|
+
## 开发
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
pnpm install
|
|
90
|
+
pnpm typecheck
|
|
91
|
+
pnpm test
|
|
92
|
+
pnpm build # 生成 lib/;lib/ 已提交,所以 git 安装无需构建
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
纯逻辑(配置、prompt、解析、渲染)在 `src/format.ts`,`src/index.ts` 只负责接到 dsh 上。
|
|
96
|
+
|
|
97
|
+
## 许可证
|
|
98
|
+
|
|
99
|
+
[MIT](LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# DSH allows one asynchronous session-title provider at a time, so the
|
|
2
|
+
# shipped model-backed provider is switched off in favour of this one.
|
|
3
|
+
- id: session-title-llm
|
|
4
|
+
disabled: true
|
|
5
|
+
|
|
6
|
+
- insert:
|
|
7
|
+
- id: session-title-layout
|
|
8
|
+
name: @shellraining/dsh-session-title-layout
|
|
9
|
+
config: {}
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect x="4" y="7" width="28" height="22" rx="4" fill="#2F3B52" />
|
|
3
|
+
<rect x="8" y="12" width="9" height="4" rx="2" fill="#7CE0B0" />
|
|
4
|
+
<rect x="19" y="12" width="9" height="4" rx="2" fill="#E8EEF7" fill-opacity="0.9" />
|
|
5
|
+
<rect x="8" y="20" width="20" height="3" rx="1.5" fill="#E8EEF7" fill-opacity="0.45" />
|
|
6
|
+
</svg>
|
package/lib/format.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure title-format logic: configuration, preset expansion, the model prompt,
|
|
3
|
+
* model-output parsing, and rendering. Nothing here touches the Harness, so it
|
|
4
|
+
* is unit-testable without a Cordis context.
|
|
5
|
+
*/
|
|
6
|
+
/** Parts a title can be assembled from. `summary` is the only mandatory one. */
|
|
7
|
+
export declare const SEGMENTS: readonly ["type", "date", "summary"];
|
|
8
|
+
export type Segment = (typeof SEGMENTS)[number];
|
|
9
|
+
/** Built-in layouts. A preset only supplies defaults for `segments` and `separator`. */
|
|
10
|
+
export declare const PRESETS: {
|
|
11
|
+
/** `feat/261022/add login flow` */
|
|
12
|
+
readonly conventional: {
|
|
13
|
+
readonly segments: readonly ["type", "date", "summary"];
|
|
14
|
+
readonly separator: "/";
|
|
15
|
+
};
|
|
16
|
+
/** `feat/add login flow` */
|
|
17
|
+
readonly "type-summary": {
|
|
18
|
+
readonly segments: readonly ["type", "summary"];
|
|
19
|
+
readonly separator: "/";
|
|
20
|
+
};
|
|
21
|
+
/** `add login flow` */
|
|
22
|
+
readonly summary: {
|
|
23
|
+
readonly segments: readonly ["summary"];
|
|
24
|
+
readonly separator: " ";
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export type PresetName = keyof typeof PRESETS;
|
|
28
|
+
export declare const DEFAULT_TYPES: readonly string[];
|
|
29
|
+
export type DateSource = "session-created" | "now";
|
|
30
|
+
export type AutomaticMode = "first-prompt" | "all-prompts";
|
|
31
|
+
/** User-facing plugin configuration; every field is optional. */
|
|
32
|
+
export interface Config {
|
|
33
|
+
/** Layout defaults. Default `conventional`. */
|
|
34
|
+
preset?: PresetName;
|
|
35
|
+
/** Explicit segment order; overrides the preset. Must contain `summary`. */
|
|
36
|
+
segments?: Segment[];
|
|
37
|
+
/** Text placed between segments; overrides the preset. */
|
|
38
|
+
separator?: string;
|
|
39
|
+
/** Allowed values of the `type` segment. Ignored when `type` is not a segment. */
|
|
40
|
+
types?: string[];
|
|
41
|
+
/** Tokens: YYYY YY MM DD. Default `YYMMDD`. Ignored when `date` is not a segment. */
|
|
42
|
+
dateFormat?: string;
|
|
43
|
+
/** Which instant `date` shows. `session-created` keeps it stable across regenerations. */
|
|
44
|
+
dateSource?: DateSource;
|
|
45
|
+
/** Soft cap, in characters, requested for the summary and enforced on the result. */
|
|
46
|
+
summaryMaxLength?: number;
|
|
47
|
+
/** Language the summary is written in, e.g. `中文` or `English`. Default: the language the user wrote in. */
|
|
48
|
+
summaryLanguage?: string;
|
|
49
|
+
/** When new prompts trigger regeneration. */
|
|
50
|
+
automatic?: AutomaticMode;
|
|
51
|
+
/** Auxiliary model route; both or neither. Default: the session's main route. */
|
|
52
|
+
provider?: string;
|
|
53
|
+
model?: string;
|
|
54
|
+
/** Budget, in characters, of user text sent to the model. */
|
|
55
|
+
maxInputChars?: number;
|
|
56
|
+
maxOutputTokens?: number;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
}
|
|
59
|
+
export interface ResolvedConfig {
|
|
60
|
+
readonly segments: readonly Segment[];
|
|
61
|
+
readonly separator: string;
|
|
62
|
+
readonly types: readonly string[];
|
|
63
|
+
readonly dateFormat: string;
|
|
64
|
+
readonly dateSource: DateSource;
|
|
65
|
+
readonly summaryMaxLength: number;
|
|
66
|
+
readonly summaryLanguage: string | undefined;
|
|
67
|
+
readonly automatic: AutomaticMode;
|
|
68
|
+
readonly route: {
|
|
69
|
+
readonly provider: string;
|
|
70
|
+
readonly model: string;
|
|
71
|
+
} | undefined;
|
|
72
|
+
readonly maxInputChars: number;
|
|
73
|
+
readonly maxOutputTokens: number;
|
|
74
|
+
readonly timeoutMs: number;
|
|
75
|
+
}
|
|
76
|
+
/** Validate user configuration and expand the preset. Throws on any invalid field. */
|
|
77
|
+
export declare function resolveConfig(config?: Config): ResolvedConfig;
|
|
78
|
+
/** System prompt asking only for the model-written segments (`type`, `summary`). */
|
|
79
|
+
export declare function buildSystemPrompt(config: ResolvedConfig): string;
|
|
80
|
+
/** The model-written parts of a title. */
|
|
81
|
+
export interface ModelParts {
|
|
82
|
+
readonly type: string | undefined;
|
|
83
|
+
readonly summary: string;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse the model reply. Throws when it is unusable, so the caller keeps the
|
|
87
|
+
* previous title instead of writing a guess.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parseModelOutput(raw: string, config: ResolvedConfig): ModelParts;
|
|
90
|
+
/** Format an epoch-millisecond instant in local time using YYYY, YY, MM, DD tokens. */
|
|
91
|
+
export declare function formatDate(epochMs: number, format: string): string;
|
|
92
|
+
/** Join the enabled segments in configured order. `date` is rendered by the caller's clock. */
|
|
93
|
+
export declare function renderTitle(parts: ModelParts, dateText: string, config: ResolvedConfig): string;
|
|
94
|
+
/**
|
|
95
|
+
* Choose which messages to send: the first one, then the newest that fit the
|
|
96
|
+
* character budget. Returned in log order.
|
|
97
|
+
*/
|
|
98
|
+
export declare function selectMessages<T extends {
|
|
99
|
+
readonly text: string;
|
|
100
|
+
}>(messages: readonly T[], budget: number, perMessageLimit?: number): {
|
|
101
|
+
message: T;
|
|
102
|
+
text: string;
|
|
103
|
+
}[];
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure title-format logic: configuration, preset expansion, the model prompt,
|
|
3
|
+
* model-output parsing, and rendering. Nothing here touches the Harness, so it
|
|
4
|
+
* is unit-testable without a Cordis context.
|
|
5
|
+
*/
|
|
6
|
+
/** Parts a title can be assembled from. `summary` is the only mandatory one. */
|
|
7
|
+
export const SEGMENTS = ["type", "date", "summary"];
|
|
8
|
+
/** Built-in layouts. A preset only supplies defaults for `segments` and `separator`. */
|
|
9
|
+
export const PRESETS = {
|
|
10
|
+
/** `feat/261022/add login flow` */
|
|
11
|
+
conventional: { segments: ["type", "date", "summary"], separator: "/" },
|
|
12
|
+
/** `feat/add login flow` */
|
|
13
|
+
"type-summary": { segments: ["type", "summary"], separator: "/" },
|
|
14
|
+
/** `add login flow` */
|
|
15
|
+
summary: { segments: ["summary"], separator: " " },
|
|
16
|
+
};
|
|
17
|
+
export const DEFAULT_TYPES = [
|
|
18
|
+
"feat",
|
|
19
|
+
"fix",
|
|
20
|
+
"refactor",
|
|
21
|
+
"docs",
|
|
22
|
+
"chore",
|
|
23
|
+
"style",
|
|
24
|
+
"test",
|
|
25
|
+
"perf",
|
|
26
|
+
"build",
|
|
27
|
+
"ci",
|
|
28
|
+
"revert",
|
|
29
|
+
];
|
|
30
|
+
function positiveInt(value, fallback, label) {
|
|
31
|
+
const resolved = value ?? fallback;
|
|
32
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1) {
|
|
33
|
+
throw new Error(`session-title-layout: ${label} must be a positive safe integer`);
|
|
34
|
+
}
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
|
37
|
+
/** Validate user configuration and expand the preset. Throws on any invalid field. */
|
|
38
|
+
export function resolveConfig(config = {}) {
|
|
39
|
+
const presetName = config.preset ?? "conventional";
|
|
40
|
+
const preset = Object.hasOwn(PRESETS, presetName) ? PRESETS[presetName] : undefined;
|
|
41
|
+
if (preset === undefined) {
|
|
42
|
+
throw new Error(`session-title-layout: unknown preset "${String(presetName)}"`);
|
|
43
|
+
}
|
|
44
|
+
const segments = config.segments ?? preset.segments;
|
|
45
|
+
if (segments.length === 0 || new Set(segments).size !== segments.length) {
|
|
46
|
+
throw new Error("session-title-layout: segments must be non-empty and unique");
|
|
47
|
+
}
|
|
48
|
+
for (const segment of segments) {
|
|
49
|
+
if (!SEGMENTS.includes(segment)) {
|
|
50
|
+
throw new Error(`session-title-layout: unknown segment "${String(segment)}"`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!segments.includes("summary")) {
|
|
54
|
+
throw new Error('session-title-layout: segments must include "summary"');
|
|
55
|
+
}
|
|
56
|
+
const types = (config.types ?? DEFAULT_TYPES).map((type) => type.trim().toLowerCase());
|
|
57
|
+
if (segments.includes("type") && (types.length === 0 || types.some((type) => type === ""))) {
|
|
58
|
+
throw new Error("session-title-layout: types must contain non-empty strings");
|
|
59
|
+
}
|
|
60
|
+
const automatic = config.automatic ?? "first-prompt";
|
|
61
|
+
if (automatic !== "first-prompt" && automatic !== "all-prompts") {
|
|
62
|
+
throw new Error('session-title-layout: automatic must be "first-prompt" or "all-prompts"');
|
|
63
|
+
}
|
|
64
|
+
const summaryLanguage = config.summaryLanguage?.trim();
|
|
65
|
+
if (config.summaryLanguage !== undefined && summaryLanguage === "") {
|
|
66
|
+
throw new Error("session-title-layout: summaryLanguage must not be empty");
|
|
67
|
+
}
|
|
68
|
+
const dateSource = config.dateSource ?? "session-created";
|
|
69
|
+
if (dateSource !== "session-created" && dateSource !== "now") {
|
|
70
|
+
throw new Error('session-title-layout: dateSource must be "session-created" or "now"');
|
|
71
|
+
}
|
|
72
|
+
if ((config.provider === undefined) !== (config.model === undefined)) {
|
|
73
|
+
throw new Error("session-title-layout: provider and model must be set together");
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
segments,
|
|
77
|
+
separator: config.separator ?? preset.separator,
|
|
78
|
+
types,
|
|
79
|
+
dateFormat: config.dateFormat ?? "YYMMDD",
|
|
80
|
+
dateSource,
|
|
81
|
+
summaryMaxLength: positiveInt(config.summaryMaxLength, 40, "summaryMaxLength"),
|
|
82
|
+
summaryLanguage,
|
|
83
|
+
automatic,
|
|
84
|
+
route: config.provider !== undefined && config.model !== undefined
|
|
85
|
+
? { provider: config.provider, model: config.model }
|
|
86
|
+
: undefined,
|
|
87
|
+
maxInputChars: positiveInt(config.maxInputChars, 8000, "maxInputChars"),
|
|
88
|
+
maxOutputTokens: positiveInt(config.maxOutputTokens, 2048, "maxOutputTokens"),
|
|
89
|
+
timeoutMs: positiveInt(config.timeoutMs, 60_000, "timeoutMs"),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** System prompt asking only for the model-written segments (`type`, `summary`). */
|
|
93
|
+
export function buildSystemPrompt(config) {
|
|
94
|
+
const wantsType = config.segments.includes("type");
|
|
95
|
+
const shape = wantsType ? '{"type": "...", "summary": "..."}' : '{"summary": "..."}';
|
|
96
|
+
const lines = [
|
|
97
|
+
"Generate a title for an AI coding-assistant session from the user's messages.",
|
|
98
|
+
`Reply with ONLY one JSON object of exactly this shape, with no code fence and no commentary: ${shape}`,
|
|
99
|
+
];
|
|
100
|
+
if (wantsType) {
|
|
101
|
+
lines.push(`- "type" is exactly one of: ${config.types.join(", ")}.`);
|
|
102
|
+
}
|
|
103
|
+
lines.push(`- "summary" is a short plain phrase, at most ${config.summaryMaxLength} characters, that lets a reader tell what the session is about.`, config.summaryLanguage === undefined
|
|
104
|
+
? '- Write "summary" in the language the user wrote in. No Markdown, no surrounding quotes, no trailing punctuation.'
|
|
105
|
+
: `- Write "summary" in ${config.summaryLanguage}, whatever language the user wrote in. No Markdown, no surrounding quotes, no trailing punctuation.`, "Do not reason or explain. Output the JSON object immediately.");
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
function truncateChars(input, max) {
|
|
109
|
+
const chars = Array.from(input);
|
|
110
|
+
return chars.length <= max ? input : chars.slice(0, max).join("").trimEnd();
|
|
111
|
+
}
|
|
112
|
+
function cleanSummary(input, max) {
|
|
113
|
+
const collapsed = input.replace(/\s+/gu, " ").trim();
|
|
114
|
+
const unquoted = collapsed.replace(/^["'`“”‘’]+|["'`“”‘’]+$/gu, "").trim();
|
|
115
|
+
return truncateChars(unquoted, max);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Parse the model reply. Throws when it is unusable, so the caller keeps the
|
|
119
|
+
* previous title instead of writing a guess.
|
|
120
|
+
*/
|
|
121
|
+
export function parseModelOutput(raw, config) {
|
|
122
|
+
const start = raw.indexOf("{");
|
|
123
|
+
const end = raw.lastIndexOf("}");
|
|
124
|
+
if (start === -1 || end <= start) {
|
|
125
|
+
throw new Error("session-title-layout: model reply contains no JSON object");
|
|
126
|
+
}
|
|
127
|
+
let value;
|
|
128
|
+
try {
|
|
129
|
+
value = JSON.parse(raw.slice(start, end + 1));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw new Error("session-title-layout: model reply is not valid JSON");
|
|
133
|
+
}
|
|
134
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
135
|
+
throw new Error("session-title-layout: model reply is not a JSON object");
|
|
136
|
+
}
|
|
137
|
+
const record = value;
|
|
138
|
+
const summary = typeof record["summary"] === "string"
|
|
139
|
+
? cleanSummary(record["summary"], config.summaryMaxLength)
|
|
140
|
+
: "";
|
|
141
|
+
if (summary === "") {
|
|
142
|
+
throw new Error("session-title-layout: model reply has no summary");
|
|
143
|
+
}
|
|
144
|
+
let type;
|
|
145
|
+
if (config.segments.includes("type")) {
|
|
146
|
+
const candidate = typeof record["type"] === "string" ? record["type"].trim().toLowerCase() : "";
|
|
147
|
+
if (!config.types.includes(candidate)) {
|
|
148
|
+
throw new Error(`session-title-layout: model reply type "${candidate}" is not allowed`);
|
|
149
|
+
}
|
|
150
|
+
type = candidate;
|
|
151
|
+
}
|
|
152
|
+
return { type, summary };
|
|
153
|
+
}
|
|
154
|
+
/** Format an epoch-millisecond instant in local time using YYYY, YY, MM, DD tokens. */
|
|
155
|
+
export function formatDate(epochMs, format) {
|
|
156
|
+
const date = new Date(epochMs);
|
|
157
|
+
const year = String(date.getFullYear());
|
|
158
|
+
const values = {
|
|
159
|
+
YYYY: year,
|
|
160
|
+
YY: year.slice(-2),
|
|
161
|
+
MM: String(date.getMonth() + 1).padStart(2, "0"),
|
|
162
|
+
DD: String(date.getDate()).padStart(2, "0"),
|
|
163
|
+
};
|
|
164
|
+
return format.replace(/YYYY|YY|MM|DD/gu, (token) => values[token] ?? token);
|
|
165
|
+
}
|
|
166
|
+
/** Join the enabled segments in configured order. `date` is rendered by the caller's clock. */
|
|
167
|
+
export function renderTitle(parts, dateText, config) {
|
|
168
|
+
const pieces = config.segments.map((segment) => {
|
|
169
|
+
switch (segment) {
|
|
170
|
+
case "type":
|
|
171
|
+
return parts.type ?? "";
|
|
172
|
+
case "date":
|
|
173
|
+
return dateText;
|
|
174
|
+
case "summary":
|
|
175
|
+
return parts.summary;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
return pieces.join(config.separator);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Choose which messages to send: the first one, then the newest that fit the
|
|
182
|
+
* character budget. Returned in log order.
|
|
183
|
+
*/
|
|
184
|
+
export function selectMessages(messages, budget, perMessageLimit = 2000) {
|
|
185
|
+
const clip = (message) => ({ message, text: message.text.slice(0, perMessageLimit) });
|
|
186
|
+
const first = messages[0];
|
|
187
|
+
if (first === undefined)
|
|
188
|
+
return [];
|
|
189
|
+
const chosen = [clip(first)];
|
|
190
|
+
let used = chosen[0]?.text.length ?? 0;
|
|
191
|
+
const tail = [];
|
|
192
|
+
for (let index = messages.length - 1; index > 0; index -= 1) {
|
|
193
|
+
const message = messages[index];
|
|
194
|
+
if (message === undefined)
|
|
195
|
+
continue;
|
|
196
|
+
const entry = clip(message);
|
|
197
|
+
if (used + entry.text.length > budget)
|
|
198
|
+
break;
|
|
199
|
+
used += entry.text.length;
|
|
200
|
+
tail.unshift(entry);
|
|
201
|
+
}
|
|
202
|
+
return [...chosen, ...tail];
|
|
203
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-title provider that asks the model for the meaningful parts of a
|
|
3
|
+
* title (an optional `type` and a plain-language `summary`) and assembles them
|
|
4
|
+
* with the segments, separator and date format the user configured.
|
|
5
|
+
*
|
|
6
|
+
* When the model reply is unusable the call throws, and the session service
|
|
7
|
+
* keeps whatever title it already has.
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
10
|
+
import { type Config } from "./format.ts";
|
|
11
|
+
export type { Config } from "./format.ts";
|
|
12
|
+
export declare const name = "session-title-layout";
|
|
13
|
+
export declare const inject: string[];
|
|
14
|
+
export declare function apply(ctx: Context, rawConfig?: Config): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { buildSystemPrompt, formatDate, parseModelOutput, renderTitle, resolveConfig, selectMessages, } from "./format.js";
|
|
2
|
+
export const name = "session-title-layout";
|
|
3
|
+
export const inject = ["sessionTitle", "llm"];
|
|
4
|
+
// The brand constructor is an identity function; a cast keeps the built output
|
|
5
|
+
// free of runtime imports from Harness packages.
|
|
6
|
+
const PROVIDER_ID = name;
|
|
7
|
+
async function collectText(stream) {
|
|
8
|
+
let text = "";
|
|
9
|
+
for await (const chunk of stream) {
|
|
10
|
+
if (chunk.type === "text-delta") {
|
|
11
|
+
text += chunk.text;
|
|
12
|
+
}
|
|
13
|
+
else if (chunk.type === "finish") {
|
|
14
|
+
const reason = chunk.reason;
|
|
15
|
+
if (reason.kind === "error" || reason.kind === "aborted") {
|
|
16
|
+
throw new Error(`${name}: title model call failed: ${reason.failure.message}`);
|
|
17
|
+
}
|
|
18
|
+
if (reason.kind !== "stop" && reason.kind !== "max-tokens") {
|
|
19
|
+
throw new Error(`${name}: unexpected finish reason "${reason.kind}"`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
function resolveRoute(config, request) {
|
|
26
|
+
const route = config.route ?? request.route;
|
|
27
|
+
if (route === undefined) {
|
|
28
|
+
throw new Error(`${name}: no model route; set provider and model in the plugin config`);
|
|
29
|
+
}
|
|
30
|
+
return { provider: route.provider, model: route.model };
|
|
31
|
+
}
|
|
32
|
+
async function generate(ctx, config, request) {
|
|
33
|
+
const route = resolveRoute(config, request);
|
|
34
|
+
const selected = selectMessages(request.messages, config.maxInputChars);
|
|
35
|
+
if (selected.length === 0) {
|
|
36
|
+
throw new Error(`${name}: no eligible messages`);
|
|
37
|
+
}
|
|
38
|
+
const input = {
|
|
39
|
+
role: "user",
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: "text",
|
|
43
|
+
text: JSON.stringify(selected.map((entry) => ({ text: entry.text }))),
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const stream = ctx.llm.stream({
|
|
48
|
+
provider: route.provider,
|
|
49
|
+
model: route.model,
|
|
50
|
+
messages: [input],
|
|
51
|
+
system: buildSystemPrompt(config),
|
|
52
|
+
maxTokens: config.maxOutputTokens,
|
|
53
|
+
sessionId: request.session.id,
|
|
54
|
+
purpose: "session-title",
|
|
55
|
+
signal: AbortSignal.any([request.signal, AbortSignal.timeout(config.timeoutMs)]),
|
|
56
|
+
});
|
|
57
|
+
const parts = parseModelOutput(await collectText(stream), config);
|
|
58
|
+
const dateInstant = config.dateSource === "now" ? Date.now() : request.session.header.createdAt;
|
|
59
|
+
return {
|
|
60
|
+
title: renderTitle(parts, formatDate(dateInstant, config.dateFormat), config),
|
|
61
|
+
messageSeqs: selected.map((entry) => entry.message.seq),
|
|
62
|
+
model: route,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function apply(ctx, rawConfig = {}) {
|
|
66
|
+
const config = resolveConfig(rawConfig);
|
|
67
|
+
ctx.effect(() => ctx.sessionTitle.register({
|
|
68
|
+
id: PROVIDER_ID,
|
|
69
|
+
automatic: config.automatic,
|
|
70
|
+
generate: (request) => generate(ctx, config, request),
|
|
71
|
+
}), `${name}: provider`);
|
|
72
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shellraining/dsh-session-title-layout",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DeepSeek Harness session-title provider with a configurable layout (type, date, summary), separator and summary language.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"session-title"
|
|
10
|
+
],
|
|
11
|
+
"author": "shellRaining",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/shellRaining/dsh-plugins.git",
|
|
16
|
+
"directory": "plugins/session-title-layout"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/shellRaining/dsh-plugins/tree/main/plugins/session-title-layout#readme",
|
|
19
|
+
"bugs": "https://github.com/shellRaining/dsh-plugins/issues",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./lib/index.js",
|
|
22
|
+
"types": "./lib/index.d.ts",
|
|
23
|
+
"icon": "./icon.svg",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./lib/index.d.ts",
|
|
27
|
+
"default": "./lib/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
30
|
+
"./locale/*.json": "./locale/*.json",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib",
|
|
35
|
+
"locale",
|
|
36
|
+
"icon.svg",
|
|
37
|
+
"cordis.patch.yml"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20",
|
|
41
|
+
"dsh": ">=0.2.1-rc.2 <0.3.0"
|
|
42
|
+
},
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": "./cordis.patch.yml"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@deepseek-ai/cordis": "~4.0.5"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
53
|
+
"@deepseek-ai/dsh-agent": "0.2.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-brand": "0.2.1-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-llm": "0.2.1-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-session": "0.2.1-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-session-projection": "0.2.1-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-session-title": "0.2.1-rc.2",
|
|
59
|
+
"@types/node": "^24.19.2",
|
|
60
|
+
"typescript": "5.9.3"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "tsc -p tsconfig.json",
|
|
67
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
68
|
+
"test": "node --test test/*.test.ts"
|
|
69
|
+
}
|
|
6
70
|
}
|