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 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
+ [![npm](https://img.shields.io/npm/v/dsh-offpeak-guard?color=blue)](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 price; every other hour (lunch, night, weekends) is off-peak at 50% (time zone `Asia/Shanghai` by default).
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 `config` of the `offpeak-guard` row in `cordis.patch.yml`:
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
- | `timezone` | `Asia/Shanghai` | Pricing time zone |
40
- | `peakWeekdays` | `[1,2,3,4,5]` | Weekdays that have peak windows (0 = Sunday) |
41
- | `peakWindows` | 09:00–12:00, 14:00–18:00 | Peak windows; edit these when DeepSeek changes prices |
42
- | `discount` | `0.5` | Off-peak discount, used for the "50%" label |
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
+ [![npm](https://img.shields.io/npm/v/dsh-offpeak-guard?color=blue)](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
- - **时间识别**:工作日 `09:00–12:00`、`14:00–18:00` 为峰值原价;其余时间(含午间、夜间、周末)为谷价,按 5 折计费(默认 `Asia/Shanghai`)。
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
- | `timezone` | `Asia/Shanghai` | 计价时区 |
40
- | `peakWeekdays` | `[1,2,3,4,5]` | 峰时适用的星期(0=周日) |
41
- | `peakWindows` | 09:00–12:00、14:00–18:00 | 峰时窗口;官方调价时改这里 |
42
- | `discount` | `0.5` | 谷价折扣,仅用于显示「5 折」 |
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
- # 规则来自 DeepSeek 夜间折扣:工作日 09:00–12:00、14:00–18:00 为峰值原价,
3
- # 其余时间(含午间、夜间与周末)为谷价,按 5 折计费。
4
- # 官方调价时只改这里的 peakWindows / discount 即可。
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
- export function apply(ctx, config = {}) {
17
- const guard = createGuard(ctx, config ?? {});
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
- timezone: rules.timezone,
212
- peakWeekdays: rules.peakWeekdays,
213
- peakWindows: rules.peakWindows.map((item) => ({ start: item.start, end: item.end })),
214
- discount: rules.discount,
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 when = formatDateClock(new Date(Number(item.at) || Date.now()), rules.timezone);
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(), rules), state);
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, rules);
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(), rules);
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
  }
@@ -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 的「夜间折扣」:工作日 09:00–12:00、14:00–18:00 为高峰原价,
5
- * 其余时间(含午间、夜间与周末)为谷价,谷价按 5 折计费。时区默认 Asia/Shanghai。
6
- * 所有窗口都由配置给出,不写死在调用处,方便官方调价后只改 patch。
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
- * 把 patch 里的原始配置归一成引擎可用的规则;坏值逐项退回默认,不整体失败。
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 rawWindows) {
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 DEFAULT_RULES.peakWindows) {
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 rawDiscount = Number(config.discount);
88
- const discount = Number.isFinite(rawDiscount) && rawDiscount > 0 && rawDiscount <= 1 ? rawDiscount : DEFAULT_RULES.discount;
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 { timezone, peakWeekdays: weekdays, peakWindows: windows, discount };
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 peakDay = rules.peakWeekdays.includes(parts.weekday);
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 <= 8; offset += 1) {
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.1.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"