dsh-session-guard 0.1.2 → 0.2.0-beta.1

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/src/targets.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * dsh-session-guard — 会话「最近一次真实目标」追踪(纯函数 + 小状态表)。
3
+ *
4
+ * 主信号:`session/event` 的 `request/header`(**两个版本都有**,findings F5)。
5
+ * `session.append('request/header', { header, reason })`,`header.config.{provider,model}`
6
+ * 是这次请求真正要去的路由(`installModelSelection` 覆盖之后的值)。
7
+ * 加速信号:`model/selection`(**仅 0.1.2+**)→ 用户切模型时立即更新;
8
+ * 0.1.1 侧不存在该事件,忽略即可(退化为「等下次请求」)。
9
+ *
10
+ * 形状异常 / 缺失一律安全降级为 `unknown`,绝不抛出。
11
+ */
12
+
13
+ /** 未知目标哨兵(保守:高峰时按「可能是官方」处理)。 */
14
+ export const UNKNOWN = 'unknown'
15
+
16
+ /** 规范化一个 provider/model 对。 */
17
+ function shape(provider, model) {
18
+ const p = typeof provider === 'string' && provider !== '' ? provider : UNKNOWN
19
+ const m = typeof model === 'string' && model !== '' ? model : UNKNOWN
20
+ return { provider: p, model: m }
21
+ }
22
+
23
+ /**
24
+ * 从会话事件中读出目标(只认 `request/header` / `model/selection`)。
25
+ * @param {object} event 会话事件
26
+ * @returns {{provider:string, model:string}|null} 不关心的类型返回 null
27
+ */
28
+ export function readTarget(event) {
29
+ const type = event && event.type
30
+ if (type === 'request/header') {
31
+ const config = event.data && event.data.header && event.data.header.config
32
+ return shape(config && config.provider, config && config.model)
33
+ }
34
+ if (type === 'model/selection') {
35
+ const sel = event.data
36
+ return shape(sel && sel.provider, sel && sel.model)
37
+ }
38
+ return null
39
+ }
40
+
41
+ /** 目标表:`sessionId → {provider, model, at}`。 */
42
+ export function createTargets() {
43
+ /** @type {Map<string, {provider:string, model:string, at:number}>} */
44
+ const map = new Map()
45
+ return {
46
+ /** 用一条会话事件更新目标;返回写入的目标(不关心的事件返回 null)。 */
47
+ update(sessionId, event, at = Date.now()) {
48
+ const key = String(sessionId ?? '')
49
+ if (key === '') return null
50
+ const t = readTarget(event)
51
+ if (t === null) return null
52
+ const rec = { ...t, at }
53
+ map.set(key, rec)
54
+ return rec
55
+ },
56
+ get(sessionId) {
57
+ return map.get(String(sessionId ?? '')) ?? null
58
+ },
59
+ /** provider 值(没有记录 → unknown,保守处理)。 */
60
+ providerOf(sessionId) {
61
+ const rec = map.get(String(sessionId ?? ''))
62
+ return rec ? rec.provider : UNKNOWN
63
+ },
64
+ clear(sessionId) {
65
+ return map.delete(String(sessionId ?? ''))
66
+ },
67
+ entries: () => [...map.entries()],
68
+ size: () => map.size,
69
+ }
70
+ }
package/src/time.js CHANGED
@@ -1,104 +1,195 @@
1
- /**
2
- * dsh-session-guard — 时间判定(纯函数,零依赖,可单测)。
3
- *
4
- * 峰谷判定必须基于 DeepSeek 官方计费基准——北京时间(UTC+8),
5
- * 而不是用户配置的本地时区。用硬编码 BILLING_TIMEZONE = 'Asia/Shanghai'。
6
- *
7
- * 周末判定基于用户配置时区(settings.timezone),因为"周末"是用户本地概念。
8
- *
9
- * 两者都通过 `Intl.DateTimeFormat(timeZone)` 投影,避免裸 `getUTCDay()`
10
- * 导致北京周末边界错 8 小时的 bug。
11
- *
12
- * 高峰时段为左闭右开 [start, end);跨午夜窗口(start > end)安全。
13
- */
14
-
15
- /**
16
- * DeepSeek 峰谷计费基准时区(固定北京时间 UTC+8)。
17
- * 峰谷窗口判定必须用此时区,不受用户 timezone 配置影响。
18
- */
19
- export const BILLING_TIMEZONE = 'Asia/Shanghai'
20
-
21
- /** 解析 "HH:mm" → 当日分钟数,非法返回 null。 */
22
- export function parseHHMM(s) {
23
- const m = /^(\d{1,2}):(\d{2})$/.exec(String(s).trim())
24
- if (!m) return null
25
- const h = Number(m[1])
26
- const mi = Number(m[2])
27
- if (h > 23 || mi > 59) return null
28
- return h * 60 + mi
29
- }
30
-
31
- /** 闭开区间 [s, e),跨午夜安全(s > e 时视为跨天环绕)。 */
32
- export function inWindow(t, s, e) {
33
- if (s === e) return false
34
- return s < e ? t >= s && t < e : t >= s || t < e
35
- }
36
-
37
- /**
38
- * 把某时刻投影为配置时区(IANA,DST 感知)的墙钟分量。
39
- * @param {string} tz IANA 时区名,如 "Asia/Shanghai"
40
- * @param {Date} date
41
- * @returns {{year:number,month:number,day:number,weekday:number,minutes:number}}
42
- * weekday: 0=周日 ... 6=周六(与 getUTCDay 同约定,但基于配置时区的日期)
43
- */
44
- export function wallClock(tz, date) {
45
- const f = new Intl.DateTimeFormat('en-US', {
46
- timeZone: tz,
47
- hour12: false,
48
- year: 'numeric',
49
- month: '2-digit',
50
- day: '2-digit',
51
- hour: '2-digit',
52
- minute: '2-digit',
53
- })
54
- const parts = {}
55
- for (const p of f.formatToParts(date)) parts[p.type] = p.value
56
- const year = Number(parts.year)
57
- const month = Number(parts.month)
58
- const day = Number(parts.day)
59
- // 由该时区的「今天」日期反推 weekday,避免 UTC 边界错位(D1)。
60
- const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
61
- const minutes = Number(parts.hour) * 60 + Number(parts.minute)
62
- return { year, month, day, weekday, minutes }
63
- }
64
-
65
- /** 周末(0=周日, 6=周六)。 */
66
- export function isWeekend(weekday) {
67
- return weekday === 0 || weekday === 6
68
- }
69
-
70
- /** 某墙钟分钟是否处于任一高峰窗口内。 */
71
- export function isInPeak(wc, windows) {
72
- return windows.some((w) => {
73
- const s = parseHHMM(w && w.start)
74
- const e = parseHHMM(w && w.end)
75
- if (s === null || e === null) return false
76
- return inWindow(wc.minutes, s, e)
77
- })
78
- }
79
-
80
- /**
81
- * 主判定:此刻是否应触发高峰暂停。
82
- *
83
- * - 峰谷判定:固定使用 BILLING_TIMEZONE(北京时间),与 DeepSeek 官方计费一致;
84
- * - 周末判定:使用 settings.timezone(用户本地时区),因为周末是用户本地概念。
85
- *
86
- * @param {object} settings { enabled, weekendMode, timezone, peakWindows }
87
- * @param {Date} date
88
- * @returns {{pause:boolean, reason:string}}
89
- * reason: 'disabled' | 'weekend' | 'peak' | 'off-peak'
90
- */
91
- export function shouldPause(settings, date) {
92
- if (!settings || settings.enabled !== true) return { pause: false, reason: 'disabled' }
93
- // 周末判定:用用户配置时区(用户本地的周末)
94
- const wcUser = wallClock(settings.timezone, date)
95
- if (settings.weekendMode === true && isWeekend(wcUser.weekday)) {
96
- return { pause: false, reason: 'weekend' }
97
- }
98
- // 峰谷判定:固定北京时间(DeepSeek 官方计费基准)
99
- const wcBilling = wallClock(BILLING_TIMEZONE, date)
100
- if (isInPeak(wcBilling, settings.peakWindows || [])) {
101
- return { pause: true, reason: 'peak' }
102
- }
103
- return { pause: false, reason: 'off-peak' }
104
- }
1
+ /**
2
+ * dsh-session-guard — 时间判定(纯函数,零依赖,可单测)。
3
+ *
4
+ * 峰谷判定必须基于 DeepSeek 官方计费基准——北京时间(UTC+8),
5
+ * 而不是用户配置的本地时区。用硬编码 BILLING_TIMEZONE = 'Asia/Shanghai'。
6
+ *
7
+ * 周末判定基于用户配置时区(settings.timezone),因为"周末"是用户本地概念。
8
+ *
9
+ * 两者都通过 `Intl.DateTimeFormat(timeZone)` 投影,避免裸 `getUTCDay()`
10
+ * 导致北京周末边界错 8 小时的 bug。
11
+ *
12
+ * 高峰时段为左闭右开 [start, end);跨午夜窗口(start > end)安全。
13
+ */
14
+
15
+ /**
16
+ * DeepSeek 峰谷计费基准时区(固定北京时间 UTC+8)。
17
+ * 峰谷窗口判定必须用此时区,不受用户 timezone 配置影响。
18
+ */
19
+ export const BILLING_TIMEZONE = 'Asia/Shanghai'
20
+
21
+ /** 解析 "HH:mm" → 当日分钟数,非法返回 null。 */
22
+ export function parseHHMM(s) {
23
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(s).trim())
24
+ if (!m) return null
25
+ const h = Number(m[1])
26
+ const mi = Number(m[2])
27
+ if (h > 23 || mi > 59) return null
28
+ return h * 60 + mi
29
+ }
30
+
31
+ /** 闭开区间 [s, e),跨午夜安全(s > e 时视为跨天环绕)。 */
32
+ export function inWindow(t, s, e) {
33
+ if (s === e) return false
34
+ return s < e ? t >= s && t < e : t >= s || t < e
35
+ }
36
+
37
+ /**
38
+ * 把某时刻投影为配置时区(IANA,DST 感知)的墙钟分量。
39
+ * @param {string} tz IANA 时区名,如 "Asia/Shanghai"
40
+ * @param {Date} date
41
+ * @returns {{year:number,month:number,day:number,weekday:number,minutes:number}}
42
+ * weekday: 0=周日 ... 6=周六(与 getUTCDay 同约定,但基于配置时区的日期)
43
+ */
44
+ export function wallClock(tz, date) {
45
+ const f = new Intl.DateTimeFormat('en-US', {
46
+ timeZone: tz,
47
+ hour12: false,
48
+ year: 'numeric',
49
+ month: '2-digit',
50
+ day: '2-digit',
51
+ hour: '2-digit',
52
+ minute: '2-digit',
53
+ })
54
+ const parts = {}
55
+ for (const p of f.formatToParts(date)) parts[p.type] = p.value
56
+ const year = Number(parts.year)
57
+ const month = Number(parts.month)
58
+ const day = Number(parts.day)
59
+ // 由该时区的「今天」日期反推 weekday,避免 UTC 边界错位(D1)。
60
+ const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
61
+ const minutes = Number(parts.hour) * 60 + Number(parts.minute)
62
+ return { year, month, day, weekday, minutes }
63
+ }
64
+
65
+ /** 周末(0=周日, 6=周六)。 */
66
+ export function isWeekend(weekday) {
67
+ return weekday === 0 || weekday === 6
68
+ }
69
+
70
+ /** 某墙钟分钟是否处于任一高峰窗口内。 */
71
+ export function isInPeak(wc, windows) {
72
+ return windows.some((w) => {
73
+ const s = parseHHMM(w && w.start)
74
+ const e = parseHHMM(w && w.end)
75
+ if (s === null || e === null) return false
76
+ return inWindow(wc.minutes, s, e)
77
+ })
78
+ }
79
+
80
+ /**
81
+ * 主判定:此刻是否应触发高峰暂停。
82
+ *
83
+ * - 峰谷判定:固定使用 BILLING_TIMEZONE(北京时间),与 DeepSeek 官方计费一致;
84
+ * - 周末判定:使用 settings.timezone(用户本地时区),因为周末是用户本地概念。
85
+ *
86
+ * @param {object} settings { enabled, weekendMode, timezone, peakWindows }
87
+ * @param {Date} date
88
+ * @returns {{pause:boolean, reason:string}}
89
+ * reason: 'disabled' | 'weekend' | 'peak' | 'off-peak'
90
+ */
91
+ export function shouldPause(settings, date) {
92
+ if (!settings || settings.enabled !== true) return { pause: false, reason: 'disabled' }
93
+ // 周末判定:用用户配置时区(用户本地的周末)
94
+ const wcUser = wallClock(settings.timezone, date)
95
+ if (settings.weekendMode === true && isWeekend(wcUser.weekday)) {
96
+ return { pause: false, reason: 'weekend' }
97
+ }
98
+ // 峰谷判定:固定北京时间(DeepSeek 官方计费基准)
99
+ const wcBilling = wallClock(BILLING_TIMEZONE, date)
100
+ if (isInPeak(wcBilling, settings.peakWindows || [])) {
101
+ return { pause: true, reason: 'peak' }
102
+ }
103
+ return { pause: false, reason: 'off-peak' }
104
+ }
105
+
106
+ /**
107
+ * 某时区「本地 00:00」对应的绝对时刻(毫秒)。
108
+ * 通过两次投影迭代消掉时区偏移(第二次覆盖 DST 切换日)。
109
+ * @param {string} tz IANA 时区名
110
+ * @param {number} year
111
+ * @param {number} month 1-12
112
+ * @param {number} day
113
+ * @returns {number} epoch 毫秒
114
+ */
115
+ export function localMidnight(tz, year, month, day) {
116
+ const target = Date.UTC(year, month - 1, day)
117
+ let guess = target
118
+ for (let i = 0; i < 2; i++) {
119
+ const wc = wallClock(tz, new Date(guess))
120
+ const actual = Date.UTC(wc.year, wc.month - 1, wc.day, Math.floor(wc.minutes / 60), wc.minutes % 60)
121
+ guess -= actual - target
122
+ }
123
+ return guess
124
+ }
125
+
126
+ /**
127
+ * 从 `date` 出发、下一个「确定非峰」的时刻;找不到返回 null。
128
+ * 取两个候选的最小值:
129
+ * (a) 当前所在高峰窗口的结束时刻(计费时区,跨午夜安全);
130
+ * (b) 下一个周末日的本地 00:00(周末整天非峰,仅 weekendMode 开启时)。
131
+ * @param {object} settings
132
+ * @param {Date} date 已知处于高峰
133
+ * @returns {number|null}
134
+ */
135
+ function nextOffPeakInstant(settings, date) {
136
+ const t0 = date.getTime()
137
+ // 分钟对齐:秒/毫秒在墙钟分钟偏移下与时区无关(所有 IANA 偏移均为整分钟)
138
+ const minuteStart = t0 - (date.getUTCSeconds() * 1000 + date.getUTCMilliseconds())
139
+ const candidates = []
140
+ const wcBilling = wallClock(BILLING_TIMEZONE, date)
141
+ for (const w of settings.peakWindows || []) {
142
+ const s = parseHHMM(w && w.start)
143
+ const e = parseHHMM(w && w.end)
144
+ if (s === null || e === null) continue
145
+ if (!inWindow(wcBilling.minutes, s, e)) continue
146
+ // 跨午夜窗口(s > e)在 m < e 时结束于当日,m >= s 时结束于次日
147
+ const deltaMin =
148
+ s < e
149
+ ? e - wcBilling.minutes
150
+ : wcBilling.minutes >= s
151
+ ? e + 1440 - wcBilling.minutes
152
+ : e - wcBilling.minutes
153
+ candidates.push(minuteStart + deltaMin * 60_000)
154
+ }
155
+ if (settings.weekendMode === true) {
156
+ const wcUser = wallClock(settings.timezone, date)
157
+ for (let k = 1; k <= 7; k++) {
158
+ const day = new Date(Date.UTC(wcUser.year, wcUser.month - 1, wcUser.day + k))
159
+ if (!isWeekend(day.getUTCDay())) continue
160
+ candidates.push(localMidnight(settings.timezone, day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate()))
161
+ break
162
+ }
163
+ }
164
+ let best = null
165
+ for (const c of candidates) {
166
+ if (!Number.isFinite(c) || c <= t0) continue
167
+ if (best === null || c < best) best = c
168
+ }
169
+ return best
170
+ }
171
+
172
+ /**
173
+ * 距下一个「非峰」时刻的毫秒数(hold 释放定时用)。
174
+ *
175
+ * - 当前已非峰(含周末 / disabled)→ 0;
176
+ * - 处于高峰 → 当前窗口结束时刻(跨午夜安全);
177
+ * - weekendMode 开启且窗口会跨进周末 → 取「周末开始」这个更早的时刻;
178
+ * - 配置异常(非法窗口 / 无法算出)→ 0(由 30s tick 兜底,不挂死)。
179
+ * @param {object} settings 同 shouldPause
180
+ * @param {Date} [date]
181
+ * @returns {number} 毫秒(>= 0)
182
+ */
183
+ export function msUntilOffPeak(settings, date = new Date()) {
184
+ if (!settings || settings.enabled !== true) return 0
185
+ const t0 = date.getTime()
186
+ let probe = date
187
+ // 相邻窗口 / 设置热改时逐级往后找;上限 8 次防死循环。
188
+ for (let i = 0; i < 8; i++) {
189
+ if (!shouldPause(settings, probe).pause) return Math.max(0, probe.getTime() - t0)
190
+ const next = nextOffPeakInstant(settings, probe)
191
+ if (next === null) return 0
192
+ probe = new Date(next)
193
+ }
194
+ return Math.max(0, probe.getTime() - t0)
195
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * dsh-session-guard — `tool/result` 调用 id 的双形态读取(**零依赖**,可单测)。
3
+ *
4
+ * 为什么需要它:DSH 0.1.1-rc.2 与 0.1.2-rc.1 的 `session/event` 形状一致
5
+ * (已逐行核对 `packages/session/session-persistence/src/coordinator.ts` 的
6
+ * `tool/result` 分支,两版本都保留两种形态),但**同一条记录有两种历史形态**:
7
+ *
8
+ * - 现行形态:`event.data.message.content[].toolCallId`(`tool-result` 块);
9
+ * - 遗留形态:`event.data.message.source.callId`(旧记录经持久化协调器迁移后的
10
+ * 形态,两版本的回放日志里都可能出现)。
11
+ *
12
+ * 读取顺序:优先块上的 `toolCallId`(更精确),缺失才回退 `source.callId`。
13
+ * **不要改成单读**——只读块会在旧记录上丢 id(in-flight 工具无法落地),
14
+ * 只读 `source.callId` 会在新记录上丢 id(暂停点判定失效)。
15
+ *
16
+ * 本文件刻意不导入任何 `@deepseek-ai/*` 包,因此可在没有 DSH 运行时依赖的
17
+ * 环境下直接单测(见 `tests/tool-call-id.test.mjs`)。
18
+ */
19
+
20
+ /**
21
+ * 读一条 `tool/result` 记录(`event.data.message`)的调用 id。
22
+ * @param {unknown} message - `event.data.message`(可能缺失或非对象)
23
+ * @returns {string|undefined} 调用 id;两种形态都缺时返回 undefined
24
+ */
25
+ export function readToolResultCallId(message) {
26
+ if (message === null || typeof message !== 'object') return undefined
27
+ const content = Array.isArray(message.content) ? message.content : []
28
+ for (const block of content) {
29
+ if (block?.type === 'tool-result' && typeof block.toolCallId === 'string') return block.toolCallId
30
+ }
31
+ const legacy = message.source?.callId
32
+ return typeof legacy === 'string' ? legacy : undefined
33
+ }
34
+
35
+ /**
36
+ * 读一个 `user/message` 内容块(`tool-result` 形态)的调用 id。
37
+ * @param {unknown} block - 内容块
38
+ * @returns {string|undefined} 调用 id;非 `tool-result` 或 id 非字符串时 undefined
39
+ */
40
+ export function readToolResultBlockId(block) {
41
+ if (block === null || typeof block !== 'object') return undefined
42
+ if (block.type !== 'tool-result') return undefined
43
+ return typeof block.toolCallId === 'string' ? block.toolCallId : undefined
44
+ }