dsh-offpeak-guard 0.1.0 → 0.2.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/CHANGELOG.md +8 -0
- package/README.en.md +45 -6
- package/README.md +45 -6
- package/cordis.patch.yml +11 -3
- package/index.js +129 -3
- package/lib/guard.js +34 -12
- package/lib/holidays.js +60 -0
- package/lib/peak.js +131 -34
- package/lib/tools.js +19 -1
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- 默认规则改为 DeepSeek 官方峰谷:工作日 09:00–12:00、14:00–18:00 峰时,周末与**中国法定节假日全天**谷价;内置 2026 年节假日列表。
|
|
6
|
+
- 新增 `preset`:`auto` / `deepseek-official` / `workbuddy` / `custom`,按 provider 自动选择(WorkBuddy/CodeBuddy → workbuddy,其余 → 官方)。
|
|
7
|
+
- 新增 `Config` schema:DSH 设置页可直接修改时区、窗口、豁免模型、提醒阈值等。
|
|
8
|
+
- 新增 `DSH_OFFPEAK_*` 环境变量覆盖(优先级:环境变量 > 配置 > 默认),便于部署方统一注入。
|
|
9
|
+
- 状态工具与界面显示当前规则和节假日状态。
|
|
10
|
+
|
|
3
11
|
## 0.1.0 — 2026-11-05
|
|
4
12
|
|
|
5
13
|
- 首个公开版本。
|
package/README.en.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# Off-peak guard (dsh-offpeak-guard)
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-offpeak-guard)
|
|
4
|
+
|
|
5
|
+
Published on npm: install it from the DSH Plugins page, or run
|
|
6
|
+
`dsh plugin --profile <profile> add dsh-offpeak-guard`.
|
|
7
|
+
|
|
3
8
|
Schedule work around **DeepSeek peak/off-peak pricing** inside DeepSeek Harness:
|
|
4
9
|
|
|
5
|
-
- **Time awareness**: weekdays `09:00–12:00` and `14:00–18:00` are peak
|
|
10
|
+
- **Time awareness**: per DeepSeek's official pricing, Beijing-time weekdays (excluding Chinese statutory holidays) `09:00–12:00` and `14:00–18:00` are peak; every other hour (weekends, holidays, lunch, night) is off-peak at 50%. Built-in Chinese holidays cover 2026; add later years with `holidays`.
|
|
6
11
|
- **Model awareness**: only sessions using a peak-priced model (model name contains `deepseek` by default; exempt models one by one with `exemptModels`) are paused/resumed. Other models are untouched.
|
|
7
12
|
- **Work planning**:
|
|
8
13
|
- peak starts → running tasks are paused (the pending inbox is kept, no message is lost);
|
|
@@ -31,15 +36,17 @@ Red blink within the alert window before peak (sample):
|
|
|
31
36
|
|
|
32
37
|
## Configuration
|
|
33
38
|
|
|
34
|
-
The `
|
|
39
|
+
The `offpeak-guard` row's `config` in `cordis.patch.yml`, or the plugin settings form in DSH (a Config schema is declared):
|
|
35
40
|
|
|
36
41
|
| Key | Default | Meaning |
|
|
37
42
|
| --- | --- | --- |
|
|
38
43
|
| `enabled` | `true` | Master switch |
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
44
|
+
| `preset` | `auto` | `auto` picks by provider: WorkBuddy/CodeBuddy → `workbuddy`, otherwise `deepseek-official`; can be pinned to `deepseek-official` / `workbuddy` / `custom` |
|
|
45
|
+
| `timezone` | `Asia/Shanghai` | Pricing time zone (DeepSeek bills in Beijing time) |
|
|
46
|
+
| `peakWeekdays` | `[1,2,3,4,5]` | Weekdays with peak windows (0 = Sunday); only used by `custom` |
|
|
47
|
+
| `peakWindows` | 09:00–12:00, 14:00–18:00 | Peak windows; only used by `custom` |
|
|
48
|
+
| `discount` | `0.5` | Off-peak discount, display only; only used by `custom` |
|
|
49
|
+
| `holidays` | `[]` | Extra statutory holidays (`YYYY-MM-DD`), appended to the built-in 2026 list for the official/WorkBuddy presets |
|
|
43
50
|
| `modelPattern` | `deepseek` | Model/provider name containing it counts as peak-priced |
|
|
44
51
|
| `exemptModels` | `[]` | Model ids exempted one by one |
|
|
45
52
|
| `pauseRunningOnPeak` | `true` | Pause running tasks when peak starts |
|
|
@@ -50,6 +57,36 @@ The `config` of the `offpeak-guard` row in `cordis.patch.yml`:
|
|
|
50
57
|
| `bypassMinutes` | `10` | Grace period after a manual resume |
|
|
51
58
|
| `alertMinutes` | `10` | Blink the sidebar clock within this many minutes of a switch (0 = never) |
|
|
52
59
|
|
|
60
|
+
### Environment variables
|
|
61
|
+
|
|
62
|
+
Deployments can inject settings with environment variables. Priority: **env > config > default**. Invalid values are ignored.
|
|
63
|
+
|
|
64
|
+
| Variable | Example | Config field |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `DSH_OFFPEAK_ENABLED` | `0` / `1` | `enabled` |
|
|
67
|
+
| `DSH_OFFPEAK_PRESET` | `custom` | `preset` |
|
|
68
|
+
| `DSH_OFFPEAK_TIMEZONE` | `Asia/Shanghai` | `timezone` |
|
|
69
|
+
| `DSH_OFFPEAK_PEAK_WEEKDAYS` | `1,2,3,4,5` | `peakWeekdays` |
|
|
70
|
+
| `DSH_OFFPEAK_PEAK_WINDOWS` | `09:00-12:00,14:00-18:00` | `peakWindows` |
|
|
71
|
+
| `DSH_OFFPEAK_DISCOUNT` | `0.5` | `discount` |
|
|
72
|
+
| `DSH_OFFPEAK_HOLIDAYS` | `2026-10-01,2026-10-02` | `holidays` |
|
|
73
|
+
| `DSH_OFFPEAK_MODEL_PATTERN` | `deepseek` | `modelPattern` |
|
|
74
|
+
| `DSH_OFFPEAK_EXEMPT_MODELS` | `deepseek-v4-pro,deepseek-flash` | `exemptModels` |
|
|
75
|
+
| `DSH_OFFPEAK_PAUSE_RUNNING` | `0` / `1` | `pauseRunningOnPeak` |
|
|
76
|
+
| `DSH_OFFPEAK_DEFER_NEW` | `0` / `1` | `deferNewTurnsOnPeak` |
|
|
77
|
+
| `DSH_OFFPEAK_RESUME` | `0` / `1` | `resumeOnOffPeak` |
|
|
78
|
+
| `DSH_OFFPEAK_POLL_SECONDS` | `15` | `pollSeconds` |
|
|
79
|
+
| `DSH_OFFPEAK_RESUME_DELAY_SECONDS` | `3` | `resumeDelaySeconds` |
|
|
80
|
+
| `DSH_OFFPEAK_BYPASS_MINUTES` | `10` | `bypassMinutes` |
|
|
81
|
+
| `DSH_OFFPEAK_ALERT_MINUTES` | `10` | `alertMinutes` |
|
|
82
|
+
|
|
83
|
+
### Presets
|
|
84
|
+
|
|
85
|
+
- `deepseek-official` (default): official weekday peak windows plus built-in 2026 Chinese holidays (holidays are off-peak all day).
|
|
86
|
+
- `workbuddy`: same peak windows, labeled as WorkBuddy credit pricing; selected automatically for WorkBuddy/CodeBuddy providers.
|
|
87
|
+
- `custom`: use `peakWeekdays` / `peakWindows` / `discount` / `holidays` exactly, for other providers or self-hosted pricing.
|
|
88
|
+
|
|
89
|
+
|
|
53
90
|
## Data
|
|
54
91
|
|
|
55
92
|
State and snapshots live in `$DSH_HOME/offpeak-guard/`:
|
|
@@ -65,6 +102,8 @@ Pause uses `sessionController.cancel`, which ends only the current turn and keep
|
|
|
65
102
|
|
|
66
103
|
## Boundaries
|
|
67
104
|
|
|
105
|
+
- Built-in statutory holidays cover 2026 only; after the 2027 schedule is published, upgrade the plugin or append dates with `holidays` / `DSH_OFFPEAK_HOLIDAYS`, otherwise holidays are treated as ordinary weekdays.
|
|
106
|
+
- Named presets have fixed windows; use `preset: custom` for other providers or self-hosted pricing.
|
|
68
107
|
- Only root sessions with a working directory are handled; subagent sessions are skipped so parents do not hang.
|
|
69
108
|
- A plugin restart does not cancel tasks that are already running (so installing the plugin cannot kill the current conversation); the next peak or new task follows the rules again.
|
|
70
109
|
- Manual `resume_now` grants those sessions a 10-minute grace period so they are not paused again immediately.
|
package/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# 错峰调度 · 峰谷守卫(dsh-offpeak-guard)
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-offpeak-guard)
|
|
4
|
+
|
|
5
|
+
已发布到 npm:在 DSH 的插件页搜索 `dsh-offpeak-guard` 安装,或运行
|
|
6
|
+
`dsh plugin --profile <profile> add dsh-offpeak-guard`。
|
|
7
|
+
|
|
3
8
|
在 DeepSeek Harness 里按 **DeepSeek 峰谷计价** 安排工作:
|
|
4
9
|
|
|
5
|
-
-
|
|
10
|
+
- **时间识别**:按 DeepSeek 官方规则,北京时间周一至周五(不含中国法定节假日)`09:00–12:00`、`14:00–18:00` 为峰值原价;其余时间(含周末、法定节假日全天)为谷价,按 5 折计费。内置 2026 年法定节假日;后续年份可用 `holidays` 追加。
|
|
6
11
|
- **模型识别**:只有使用峰谷计价模型(默认模型名含 `deepseek`,可在 `exemptModels` 里逐项豁免)的会话才会被暂停/恢复;用其它模型的会话不受影响。
|
|
7
12
|
- **工作规划**:
|
|
8
13
|
- 峰时开始 → 暂停正在运行的任务(保留待处理队列,不丢消息);
|
|
@@ -31,15 +36,17 @@
|
|
|
31
36
|
|
|
32
37
|
## 配置
|
|
33
38
|
|
|
34
|
-
`cordis.patch.yml` 里那一行 `offpeak-guard` 的 `config
|
|
39
|
+
`cordis.patch.yml` 里那一行 `offpeak-guard` 的 `config`,或在 DSH 设置页的插件配置里修改(已声明 Config schema):
|
|
35
40
|
|
|
36
41
|
| 键 | 默认 | 说明 |
|
|
37
42
|
| --- | --- | --- |
|
|
38
43
|
| `enabled` | `true` | 总开关 |
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
44
|
+
| `preset` | `auto` | `auto` 按 provider 自动选:WorkBuddy/CodeBuddy → `workbuddy`,其余 → `deepseek-official`;也可固定为 `deepseek-official` / `workbuddy` / `custom` |
|
|
45
|
+
| `timezone` | `Asia/Shanghai` | 计价时区(DeepSeek 计费按北京时间) |
|
|
46
|
+
| `peakWeekdays` | `[1,2,3,4,5]` | 峰时适用的星期(0=周日);仅 `custom` 预设使用 |
|
|
47
|
+
| `peakWindows` | 09:00–12:00、14:00–18:00 | 峰时窗口;仅 `custom` 预设使用,官方调价时改这里 |
|
|
48
|
+
| `discount` | `0.5` | 谷价折扣,仅用于显示「5 折」;仅 `custom` 预设使用 |
|
|
49
|
+
| `holidays` | `[]` | 追加的法定节假日(`YYYY-MM-DD`),官方/WorkBuddy 预设会在内置 2026 列表上追加 |
|
|
43
50
|
| `modelPattern` | `deepseek` | 模型名/供应商名包含它即视为峰谷计价模型 |
|
|
44
51
|
| `exemptModels` | `[]` | 逐项豁免的模型 id |
|
|
45
52
|
| `pauseRunningOnPeak` | `true` | 峰时开始时暂停正在运行的任务 |
|
|
@@ -50,6 +57,36 @@
|
|
|
50
57
|
| `bypassMinutes` | `10` | 手动恢复后的豁免期,避免立刻又被暂停 |
|
|
51
58
|
| `alertMinutes` | `10` | 左栏计时在距切换多少分钟内开始闪烁(0 = 不闪) |
|
|
52
59
|
|
|
60
|
+
### 环境变量
|
|
61
|
+
|
|
62
|
+
部署方可以用环境变量统一注入,优先级:**环境变量 > 配置 > 默认**。无效值会被忽略。
|
|
63
|
+
|
|
64
|
+
| 环境变量 | 例子 | 对应配置 |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `DSH_OFFPEAK_ENABLED` | `0` / `1` | `enabled` |
|
|
67
|
+
| `DSH_OFFPEAK_PRESET` | `custom` | `preset` |
|
|
68
|
+
| `DSH_OFFPEAK_TIMEZONE` | `Asia/Shanghai` | `timezone` |
|
|
69
|
+
| `DSH_OFFPEAK_PEAK_WEEKDAYS` | `1,2,3,4,5` | `peakWeekdays` |
|
|
70
|
+
| `DSH_OFFPEAK_PEAK_WINDOWS` | `09:00-12:00,14:00-18:00` | `peakWindows` |
|
|
71
|
+
| `DSH_OFFPEAK_DISCOUNT` | `0.5` | `discount` |
|
|
72
|
+
| `DSH_OFFPEAK_HOLIDAYS` | `2026-10-01,2026-10-02` | `holidays` |
|
|
73
|
+
| `DSH_OFFPEAK_MODEL_PATTERN` | `deepseek` | `modelPattern` |
|
|
74
|
+
| `DSH_OFFPEAK_EXEMPT_MODELS` | `deepseek-v4-pro,deepseek-flash` | `exemptModels` |
|
|
75
|
+
| `DSH_OFFPEAK_PAUSE_RUNNING` | `0` / `1` | `pauseRunningOnPeak` |
|
|
76
|
+
| `DSH_OFFPEAK_DEFER_NEW` | `0` / `1` | `deferNewTurnsOnPeak` |
|
|
77
|
+
| `DSH_OFFPEAK_RESUME` | `0` / `1` | `resumeOnOffPeak` |
|
|
78
|
+
| `DSH_OFFPEAK_POLL_SECONDS` | `15` | `pollSeconds` |
|
|
79
|
+
| `DSH_OFFPEAK_RESUME_DELAY_SECONDS` | `3` | `resumeDelaySeconds` |
|
|
80
|
+
| `DSH_OFFPEAK_BYPASS_MINUTES` | `10` | `bypassMinutes` |
|
|
81
|
+
| `DSH_OFFPEAK_ALERT_MINUTES` | `10` | `alertMinutes` |
|
|
82
|
+
|
|
83
|
+
### 预设
|
|
84
|
+
|
|
85
|
+
- `deepseek-official`(默认):官方工作日峰时 + 内置 2026 法定节假日(节假日全天谷价)。
|
|
86
|
+
- `workbuddy`:WorkBuddy 积分规则,峰时窗口与官方相同;按 provider 自动选择时用于标注来源。
|
|
87
|
+
- `custom`:完全使用 `peakWeekdays` / `peakWindows` / `discount` / `holidays`,适合其他供应商或自建计价。
|
|
88
|
+
|
|
89
|
+
|
|
53
90
|
## 数据
|
|
54
91
|
|
|
55
92
|
状态与快照都在 `$DSH_HOME/offpeak-guard/`:
|
|
@@ -65,6 +102,8 @@
|
|
|
65
102
|
|
|
66
103
|
## 边界
|
|
67
104
|
|
|
105
|
+
- 内置法定节假日目前覆盖 2026 年;2027 年安排公布后可升级插件,或用 `holidays` / `DSH_OFFPEAK_HOLIDAYS` 追加,否则节假日会被当作普通工作日。
|
|
106
|
+
- 命名预设的窗口是固定的;需要其他供应商/自建计价时用 `preset: custom` 配 `peakWindows`。
|
|
68
107
|
- 只处理有工作目录、非子代理的根会话,避免打断子代理让父会话卡住。
|
|
69
108
|
- 插件自身重启时不会主动打断正在跑的任务(避免刚安装就停掉当前对话);之后进入峰时或新起任务仍按规则处理。
|
|
70
109
|
- 手动「立即恢复」会给这些会话 10 分钟豁免,避免峰时刚恢复又被停回去。
|
package/cordis.patch.yml
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
# dsh-offpeak-guard:DeepSeek 峰谷计价守卫。
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# 默认按 DeepSeek 官方「模型 & 价格」规则:
|
|
3
|
+
# 北京时间周一至周五(不含中国法定节假日)09:00–12:00、14:00–18:00 为高峰;
|
|
4
|
+
# 其余时段(含周末、法定节假日全天)为空闲,空闲价为高峰价的一半。
|
|
5
|
+
# preset:
|
|
6
|
+
# auto 按 provider 自动选(WorkBuddy/CodeBuddy 走 workbuddy,其余走 deepseek-official)
|
|
7
|
+
# deepseek-official 官方规则(含内置 2026 法定节假日)
|
|
8
|
+
# workbuddy WorkBuddy 积分规则(时段相同)
|
|
9
|
+
# custom 用下面 peakWeekdays / peakWindows / discount / holidays
|
|
10
|
+
# 环境变量(部署方注入,优先级高于本文件):DSH_OFFPEAK_*,详见 README。
|
|
5
11
|
- insert:
|
|
6
12
|
- id: offpeak-guard
|
|
7
13
|
name: 'dsh-offpeak-guard'
|
|
8
14
|
config:
|
|
9
15
|
enabled: true
|
|
16
|
+
preset: 'auto'
|
|
10
17
|
timezone: 'Asia/Shanghai'
|
|
11
18
|
peakWeekdays: [1, 2, 3, 4, 5]
|
|
12
19
|
peakWindows:
|
|
@@ -15,6 +22,7 @@
|
|
|
15
22
|
- start: '14:00'
|
|
16
23
|
end: '18:00'
|
|
17
24
|
discount: 0.5
|
|
25
|
+
holidays: []
|
|
18
26
|
modelPattern: 'deepseek'
|
|
19
27
|
exemptModels: []
|
|
20
28
|
pauseRunningOnPeak: true
|
package/index.js
CHANGED
|
@@ -2,19 +2,145 @@
|
|
|
2
2
|
* dsh-offpeak-guard 入口(Host 半)。
|
|
3
3
|
*
|
|
4
4
|
* 三件事:
|
|
5
|
-
* 1. 时间识别 —— 按 DeepSeek
|
|
5
|
+
* 1. 时间识别 —— 按 DeepSeek 官方峰谷规则(工作日 9–12、14–18,法定节假日全天谷价)判断时段;
|
|
6
6
|
* 2. 模型识别 —— 只有使用峰谷计价模型的会话才会被暂停/恢复;
|
|
7
7
|
* 3. 工作规划 —— 峰时暂停运行中的任务、延后新任务,谷价开始时自动恢复;
|
|
8
8
|
* 另提供 offpeak_plan 把稍后要做的事排进谷价时段。
|
|
9
|
+
*
|
|
10
|
+
* 配置优先级:环境变量(DSH_OFFPEAK_*)> 设置/patch > 代码默认。
|
|
9
11
|
*/
|
|
12
|
+
import z from '@deepseek-ai/schemastery';
|
|
10
13
|
import { createGuard } from './lib/guard.js';
|
|
11
14
|
import { createOffpeakTools } from './lib/tools.js';
|
|
12
15
|
|
|
13
16
|
export const name = 'offpeak-guard';
|
|
14
17
|
export const inject = ['tools'];
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
const OFFICIAL_WINDOWS = [
|
|
20
|
+
{ start: '09:00', end: '12:00' },
|
|
21
|
+
{ start: '14:00', end: '18:00' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const Config = z.object({
|
|
25
|
+
enabled: z.boolean().default(true),
|
|
26
|
+
preset: z.string().default('auto'),
|
|
27
|
+
timezone: z.string().default('Asia/Shanghai'),
|
|
28
|
+
peakWeekdays: z.array(z.natural().max(6)).default([1, 2, 3, 4, 5]),
|
|
29
|
+
peakWindows: z
|
|
30
|
+
.array(z.object({ start: z.string(), end: z.string() }))
|
|
31
|
+
.default(OFFICIAL_WINDOWS),
|
|
32
|
+
discount: z.number().min(0.1).max(1).default(0.5),
|
|
33
|
+
holidays: z.array(z.string()).default([]),
|
|
34
|
+
modelPattern: z.string().default('deepseek'),
|
|
35
|
+
exemptModels: z.array(z.string()).default([]),
|
|
36
|
+
pauseRunningOnPeak: z.boolean().default(true),
|
|
37
|
+
deferNewTurnsOnPeak: z.boolean().default(true),
|
|
38
|
+
resumeOnOffPeak: z.boolean().default(true),
|
|
39
|
+
pollSeconds: z.natural().min(5).max(300).default(15),
|
|
40
|
+
resumeDelaySeconds: z.natural().min(0).max(120).default(3),
|
|
41
|
+
bypassMinutes: z.natural().min(0).max(120).default(10),
|
|
42
|
+
alertMinutes: z.natural().min(0).max(120).default(10),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function parseBoolean(value) {
|
|
46
|
+
const text = String(value).trim().toLowerCase();
|
|
47
|
+
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
|
48
|
+
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseNumber(value) {
|
|
53
|
+
const number = Number(String(value).trim());
|
|
54
|
+
return Number.isFinite(number) ? number : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseWeekdays(value) {
|
|
58
|
+
const days = String(value)
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((item) => Number(item.trim()))
|
|
61
|
+
.filter((item) => Number.isInteger(item) && item >= 0 && item <= 6);
|
|
62
|
+
return days.length > 0 ? days : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseWindows(value) {
|
|
66
|
+
const windows = [];
|
|
67
|
+
for (const part of String(value).split(',')) {
|
|
68
|
+
const match = /^\s*(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})\s*$/.exec(part);
|
|
69
|
+
if (match !== null) windows.push({ start: match[1], end: match[2] });
|
|
70
|
+
}
|
|
71
|
+
return windows.length > 0 ? windows : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseList(value) {
|
|
75
|
+
const items = String(value)
|
|
76
|
+
.split(',')
|
|
77
|
+
.map((item) => item.trim())
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
return items.length > 0 ? items : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 读取部署环境变量。用于容器/CI/多实例统一注入,避免每个用户改 patch。
|
|
84
|
+
* 无效值忽略,不改变原有配置。
|
|
85
|
+
*/
|
|
86
|
+
export function envConfig(env = process.env) {
|
|
87
|
+
const out = {};
|
|
88
|
+
if (env.DSH_OFFPEAK_ENABLED !== undefined) {
|
|
89
|
+
const value = parseBoolean(env.DSH_OFFPEAK_ENABLED);
|
|
90
|
+
if (value !== undefined) out.enabled = value;
|
|
91
|
+
}
|
|
92
|
+
if (env.DSH_OFFPEAK_PRESET) out.preset = String(env.DSH_OFFPEAK_PRESET).trim();
|
|
93
|
+
if (env.DSH_OFFPEAK_TIMEZONE) out.timezone = String(env.DSH_OFFPEAK_TIMEZONE).trim();
|
|
94
|
+
if (env.DSH_OFFPEAK_PEAK_WEEKDAYS !== undefined) {
|
|
95
|
+
const value = parseWeekdays(env.DSH_OFFPEAK_PEAK_WEEKDAYS);
|
|
96
|
+
if (value !== undefined) out.peakWeekdays = value;
|
|
97
|
+
}
|
|
98
|
+
if (env.DSH_OFFPEAK_PEAK_WINDOWS !== undefined) {
|
|
99
|
+
const value = parseWindows(env.DSH_OFFPEAK_PEAK_WINDOWS);
|
|
100
|
+
if (value !== undefined) out.peakWindows = value;
|
|
101
|
+
}
|
|
102
|
+
if (env.DSH_OFFPEAK_DISCOUNT !== undefined) {
|
|
103
|
+
const value = parseNumber(env.DSH_OFFPEAK_DISCOUNT);
|
|
104
|
+
if (value !== undefined) out.discount = value;
|
|
105
|
+
}
|
|
106
|
+
if (env.DSH_OFFPEAK_HOLIDAYS !== undefined) {
|
|
107
|
+
const value = parseList(env.DSH_OFFPEAK_HOLIDAYS);
|
|
108
|
+
if (value !== undefined) out.holidays = value;
|
|
109
|
+
}
|
|
110
|
+
if (env.DSH_OFFPEAK_MODEL_PATTERN) out.modelPattern = String(env.DSH_OFFPEAK_MODEL_PATTERN).trim();
|
|
111
|
+
if (env.DSH_OFFPEAK_EXEMPT_MODELS !== undefined) {
|
|
112
|
+
const value = parseList(env.DSH_OFFPEAK_EXEMPT_MODELS);
|
|
113
|
+
if (value !== undefined) out.exemptModels = value;
|
|
114
|
+
}
|
|
115
|
+
const booleans = [
|
|
116
|
+
['DSH_OFFPEAK_PAUSE_RUNNING', 'pauseRunningOnPeak'],
|
|
117
|
+
['DSH_OFFPEAK_DEFER_NEW', 'deferNewTurnsOnPeak'],
|
|
118
|
+
['DSH_OFFPEAK_RESUME', 'resumeOnOffPeak'],
|
|
119
|
+
];
|
|
120
|
+
for (const [key, field] of booleans) {
|
|
121
|
+
if (env[key] !== undefined) {
|
|
122
|
+
const value = parseBoolean(env[key]);
|
|
123
|
+
if (value !== undefined) out[field] = value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const numbers = [
|
|
127
|
+
['DSH_OFFPEAK_POLL_SECONDS', 'pollSeconds'],
|
|
128
|
+
['DSH_OFFPEAK_RESUME_DELAY_SECONDS', 'resumeDelaySeconds'],
|
|
129
|
+
['DSH_OFFPEAK_BYPASS_MINUTES', 'bypassMinutes'],
|
|
130
|
+
['DSH_OFFPEAK_ALERT_MINUTES', 'alertMinutes'],
|
|
131
|
+
];
|
|
132
|
+
for (const [key, field] of numbers) {
|
|
133
|
+
if (env[key] !== undefined) {
|
|
134
|
+
const value = parseNumber(env[key]);
|
|
135
|
+
if (value !== undefined) out[field] = value;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function apply(ctx, rawConfig = {}) {
|
|
142
|
+
const config = { ...(rawConfig ?? {}), ...envConfig() };
|
|
143
|
+
const guard = createGuard(ctx, config);
|
|
18
144
|
|
|
19
145
|
ctx.effect(() => guard.start(), 'offpeak-guard: tick loop');
|
|
20
146
|
|
package/lib/guard.js
CHANGED
|
@@ -74,6 +74,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
74
74
|
|
|
75
75
|
const bypass = new Map();
|
|
76
76
|
const deferring = new Set();
|
|
77
|
+
const rulesCache = new Map();
|
|
77
78
|
const busy = { tick: false, pause: false, resume: false, plans: false };
|
|
78
79
|
let timer = null;
|
|
79
80
|
let lastPhase = null;
|
|
@@ -161,6 +162,22 @@ export function createGuard(ctx, config = {}) {
|
|
|
161
162
|
return session;
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
function rulesFor(provider) {
|
|
166
|
+
const key = String(provider ?? '');
|
|
167
|
+
if (!rulesCache.has(key)) rulesCache.set(key, resolveRules(config, provider));
|
|
168
|
+
return rulesCache.get(key);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function currentProvider() {
|
|
172
|
+
const active = liveAgents().find((agent) => agent.status === 'running');
|
|
173
|
+
if (active !== undefined) return modelOf(active).provider;
|
|
174
|
+
try {
|
|
175
|
+
return service('agentDefaultModel')?.currentSelection?.()?.provider ?? null;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
164
181
|
const modelLabelCache = new Map();
|
|
165
182
|
|
|
166
183
|
async function resolveModelLabel(provider, model) {
|
|
@@ -205,15 +222,19 @@ export function createGuard(ctx, config = {}) {
|
|
|
205
222
|
peakPriced: isPeakPriced(route.provider, route.model),
|
|
206
223
|
};
|
|
207
224
|
}
|
|
225
|
+
const activeRules = rulesFor(currentProvider());
|
|
208
226
|
return {
|
|
209
227
|
version: 1,
|
|
210
228
|
generatedAt: Date.now(),
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
229
|
+
preset: desc.preset,
|
|
230
|
+
presetLabel: desc.presetLabel,
|
|
231
|
+
timezone: desc.timezone,
|
|
232
|
+
peakWeekdays: activeRules.peakWeekdays,
|
|
233
|
+
peakWindows: activeRules.peakWindows.map((item) => ({ start: item.start, end: item.end })),
|
|
234
|
+
discount: desc.discount,
|
|
215
235
|
phase: desc.phase,
|
|
216
236
|
phaseLabel: desc.phaseLabel,
|
|
237
|
+
holiday: desc.holiday === true,
|
|
217
238
|
localTime: desc.localTime,
|
|
218
239
|
weekday: desc.weekday,
|
|
219
240
|
window: desc.window,
|
|
@@ -261,7 +282,8 @@ export function createGuard(ctx, config = {}) {
|
|
|
261
282
|
}
|
|
262
283
|
|
|
263
284
|
function resumeText(item) {
|
|
264
|
-
const
|
|
285
|
+
const itemRules = rulesFor(item.provider);
|
|
286
|
+
const when = formatDateClock(new Date(Number(item.at) || Date.now()), itemRules.timezone);
|
|
265
287
|
const modelText = item.model ? `(模型 ${item.model})` : '';
|
|
266
288
|
return [
|
|
267
289
|
`【错峰调度】谷价时段已经开始,现在继续执行任务。`,
|
|
@@ -314,8 +336,6 @@ export function createGuard(ctx, config = {}) {
|
|
|
314
336
|
try {
|
|
315
337
|
const state = await stateFile.read();
|
|
316
338
|
const now = Date.now();
|
|
317
|
-
const desc = describeWindow(now, rules);
|
|
318
|
-
if (desc.phase !== 'peak') return [];
|
|
319
339
|
const paused = [];
|
|
320
340
|
let changed = false;
|
|
321
341
|
for (const agent of liveAgents()) {
|
|
@@ -325,6 +345,8 @@ export function createGuard(ctx, config = {}) {
|
|
|
325
345
|
const { provider, model } = modelOf(agent);
|
|
326
346
|
if (!isPeakPriced(provider, model)) continue;
|
|
327
347
|
if ((bypass.get(agent.id) ?? 0) > now) continue;
|
|
348
|
+
const desc = describeWindow(now, rulesFor(provider));
|
|
349
|
+
if (desc.phase !== 'peak') continue;
|
|
328
350
|
const existing = state.interrupted.find((item) => item.sessionId === agent.id && item.status === 'paused');
|
|
329
351
|
if (existing !== undefined) {
|
|
330
352
|
existing.at = now;
|
|
@@ -438,13 +460,13 @@ export function createGuard(ctx, config = {}) {
|
|
|
438
460
|
if (disposed || !options.enabled || !options.deferNewTurnsOnPeak || busy.pause) return;
|
|
439
461
|
if (deferring.has(agent.id)) return;
|
|
440
462
|
const now = Date.now();
|
|
441
|
-
const desc = describeWindow(now, rules);
|
|
442
|
-
if (desc.phase !== 'peak') return;
|
|
443
463
|
if ((bypass.get(agent.id) ?? 0) > now) return;
|
|
444
464
|
const session = guardableSession(agent);
|
|
445
465
|
if (session === null) return;
|
|
446
466
|
const { provider, model } = modelOf(agent);
|
|
447
467
|
if (!isPeakPriced(provider, model)) return;
|
|
468
|
+
const desc = describeWindow(now, rulesFor(provider));
|
|
469
|
+
if (desc.phase !== 'peak') return;
|
|
448
470
|
deferring.add(agent.id);
|
|
449
471
|
try {
|
|
450
472
|
const state = await stateFile.read();
|
|
@@ -471,7 +493,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
471
493
|
await stateFile.write(state);
|
|
472
494
|
log('warn', `暂停新任务 ${agent.id} 失败:${messageOf(error)}`);
|
|
473
495
|
}
|
|
474
|
-
await publish(describeWindow(Date.now(),
|
|
496
|
+
await publish(describeWindow(Date.now(), rulesFor(provider)), state);
|
|
475
497
|
} finally {
|
|
476
498
|
deferring.delete(agent.id);
|
|
477
499
|
}
|
|
@@ -483,7 +505,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
483
505
|
try {
|
|
484
506
|
const state = await stateFile.read();
|
|
485
507
|
const now = Date.now();
|
|
486
|
-
const desc = describeWindow(now,
|
|
508
|
+
const desc = describeWindow(now, rulesFor(currentProvider()));
|
|
487
509
|
|
|
488
510
|
if (!restoreChecked) {
|
|
489
511
|
restoreChecked = true;
|
|
@@ -552,7 +574,7 @@ export function createGuard(ctx, config = {}) {
|
|
|
552
574
|
|
|
553
575
|
async function status() {
|
|
554
576
|
const state = await stateFile.read();
|
|
555
|
-
const desc = describeWindow(Date.now(),
|
|
577
|
+
const desc = describeWindow(Date.now(), rulesFor(currentProvider()));
|
|
556
578
|
await publish(desc, state);
|
|
557
579
|
return { desc, state, snapshot: await snapshotOf(desc, state) };
|
|
558
580
|
}
|
package/lib/holidays.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中国法定节假日(用于 DeepSeek 官方峰谷规则)。
|
|
3
|
+
*
|
|
4
|
+
* 官方口径(api-docs.deepseek.com/zh-cn/quick_start/pricing):
|
|
5
|
+
* 北京时间周一至周五(不含中国法定节假日)09:00–12:00、14:00–18:00 为高峰;
|
|
6
|
+
* 其余时段,包括周末及法定节假日全天,均为空闲(谷价)。
|
|
7
|
+
*
|
|
8
|
+
* 调休上班的周末也按空闲处理,所以这里只需要“放假日”列表,不需要补班列表。
|
|
9
|
+
* 官方每年 11 月左右公布下一年安排;列表过期时用户可以用配置项 holidays 追加/覆盖。
|
|
10
|
+
*/
|
|
11
|
+
export const CN_HOLIDAYS = {
|
|
12
|
+
2026: [
|
|
13
|
+
'2026-01-01',
|
|
14
|
+
'2026-01-02',
|
|
15
|
+
'2026-01-03',
|
|
16
|
+
// 春节
|
|
17
|
+
'2026-02-15',
|
|
18
|
+
'2026-02-16',
|
|
19
|
+
'2026-02-17',
|
|
20
|
+
'2026-02-18',
|
|
21
|
+
'2026-02-19',
|
|
22
|
+
'2026-02-20',
|
|
23
|
+
'2026-02-21',
|
|
24
|
+
'2026-02-22',
|
|
25
|
+
'2026-02-23',
|
|
26
|
+
// 清明
|
|
27
|
+
'2026-04-04',
|
|
28
|
+
'2026-04-05',
|
|
29
|
+
'2026-04-06',
|
|
30
|
+
// 劳动节
|
|
31
|
+
'2026-05-01',
|
|
32
|
+
'2026-05-02',
|
|
33
|
+
'2026-05-03',
|
|
34
|
+
'2026-05-04',
|
|
35
|
+
'2026-05-05',
|
|
36
|
+
// 端午
|
|
37
|
+
'2026-06-19',
|
|
38
|
+
'2026-06-20',
|
|
39
|
+
'2026-06-21',
|
|
40
|
+
// 中秋
|
|
41
|
+
'2026-09-25',
|
|
42
|
+
'2026-09-26',
|
|
43
|
+
'2026-09-27',
|
|
44
|
+
// 国庆
|
|
45
|
+
'2026-10-01',
|
|
46
|
+
'2026-10-02',
|
|
47
|
+
'2026-10-03',
|
|
48
|
+
'2026-10-04',
|
|
49
|
+
'2026-10-05',
|
|
50
|
+
'2026-10-06',
|
|
51
|
+
'2026-10-07',
|
|
52
|
+
],
|
|
53
|
+
// 2027 年安排发布后可在这里补;也可以用 config.holidays 临时追加。
|
|
54
|
+
2027: [],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** 所有内置节假日日期(YYYY-MM-DD)的并集。 */
|
|
58
|
+
export function builtinHolidays() {
|
|
59
|
+
return Object.values(CN_HOLIDAYS).flat();
|
|
60
|
+
}
|
package/lib/peak.js
CHANGED
|
@@ -1,26 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 峰谷时间引擎(纯函数,无框架依赖)。
|
|
3
3
|
*
|
|
4
|
-
* 规则来自 DeepSeek
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 规则来自 DeepSeek 官方「模型 & 价格」:
|
|
5
|
+
* 北京时间周一至周五(不含中国法定节假日)09:00–12:00、14:00–18:00 为高峰;
|
|
6
|
+
* 其余时段(含周末、法定节假日全天)为空闲,空闲价为高峰价的一半。
|
|
7
|
+
* 另保留 WorkBuddy 积分预设(时段相同,仅用于按 provider 标注来源)与自定义预设。
|
|
7
8
|
*/
|
|
9
|
+
import { builtinHolidays } from './holidays.js';
|
|
8
10
|
|
|
9
11
|
const MINUTE_MS = 60_000;
|
|
10
12
|
const DAY_MS = 86_400_000;
|
|
11
13
|
const WEEKDAY_INDEX = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
12
14
|
const WEEKDAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
|
13
15
|
|
|
16
|
+
export const OFFICIAL_WINDOWS = [
|
|
17
|
+
{ start: '09:00', end: '12:00' },
|
|
18
|
+
{ start: '14:00', end: '18:00' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const PRESETS = {
|
|
22
|
+
'deepseek-official': {
|
|
23
|
+
label: 'DeepSeek 官方',
|
|
24
|
+
timezone: 'Asia/Shanghai',
|
|
25
|
+
peakWeekdays: [1, 2, 3, 4, 5],
|
|
26
|
+
peakWindows: OFFICIAL_WINDOWS,
|
|
27
|
+
discount: 0.5,
|
|
28
|
+
holidays: 'official',
|
|
29
|
+
},
|
|
30
|
+
workbuddy: {
|
|
31
|
+
label: 'WorkBuddy 积分',
|
|
32
|
+
timezone: 'Asia/Shanghai',
|
|
33
|
+
peakWeekdays: [1, 2, 3, 4, 5],
|
|
34
|
+
peakWindows: OFFICIAL_WINDOWS,
|
|
35
|
+
discount: 0.5,
|
|
36
|
+
holidays: 'official',
|
|
37
|
+
},
|
|
38
|
+
custom: {
|
|
39
|
+
label: '自定义',
|
|
40
|
+
timezone: 'Asia/Shanghai',
|
|
41
|
+
peakWeekdays: [1, 2, 3, 4, 5],
|
|
42
|
+
peakWindows: OFFICIAL_WINDOWS,
|
|
43
|
+
discount: 0.5,
|
|
44
|
+
holidays: 'config',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
14
48
|
export const DEFAULT_RULES = {
|
|
49
|
+
preset: 'deepseek-official',
|
|
50
|
+
presetLabel: 'DeepSeek 官方',
|
|
15
51
|
timezone: 'Asia/Shanghai',
|
|
16
52
|
peakWeekdays: [1, 2, 3, 4, 5],
|
|
17
|
-
peakWindows:
|
|
18
|
-
{ start: '09:00', end: '12:00' },
|
|
19
|
-
{ start: '14:00', end: '18:00' },
|
|
20
|
-
],
|
|
53
|
+
peakWindows: OFFICIAL_WINDOWS,
|
|
21
54
|
discount: 0.5,
|
|
55
|
+
holidays: new Set(),
|
|
22
56
|
};
|
|
23
57
|
|
|
58
|
+
/** 按 provider 自动选预设:WorkBuddy/CodeBuddy 走积分预设,其余走官方。 */
|
|
59
|
+
export function resolvePreset(config = {}, provider) {
|
|
60
|
+
const explicit = typeof config.preset === 'string' ? config.preset.trim() : 'auto';
|
|
61
|
+
if (explicit !== 'auto' && explicit !== '') {
|
|
62
|
+
return PRESETS[explicit] === undefined ? 'deepseek-official' : explicit;
|
|
63
|
+
}
|
|
64
|
+
const name = String(provider ?? '').toLowerCase();
|
|
65
|
+
if (/workbuddy|codebuddy/.test(name)) return 'workbuddy';
|
|
66
|
+
return 'deepseek-official';
|
|
67
|
+
}
|
|
68
|
+
|
|
24
69
|
/** 解析 "HH:MM"。非法值返回 null,由调用方决定退回默认。 */
|
|
25
70
|
function parseHm(value) {
|
|
26
71
|
if (typeof value !== 'string') return null;
|
|
@@ -40,28 +85,10 @@ function hmText(minutes) {
|
|
|
40
85
|
return `${pad2(Math.floor(minutes / 60))}:${pad2(minutes % 60)}`;
|
|
41
86
|
}
|
|
42
87
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* @param {Record<string, unknown>} [config]
|
|
46
|
-
*/
|
|
47
|
-
export function resolveRules(config = {}) {
|
|
48
|
-
const timezone =
|
|
49
|
-
typeof config.timezone === 'string' && config.timezone.trim() !== ''
|
|
50
|
-
? config.timezone.trim()
|
|
51
|
-
: DEFAULT_RULES.timezone;
|
|
52
|
-
|
|
53
|
-
let weekdays = Array.isArray(config.peakWeekdays)
|
|
54
|
-
? config.peakWeekdays.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6)
|
|
55
|
-
: DEFAULT_RULES.peakWeekdays;
|
|
56
|
-
if (weekdays.length === 0) weekdays = [...DEFAULT_RULES.peakWeekdays];
|
|
57
|
-
weekdays = [...new Set(weekdays)].sort((a, b) => a - b);
|
|
58
|
-
|
|
59
|
-
const rawWindows =
|
|
60
|
-
Array.isArray(config.peakWindows) && config.peakWindows.length > 0
|
|
61
|
-
? config.peakWindows
|
|
62
|
-
: DEFAULT_RULES.peakWindows;
|
|
88
|
+
function parseWindows(rawWindows, fallback) {
|
|
89
|
+
const source = Array.isArray(rawWindows) && rawWindows.length > 0 ? rawWindows : fallback;
|
|
63
90
|
const windows = [];
|
|
64
|
-
for (const raw of
|
|
91
|
+
for (const raw of source) {
|
|
65
92
|
const start = parseHm(raw?.start);
|
|
66
93
|
const end = parseHm(raw?.end);
|
|
67
94
|
if (start === null || end === null) continue;
|
|
@@ -71,7 +98,7 @@ export function resolveRules(config = {}) {
|
|
|
71
98
|
windows.push({ startMin, endMin, start: hmText(startMin), end: hmText(endMin) });
|
|
72
99
|
}
|
|
73
100
|
if (windows.length === 0) {
|
|
74
|
-
for (const raw of
|
|
101
|
+
for (const raw of fallback) {
|
|
75
102
|
const start = parseHm(raw.start);
|
|
76
103
|
const end = parseHm(raw.end);
|
|
77
104
|
windows.push({
|
|
@@ -83,11 +110,58 @@ export function resolveRules(config = {}) {
|
|
|
83
110
|
}
|
|
84
111
|
}
|
|
85
112
|
windows.sort((a, b) => a.startMin - b.startMin);
|
|
113
|
+
return windows;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeDates(values) {
|
|
117
|
+
if (!Array.isArray(values)) return [];
|
|
118
|
+
return values
|
|
119
|
+
.map((value) => String(value).trim())
|
|
120
|
+
.filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 把 patch / 设置 / 环境变量归一成引擎规则。
|
|
125
|
+
* 命名预设使用预设自带的时间窗口;custom 预设使用配置里的窗口。
|
|
126
|
+
* @param {Record<string, unknown>} [config]
|
|
127
|
+
* @param {string} [provider] 当前会话的 provider,用于 auto 预设判断
|
|
128
|
+
*/
|
|
129
|
+
export function resolveRules(config = {}, provider) {
|
|
130
|
+
const presetName = resolvePreset(config, provider);
|
|
131
|
+
const preset = PRESETS[presetName] ?? PRESETS['deepseek-official'];
|
|
132
|
+
const custom = presetName === 'custom';
|
|
133
|
+
|
|
134
|
+
const timezone =
|
|
135
|
+
typeof config.timezone === 'string' && config.timezone.trim() !== ''
|
|
136
|
+
? config.timezone.trim()
|
|
137
|
+
: preset.timezone;
|
|
138
|
+
|
|
139
|
+
let weekdays = custom && Array.isArray(config.peakWeekdays)
|
|
140
|
+
? config.peakWeekdays.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6)
|
|
141
|
+
: preset.peakWeekdays;
|
|
142
|
+
if (weekdays.length === 0) weekdays = [...preset.peakWeekdays];
|
|
143
|
+
weekdays = [...new Set(weekdays)].sort((a, b) => a - b);
|
|
144
|
+
|
|
145
|
+
const windows = parseWindows(custom ? config.peakWindows : undefined, preset.peakWindows);
|
|
146
|
+
|
|
147
|
+
const rawDiscount = custom ? Number(config.discount) : preset.discount;
|
|
148
|
+
const discount = Number.isFinite(rawDiscount) && rawDiscount > 0 && rawDiscount <= 1 ? rawDiscount : 0.5;
|
|
86
149
|
|
|
87
|
-
const
|
|
88
|
-
const
|
|
150
|
+
const configuredHolidays = normalizeDates(config.holidays);
|
|
151
|
+
const holidayDates = preset.holidays === 'official'
|
|
152
|
+
? [...builtinHolidays(), ...configuredHolidays]
|
|
153
|
+
: configuredHolidays;
|
|
154
|
+
const holidays = new Set(holidayDates);
|
|
89
155
|
|
|
90
|
-
return {
|
|
156
|
+
return {
|
|
157
|
+
preset: presetName,
|
|
158
|
+
presetLabel: preset.label,
|
|
159
|
+
timezone,
|
|
160
|
+
peakWeekdays: weekdays,
|
|
161
|
+
peakWindows: windows,
|
|
162
|
+
discount,
|
|
163
|
+
holidays,
|
|
164
|
+
};
|
|
91
165
|
}
|
|
92
166
|
|
|
93
167
|
const formatterCache = new Map();
|
|
@@ -126,6 +200,14 @@ export function zonedParts(date, timeZone) {
|
|
|
126
200
|
};
|
|
127
201
|
}
|
|
128
202
|
|
|
203
|
+
function dateKey(parts) {
|
|
204
|
+
return `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isHoliday(parts, rules) {
|
|
208
|
+
return rules.holidays instanceof Set && rules.holidays.has(dateKey(parts));
|
|
209
|
+
}
|
|
210
|
+
|
|
129
211
|
function zoneOffsetMs(date, timeZone) {
|
|
130
212
|
const parts = zonedParts(date, timeZone);
|
|
131
213
|
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
|
@@ -156,17 +238,28 @@ function dayAt(parts, offsetDays) {
|
|
|
156
238
|
};
|
|
157
239
|
}
|
|
158
240
|
|
|
241
|
+
function dayParts(day) {
|
|
242
|
+
return {
|
|
243
|
+
year: day.year,
|
|
244
|
+
month: day.month,
|
|
245
|
+
day: day.day,
|
|
246
|
+
weekday: day.weekday,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
159
250
|
/** 当前时刻所处的计价时段。 */
|
|
160
251
|
export function phaseAt(date, rules) {
|
|
161
252
|
const parts = zonedParts(date, rules.timezone);
|
|
162
253
|
const minute = parts.hour * 60 + parts.minute;
|
|
163
|
-
const
|
|
254
|
+
const holiday = isHoliday(parts, rules);
|
|
255
|
+
const peakDay = !holiday && rules.peakWeekdays.includes(parts.weekday);
|
|
164
256
|
const window = peakDay ? rules.peakWindows.find((item) => minute >= item.startMin && minute < item.endMin) : undefined;
|
|
165
257
|
return {
|
|
166
258
|
phase: window === undefined ? 'offpeak' : 'peak',
|
|
167
259
|
window: window ?? null,
|
|
168
260
|
parts,
|
|
169
261
|
minute,
|
|
262
|
+
holiday,
|
|
170
263
|
};
|
|
171
264
|
}
|
|
172
265
|
|
|
@@ -186,9 +279,10 @@ export function nextBoundary(date, rules) {
|
|
|
186
279
|
};
|
|
187
280
|
}
|
|
188
281
|
const nowMinutes = current.minute + parts.second / 60;
|
|
189
|
-
for (let offset = 0; offset <=
|
|
282
|
+
for (let offset = 0; offset <= 10; offset += 1) {
|
|
190
283
|
const day = dayAt(parts, offset);
|
|
191
284
|
if (!rules.peakWeekdays.includes(day.weekday)) continue;
|
|
285
|
+
if (isHoliday(dayParts(day), rules)) continue;
|
|
192
286
|
for (const window of rules.peakWindows) {
|
|
193
287
|
if (offset === 0 && window.startMin <= nowMinutes) continue;
|
|
194
288
|
return {
|
|
@@ -259,10 +353,13 @@ export function describeWindow(nowMs, rules) {
|
|
|
259
353
|
const discountText = `${(rules.discount * 10).toFixed(1).replace(/\.0$/, '')} 折`;
|
|
260
354
|
return {
|
|
261
355
|
now: nowMs,
|
|
356
|
+
preset: rules.preset,
|
|
357
|
+
presetLabel: rules.presetLabel,
|
|
262
358
|
timezone: rules.timezone,
|
|
263
359
|
phase: current.phase,
|
|
264
360
|
phaseLabel: current.phase === 'peak' ? '峰值原价' : `谷价 ${discountText}`,
|
|
265
361
|
discount: rules.discount,
|
|
362
|
+
holiday: current.holiday,
|
|
266
363
|
localTime: formatDateClock(now, rules.timezone),
|
|
267
364
|
weekday: formatWeekday(now, rules.timezone),
|
|
268
365
|
window: current.phase === 'peak' ? { start: bounds.start, end: bounds.end, startAt: bounds.startAt, endAt: bounds.endAt } : null,
|
package/lib/tools.js
CHANGED
|
@@ -24,7 +24,24 @@ function timeLine(desc) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function phaseLine(desc) {
|
|
27
|
-
return `时段:${desc.phaseLabel}`;
|
|
27
|
+
return `时段:${desc.phaseLabel}${desc.holiday === true ? '(法定节假日,全天谷价)' : ''}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function weekdayText(days) {
|
|
31
|
+
const names = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
|
32
|
+
const list = Array.isArray(days) ? [...new Set(days)].sort((a, b) => a - b) : [];
|
|
33
|
+
if (list.length === 7) return '每天';
|
|
34
|
+
if (list.length === 5 && list.every((day, index) => day === index + 1)) return '周一至周五';
|
|
35
|
+
if (list.length === 0) return '无';
|
|
36
|
+
return list.map((day) => names[day] ?? String(day)).join('、');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ruleLine(desc, snapshot) {
|
|
40
|
+
const windows = Array.isArray(snapshot.peakWindows)
|
|
41
|
+
? snapshot.peakWindows.map((item) => `${item.start}–${item.end}`).join('、')
|
|
42
|
+
: '—';
|
|
43
|
+
const holiday = desc.holiday === true ? ' · 今天法定节假日全天空闲' : '';
|
|
44
|
+
return `规则:${desc.presetLabel} · ${weekdayText(snapshot.peakWeekdays)} ${windows}${holiday}`;
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
function nextLine(desc) {
|
|
@@ -85,6 +102,7 @@ function statusText(desc, snapshot) {
|
|
|
85
102
|
return [
|
|
86
103
|
timeLine(desc),
|
|
87
104
|
phaseLine(desc),
|
|
105
|
+
ruleLine(desc, snapshot),
|
|
88
106
|
nextLine(desc),
|
|
89
107
|
modelLine(snapshot),
|
|
90
108
|
guardLine(snapshot),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-offpeak-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DeepSeek peak/off-peak pricing guard for DSH: detects the price window and current model, pauses running tasks at peak, resumes them automatically when off-peak begins, and schedules work for the next off-peak window.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -56,6 +56,9 @@
|
|
|
56
56
|
"node": ">=22.5",
|
|
57
57
|
"dsh": ">=0.2.1-rc.2 <0.3.0"
|
|
58
58
|
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@deepseek-ai/schemastery": "^3.18.5"
|
|
61
|
+
},
|
|
59
62
|
"dsh": {
|
|
60
63
|
"bundle": {
|
|
61
64
|
"patch": "./cordis.patch.yml"
|