@telosmaylx/dsh-session-notify 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/lib/core.js ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * dsh-session-complete-notify —— 纯逻辑层(零外部依赖,可独立测试)。
3
+ *
4
+ * 职责:
5
+ * - 轮次计时/用量跟踪(turn/start 起表,assistant/message 累计 token)
6
+ * - 会话完成系统消息的文案构建(summary 一行 + content 全文)
7
+ */
8
+
9
+ /** 语言代码(设置面板可选值)。 */
10
+ export const LANGUAGES = ['zh', 'zh-tw', 'en', 'ja', 'ko']
11
+
12
+ /**
13
+ * 各语言文案表:reason 标签 + 通用标签 + 分隔符/用时/消耗措辞。
14
+ * 键为 LANGUAGES 中的代码;缺省回落 zh。
15
+ */
16
+ const LABELS = {
17
+ zh: { completed: '会话已完成', aborted: '会话已中止', blocked: '会话被阻塞', error: '会话出错', 'max-tokens': '会话达到输出上限', interrupted: '会话已中断', generic: '会话已结束', sep: ':', dur: '用时', use: '消耗' },
18
+ 'zh-tw': { completed: '會話已完成', aborted: '會話已中止', blocked: '會話被阻塞', error: '會話出錯', 'max-tokens': '會話達到輸出上限', interrupted: '會話已中斷', generic: '會話已結束', sep: ':', dur: '用時', use: '消耗' },
19
+ en: { completed: 'Session completed', aborted: 'Session aborted', blocked: 'Session blocked', error: 'Session failed', 'max-tokens': 'Session hit the output-token cap', interrupted: 'Session interrupted', generic: 'Session ended', sep: ': ', dur: 'took', use: 'used' },
20
+ ja: { completed: 'セッション完了', aborted: 'セッション中止', blocked: 'セッションがブロックされました', error: 'セッションエラー', 'max-tokens': '出力トークン上限に到達', interrupted: 'セッションが中断されました', generic: 'セッション終了', sep: ':', dur: '所要', use: '消費' },
21
+ ko: { completed: '세션 완료', aborted: '세션 중단됨', blocked: '세션 차단됨', error: '세션 오류', 'max-tokens': '출력 토큰 한도 도달', interrupted: '세션 중단됨', generic: '세션 종료', sep: ': ', dur: '소요', use: '소모' },
22
+ }
23
+
24
+ function tableFor(language) {
25
+ return LABELS[language] || LABELS.zh
26
+ }
27
+
28
+ /**
29
+ * 按语言取 reason 标签。
30
+ * @param {string} kind
31
+ * @param {string} language - LANGUAGES 之一
32
+ * @returns {string}
33
+ */
34
+ export function labelFor(kind, language = 'zh') {
35
+ return tableFor(language)[kind] ?? tableFor(language).generic
36
+ }
37
+
38
+ /**
39
+ * 判断一个会话是否为子代理会话(子代理由父会话编排,通常不需要逐轮提醒)。
40
+ * @param {{ header?: { origin?: string; delegationDepth?: number } }} session
41
+ * @returns {boolean}
42
+ */
43
+ export function isSubagentSession(session) {
44
+ const header = session?.header ?? {}
45
+ return header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0
46
+ }
47
+
48
+ /**
49
+ * 每个(会话, 轮次)的进行时状态:开始时间 + 累计 token 用量。
50
+ * key 约定:`${sessionId}:${turn}`。
51
+ */
52
+ export function createTurnTracker() {
53
+ const turns = new Map()
54
+ return {
55
+ /** turn/start:起表。 */
56
+ start(key, time) {
57
+ turns.set(key, { startedAt: time, usage: null })
58
+ },
59
+ /** assistant/message:一步的 usage 并入轮次累计。 */
60
+ addUsage(key, usage) {
61
+ const state = turns.get(key)
62
+ if (!state || !usage) return
63
+ state.usage = state.usage ? addTokenUsage(state.usage, usage) : { ...usage }
64
+ },
65
+ /** turn/end:读取并清除。缺起表(如恢复日志边界)时以 end 时间作答。 */
66
+ end(key, time) {
67
+ const state = turns.get(key)
68
+ turns.delete(key)
69
+ return {
70
+ startedAt: state?.startedAt ?? time,
71
+ endedAt: time,
72
+ usage: state?.usage ?? null,
73
+ }
74
+ },
75
+ }
76
+ }
77
+
78
+ /** 合并两次 token 用量(字段按需相加,缺失字段保持缺席)。 */
79
+ function addTokenUsage(a, b) {
80
+ const sum = (x, y) => (Number.isFinite(x) ? x : 0) + (Number.isFinite(y) ? y : 0)
81
+ const merged = {
82
+ inputTokens: sum(a.inputTokens, b.inputTokens),
83
+ outputTokens: sum(a.outputTokens, b.outputTokens),
84
+ }
85
+ if (a.cacheReadTokens != null || b.cacheReadTokens != null) merged.cacheReadTokens = sum(a.cacheReadTokens, b.cacheReadTokens)
86
+ if (a.cacheWriteTokens != null || b.cacheWriteTokens != null) merged.cacheWriteTokens = sum(a.cacheWriteTokens, b.cacheWriteTokens)
87
+ if (a.reasoningTokens != null || b.reasoningTokens != null) merged.reasoningTokens = sum(a.reasoningTokens, b.reasoningTokens)
88
+ return merged
89
+ }
90
+
91
+ /**
92
+ * 人类可读时长:zh/zh-tw `12 秒` `3 分 25 秒`;en `12s` `3m25s`;
93
+ * ja `12 秒` `3 分 25 秒` `1 時間 4 分`;ko `12초` `3분 25초` `1시간 4분`。
94
+ * @param {number} ms
95
+ * @param {string} [language]
96
+ * @returns {string}
97
+ */
98
+ export function formatDuration(ms, language = 'zh') {
99
+ const totalSeconds = Math.max(0, Math.round(ms / 1000))
100
+ const lang = LABELS[language] ? language : 'zh'
101
+ if (lang === 'en') {
102
+ if (totalSeconds < 60) return `${totalSeconds}s`
103
+ const minutes = Math.floor(totalSeconds / 60)
104
+ const seconds = totalSeconds % 60
105
+ if (minutes < 60) return seconds ? `${minutes}m${seconds}s` : `${minutes}m`
106
+ return `${Math.floor(minutes / 60)}h${minutes % 60}m`
107
+ }
108
+ if (lang === 'ja') {
109
+ if (totalSeconds < 60) return `${totalSeconds} 秒`
110
+ const minutes = Math.floor(totalSeconds / 60)
111
+ const seconds = totalSeconds % 60
112
+ if (minutes < 60) return seconds ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分`
113
+ return `${Math.floor(minutes / 60)} 時間 ${minutes % 60} 分`
114
+ }
115
+ if (lang === 'ko') {
116
+ if (totalSeconds < 60) return `${totalSeconds}초`
117
+ const minutes = Math.floor(totalSeconds / 60)
118
+ const seconds = totalSeconds % 60
119
+ if (minutes < 60) return seconds ? `${minutes}분 ${seconds}초` : `${minutes}분`
120
+ return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분`
121
+ }
122
+ // zh / zh-tw
123
+ if (totalSeconds < 60) return `${totalSeconds} 秒`
124
+ const minutes = Math.floor(totalSeconds / 60)
125
+ const seconds = totalSeconds % 60
126
+ if (minutes < 60) return seconds ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`
127
+ const hours = Math.floor(minutes / 60)
128
+ return `${hours} 小时 ${minutes % 60} 分`
129
+ }
130
+
131
+ /**
132
+ * 用量一行:zh/zh-tw `1,240 輸入 / 3,560 輸出`;en `1,240 in / 3,560 out`;
133
+ * ja `1,240 入力 / 3,560 出力`;ko `1,240 입력 / 3,560 출력`(不带 tokens 单位后缀)。
134
+ * 输入 = 未缓存 + 缓存读 + 缓存写。
135
+ * @param {{ inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
136
+ * @param {string} [language]
137
+ * @returns {string | null}
138
+ */
139
+ export function summarizeUsage(usage, language = 'zh') {
140
+ if (!usage) return null
141
+ const input = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
142
+ const output = usage.outputTokens ?? 0
143
+ const fmt = (n) => n.toLocaleString('en-US')
144
+ const lang = LABELS[language] ? language : 'zh'
145
+ const iw = { zh: '输入', 'zh-tw': '輸入', en: 'in', ja: '入力', ko: '입력' }[lang]
146
+ const ow = { zh: '输出', 'zh-tw': '輸出', en: 'out', ja: '出力', ko: '출력' }[lang]
147
+ return `${fmt(input)} ${iw} / ${fmt(output)} ${ow}`
148
+ }
149
+
150
+ /**
151
+ * 缓存命中率:缓存读 token /(未缓存输入 + 缓存读 + 缓存写)——无缓存数据返回空串。
152
+ * @param {{ inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
153
+ * @returns {string}
154
+ */
155
+ export function summarizeCache(usage) {
156
+ if (!usage) return ''
157
+ const read = usage.cacheReadTokens ?? 0
158
+ const write = usage.cacheWriteTokens ?? 0
159
+ const plain = usage.inputTokens ?? 0
160
+ const total = plain + read + write
161
+ if (!(total > 0)) return ''
162
+ const pct = Math.round((read / total) * 1000) / 10
163
+ return `${pct}%`
164
+ }
165
+
166
+ /**
167
+ * 缓存命中率(官方 tokenUsage 投影口径,四桶不重叠):
168
+ * 缓存读 /(未缓存输入 + 缓存读 + 缓存写)——与 dsh-web-ui 同源。
169
+ * @param {{ uncachedInputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }} usage
170
+ * @returns {string}
171
+ */
172
+ export function officialCacheRate(usage) {
173
+ if (!usage) return ''
174
+ const read = usage.cacheReadTokens ?? 0
175
+ const total = (usage.uncachedInputTokens ?? 0) + read + (usage.cacheWriteTokens ?? 0)
176
+ if (!(total > 0)) return ''
177
+ return `${Math.round((read / total) * 1000) / 10}%`
178
+ }
179
+
180
+ /**
181
+ * 生成速度(官方 sessionStats 投影口径):输出 token ÷ 解码耗时(tok/s)——
182
+ * 与 dsh-web-ui 状态栏同口径(不含排队/准备/工具时间)。
183
+ * @param {{ decodeTokens?: number; decodeMs?: number }} stats
184
+ * @returns {string}
185
+ */
186
+ export function officialTps(stats) {
187
+ if (!stats || !(stats.decodeMs > 0)) return ''
188
+ return `${Math.round((stats.decodeTokens ?? 0) / (stats.decodeMs / 1000))} tok/s`
189
+ }
190
+
191
+ /**
192
+ * 生成速度:输出 token / 用时秒(tok/s)——无数据返回空串。
193
+ * @param {{ outputTokens?: number }} usage
194
+ * @param {number} ms
195
+ * @returns {string}
196
+ */
197
+ export function tpsOf(usage, ms) {
198
+ if (!usage || !(ms > 0)) return ''
199
+ const output = usage.outputTokens ?? 0
200
+ return `${Math.round(output / (ms / 1000))} tok/s`
201
+ }
202
+
203
+ /**
204
+ * 构建系统消息:可折叠行的 summary(= 正文渲染结果的截断,≤120 字符)
205
+ * + 展开/模型可见的全文。
206
+ *
207
+ * @param {string} kind - turn/end 的 reason.kind。
208
+ * @param {{ error?: { message?: string } } | undefined} reasonData
209
+ * @param {{ startedAt: number; endedAt: number; usage?: object | null; includeDuration?: boolean; includeUsage?: boolean; cacheValue?: string; tpsValue?: string; titleValue?: string }} info
210
+ * @param {{ language?: 'zh' | 'en'; templates?: Record<string, string> }} [custom] -
211
+ * language 切换文案语言;templates[reason] 非空时作为正文模板,
212
+ * 支持占位符:{label} {title} {duration} {usage} {error} {cache} {tps}。
213
+ * @returns {{ summary: string; text: string }}
214
+ */
215
+ export function buildNotice(kind, reasonData, { startedAt, endedAt, usage = null, includeDuration = true, includeUsage = true, cacheValue, tpsValue, titleValue }, custom = {}) {
216
+ const language = LABELS[custom.language] ? custom.language : 'zh'
217
+ const L = tableFor(language)
218
+ const templates = custom.templates ?? {}
219
+ const label = labelFor(kind, language)
220
+ const detail = detailFor(reasonData, language)
221
+ let summary = `${label}${detail}`
222
+ const durationLine = includeDuration ? formatDuration(endedAt - startedAt, language) : null
223
+ const usageLine = includeUsage ? summarizeUsage(usage, language) : null
224
+ // 缓存命中/速度:优先官方投影口径(调用方传入),无则退回用量聚合估算
225
+ const cacheLine = typeof cacheValue === 'string' ? cacheValue : (includeUsage ? summarizeCache(usage) : '')
226
+ const tpsLine = typeof tpsValue === 'string' ? tpsValue : (includeUsage ? tpsOf(usage, endedAt - startedAt) : '')
227
+ const parts = []
228
+ if (durationLine) parts.push(`${L.dur} ${durationLine}`)
229
+ if (usageLine) parts.push(`${L.use} ${usageLine}`)
230
+ const suffix = parts.length ? `(${parts.join(',')})` : ''
231
+ const template = templates[kind]
232
+ let text
233
+ if (typeof template === 'string' && template.trim() !== '') {
234
+ // 自定义模板:占位符替换,保持 summary 与正文一致的语义
235
+ text = renderTemplate(template, {
236
+ label,
237
+ duration: durationLine ?? '',
238
+ usage: usageLine ?? '',
239
+ error: plainError(reasonData) || 'none', // 无报错时显示 none,而非空串
240
+ cache: cacheLine,
241
+ tps: tpsLine,
242
+ title: typeof titleValue === 'string' ? titleValue : '',
243
+ })
244
+ } else if (language === 'en') {
245
+ text = `${summary}${suffix ? ` (${parts.join(', ')}).` : '.'}`
246
+ } else if (language === 'ja') {
247
+ text = `${summary}${suffix ? `(${parts.join('、')})。` : '。'}`
248
+ } else if (language === 'ko') {
249
+ text = `${summary}${suffix ? `(${parts.join(', ')})。` : '。'}`
250
+ } else {
251
+ text = `${summary}${suffix}。`
252
+ }
253
+ return {
254
+ // 折叠行 summary 与正文同源:自定义模板(含 {title})与默认文案都展示
255
+ // 渲染结果(截断至 120 字符)——只看折叠行的用户必须能看见真实标题与
256
+ // 用时/消耗,否则会误以为 {title} 没有生效(历史教训:summary 只放标签
257
+ // 「会话已完成」,用户展开前完全看不到内容)。
258
+ summary: bound(text, 120),
259
+ text,
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 模板占位符替换:{label} 已全面移除(模板中直接剥除);支持 {duration} {usage} {error} {cache} {tps}。
265
+ * @param {string} template
266
+ * @param {{ label: string; duration: string; usage: string; error: string; cache: string; tps: string }} vars
267
+ * @returns {string}
268
+ */
269
+ export function renderTemplate(template, vars) {
270
+ return String(template)
271
+ .replace(/\{label\}/g, '')
272
+ .replaceAll('{title}', vars.title ?? '')
273
+ .replaceAll('{duration}', vars.duration)
274
+ .replaceAll('{usage}', vars.usage)
275
+ .replaceAll('{error}', vars.error)
276
+ .replaceAll('{cache}', vars.cache)
277
+ .replaceAll('{tps}', vars.tps)
278
+ }
279
+
280
+ /** error reason 的纯文本详情(单行、截断;无则空串)。 */
281
+ function plainError(reasonData) {
282
+ const message = reasonData?.error?.message
283
+ if (!message) return ''
284
+ const flat = String(message).replace(/[\r\n]+/g, ' ').trim()
285
+ return flat.length > 80 ? `${flat.slice(0, 80)}…` : flat
286
+ }
287
+
288
+ /** error reason 的错误详情(单行、截断,分隔符随语言)。 */
289
+ function detailFor(reasonData, language = 'zh') {
290
+ const message = reasonData?.error?.message
291
+ if (!message) return ''
292
+ const flat = String(message).replace(/[\r\n]+/g, ' ').trim()
293
+ if (!flat) return ''
294
+ const sep = tableFor(language).sep
295
+ return `${sep}${flat.length > 40 ? `${flat.slice(0, 40)}…` : flat}`
296
+ }
297
+
298
+ /** 按字符数截断(≤ max)。 */
299
+ function bound(text, max) {
300
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text
301
+ }
302
+
303
+ export const __internals = { LABELS, addTokenUsage, detailFor, bound }
package/lib/index.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * dsh-session-complete-notify:会话完成系统消息提醒插件(host 平面)。
3
+ *
4
+ * 原理:订阅 session/event 火线——
5
+ * - turn/start 记下轮次开始时间;
6
+ * - assistant/message 累加该轮 token 用量;
7
+ * - turn/end 按 reason.kind(completed/aborted/blocked/error/max-tokens)
8
+ * 组装一条系统消息,并以 plugin-source 的 user/message(form: 'notice')
9
+ * 追加进会话日志:Web UI 把它渲染为可折叠的系统提示行(醒目提醒用户),
10
+ * 并随 JSONL 持久化,恢复/回放后依然可见。
11
+ *
12
+ * 设计取舍:
13
+ * - 只响应「实时」事件:resume/replay 不会重放旧通知,不会在加载会话时刷屏;
14
+ * - 追加的事件类型是 user/message,与自身监听目标(turn/*)不相交,
15
+ * 天然免疫自我循环;
16
+ * - 零外部 import(@deepseek-ai/* 无法从本仓库目录解析),
17
+ * UserMessage 对象按 dsh-llm 的 createUserMessage 契约手工构造
18
+ * (id = crypto.randomUUID(),deep-freeze 由 session.append 的
19
+ * adoptSessionEvent 快照阶段完成)。
20
+ *
21
+ * 装配(cordis.patch.yml 或 dsh plugin add):
22
+ * - id: session-complete-notify
23
+ * name: '@telosmaylx/dsh-session-notify'
24
+ * config:
25
+ * reasons: [completed, aborted, blocked, error, max-tokens]
26
+ * skipSubagents: true
27
+ * includeDuration: true
28
+ * includeUsage: true
29
+ */
30
+
31
+ import { appendFileSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+ import { homedir } from 'node:os'
34
+ import { createRequire } from 'node:module'
35
+ import { createTurnTracker, buildNotice, isSubagentSession, officialCacheRate, officialTps } from './core.js?v=1' // v=1: 缓存破坏——HMR 重载按 URL 键控
36
+
37
+ /** Cordis loader 诊断用插件名。 */
38
+ export const name = 'session-complete-notify'
39
+
40
+ /** 系统消息 source.plugin 标识(UI 按插件名着色/标注来源)。 */
41
+ const PLUGIN_ID = 'dsh-session-notify'
42
+
43
+ /** 设置命名空间(官方「设置 → 插件」面板的键)。 */
44
+ const SETTINGS_NS = 'session-complete-notify'
45
+
46
+ /**
47
+ * 本插件从仓库目录以 realpath 加载,裸导入(@deepseek-ai/*)无法解析;
48
+ * 用 createRequire 锚定 profile 共享依赖枢纽(.dsh/profiles/node_modules),
49
+ * 取到与宿主同源(realpath 相同)的 schemastery 实例来构造设置 schema。
50
+ */
51
+ const HUB_REQUIRE = createRequire(join(homedir(), '.dsh', 'profiles', 'node_modules', '__scn_anchor__.js'))
52
+
53
+ /** 默认配置。 */
54
+ const DEFAULT_OPTIONS = {
55
+ /**
56
+ * 触发提醒的 turn/end reason 白名单。默认排除 interrupted
57
+ * (崩溃恢复后由持久化后端补写的孤儿轮次关闭标记,用户视角的“完成”不含它)。
58
+ */
59
+ reasons: ['completed', 'aborted', 'blocked', 'error', 'max-tokens'],
60
+ /** 跳过子代理会话:子孙会话由父会话编排,逐轮提醒是噪音。 */
61
+ skipSubagents: true,
62
+ /** 系统消息附带轮次用时。 */
63
+ includeDuration: true,
64
+ /** 系统消息附带 token 用量。 */
65
+ includeUsage: true,
66
+ }
67
+
68
+ /** 设置面板可见字段的默认值(与 Config 同构;仓库里的 schema 默认值与此一致)。 */
69
+ const DEFAULT_SETTINGS = {
70
+ language: 'zh',
71
+ templates: { completed: '', error: '', aborted: '', blocked: '', 'max-tokens': '' },
72
+ titleTemplate: '',
73
+ includeDuration: true,
74
+ includeUsage: true,
75
+ skipSubagents: true,
76
+ }
77
+
78
+ /** 从任意输入规整为设置形状(容忍缺失/多余字段)。 */
79
+ function sanitizeSettings(raw) {
80
+ const src = raw && typeof raw === 'object' ? raw : {}
81
+ const templatesRaw = src.templates && typeof src.templates === 'object' ? src.templates : {}
82
+ const templates = { ...DEFAULT_SETTINGS.templates }
83
+ for (const key of Object.keys(templates)) {
84
+ if (typeof templatesRaw[key] === 'string') templates[key] = templatesRaw[key]
85
+ }
86
+ return {
87
+ language: ['zh', 'zh-tw', 'en', 'ja', 'ko'].includes(src.language) ? src.language : 'zh',
88
+ templates,
89
+ titleTemplate: typeof src.titleTemplate === 'string' ? src.titleTemplate : '',
90
+ includeDuration: typeof src.includeDuration === 'boolean' ? src.includeDuration : true,
91
+ includeUsage: typeof src.includeUsage === 'boolean' ? src.includeUsage : true,
92
+ skipSubagents: typeof src.skipSubagents === 'boolean' ? src.skipSubagents : true,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 插件入口。
98
+ * @param {import('cordis').Context} ctx
99
+ * @param {Partial<typeof DEFAULT_OPTIONS>} [config]
100
+ */
101
+ export const inject = ['settings']
102
+
103
+ export function apply(ctx, config = {}) {
104
+ const options = { ...DEFAULT_OPTIONS, ...config }
105
+ const reasons = new Set(Array.isArray(options.reasons) ? options.reasons : DEFAULT_OPTIONS.reasons)
106
+ const tracker = createTurnTracker()
107
+ let settings = DEFAULT_SETTINGS
108
+ let projRegistry = null // sessionProjections 服务(非注入可选依赖,经 ctx.inject 捕获)
109
+
110
+ // 官方设置命名空间:设置面板(设置 → 插件)可编辑;user 层持久化在 settings 文档。
111
+ // 重试兜底:热重载时旧 fiber 注销与新 fiber 注册存在竞态,register 可能因
112
+ // duplicate 被拒——短暂重试直至成功(生产无重载时一次即中)。
113
+ try {
114
+ const Schema = HUB_REQUIRE('@deepseek-ai/schemastery')
115
+ const schema = Schema.object({
116
+ language: Schema.union(['zh', 'zh-tw', 'en', 'ja', 'ko']).default('zh'),
117
+ templates: Schema.object({
118
+ completed: Schema.string().default(''),
119
+ error: Schema.string().default(''),
120
+ aborted: Schema.string().default(''),
121
+ blocked: Schema.string().default(''),
122
+ 'max-tokens': Schema.string().default(''),
123
+ }),
124
+ titleTemplate: Schema.string().default(''),
125
+ includeDuration: Schema.boolean().default(true),
126
+ includeUsage: Schema.boolean().default(true),
127
+ skipSubagents: Schema.boolean().default(true),
128
+ })
129
+ let attempts = 0
130
+ const tryRegister = () => {
131
+ try {
132
+ const scope = ctx.settings.register(SETTINGS_NS, schema, { applies: 'live' })
133
+ settings = sanitizeSettings(scope.get())
134
+ scope.watch((next) => {
135
+ settings = sanitizeSettings(next)
136
+ fileLog(`settings updated: ${JSON.stringify({ language: settings.language, templates: Object.fromEntries(Object.entries(settings.templates).filter(([, v]) => v)) })}`)
137
+ })
138
+ fileLog('settings namespace registered')
139
+ } catch (err) {
140
+ if (attempts < 8) {
141
+ attempts += 1
142
+ setTimeout(tryRegister, 400 * attempts)
143
+ fileLog(`settings register retry ${attempts}: ${err?.message ?? err}`)
144
+ } else {
145
+ warn(ctx, `设置命名空间注册失败(使用默认值): ${err?.message ?? err}`)
146
+ fileLog(`settings register FAILED after ${attempts} attempts: ${err?.message ?? err}`)
147
+ }
148
+ }
149
+ }
150
+ tryRegister()
151
+ } catch (err) {
152
+ warn(ctx, `设置依赖加载失败: ${err?.message ?? err}`)
153
+ fileLog(`settings deps load FAILED: ${err?.message ?? err}`)
154
+ }
155
+
156
+ // 会话投影:把每个会话「最近的系统消息全文」注册为一个投影单元(key =
157
+ // session-complete-notify)。宿主会对**所有会话**(含后台/未打开窗口的)
158
+ // 推送该值 → 客户端推送正文因此跨会话一致,不再依赖事件窗口是否打开。
159
+ try {
160
+ const z = HUB_REQUIRE('zod')
161
+ if (typeof ctx.inject === 'function') {
162
+ ctx.inject(['sessionProjections'], (scoped) => {
163
+ // 说明:注入器(dsh-super-injector)为插件提供的是二级上下文的注册表
164
+ // 实例,与 host 对客户端推送/列表快照所用的实例可能不同;优先取
165
+ // ctx.root.get(最靠近宿主根的一份),拿不到时回退注入实例。
166
+ // 只注册进注入实例时,客户端可能读不到本投影单元 → 后台会话推送正文
167
+ // 走降级路径(详情见会话内系统消息),属尽力而为,不影响会话内系统消息。
168
+ const rootRegistry = (typeof ctx.root?.get === 'function' && ctx.root.get('sessionProjections')) || scoped.sessionProjections
169
+ projRegistry = rootRegistry
170
+ try {
171
+ rootRegistry.register({
172
+ key: SETTINGS_NS,
173
+ schema: z.string(),
174
+ init: () => '',
175
+ apply: (state, event) => {
176
+ if (event.type !== 'user/message') return state
177
+ const src = event.data?.source ?? {}
178
+ if (src.kind !== 'plugin' || src.plugin !== PLUGIN_ID) return state
179
+ const text = ((event.data?.content ?? []).map((b) => b?.text ?? '')).join('').trim()
180
+ return text || state
181
+ },
182
+ view: (state) => state,
183
+ stateVersion: 1,
184
+ })
185
+ fileLog('session-projections unit registered (key=session-complete-notify)')
186
+ } catch (err) {
187
+ warn(ctx, `投影单元注册失败: ${err?.message ?? err}`)
188
+ }
189
+ })
190
+ }
191
+ } catch (err) {
192
+ warn(ctx, `投影依赖加载失败: ${err?.message ?? err}`)
193
+ }
194
+
195
+ ctx.on('session/event', (session, event) => {
196
+ switch (event.type) {
197
+ case 'turn/start':
198
+ tracker.start(`${session.id}:${event.data.turn}`, event.time)
199
+ return
200
+ case 'assistant/message':
201
+ tracker.addUsage(`${session.id}:${event.data.turn}`, event.data.usage)
202
+ return
203
+ case 'turn/end': {
204
+ const key = `${session.id}:${event.data.turn}`
205
+ const state = tracker.end(key, event.time)
206
+ const kind = event.data.reason?.kind
207
+ if (!reasons.has(kind)) return
208
+ if (isSubagentSession(session)) return // 默认跳过子代理会话(子代理由父会话编排)
209
+ // 官方投影口径:tokenUsage(缓存命中率)+ sessionStats(tok/s 生成速度)
210
+ // 与 dsh-web-ui 状态栏同源;投影由宿主维护,无插件内存状态,重载也不丢。
211
+ // 用户以「标签是否插入」控制显示(用量/用时无独立开关)。
212
+ let cacheValue
213
+ let tpsValue
214
+ let titleValue = ''
215
+ try {
216
+ if (projRegistry) {
217
+ const snap = projRegistry.snapshot(session)
218
+ const usage = snap?.values?.tokenUsage
219
+ const stats = snap?.values?.sessionStats
220
+ cacheValue = officialCacheRate(usage)
221
+ tpsValue = officialTps(stats)
222
+ titleValue = typeof snap?.values?.title === 'string' ? snap.values.title : ''
223
+ }
224
+ } catch (projErr) {
225
+ warn(ctx, `投影快照读取失败: ${projErr?.message ?? projErr}`)
226
+ }
227
+ const notice = buildNotice(kind, event.data.reason, {
228
+ ...state,
229
+ includeDuration: true, // 标签即开关:{duration} 插了才显示
230
+ includeUsage: true, // 标签即开关:{usage}/{cache}/{tps} 插了才显示
231
+ cacheValue,
232
+ tpsValue,
233
+ titleValue,
234
+ }, { language: settings.language, templates: settings.templates })
235
+ // 边界约束:session/event 观察者回调运行在 turn/end 那次 append 的
236
+ // 发布边界之内(dsh-session 在 dispatch 前 set entry.appending,
237
+ // finally 中复位),此时同步 append 会被拒绝:
238
+ // "session append cannot reenter while another append is being published"
239
+ // 推迟到微任务——微任务队列在本次同步栈(含 finally 复位)之后才跑。
240
+ queueMicrotask(() => appendNotice(ctx, session, notice))
241
+ return
242
+ }
243
+ default:
244
+ return
245
+ }
246
+ })
247
+ }
248
+
249
+ /** 把系统消息追加进会话日志(失败只记日志,绝不抛出破坏 event 火线)。 */
250
+ function appendNotice(ctx, session, notice) {
251
+ try {
252
+ session.append(
253
+ 'user/message',
254
+ {
255
+ id: newId(),
256
+ role: 'user',
257
+ content: [{ type: 'text', text: notice.text }],
258
+ source: { kind: 'plugin', plugin: PLUGIN_ID, form: 'notice', summary: notice.summary },
259
+ },
260
+ { surfaceOp: 'append' },
261
+ )
262
+ } catch (err) {
263
+ warn(ctx, `追加系统消息失败: ${err?.message ?? String(err)}`)
264
+ fileLog(`追加失败 ${session.id}: ${err?.stack ?? err}`)
265
+ }
266
+ }
267
+
268
+ /** 追加失败时落一个调试文件(~/.dsh/session-complete-notify.log),便于排查。 */
269
+ function fileLog(line) {
270
+ try {
271
+ appendFileSync(join(homedir(), '.dsh', 'session-complete-notify.log'), `${new Date().toISOString()} ${line}\n`, 'utf8')
272
+ } catch {
273
+ /* 尽力而为 */
274
+ }
275
+ }
276
+
277
+ /** crypto.randomUUID(Node 18+ 全局存在;旧环境回退到时间戳+随机)。 */
278
+ function newId() {
279
+ return globalThis.crypto?.randomUUID?.() ?? `n-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
280
+ }
281
+
282
+ /** 日志上报(ctx.logger 缺失时落到 console.warn)。 */
283
+ function warn(ctx, message) {
284
+ try {
285
+ const logger = ctx.logger
286
+ if (logger && typeof logger.warn === 'function') logger.warn(`[${PLUGIN_ID}] ${message}`)
287
+ else console.warn(`[${PLUGIN_ID}] ${message}`)
288
+ } catch {
289
+ /* 上报是尽力而为 */
290
+ }
291
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@telosmaylx/dsh-session-notify",
3
+ "version": "0.1.0",
4
+ "description": "DSH session completion notifier: appends a plugin system message to the session log on turn/end and pushes browser notifications (Web Notification + toast), with a fully customizable official settings panel (presets, 5-language templates, cache-hit rate & tok/s from official projections).",
5
+ "private": false,
6
+ "type": "module",
7
+ "main": "./lib/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "default": "./lib/index.js"
11
+ },
12
+ "./client": {
13
+ "default": "./lib/client.js"
14
+ },
15
+ "./core": {
16
+ "default": "./lib/core.js"
17
+ },
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "lib",
22
+ "scripts",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "keywords": [
30
+ "deepseek-harness",
31
+ "dsh",
32
+ "dsh-plugin",
33
+ "session-notify",
34
+ "notification",
35
+ "completion-reminder",
36
+ "agent"
37
+ ],
38
+ "author": "dsh-session-notify contributors",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/TelosmaYLX/dsh-session-notify.git"
43
+ },
44
+ "homepage": "https://github.com/TelosmaYLX/dsh-session-notify#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/TelosmaYLX/dsh-session-notify/issues"
47
+ },
48
+ "peerDependencies": {
49
+ "cordis": ">=4.0.0-rc <5"
50
+ },
51
+ "engines": {
52
+ "node": ">=22"
53
+ },
54
+ "dsh": {
55
+ "client": {
56
+ "inject": [
57
+ "@deepseek-ai/dsh-client-runtime"
58
+ ],
59
+ "platform": "web"
60
+ }
61
+ },
62
+ "scripts": {
63
+ "build": "bash scripts/build.sh || node --check lib/index.js && node --check lib/core.js && node --check lib/client.js",
64
+ "prepublishOnly": "node --check lib/index.js && node --check lib/core.js && node --check lib/client.js"
65
+ }
66
+ }
@@ -0,0 +1,16 @@
1
+ #!/bin/bash
2
+ # Build dsh-session-complete-notify: the plugin is hand-written zero-dependency
3
+ # ESM — no tsc step, just syntax-verify the shipped libs.
4
+ set -euo pipefail
5
+
6
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+ cd "$ROOT"
8
+
9
+ if [ ! -f lib/index.js ] || [ ! -f lib/core.js ]; then
10
+ echo "build: lib/index.js or lib/core.js missing" >&2
11
+ exit 1
12
+ fi
13
+
14
+ node --check lib/index.js
15
+ node --check lib/core.js
16
+ echo "=== Build complete (lib verified, ${PWD}) ==="