dsh-session-title-summary 0.0.0-stage → 1.7.2
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 +661 -0
- package/backfill.js +159 -0
- package/client.js +937 -0
- package/cordis.patch.yml +92 -0
- package/icon.svg +6 -0
- package/index.js +372 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/monitor.js +74 -0
- package/package.json +70 -4
- package/panel-api.js +40 -0
- package/pure.js +255 -0
- package/store.js +82 -0
- package/README.md +0 -3
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# ctx.sessionTitle.register() is single-slot ("the sole optional title
|
|
2
|
+
# provider"). The shipped experimental titles bundle registers
|
|
3
|
+
# optional-session-title-all-prompts and disables the built-in first-prompt
|
|
4
|
+
# row session-title-llm, so both must stay disabled for this provider to
|
|
5
|
+
# register. Idempotent overrides: harmless when a row is already disabled.
|
|
6
|
+
- id: optional-session-title-all-prompts
|
|
7
|
+
disabled: true
|
|
8
|
+
|
|
9
|
+
- id: session-title-llm
|
|
10
|
+
disabled: true
|
|
11
|
+
|
|
12
|
+
- insert:
|
|
13
|
+
- id: session-title-summary
|
|
14
|
+
name: 'dsh-session-title-summary'
|
|
15
|
+
config:
|
|
16
|
+
# 每条人类消息后重新分析(all-prompts)或仅首条(first-prompt)
|
|
17
|
+
cadence: all-prompts
|
|
18
|
+
# 标题格式:MMDD | 分类 | 主题;失败时回退 MMDD | 简短标题
|
|
19
|
+
datePrefix: true
|
|
20
|
+
timeZone: Asia/Shanghai
|
|
21
|
+
# 优先使用的内置分类
|
|
22
|
+
builtinCategories: [功能, 设计, 整改, 优化, 发布, 分析, 文档]
|
|
23
|
+
# 初始自定义分类(运行中模型引入的新分类会动态追加到
|
|
24
|
+
# ~/.dsh/session-title-summary/categories.json)
|
|
25
|
+
customCategories: [系统, 开发, 部署]
|
|
26
|
+
# 同义归并:模型说法 → 标准分类(先整词后包含匹配,按序生效)
|
|
27
|
+
synonyms:
|
|
28
|
+
- [系统部署, 发布]
|
|
29
|
+
- [部署工作, 发布]
|
|
30
|
+
- [上线, 发布]
|
|
31
|
+
- [代码开发, 功能]
|
|
32
|
+
- [问题修复, 整改]
|
|
33
|
+
- [bug修复, 整改]
|
|
34
|
+
- [bug 修复, 整改]
|
|
35
|
+
- [修bug, 整改]
|
|
36
|
+
- [性能提升, 优化]
|
|
37
|
+
- [性能优化, 优化]
|
|
38
|
+
- [方案调研, 分析]
|
|
39
|
+
- [页面设计, 设计]
|
|
40
|
+
- [接口说明整理, 文档]
|
|
41
|
+
- [开发类, 开发]
|
|
42
|
+
- [开发工作, 开发]
|
|
43
|
+
# 第一阶段本地快速判断:短首条消息命中关键词时跳过模型调用
|
|
44
|
+
localFastPath: true
|
|
45
|
+
fastPathKeywords:
|
|
46
|
+
- [部署, 发布]
|
|
47
|
+
- [上线, 发布]
|
|
48
|
+
- [发布, 发布]
|
|
49
|
+
- [修复, 整改]
|
|
50
|
+
- [fix, 整改]
|
|
51
|
+
- [bug, 整改]
|
|
52
|
+
- [报错, 整改]
|
|
53
|
+
- [崩溃, 整改]
|
|
54
|
+
- [优化, 优化]
|
|
55
|
+
- [重构, 优化]
|
|
56
|
+
- [提速, 优化]
|
|
57
|
+
- [设计, 设计]
|
|
58
|
+
- [UI, 设计]
|
|
59
|
+
- [界面, 设计]
|
|
60
|
+
- [原型, 设计]
|
|
61
|
+
- [文档, 文档]
|
|
62
|
+
- [README, 文档]
|
|
63
|
+
- [注释, 文档]
|
|
64
|
+
- [调研, 分析]
|
|
65
|
+
- [选型, 分析]
|
|
66
|
+
- [评估, 分析]
|
|
67
|
+
- [对比, 分析]
|
|
68
|
+
fastPathMaxChars: 60
|
|
69
|
+
# 置信度阈值:低于则回退两段式标题
|
|
70
|
+
minConfidence: 0.6
|
|
71
|
+
topicMaxChars: 24
|
|
72
|
+
# 长会话窗口:首 N 条 + 尾 M 条
|
|
73
|
+
windowFirst: 6
|
|
74
|
+
windowLast: 12
|
|
75
|
+
# none | suffix(suffix 在标题尾部追加「 · 模型id」)
|
|
76
|
+
modelTag: none
|
|
77
|
+
maxInputBytes: 8192
|
|
78
|
+
maxOutputTokens: 200
|
|
79
|
+
timeoutMs: 60000
|
|
80
|
+
# 历史会话回填:激活后一次性扫描持久化会话(新到旧,上限 50 个),
|
|
81
|
+
# 跳过:已存活会话 / 用户手动命名 / 已是合法格式 / 子代理会话 /
|
|
82
|
+
# 事件数 < backfillMinEvents;其余经同一标题管线重命名并写回日志
|
|
83
|
+
backfill: true
|
|
84
|
+
backfillMaxSessions: 50
|
|
85
|
+
backfillMinEvents: 3
|
|
86
|
+
backfillDelayMs: 800
|
|
87
|
+
backfillStartupDelayMs: 15000
|
|
88
|
+
backfillIncludeUserTitles: false
|
|
89
|
+
backfillIncludeSubagents: false
|
|
90
|
+
# 可选固定标题模型路由(provider 与 model 必须成对;缺省跟随会话模型)
|
|
91
|
+
# provider: deepseek
|
|
92
|
+
# model: deepseek-chat
|
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
|
2
|
+
<rect x="4" y="4" width="56" height="56" rx="14" fill="#1E66F5" fill-opacity="0.14"/>
|
|
3
|
+
<path d="M12 22a6 6 0 0 1 6-6h18l14 14-14 14H18a6 6 0 0 1-6-6V22Z" stroke="#1E66F5" stroke-width="4" stroke-linejoin="round"/>
|
|
4
|
+
<circle cx="23" cy="32" r="3.2" fill="#1E66F5"/>
|
|
5
|
+
<path d="M40 25.5 45.5 31 40 36.5M33 25.5 38.5 31 33 36.5" stroke="#1E66F5" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
6
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { SessionTitleProviderId, normalizeSessionTitle } from "@deepseek-ai/dsh-session-title";
|
|
3
|
+
import { SessionTitleLlmConfigFields, executeSessionTitleLlm, resolveSessionTitleLlmConfig } from "@deepseek-ai/dsh-session-title-llm";
|
|
4
|
+
import {
|
|
5
|
+
selectWindow, mmdd, composeTitle, composeFallbackTitle, parseTitleFormat, mapCategory,
|
|
6
|
+
fastPathCategory, cleanForSend, parseModelJson, interpretModelResult, cleanShortTitle
|
|
7
|
+
} from "./pure.js";
|
|
8
|
+
import { CategoryStore, defaultStorePath } from "./store.js";
|
|
9
|
+
import { backfillHistoricalSessions, sleep } from "./backfill.js";
|
|
10
|
+
import { RunMonitor } from "./monitor.js";
|
|
11
|
+
import { registerPanelApi } from "./panel-api.js";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
/** Summary-title provider for `ctx.sessionTitle` with categorized `MMDD | 分类 | 主题` titles. */
|
|
17
|
+
const name = "session-title-summary";
|
|
18
|
+
|
|
19
|
+
const inject = [
|
|
20
|
+
"sessionTitle",
|
|
21
|
+
"llm",
|
|
22
|
+
"sessions",
|
|
23
|
+
"sessionPersistence",
|
|
24
|
+
"connection"
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Default synonym table: model wording → canonical category (spec examples plus safe extensions). */
|
|
28
|
+
const DEFAULT_SYNONYMS = [
|
|
29
|
+
["系统部署", "发布"],
|
|
30
|
+
["部署工作", "发布"],
|
|
31
|
+
["上线", "发布"],
|
|
32
|
+
["代码开发", "功能"],
|
|
33
|
+
["问题修复", "整改"],
|
|
34
|
+
["bug修复", "整改"],
|
|
35
|
+
["bug 修复", "整改"],
|
|
36
|
+
["修bug", "整改"],
|
|
37
|
+
["性能提升", "优化"],
|
|
38
|
+
["性能优化", "优化"],
|
|
39
|
+
["方案调研", "分析"],
|
|
40
|
+
["页面设计", "设计"],
|
|
41
|
+
["接口说明整理", "文档"],
|
|
42
|
+
["开发类", "开发"],
|
|
43
|
+
["开发工作", "开发"]
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** Default local fast-path keywords: unambiguous verbs → builtin categories. */
|
|
47
|
+
const DEFAULT_FAST_PATH = [
|
|
48
|
+
["部署", "发布"],
|
|
49
|
+
["上线", "发布"],
|
|
50
|
+
["发布", "发布"],
|
|
51
|
+
["修复", "整改"],
|
|
52
|
+
["fix", "整改"],
|
|
53
|
+
["bug", "整改"],
|
|
54
|
+
["报错", "整改"],
|
|
55
|
+
["崩溃", "整改"],
|
|
56
|
+
["优化", "优化"],
|
|
57
|
+
["重构", "优化"],
|
|
58
|
+
["提速", "优化"],
|
|
59
|
+
["设计", "设计"],
|
|
60
|
+
["UI", "设计"],
|
|
61
|
+
["界面", "设计"],
|
|
62
|
+
["原型", "设计"],
|
|
63
|
+
["文档", "文档"],
|
|
64
|
+
["README", "文档"],
|
|
65
|
+
["注释", "文档"],
|
|
66
|
+
["调研", "分析"],
|
|
67
|
+
["选型", "分析"],
|
|
68
|
+
["评估", "分析"],
|
|
69
|
+
["对比", "分析"]
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const pairList = () => z.array(z.tuple([z.string(), z.string()]));
|
|
73
|
+
const positiveInt = () => z.number().step(1).min(1);
|
|
74
|
+
|
|
75
|
+
/** Loader schema: category policy, pipeline gates, and shared execution controls. */
|
|
76
|
+
const Config = z.object({
|
|
77
|
+
cadence: z.union([z.const("first-prompt"), z.const("all-prompts")]).default("all-prompts"),
|
|
78
|
+
datePrefix: z.boolean().default(true),
|
|
79
|
+
timeZone: z.string().default("Asia/Shanghai"),
|
|
80
|
+
builtinCategories: z.array(z.string()).default(["功能", "设计", "整改", "优化", "发布", "分析", "文档"]),
|
|
81
|
+
customCategories: z.array(z.string()).default(["系统", "开发", "部署"]),
|
|
82
|
+
synonyms: pairList().default(DEFAULT_SYNONYMS),
|
|
83
|
+
localFastPath: z.boolean().default(true),
|
|
84
|
+
fastPathKeywords: pairList().default(DEFAULT_FAST_PATH),
|
|
85
|
+
fastPathMaxChars: positiveInt().default(60),
|
|
86
|
+
minConfidence: z.number().min(0).max(1).default(0.6),
|
|
87
|
+
topicMaxChars: positiveInt().default(24),
|
|
88
|
+
windowFirst: positiveInt().default(6),
|
|
89
|
+
windowLast: z.number().step(1).min(0).default(12),
|
|
90
|
+
modelTag: z.union([z.const("none"), z.const("suffix")]).default("none"),
|
|
91
|
+
maxInputBytes: positiveInt().default(8192),
|
|
92
|
+
maxOutputTokens: positiveInt().default(200),
|
|
93
|
+
timeoutMs: positiveInt().default(60000),
|
|
94
|
+
backfill: z.boolean().default(true),
|
|
95
|
+
backfillMaxSessions: positiveInt().default(50),
|
|
96
|
+
backfillMinEvents: positiveInt().default(3),
|
|
97
|
+
backfillDelayMs: positiveInt().default(800),
|
|
98
|
+
backfillStartupDelayMs: positiveInt().default(15000),
|
|
99
|
+
backfillIncludeUserTitles: z.boolean().default(false),
|
|
100
|
+
backfillIncludeSubagents: z.boolean().default(false),
|
|
101
|
+
storePath: z.string(),
|
|
102
|
+
provider: SessionTitleLlmConfigFields.provider,
|
|
103
|
+
model: SessionTitleLlmConfigFields.model
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/** Validate one positive target. */
|
|
107
|
+
function assertTarget(label, value) {
|
|
108
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error(`session-title-summary: ${label} must be a positive integer`);
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Validate configuration and split own policy from shared execution controls. */
|
|
113
|
+
function resolveConfig(config) {
|
|
114
|
+
const {
|
|
115
|
+
cadence, datePrefix, timeZone, builtinCategories, customCategories, synonyms,
|
|
116
|
+
localFastPath, fastPathKeywords, fastPathMaxChars, minConfidence, topicMaxChars,
|
|
117
|
+
windowFirst, windowLast, modelTag, storePath,
|
|
118
|
+
backfill, backfillMaxSessions, backfillMinEvents, backfillDelayMs, backfillStartupDelayMs,
|
|
119
|
+
backfillIncludeUserTitles, backfillIncludeSubagents,
|
|
120
|
+
...execution
|
|
121
|
+
} = config;
|
|
122
|
+
if (cadence !== "first-prompt" && cadence !== "all-prompts") throw new Error("session-title-summary: cadence must be 'first-prompt' or 'all-prompts'");
|
|
123
|
+
if (modelTag !== "none" && modelTag !== "suffix") throw new Error("session-title-summary: modelTag must be 'none' or 'suffix'");
|
|
124
|
+
if (!Array.isArray(builtinCategories) || builtinCategories.length === 0) throw new Error("session-title-summary: builtinCategories must be a non-empty array");
|
|
125
|
+
return {
|
|
126
|
+
cadence, datePrefix, timeZone, builtinCategories, customCategories, synonyms,
|
|
127
|
+
localFastPath, fastPathKeywords, fastPathMaxChars, minConfidence, topicMaxChars,
|
|
128
|
+
modelTag, storePath,
|
|
129
|
+
backfill,
|
|
130
|
+
backfillMaxSessions: assertTarget("backfillMaxSessions", backfillMaxSessions),
|
|
131
|
+
backfillMinEvents: assertTarget("backfillMinEvents", backfillMinEvents),
|
|
132
|
+
backfillDelayMs: assertTarget("backfillDelayMs", backfillDelayMs),
|
|
133
|
+
backfillStartupDelayMs: assertTarget("backfillStartupDelayMs", backfillStartupDelayMs),
|
|
134
|
+
backfillIncludeUserTitles,
|
|
135
|
+
backfillIncludeSubagents,
|
|
136
|
+
execution: resolveSessionTitleLlmConfig(execution),
|
|
137
|
+
windowFirst: assertTarget("windowFirst", windowFirst),
|
|
138
|
+
windowLast: windowLast === 0 ? 0 : assertTarget("windowLast", windowLast)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Select the least supported reasoning effort: a title needs none. */
|
|
143
|
+
const selectReasoningEffort = (model) => model.reasoning?.efforts[0]?.id;
|
|
144
|
+
|
|
145
|
+
/** System instruction for the categorized JSON title request. */
|
|
146
|
+
function systemPrompt(config, customCategories, hasAnchor) {
|
|
147
|
+
return [
|
|
148
|
+
`You title AI coding-assistant sessions. Analyze the JSON object in the user message: {model, omittedMessages, messages:[{seq,text}], currentTitle?}.`,
|
|
149
|
+
`Existing categories, in priority order — prefer these when one fits reliably: ${[...config.builtinCategories, ...customCategories].join(", ")}.`,
|
|
150
|
+
"When no existing category fits, invent ONE short new category word (1-6 characters, Chinese or English).",
|
|
151
|
+
`"topic": the session's main subject in the language of the messages, about ${config.topicMaxChars} characters max, no quotes, no Markdown, no code.`,
|
|
152
|
+
`"confidence": 0 to 1, how sure you are.`,
|
|
153
|
+
...(hasAnchor ? [
|
|
154
|
+
`"currentTitle" is the existing title. If it still accurately describes the main topic or task, set "should_rename": false and repeat its category and topic exactly.`,
|
|
155
|
+
"Follow-up questions, extra details in the same topic, acknowledgements, and continue requests do not justify renaming; do not reword an adequate title.",
|
|
156
|
+
"Set \"should_rename\": true only when the messages materially change or expand the main topic so the existing title is no longer accurate."
|
|
157
|
+
] : [
|
|
158
|
+
'"should_rename": true for a new session.'
|
|
159
|
+
]),
|
|
160
|
+
'Return ONLY one compact single-line JSON object, nothing else: {"category":"…","topic":"…","confidence":0.00,"should_rename":true,"reason":"≤10 chars"}'
|
|
161
|
+
].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Frame the cleaned window as the model input. */
|
|
165
|
+
function frameInput(model, cleaned, omitted, anchorInfo) {
|
|
166
|
+
const payload = { model, omittedMessages: omitted, messages: cleaned };
|
|
167
|
+
if (anchorInfo !== null) payload.currentTitle = anchorInfo;
|
|
168
|
+
return `Categorize and title this session from the JSON object:\n${JSON.stringify(payload)}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Pick the fallback short-title text by priority: anchor topic, first message, last message. */
|
|
172
|
+
function fallbackShort(anchor, messages, maxChars) {
|
|
173
|
+
const candidates = [];
|
|
174
|
+
if (anchor !== undefined) candidates.push(parseTitleFormat(anchor)?.topic ?? anchor);
|
|
175
|
+
if (messages.length > 0) candidates.push(messages[0].text);
|
|
176
|
+
if (messages.length > 1) candidates.push(messages[messages.length - 1].text);
|
|
177
|
+
for (const candidate of candidates) {
|
|
178
|
+
const short = cleanShortTitle(candidate ?? "", maxChars);
|
|
179
|
+
if (short.length >= 2) return short;
|
|
180
|
+
}
|
|
181
|
+
return "未命名会话";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Register the categorized summary-title provider.
|
|
186
|
+
* @param ctx - context exposing session-title, LLM, and session services.
|
|
187
|
+
* @param config - category policy, pipeline gates, and shared execution controls.
|
|
188
|
+
*/
|
|
189
|
+
function apply(ctx, config) {
|
|
190
|
+
const resolved = resolveConfig(config);
|
|
191
|
+
const storePath = resolved.storePath ?? defaultStorePath();
|
|
192
|
+
const monitor = new RunMonitor(join(dirname(storePath), "runs.json"));
|
|
193
|
+
// User-selected fixed title model, persisted beside the stores and edited
|
|
194
|
+
// from the panel's config tab: set → every title generation uses this
|
|
195
|
+
// route; unset → each session follows its own (historical) model route.
|
|
196
|
+
const settingsPath = join(dirname(storePath), "settings.json");
|
|
197
|
+
const readOverride = () => {
|
|
198
|
+
try {
|
|
199
|
+
const raw = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
200
|
+
if (raw.version === 1 && typeof raw.provider === "string" && typeof raw.model === "string" && raw.provider !== "" && raw.model !== "") {
|
|
201
|
+
return { provider: raw.provider, model: raw.model };
|
|
202
|
+
}
|
|
203
|
+
} catch { /* absent or unreadable settings start unconfigured */ }
|
|
204
|
+
return undefined;
|
|
205
|
+
};
|
|
206
|
+
let routeOverride = readOverride();
|
|
207
|
+
const writeOverride = (provider, model) => {
|
|
208
|
+
routeOverride = provider && model ? { provider, model } : undefined;
|
|
209
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
210
|
+
const temp = `${settingsPath}.${randomUUID()}.tmp`;
|
|
211
|
+
writeFileSync(temp, `${JSON.stringify({ version: 1, ...(routeOverride ?? {}) })}\n`, { mode: 0o600 });
|
|
212
|
+
renameSync(temp, settingsPath);
|
|
213
|
+
};
|
|
214
|
+
const executions = new Map();
|
|
215
|
+
const trackExecution = (sessionId, patch) => {
|
|
216
|
+
if (monitor.state.current?.sessionId !== sessionId) return;
|
|
217
|
+
executions.set(sessionId, { ...executions.get(sessionId), ...patch });
|
|
218
|
+
monitor.update({ ...monitor.state.current, ...patch });
|
|
219
|
+
};
|
|
220
|
+
const store = new CategoryStore(storePath, resolved.customCategories);
|
|
221
|
+
const titleProvider = SessionTitleProviderId(name);
|
|
222
|
+
ctx.on("session/event", (session, event) => {
|
|
223
|
+
if (event.type !== "session/title-llm-request") return;
|
|
224
|
+
const route = event.data.route;
|
|
225
|
+
if (route) trackExecution(session.id, { method: "model", status: "calling-model", provider: route.provider, model: route.model });
|
|
226
|
+
});
|
|
227
|
+
ctx.sessionTitle.register({
|
|
228
|
+
id: titleProvider,
|
|
229
|
+
automatic: resolved.cadence,
|
|
230
|
+
async generate(request) {
|
|
231
|
+
const date = mmdd(request.session.header.createdAt, resolved.timeZone);
|
|
232
|
+
const knownCustom = [...new Set([...resolved.customCategories, ...store.list()])];
|
|
233
|
+
// A nonconforming existing title is NOT an anchor to keep: it is
|
|
234
|
+
// hidden from the model so generation always produces a fresh,
|
|
235
|
+
// conforming title (user rule: nonconforming titles must be renamed).
|
|
236
|
+
const rawTitle = request.currentTitle?.source.kind === "provider" ? request.currentTitle.title : undefined;
|
|
237
|
+
const anchor = rawTitle !== undefined && parseTitleFormat(rawTitle) !== null ? rawTitle : undefined;
|
|
238
|
+
const anchorInfo = anchor === undefined ? null : (() => {
|
|
239
|
+
const parsed = parseTitleFormat(anchor);
|
|
240
|
+
return parsed === null ? { topic: anchor } : parsed.category === null ? { topic: parsed.topic } : { category: parsed.category, topic: parsed.topic };
|
|
241
|
+
})();
|
|
242
|
+
const first = request.messages[0];
|
|
243
|
+
|
|
244
|
+
// Phase 1: local fast path on a short, keyword-clear first message, only for a first title.
|
|
245
|
+
let local = null;
|
|
246
|
+
if (resolved.localFastPath && anchor === undefined && first !== undefined) {
|
|
247
|
+
const head = first.text.split(/\r?\n/u)[0] ?? "";
|
|
248
|
+
if (head.trim().length > 0 && head.length <= resolved.fastPathMaxChars) {
|
|
249
|
+
const category = fastPathCategory(head, resolved.fastPathKeywords);
|
|
250
|
+
if (category !== null) local = { category, topic: cleanShortTitle(head, resolved.topicMaxChars) };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Phase 3 outcome.
|
|
255
|
+
let outcome;
|
|
256
|
+
let usedSeqs = request.messages.map((message) => message.seq);
|
|
257
|
+
|
|
258
|
+
if (local !== null && local.topic.length >= 2) {
|
|
259
|
+
trackExecution(request.session.id, { method: "local", status: "generating", provider: null, model: null });
|
|
260
|
+
outcome = { kind: "full", category: local.category, topic: local.topic };
|
|
261
|
+
} else if (request.route === undefined && resolved.execution.provider === undefined && routeOverride === undefined) {
|
|
262
|
+
// No logged model route and no fixed route configured: the LLM leg
|
|
263
|
+
// cannot run at all — go straight to the two-part fallback title.
|
|
264
|
+
trackExecution(request.session.id, { method: "fallback", status: "generating", provider: null, model: null, reason: "no-model-route" });
|
|
265
|
+
outcome = { kind: "fallback" };
|
|
266
|
+
} else {
|
|
267
|
+
// Phase 2: cleaned window through the configured fixed route, or
|
|
268
|
+
// the session's own model route when no override is configured.
|
|
269
|
+
const route = routeOverride ?? request.route;
|
|
270
|
+
const execution = routeOverride === undefined ? resolved.execution : { ...resolved.execution, provider: routeOverride.provider, model: routeOverride.model };
|
|
271
|
+
const model = { provider: route.provider, model: route.model };
|
|
272
|
+
const budget = Math.max(1024, resolved.execution.maxInputBytes - 256);
|
|
273
|
+
const { selected, omitted } = selectWindow(request.messages, budget, resolved.windowFirst, resolved.windowLast);
|
|
274
|
+
if (selected.length === 0) throw new Error("session-title-summary: no eligible human message is available");
|
|
275
|
+
const cleaned = cleanForSend(selected);
|
|
276
|
+
if (cleaned.length === 0) throw new Error("session-title-summary: no cleanable human message is available");
|
|
277
|
+
usedSeqs = cleaned.map((message) => message.seq);
|
|
278
|
+
trackExecution(request.session.id, { method: "model", status: "preparing-model" });
|
|
279
|
+
const response = await executeSessionTitleLlm(ctx, execution, request, titleProvider, {
|
|
280
|
+
system: systemPrompt(resolved, knownCustom, anchorInfo !== null),
|
|
281
|
+
input: frameInput(model, cleaned, omitted, anchorInfo),
|
|
282
|
+
messageSeqs: usedSeqs,
|
|
283
|
+
selectReasoningEffort
|
|
284
|
+
});
|
|
285
|
+
if (response.finish.kind === "tool-calls") throw new Error("session-title-summary: title output must contain text only");
|
|
286
|
+
if (response.finish.kind === "max-tokens") outcome = { kind: "fallback" };
|
|
287
|
+
else {
|
|
288
|
+
const text = response.blocks.filter((block) => block.type === "text").map((block) => block.text).join(" ");
|
|
289
|
+
outcome = interpretModelResult(parseModelJson(text), {
|
|
290
|
+
minConfidence: resolved.minConfidence,
|
|
291
|
+
hasAnchor: anchorInfo !== null,
|
|
292
|
+
topicMaxChars: resolved.topicMaxChars
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Phase 3: category mapping, validation, assembly.
|
|
298
|
+
let title;
|
|
299
|
+
if (outcome.kind === "keep" && anchor !== undefined) {
|
|
300
|
+
title = anchor;
|
|
301
|
+
const kept = parseTitleFormat(anchor)?.category;
|
|
302
|
+
if (kept !== null && kept !== undefined && !resolved.builtinCategories.includes(kept)) store.recordUse(kept);
|
|
303
|
+
} else if (outcome.kind === "full") {
|
|
304
|
+
const mapped = mapCategory(outcome.category, { builtin: resolved.builtinCategories, custom: knownCustom, synonyms: resolved.synonyms });
|
|
305
|
+
if (!mapped.matched) store.recordUse(mapped.category);
|
|
306
|
+
else if (!resolved.builtinCategories.includes(mapped.category)) store.recordUse(mapped.category, outcome.category === mapped.category ? null : outcome.category);
|
|
307
|
+
const safeTopic = cleanShortTitle(outcome.topic, resolved.topicMaxChars);
|
|
308
|
+
title = safeTopic.length < 2
|
|
309
|
+
? composeFallbackTitle(date, fallbackShort(anchor, request.messages, resolved.topicMaxChars))
|
|
310
|
+
: composeTitle(date, mapped.category, safeTopic);
|
|
311
|
+
} else {
|
|
312
|
+
title = composeFallbackTitle(date, fallbackShort(anchor, request.messages, resolved.topicMaxChars));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const normalized = normalizeSessionTitle(title, Number.MAX_SAFE_INTEGER);
|
|
316
|
+
if (normalized.length === 0) throw new Error("session-title-summary: composed title normalized to empty");
|
|
317
|
+
const effectiveRoute = routeOverride ?? request.route;
|
|
318
|
+
const finalTitle = resolved.modelTag === "suffix" && effectiveRoute !== undefined ? `${normalized} · ${effectiveRoute.model}` : normalized;
|
|
319
|
+
return { title: finalTitle, messageSeqs: usedSeqs, model: effectiveRoute };
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// One owner for single-flight execution, cancellation, durable diagnostic cache.
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
let active;
|
|
326
|
+
const startBackfill = (reason) => {
|
|
327
|
+
if (active || controller.signal.aborted) return false;
|
|
328
|
+
monitor.start(reason);
|
|
329
|
+
active = (async () => {
|
|
330
|
+
try {
|
|
331
|
+
const report = await backfillHistoricalSessions(ctx, resolved, controller.signal, {
|
|
332
|
+
onInventory: (summary) => monitor.inventory(summary),
|
|
333
|
+
onStart: (record) => monitor.update(record),
|
|
334
|
+
onUpdate: (record) => monitor.update({ ...record, ...executions.get(record.sessionId), status: record.status }),
|
|
335
|
+
onProgress: (progress) => { monitor.progress(progress); executions.delete(progress.record.sessionId); },
|
|
336
|
+
getExecution: (id) => executions.get(id)
|
|
337
|
+
});
|
|
338
|
+
monitor.finish(report, controller.signal.aborted ? controller.signal.reason : undefined);
|
|
339
|
+
} catch (error) { monitor.finish({}, error); }
|
|
340
|
+
finally { active = undefined; executions.clear(); }
|
|
341
|
+
})();
|
|
342
|
+
return true;
|
|
343
|
+
};
|
|
344
|
+
ctx.effect(() => async () => {
|
|
345
|
+
controller.abort(new Error("plugin-unloaded"));
|
|
346
|
+
await active;
|
|
347
|
+
}, "session-title-summary: scan lifetime");
|
|
348
|
+
if (resolved.backfill) void sleep(resolved.backfillStartupDelayMs, controller.signal).then(() => startBackfill("activation"), () => {});
|
|
349
|
+
registerPanelApi(ctx,
|
|
350
|
+
() => ({ version: "1.6.0", builtin: resolved.builtinCategories, custom: store.data.custom, routeOverride: routeOverride ?? null, ...monitor.snapshot() }),
|
|
351
|
+
startBackfill,
|
|
352
|
+
async (override) => {
|
|
353
|
+
writeOverride(override?.provider, override?.model);
|
|
354
|
+
return routeOverride ?? null;
|
|
355
|
+
},
|
|
356
|
+
// Live provider/model catalog from the same LLM registry the official
|
|
357
|
+
// model selector reads; each provider's failure is isolated.
|
|
358
|
+
async () => {
|
|
359
|
+
const providers = ctx.llm.listProviders();
|
|
360
|
+
const groups = await Promise.all(providers.map(async (provider) => {
|
|
361
|
+
try {
|
|
362
|
+
const models = await ctx.llm.listModels(provider.id);
|
|
363
|
+
return { id: provider.id, name: provider.name ?? provider.id, models: models.map((model) => ({ id: model.id, name: model.name ?? model.id })) };
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return { id: provider.id, name: provider.name ?? provider.id, models: [], error: String(error?.message ?? error) };
|
|
366
|
+
}
|
|
367
|
+
}));
|
|
368
|
+
return { groups };
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export { Config, apply, inject, name };
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/monitor.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Bounded diagnostic cache, not a source of session titles. Only redacted
|
|
2
|
+
// metadata is persisted; conversation bodies and model responses never enter it.
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { scrubCredentials } from './pure.js';
|
|
7
|
+
|
|
8
|
+
export function safeDiagnostic(value) {
|
|
9
|
+
return scrubCredentials(String(value ?? '')).replace(/(?:\/(?:Users|home|var|etc|opt|tmp|private|Applications|Library)\/\S+|~\/\S+|[A-Za-z]:\\\S+)/g, '[路径已隐藏]').slice(0, 300);
|
|
10
|
+
}
|
|
11
|
+
export function safeRecord(record) {
|
|
12
|
+
const result = { ...record };
|
|
13
|
+
for (const key of ['oldTitle', 'newTitle', 'reason', 'provider', 'model']) {
|
|
14
|
+
if (result[key] != null) result[key] = safeDiagnostic(result[key]);
|
|
15
|
+
}
|
|
16
|
+
result.modelLabel = result.model ? `${result.provider ? result.provider + '/' : ''}${result.model}` : '';
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
export class RunMonitor {
|
|
20
|
+
constructor(filePath) {
|
|
21
|
+
this.filePath = filePath;
|
|
22
|
+
this.state = { running: false, current: null, lastReport: null, lastRunAt: null, history: [], error: null };
|
|
23
|
+
try {
|
|
24
|
+
const saved = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
25
|
+
if (saved.version === 1 && Array.isArray(saved.history)) this.state = { ...this.state, ...saved };
|
|
26
|
+
if (this.state.running) {
|
|
27
|
+
this.state.running = false;
|
|
28
|
+
this.state.current = null;
|
|
29
|
+
if (this.state.lastReport) {
|
|
30
|
+
this.state.lastReport.state = 'interrupted';
|
|
31
|
+
this.state.history = [this.state.lastReport, ...this.state.history].slice(0, 10);
|
|
32
|
+
}
|
|
33
|
+
this.save();
|
|
34
|
+
}
|
|
35
|
+
} catch { /* Missing cache starts empty. */ }
|
|
36
|
+
}
|
|
37
|
+
snapshot() { return structuredClone(this.state); }
|
|
38
|
+
save() {
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
41
|
+
const tmp = `${this.filePath}.${randomUUID()}.tmp`;
|
|
42
|
+
writeFileSync(tmp, JSON.stringify({ version: 1, ...this.state }) + '\n', { mode: 0o600 });
|
|
43
|
+
renameSync(tmp, this.filePath);
|
|
44
|
+
} catch (error) { this.state.error = safeDiagnostic(`记录持久化失败: ${error.message}`); }
|
|
45
|
+
}
|
|
46
|
+
start(reason) {
|
|
47
|
+
this.state.running = true;
|
|
48
|
+
this.state.current = null;
|
|
49
|
+
this.state.error = null;
|
|
50
|
+
this.state.lastReport = { runId: randomUUID(), source: reason, state: 'running', startedAt: Date.now(), discovered: 0, considered: 0, scanned: 0, renamed: 0, skipped: 0, failed: 0, records: [], titles: [] };
|
|
51
|
+
this.save();
|
|
52
|
+
}
|
|
53
|
+
inventory(summary) { Object.assign(this.state.lastReport, summary); this.save(); }
|
|
54
|
+
update(record) { this.state.current = safeRecord(record); this.save(); }
|
|
55
|
+
progress({ record, report }) {
|
|
56
|
+
this.state.current = null;
|
|
57
|
+
Object.assign(this.state.lastReport, report);
|
|
58
|
+
this.state.lastReport.records.push(safeRecord(record));
|
|
59
|
+
this.state.lastReport.records = this.state.lastReport.records.slice(-1000);
|
|
60
|
+
this.save();
|
|
61
|
+
}
|
|
62
|
+
finish(report, error) {
|
|
63
|
+
const final = { ...this.state.lastReport, ...report, state: error ? (error.message === 'plugin-unloaded' ? 'interrupted' : 'failed') : 'completed', finishedAt: Date.now() };
|
|
64
|
+
final.records = (final.records ?? []).slice(-1000).map(safeRecord);
|
|
65
|
+
final.titles = (final.titles ?? []).map(safeDiagnostic);
|
|
66
|
+
this.state.running = false;
|
|
67
|
+
this.state.current = null;
|
|
68
|
+
this.state.lastRunAt = final.finishedAt;
|
|
69
|
+
this.state.lastReport = final;
|
|
70
|
+
if (error) this.state.error = safeDiagnostic(error.message ?? error);
|
|
71
|
+
this.state.history = [final, ...this.state.history].slice(0, 10);
|
|
72
|
+
this.save();
|
|
73
|
+
}
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-title-summary",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "Session-title provider for DeepSeek Harness: uses the host-provided DSH core packages, generates categorized MMDD | category | topic titles, backfills history, and provides a management panel",
|
|
4
|
+
"version": "1.7.2",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./client": "./client.js",
|
|
9
|
+
"./locale/*.json": "./locale/*.json",
|
|
10
|
+
"./icon": "./icon.svg",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"pure.js",
|
|
16
|
+
"store.js",
|
|
17
|
+
"backfill.js",
|
|
18
|
+
"monitor.js",
|
|
19
|
+
"panel-api.js",
|
|
20
|
+
"client.js",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"locale/*.json",
|
|
23
|
+
"icon.svg"
|
|
24
|
+
],
|
|
25
|
+
"license": "AGPL-3.0",
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
},
|
|
30
|
+
"client": {
|
|
31
|
+
"platform": "web",
|
|
32
|
+
"immediately": true,
|
|
33
|
+
"inject": [
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"dsh-plugin",
|
|
41
|
+
"deepseek-harness",
|
|
42
|
+
"session-title"
|
|
43
|
+
],
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/achievejia/session-title-summary.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/achievejia/session-title-summary#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/achievejia/session-title-summary/issues"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@deepseek-ai/dsh-session": "*",
|
|
54
|
+
"@deepseek-ai/dsh-session-title": "*",
|
|
55
|
+
"@deepseek-ai/dsh-session-title-llm": "*",
|
|
56
|
+
"@deepseek-ai/schemastery": "*"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@deepseek-ai/dsh-session": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"@deepseek-ai/dsh-session-title": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@deepseek-ai/dsh-session-title-llm": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@deepseek-ai/schemastery": {
|
|
69
|
+
"optional": true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|