dsh-session-tg-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/README.md +151 -0
- package/cordis.patch.yml +6 -0
- package/package.json +52 -0
- package/src/client.js +777 -0
- package/src/config.js +207 -0
- package/src/events.js +308 -0
- package/src/index.js +392 -0
- package/src/presence.js +177 -0
- package/src/screenlock.js +72 -0
- package/src/telegram.js +162 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-tg-notify — 配置模块。
|
|
3
|
+
*
|
|
4
|
+
* 配置来源优先级:Cordis 配置(cordis.yml 的 config)> 持久化文件 > 内置默认值。
|
|
5
|
+
* 持久化文件默认位于 `~/.config/dsh/session-notify.json`,可用环境变量
|
|
6
|
+
* `DSH_SESSION_NOTIFY_CONFIG` 覆盖路径。Web 设置页写入的就是这份文件,
|
|
7
|
+
* 因此运行时可改、无需重启 DSH。
|
|
8
|
+
*
|
|
9
|
+
* 本模块不依赖 @deepseek-ai/schemastery:插件以 `link:` 方式安装时
|
|
10
|
+
* pnpm 不会安装被链接包自身的依赖,零依赖可以让安装更稳。代价是配置
|
|
11
|
+
* 校验由本模块自己完成(sanitizeConfig),非法字段逐项回退默认值。
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** 支持通知的会话事件种类。 */
|
|
18
|
+
export const EVENT_KINDS = Object.freeze(['complete', 'approval', 'question', 'block', 'error']);
|
|
19
|
+
|
|
20
|
+
/** 事件种类 → 中文显示名(设置页与会话通知共用)。 */
|
|
21
|
+
export const EVENT_LABELS = Object.freeze({
|
|
22
|
+
complete: '会话完成',
|
|
23
|
+
approval: '需要审批',
|
|
24
|
+
question: '需要回答',
|
|
25
|
+
block: '目标受阻',
|
|
26
|
+
error: '运行出错'
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** 事件种类 → 通知 emoji 前缀。 */
|
|
30
|
+
export const EVENT_EMOJI = Object.freeze({
|
|
31
|
+
complete: '✅',
|
|
32
|
+
approval: '🔐',
|
|
33
|
+
question: '❓',
|
|
34
|
+
block: '⚠️',
|
|
35
|
+
error: '✗'
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** 可选提示音(浏览器端 Web Audio 合成,不需要素材文件)。 */
|
|
39
|
+
export const TONES = Object.freeze(['ding', 'alert', 'chime', 'none']);
|
|
40
|
+
|
|
41
|
+
/** 默认配置。 */
|
|
42
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
43
|
+
enabled: true,
|
|
44
|
+
/** complete 事件的最短 turn 时长(秒),低于此值不通知,避免短任务刷屏。 */
|
|
45
|
+
minDuration: 10,
|
|
46
|
+
events: Object.freeze({
|
|
47
|
+
complete: Object.freeze({ desktop: true, telegram: false }),
|
|
48
|
+
approval: Object.freeze({ desktop: true, telegram: true }),
|
|
49
|
+
question: Object.freeze({ desktop: true, telegram: true }),
|
|
50
|
+
block: Object.freeze({ desktop: true, telegram: false }),
|
|
51
|
+
error: Object.freeze({ desktop: true, telegram: false })
|
|
52
|
+
}),
|
|
53
|
+
desktop: Object.freeze({
|
|
54
|
+
enabled: true,
|
|
55
|
+
sound: true,
|
|
56
|
+
tone: 'ding'
|
|
57
|
+
}),
|
|
58
|
+
telegram: Object.freeze({
|
|
59
|
+
enabled: false,
|
|
60
|
+
botToken: '',
|
|
61
|
+
chatId: '',
|
|
62
|
+
apiBase: 'https://api.telegram.org',
|
|
63
|
+
/**
|
|
64
|
+
* 页面在后台**且屏幕已锁定**时,是否在桌面通知之外再补一条 Telegram。
|
|
65
|
+
* 注意不是「一切后台」:切到别的应用也会让页面失焦,那种情况不推手机,
|
|
66
|
+
* 否则人还在电脑前就被手机反复打扰。
|
|
67
|
+
*/
|
|
68
|
+
notifyWhenLocked: false
|
|
69
|
+
}),
|
|
70
|
+
/**
|
|
71
|
+
* 焦点信息(可见性/是否聚焦)的有效期。超过就当作「焦点未知」,退化为后台;
|
|
72
|
+
* 但**不影响**在线判定 —— 在线只看 SSE 连接是否存在。
|
|
73
|
+
* 取 90s 是为了容忍浏览器对后台标签页的定时器节流(隐藏超 5 分钟后约 1 次/分钟)。
|
|
74
|
+
*/
|
|
75
|
+
presenceTtlMs: 90000
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
79
|
+
const nonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0;
|
|
80
|
+
|
|
81
|
+
/** 深拷贝默认配置。 */
|
|
82
|
+
export function cloneDefaults() {
|
|
83
|
+
return JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 从任意输入中挑出当前支持的字段并做类型收敛,缺失/非法一律回退默认值。 */
|
|
87
|
+
export function sanitizeConfig(raw, defaults = DEFAULT_CONFIG) {
|
|
88
|
+
const input = isPlainObject(raw) ? raw : {};
|
|
89
|
+
const out = cloneDefaults();
|
|
90
|
+
|
|
91
|
+
if (typeof input.enabled === 'boolean') out.enabled = input.enabled;
|
|
92
|
+
if (Number.isFinite(input.minDuration) && input.minDuration >= 0) out.minDuration = input.minDuration;
|
|
93
|
+
if (Number.isFinite(input.presenceTtlMs) && input.presenceTtlMs >= 5000) out.presenceTtlMs = input.presenceTtlMs;
|
|
94
|
+
|
|
95
|
+
const events = isPlainObject(input.events) ? input.events : {};
|
|
96
|
+
for (const kind of EVENT_KINDS) {
|
|
97
|
+
const base = defaults.events[kind];
|
|
98
|
+
const value = isPlainObject(events[kind]) ? events[kind] : {};
|
|
99
|
+
out.events[kind] = {
|
|
100
|
+
desktop: typeof value.desktop === 'boolean' ? value.desktop : base.desktop,
|
|
101
|
+
telegram: typeof value.telegram === 'boolean' ? value.telegram : base.telegram
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const desktop = isPlainObject(input.desktop) ? input.desktop : {};
|
|
106
|
+
if (typeof desktop.enabled === 'boolean') out.desktop.enabled = desktop.enabled;
|
|
107
|
+
if (typeof desktop.sound === 'boolean') out.desktop.sound = desktop.sound;
|
|
108
|
+
if (TONES.includes(desktop.tone)) out.desktop.tone = desktop.tone;
|
|
109
|
+
|
|
110
|
+
const telegram = isPlainObject(input.telegram) ? input.telegram : {};
|
|
111
|
+
if (typeof telegram.enabled === 'boolean') out.telegram.enabled = telegram.enabled;
|
|
112
|
+
if (typeof telegram.botToken === 'string') out.telegram.botToken = telegram.botToken.trim();
|
|
113
|
+
if (typeof telegram.chatId === 'string') out.telegram.chatId = telegram.chatId.trim();
|
|
114
|
+
if (nonEmptyString(telegram.apiBase)) out.telegram.apiBase = telegram.apiBase.trim().replace(/\/+$/, '');
|
|
115
|
+
// 新键优先;旧键 notifyWhenBackground 作为迁移来源(语义已收紧为「且锁屏」)。
|
|
116
|
+
if (typeof telegram.notifyWhenLocked === 'boolean') {
|
|
117
|
+
out.telegram.notifyWhenLocked = telegram.notifyWhenLocked;
|
|
118
|
+
} else if (typeof telegram.notifyWhenBackground === 'boolean') {
|
|
119
|
+
out.telegram.notifyWhenLocked = telegram.notifyWhenBackground;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 递归深合并(over 覆盖 base,undefined 跳过)。 */
|
|
126
|
+
function deepMerge(base, over) {
|
|
127
|
+
const out = { ...base };
|
|
128
|
+
for (const key of Object.keys(over)) {
|
|
129
|
+
const value = over[key];
|
|
130
|
+
if (value === undefined) continue;
|
|
131
|
+
out[key] = isPlainObject(value) && isPlainObject(out[key]) ? deepMerge(out[key], value) : value;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 把绝对路径收敛成 `~/...` 形式,用于任何会展示给用户的地方(设置面板、日志)。
|
|
138
|
+
* 面板里直接打印绝对路径会把用户名写进界面,截图/分享时就泄漏了。
|
|
139
|
+
*/
|
|
140
|
+
export function displayPath(path, home = homedir()) {
|
|
141
|
+
if (typeof path !== 'string' || path.length === 0) return '';
|
|
142
|
+
if (typeof home === 'string' && home.length > 1 && path.startsWith(`${home}/`)) {
|
|
143
|
+
return `~${path.slice(home.length)}`;
|
|
144
|
+
}
|
|
145
|
+
return path;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 配置文件路径:环境变量优先,否则 `~/.config/dsh/session-notify.json`。 */
|
|
149
|
+
export function defaultConfigPath(env = process.env, home = homedir()) {
|
|
150
|
+
const override = env?.DSH_SESSION_NOTIFY_CONFIG;
|
|
151
|
+
return nonEmptyString(override) ? override : join(home, '.config', 'dsh', 'session-notify.json');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 读取持久化配置;文件缺失/损坏时回退默认值并返回 warning。 */
|
|
155
|
+
export function loadFileConfig(path) {
|
|
156
|
+
try {
|
|
157
|
+
const text = readFileSync(path, 'utf8');
|
|
158
|
+
return { value: sanitizeConfig(JSON.parse(text)), warning: null };
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error?.code === 'ENOENT') return { value: cloneDefaults(), warning: null };
|
|
161
|
+
return { value: cloneDefaults(), warning: `[session-notify] 配置读取失败(回退默认值): ${path}: ${error?.message ?? error}` };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 原子写入配置(临时文件 + rename),文件权限 0600 —— 内含 Telegram Bot Token。
|
|
167
|
+
* @returns 写入的内容。
|
|
168
|
+
*/
|
|
169
|
+
export function saveConfig(path, config) {
|
|
170
|
+
const text = `${JSON.stringify(config, null, 2)}\n`;
|
|
171
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
172
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
173
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
174
|
+
renameSync(tmp, path);
|
|
175
|
+
return config;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 合并配置层:文件配置为 base,Cordis 显式配置覆盖它,最后 sanitize 兜底。
|
|
180
|
+
* @param cordisConfig - Cordis 传入的配置(可能为空对象)。
|
|
181
|
+
*/
|
|
182
|
+
export function resolveConfig(cordisConfig, path) {
|
|
183
|
+
const file = loadFileConfig(path);
|
|
184
|
+
const explicit = isPlainObject(cordisConfig) ? cordisConfig : {};
|
|
185
|
+
const merged = sanitizeConfig(deepMerge(file.value, explicit));
|
|
186
|
+
return { config: merged, warning: file.warning, path };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 把设置页提交的局部补丁应用到当前配置上(深合并 + sanitize),返回新配置。
|
|
191
|
+
* 只接受已知字段;未知键被 sanitizeConfig 丢弃。
|
|
192
|
+
*/
|
|
193
|
+
export function applyPatch(current, patch) {
|
|
194
|
+
return sanitizeConfig(deepMerge(current, isPlainObject(patch) ? patch : {}));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 对外暴露配置时隐去 Bot Token(只报告是否已配置)。 */
|
|
198
|
+
export function redactConfig(config) {
|
|
199
|
+
return {
|
|
200
|
+
...config,
|
|
201
|
+
telegram: {
|
|
202
|
+
...config.telegram,
|
|
203
|
+
botToken: '',
|
|
204
|
+
hasBotToken: nonEmptyString(config.telegram.botToken)
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
package/src/events.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-tg-notify — 事件分类 + turn 跟踪。
|
|
3
|
+
*
|
|
4
|
+
* 把原始 DSH 事件归一化成「一次通知」,不在这里做任何输出或路由决策。
|
|
5
|
+
*
|
|
6
|
+
* 事件来源(均为 host 侧 Cordis 事件,不修改 DSH 核心):
|
|
7
|
+
* - `session/event` 的 `turn/end`(reason.completed) + 最终 assistant 文本 → complete
|
|
8
|
+
* - `session/event` 的 `approval/asked` → approval
|
|
9
|
+
* - `session/event` 的 `tool/call` + `ask_user_question` → question
|
|
10
|
+
* - `goal/changed` 的 block 操作 → block
|
|
11
|
+
* - `agent/error` → error
|
|
12
|
+
*
|
|
13
|
+
* 子代理过滤:DSH 中**活的**主会话 header 通常没有 delegationDepth 字段
|
|
14
|
+
* (只有经 JSONL 恢复的会话才有 0),子代理的 delegationDepth >= 1。
|
|
15
|
+
* 因此 `undefined` 或合法的 0 视为主会话,同时用 `header.origin === 'subagent'`
|
|
16
|
+
* 兜底排除子代理;异常值保守拒绝(宁可不通知,也不要被子代理刷屏)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 是否主会话(子代理会话一律不通知)。 */
|
|
20
|
+
export function isMainSession(session) {
|
|
21
|
+
const header = session?.header;
|
|
22
|
+
if (!header || header.origin === 'subagent') return false;
|
|
23
|
+
const depth = header.delegationDepth;
|
|
24
|
+
if (depth === undefined) return true;
|
|
25
|
+
return Number.isSafeInteger(depth) && depth === 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isValidTurn = (turn) => Number.isSafeInteger(turn) && turn > 0;
|
|
29
|
+
|
|
30
|
+
/** 会话的可读短标签:优先 cwd 的末段,其次 session id 前 8 位。 */
|
|
31
|
+
export function sessionLabel(session) {
|
|
32
|
+
const cwd = session?.header?.cwd;
|
|
33
|
+
if (typeof cwd === 'string' && cwd.length > 0) {
|
|
34
|
+
const parts = cwd.replace(/\/+$/, '').split('/');
|
|
35
|
+
const last = parts[parts.length - 1];
|
|
36
|
+
if (last) return last;
|
|
37
|
+
}
|
|
38
|
+
const id = session?.id;
|
|
39
|
+
return typeof id === 'string' && id.length > 0 ? id.slice(0, 8) : 'session';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 截断为单行,避免通知正文过长。 */
|
|
43
|
+
export function oneLine(text, max = 120) {
|
|
44
|
+
if (typeof text !== 'string') return '';
|
|
45
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
46
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* turn 跟踪:只为判定 `complete`(“存在最终 assistant 文本回答”)。
|
|
51
|
+
*
|
|
52
|
+
* 事件链:turn/start → [step 循环: assistant/message → tool/call → approval/asked …] → turn/end
|
|
53
|
+
* `turn/end` 的 `reason.kind === 'completed'` 只表示物理 turn 平衡关闭,
|
|
54
|
+
* 空 no-op claim 与工具 concludesTurn 路径也会产生 completed,二者没有
|
|
55
|
+
* 最终文本回答,不应通知。
|
|
56
|
+
*/
|
|
57
|
+
export function createTurnTracker() {
|
|
58
|
+
/** `${sessionId}:${turn}` → { startTime, firstUserMessage, lastAssistant, lastToolCall } */
|
|
59
|
+
const turns = new Map();
|
|
60
|
+
/** sessionId → 已消费的最大 turn 号(防止完整重放重复通知)。 */
|
|
61
|
+
const endedTurns = new Map();
|
|
62
|
+
/** sessionId → 最近一次 turn/start 的 turn 号(user/message 不自带 turn)。 */
|
|
63
|
+
const currentTurns = new Map();
|
|
64
|
+
let order = 0;
|
|
65
|
+
|
|
66
|
+
const stamp = (event) => {
|
|
67
|
+
order += 1;
|
|
68
|
+
return { order, time: Number.isFinite(event?.time) ? event.time : null };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const entryOf = (session, turn) => {
|
|
72
|
+
const sessionId = session?.id ?? 'unknown';
|
|
73
|
+
const key = `${sessionId}:${turn}`;
|
|
74
|
+
let entry = turns.get(key);
|
|
75
|
+
if (!entry) {
|
|
76
|
+
entry = { sessionId, startTime: null, firstUserMessage: null, lastAssistant: null, lastToolCall: null };
|
|
77
|
+
turns.set(key, entry);
|
|
78
|
+
}
|
|
79
|
+
return entry;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** 只认 source.kind === 'user' 的真实用户输入,排除系统注入快照。 */
|
|
83
|
+
const userText = (event) => {
|
|
84
|
+
const data = event?.data ?? {};
|
|
85
|
+
if (data.source?.kind !== 'user') return null;
|
|
86
|
+
const text = (Array.isArray(data.content) ? data.content : [])
|
|
87
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
88
|
+
.map((block) => block.text)
|
|
89
|
+
.join(' ')
|
|
90
|
+
.replace(/\s+/g, ' ')
|
|
91
|
+
.trim();
|
|
92
|
+
return text.length > 0 ? text : null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const assistantShape = (event) => {
|
|
96
|
+
const content = event?.data?.message?.content;
|
|
97
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
98
|
+
let hasText = false;
|
|
99
|
+
let hasToolCall = false;
|
|
100
|
+
for (const block of blocks) {
|
|
101
|
+
if (!block || typeof block !== 'object') continue;
|
|
102
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0) hasText = true;
|
|
103
|
+
if (block.type === 'tool-call') hasToolCall = true;
|
|
104
|
+
}
|
|
105
|
+
return { hasText, hasToolCall };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const onTurnStart = (session, event) => {
|
|
109
|
+
if (!isMainSession(session)) return;
|
|
110
|
+
const turn = event?.data?.turn;
|
|
111
|
+
if (!isValidTurn(turn)) return;
|
|
112
|
+
currentTurns.set(session.id ?? 'unknown', turn);
|
|
113
|
+
entryOf(session, turn).startTime = stamp(event).time;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const onUserMessage = (session, event) => {
|
|
117
|
+
if (!isMainSession(session)) return;
|
|
118
|
+
const turn = currentTurns.get(session?.id ?? 'unknown');
|
|
119
|
+
if (!isValidTurn(turn)) return;
|
|
120
|
+
const entry = turns.get(`${session?.id ?? 'unknown'}:${turn}`);
|
|
121
|
+
if (!entry || entry.firstUserMessage !== null) return;
|
|
122
|
+
const text = userText(event);
|
|
123
|
+
if (text !== null) entry.firstUserMessage = text;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onAssistantMessage = (session, event) => {
|
|
127
|
+
if (!isMainSession(session)) return;
|
|
128
|
+
const turn = event?.data?.turn;
|
|
129
|
+
if (!isValidTurn(turn)) return;
|
|
130
|
+
const entry = entryOf(session, turn);
|
|
131
|
+
entry.lastAssistant = { ...stamp(event), ...assistantShape(event) };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onToolCall = (session, event) => {
|
|
135
|
+
if (!isMainSession(session)) return;
|
|
136
|
+
const turn = event?.data?.turn;
|
|
137
|
+
if (!isValidTurn(turn)) return;
|
|
138
|
+
entryOf(session, turn).lastToolCall = stamp(event);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 严格 complete 判定:turn/end(completed) + 最后一个 assistant/message
|
|
143
|
+
* 是非空纯文本(不是 tool-call-only/混合)+ 其后没有新的 tool/call。
|
|
144
|
+
* @returns { turn, durationMs, summary } 或 null。
|
|
145
|
+
*/
|
|
146
|
+
const onTurnEnd = (session, event) => {
|
|
147
|
+
if (event?.type !== 'turn/end' || !isMainSession(session)) return null;
|
|
148
|
+
const turn = event?.data?.turn;
|
|
149
|
+
if (!isValidTurn(turn)) return null;
|
|
150
|
+
const sessionId = session?.id ?? 'unknown';
|
|
151
|
+
const lastEnded = endedTurns.get(sessionId);
|
|
152
|
+
const alreadyEnded = typeof lastEnded === 'number' && turn <= lastEnded;
|
|
153
|
+
if (!alreadyEnded) endedTurns.set(sessionId, turn);
|
|
154
|
+
if (currentTurns.get(sessionId) === turn) currentTurns.delete(sessionId);
|
|
155
|
+
const entry = turns.get(`${sessionId}:${turn}`);
|
|
156
|
+
turns.delete(`${sessionId}:${turn}`);
|
|
157
|
+
if (alreadyEnded || event?.data?.reason?.kind !== 'completed' || !entry) return null;
|
|
158
|
+
const lastAssistant = entry.lastAssistant;
|
|
159
|
+
const isFinalText = lastAssistant?.hasText === true && lastAssistant?.hasToolCall !== true;
|
|
160
|
+
const noToolAfter = lastAssistant !== null &&
|
|
161
|
+
(entry.lastToolCall === null || lastAssistant.order > entry.lastToolCall.order);
|
|
162
|
+
if (!isFinalText || !noToolAfter) return null;
|
|
163
|
+
const durationMs = entry.startTime !== null && Number.isFinite(event.time)
|
|
164
|
+
? Math.max(0, event.time - entry.startTime)
|
|
165
|
+
: null;
|
|
166
|
+
return { turn, durationMs, summary: entry.firstUserMessage ?? null };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const onSessionDisposed = (session) => {
|
|
170
|
+
const sessionId = session?.id;
|
|
171
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
|
172
|
+
currentTurns.delete(sessionId);
|
|
173
|
+
endedTurns.delete(sessionId);
|
|
174
|
+
for (const [key, entry] of turns) if (entry.sessionId === sessionId) turns.delete(key);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return { onTurnStart, onUserMessage, onAssistantMessage, onToolCall, onTurnEnd, onSessionDisposed };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 分类 `session/event` 的 approval/asked(等待人工批准工具操作)。 */
|
|
181
|
+
export function classifyApproval(session, event) {
|
|
182
|
+
if (!isMainSession(session) || event?.type !== 'approval/asked') return null;
|
|
183
|
+
const data = event.data ?? {};
|
|
184
|
+
const id = typeof data.id === 'string' && data.id.length > 0 ? data.id : 'unknown';
|
|
185
|
+
const sessionId = session?.id ?? 'unknown';
|
|
186
|
+
const toolName = typeof data.toolName === 'string' && data.toolName.length > 0 ? data.toolName : '(unknown tool)';
|
|
187
|
+
const reason = typeof data.reason === 'string' && data.reason.length > 0 ? data.reason : null;
|
|
188
|
+
return {
|
|
189
|
+
kind: 'approval',
|
|
190
|
+
sessionId,
|
|
191
|
+
title: '需要审批',
|
|
192
|
+
body: oneLine(reason === null ? toolName : `${toolName} — ${reason}`),
|
|
193
|
+
detail: { toolName, reason },
|
|
194
|
+
dedupeKey: `approval:${sessionId}:${id}`
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 分类 Agent 主动提问。
|
|
200
|
+
*
|
|
201
|
+
* 可靠监听点是 session 的 durable `tool/call` 事件且 name === 'ask_user_question'
|
|
202
|
+
* (`question/requested` 下行帧属 API 层,host 侧不保证可见)。
|
|
203
|
+
*/
|
|
204
|
+
export function classifyQuestion(session, event) {
|
|
205
|
+
if (!isMainSession(session) || event?.type !== 'tool/call') return null;
|
|
206
|
+
const data = event.data ?? {};
|
|
207
|
+
if (data.name !== 'ask_user_question') return null;
|
|
208
|
+
let args = {};
|
|
209
|
+
try {
|
|
210
|
+
args = typeof data.arguments === 'string' ? JSON.parse(data.arguments) : {};
|
|
211
|
+
} catch {
|
|
212
|
+
// 参数解析失败仍通知,正文退化为占位文本。
|
|
213
|
+
}
|
|
214
|
+
const questions = Array.isArray(args.questions) ? args.questions : [];
|
|
215
|
+
const first = questions[0] ?? {};
|
|
216
|
+
const text = typeof first.question === 'string' && first.question.length > 0 ? first.question : null;
|
|
217
|
+
const optionsCount = Array.isArray(first.options) ? first.options.length : 0;
|
|
218
|
+
const sessionId = session?.id ?? 'unknown';
|
|
219
|
+
const suffix = optionsCount > 0 ? `(${optionsCount} 个选项)` : '';
|
|
220
|
+
return {
|
|
221
|
+
kind: 'question',
|
|
222
|
+
sessionId,
|
|
223
|
+
title: '需要回答',
|
|
224
|
+
body: oneLine(text === null ? '(Agent 提问)' : text) + suffix,
|
|
225
|
+
detail: { questionText: text, optionsCount, questionCount: questions.length },
|
|
226
|
+
dedupeKey: `question:${sessionId}:${data.callId ?? 'unknown'}`
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 分类 `goal/changed`:只有 block 操作需要打扰用户。 */
|
|
231
|
+
export function classifyGoalChange(change) {
|
|
232
|
+
if (!change || change.operation !== 'block') return null;
|
|
233
|
+
const goal = change.goal ?? null;
|
|
234
|
+
const ref = change.ref ?? null;
|
|
235
|
+
const objective = typeof goal?.objective === 'string' && goal.objective.length > 0 ? goal.objective : null;
|
|
236
|
+
return {
|
|
237
|
+
kind: 'block',
|
|
238
|
+
sessionId: null,
|
|
239
|
+
title: '目标受阻',
|
|
240
|
+
body: oneLine(objective ?? '(未提供目标描述)'),
|
|
241
|
+
detail: { objective },
|
|
242
|
+
dedupeKey: ref ? `goal:${ref.id}@${ref.revision}` : `goal:block:${goal?.id ?? 'unknown'}`
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 分类 `agent/error`(step/turn 级错误)。 */
|
|
247
|
+
export function classifyAgentError(payload) {
|
|
248
|
+
if (!payload) return null;
|
|
249
|
+
const { agent, turn, step, error } = payload;
|
|
250
|
+
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : String(error);
|
|
251
|
+
return {
|
|
252
|
+
kind: 'error',
|
|
253
|
+
sessionId: typeof agent?.id === 'string' && agent.id.length > 0 ? agent.id : null,
|
|
254
|
+
title: '运行出错',
|
|
255
|
+
body: oneLine(message),
|
|
256
|
+
detail: { message, turn: Number.isFinite(turn) ? turn : null, step: Number.isFinite(step) ? step : null },
|
|
257
|
+
dedupeKey: `error:${agent?.id ?? 'unknown'}@${turn ?? '-'}@${step ?? '-'}`
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 分类 `session/event` 的 turn/end → complete(需配合 createTurnTracker)。 */
|
|
262
|
+
export function classifyTurnEnd(session, complete) {
|
|
263
|
+
if (!complete) return null;
|
|
264
|
+
const sessionId = session?.id ?? 'unknown';
|
|
265
|
+
const durationText = complete.durationMs === null ? '' : `(${Math.round(complete.durationMs / 1000)}s)`;
|
|
266
|
+
return {
|
|
267
|
+
kind: 'complete',
|
|
268
|
+
sessionId,
|
|
269
|
+
title: '会话完成',
|
|
270
|
+
body: oneLine(complete.summary ?? `第 ${complete.turn} 轮`) + durationText,
|
|
271
|
+
detail: { turn: complete.turn, durationMs: complete.durationMs, summary: complete.summary },
|
|
272
|
+
dedupeKey: `complete:${sessionId}:${complete.turn}`
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 去重容器:有界 FIFO,避免长会话把内存撑大。 */
|
|
277
|
+
export function createDedupe(maxKeys = 2000) {
|
|
278
|
+
const perSession = new Map();
|
|
279
|
+
const sessionless = new Set();
|
|
280
|
+
|
|
281
|
+
const remember = (store, key) => {
|
|
282
|
+
if (store.has(key)) return false;
|
|
283
|
+
if (store.size >= maxKeys) {
|
|
284
|
+
const oldest = store.values().next().value;
|
|
285
|
+
if (oldest !== undefined) store.delete(oldest);
|
|
286
|
+
}
|
|
287
|
+
store.add(key);
|
|
288
|
+
return true;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
/** @returns true = 首次出现(允许通知);false = 重复。 */
|
|
293
|
+
admit(sessionId, key) {
|
|
294
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
295
|
+
let keys = perSession.get(sessionId);
|
|
296
|
+
if (!keys) {
|
|
297
|
+
keys = new Set();
|
|
298
|
+
perSession.set(sessionId, keys);
|
|
299
|
+
}
|
|
300
|
+
return remember(keys, key);
|
|
301
|
+
}
|
|
302
|
+
return remember(sessionless, key);
|
|
303
|
+
},
|
|
304
|
+
forget(sessionId) {
|
|
305
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) perSession.delete(sessionId);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|