@dsh-plus/notify-email 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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 决策观察:tools/pre-execute 是零侵入观察点(waterfall,原样放行),
3
+ * 在工具体阻塞等待用户之前触发——时机即「agent 停下等用户决策」。
4
+ * 发送异步进行,绝不拖慢工具闸门;按 callId 去重。
5
+ * @module notify-email/watchers/decision
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+
9
+ import type { NotifyEmailService } from '../service.ts'
10
+
11
+ export function installDecisionWatcher(ctx: Context, service: NotifyEmailService): void {
12
+ const seen = new Set<string>()
13
+ const logger = ctx.logger('notify-email')
14
+ ctx.on('tools/pre-execute', (exec, next) => {
15
+ if (seen.has(exec.callId)) return next()
16
+ seen.add(exec.callId)
17
+ const sessionId = exec.agent?.id ?? 'unknown'
18
+ service
19
+ .dispatchDecision({
20
+ callId: exec.callId,
21
+ sessionId,
22
+ name: exec.name,
23
+ args: (exec.arguments ?? {}) as Record<string, unknown>,
24
+ })
25
+ .catch((error: unknown) => {
26
+ logger.warn(
27
+ `decision dispatch failed (${exec.name}, call ${exec.callId}): ` +
28
+ (error instanceof Error ? error.message : String(error)),
29
+ )
30
+ })
31
+ return next()
32
+ })
33
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * 任务停止观察:订阅 session/event 持久事件流。
3
+ * - assistant/message 记录该会话最新的交付文本;
4
+ * - turn/end(completed/error/aborted)且属 runtime root agent → 空闲防抖后下发;
5
+ * - 防抖窗口内新 turn/start 到达则取消(goal 续轮、followup 排队等场景不误报);
6
+ * - 按 session:turn:kind 去重,同一会话边界不重复通知。
7
+ * @module notify-email/watchers/turn-end
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session'
11
+
12
+ import type { NotifyEmailConfig } from '../config.ts'
13
+ import type { NotifyEmailService } from '../service.ts'
14
+
15
+ /** 只有这三类 turn 结束对用户有可决策价值;blocked/max-tokens/interrupted 不通知。 */
16
+ const NOTIFY_KINDS = new Set(['completed', 'error', 'aborted'])
17
+
18
+ interface SessionWatch {
19
+ lastDelivery?: string
20
+ timer?: ReturnType<typeof setTimeout>
21
+ readonly notified: Set<string>
22
+ }
23
+
24
+ interface ContentBlockLike {
25
+ type?: string
26
+ text?: unknown
27
+ }
28
+
29
+ /** 从 assistant 消息 content 提取纯文本交付内容(仅 text 块,忽略工具调用等)。 */
30
+ export function extractDeliveryText(content: readonly ContentBlockLike[]): string | undefined {
31
+ const text = content
32
+ .filter((b) => b.type === 'text' && typeof b.text === 'string')
33
+ .map((b) => b.text as string)
34
+ .join('\n')
35
+ .trim()
36
+ return text.length > 0 ? text : undefined
37
+ }
38
+
39
+ function extractErrorMessage(reason: unknown): string | undefined {
40
+ if (typeof reason !== 'object' || reason === null) return undefined
41
+ const error = (reason as { error?: unknown }).error
42
+ if (typeof error !== 'object' || error === null) return undefined
43
+ const message = (error as { message?: unknown }).message
44
+ return typeof message === 'string' ? message : undefined
45
+ }
46
+
47
+ export function installTurnEndWatcher(
48
+ ctx: Context,
49
+ service: NotifyEmailService,
50
+ resolveConfig: () => NotifyEmailConfig,
51
+ ): void {
52
+ const watches = new Map<string, SessionWatch>()
53
+ const logger = ctx.logger('notify-email')
54
+
55
+ const watchOf = (sessionId: string): SessionWatch => {
56
+ let watch = watches.get(sessionId)
57
+ if (watch === undefined) {
58
+ watch = { notified: new Set() }
59
+ watches.set(sessionId, watch)
60
+ }
61
+ return watch
62
+ }
63
+
64
+ const fire = (session: Session, turn: number, kind: string, errorMessage?: string): void => {
65
+ const agent = ctx.agents.get(session.id)
66
+ if (agent === undefined) return
67
+ if (agent.status !== 'idle' || agent.inbox.hasPending) return
68
+ if (!ctx.agents.roots().includes(agent)) return
69
+ const watch = watchOf(session.id)
70
+ const key = `${turn}:${kind}`
71
+ if (watch.notified.has(key)) return
72
+ watch.notified.add(key)
73
+ service
74
+ .dispatchTurnEnd({
75
+ sessionId: session.id,
76
+ turn,
77
+ kind,
78
+ ...(errorMessage !== undefined ? { errorMessage } : {}),
79
+ ...(watch.lastDelivery !== undefined ? { lastDelivery: watch.lastDelivery } : {}),
80
+ })
81
+ .catch((error: unknown) => {
82
+ logger.warn(
83
+ `turn-end dispatch failed (${session.id}, turn ${turn}, ${kind}): ` +
84
+ (error instanceof Error ? error.message : String(error)),
85
+ )
86
+ })
87
+ }
88
+
89
+ const onTurnEnd = (session: Session, turn: number, reason: TurnEndReason): void => {
90
+ if (!NOTIFY_KINDS.has(reason.kind)) return
91
+ const watch = watchOf(session.id)
92
+ if (watch.timer !== undefined) clearTimeout(watch.timer)
93
+ const errorMessage = extractErrorMessage(reason)
94
+ watch.timer = setTimeout(
95
+ () => {
96
+ watch.timer = undefined
97
+ fire(session, turn, reason.kind, errorMessage)
98
+ },
99
+ resolveConfig().idleDebounceMs,
100
+ )
101
+ }
102
+
103
+ ctx.on('session/event', (session, event) => {
104
+ if (event.type === 'assistant/message') {
105
+ const text = extractDeliveryText(event.data.message.content)
106
+ if (text !== undefined) watchOf(session.id).lastDelivery = text
107
+ return
108
+ }
109
+ if (event.type === 'turn/start') {
110
+ const watch = watches.get(session.id)
111
+ if (watch?.timer !== undefined) {
112
+ clearTimeout(watch.timer)
113
+ watch.timer = undefined
114
+ }
115
+ return
116
+ }
117
+ if (event.type === 'turn/end') onTurnEnd(session, event.data.turn, event.data.reason)
118
+ })
119
+
120
+ ctx.on('session/disposed', (session) => {
121
+ const watch = watches.get(session.id)
122
+ if (watch?.timer !== undefined) clearTimeout(watch.timer)
123
+ watches.delete(session.id)
124
+ })
125
+
126
+ ctx.effect(
127
+ () => () => {
128
+ for (const watch of watches.values()) {
129
+ if (watch.timer !== undefined) clearTimeout(watch.timer)
130
+ }
131
+ watches.clear()
132
+ },
133
+ 'notify-email: turn-end timers',
134
+ )
135
+ }