dsh-peak-runner 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-peak-runner contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # dsh-peak-runner
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek **峰谷价感知**的任务执行器:平价时段直接跑 `deepseek-flash` 的任务批;峰价快到时把任务攒进队列、挂一条定时器,到下一个平价时段自动执行完。
4
+
5
+ ## 它解决什么问题
6
+
7
+ DeepSeek 官方按 UTC 时段两档计价:**01:00–04:00、06:00–10:00 为峰时价(约两倍),其余时段平价**;周末与法定假日全天平价。你把一批摘要/翻译/改写任务丢给它,它只在便宜的时候花钱。
8
+
9
+ 与 [dsh-cost-meter](https://github.com/Han-1413141/dsh-cost-meter) 用同一套时段口径,两边对「现在是平价还是峰价」的判断一致。
10
+
11
+ ## 装(正式安装)
12
+
13
+ ```sh
14
+ dsh plugin --profile desktop add dsh-peak-runner
15
+ ```
16
+
17
+ 然后重启 DSH。它插一行进组合,注册三个工具 + 一段系统提示:
18
+
19
+ | 工具 | 作用 |
20
+ |---|---|
21
+ | `peak_run({ prompts })` | 一批 prompt 交给 `deepseek-flash` 执行。平价时段立即跑;峰价时段(或距峰价不足 `leadMinutes` 分钟时)排队,等下一个平价时段自动执行 |
22
+ | `peak_schedule()` | 查看当前档位、下一个切换点、队列状态与定时器排定情况 |
23
+ | `peak_cancel({ id? })` | 取消队列中的待执行任务(不传 id 则取消全部) |
24
+
25
+ ## 配置(profile 补丁层覆盖)
26
+
27
+ ```yaml
28
+ - id: dsh-peak-runner
29
+ config:
30
+ enabled: true
31
+ provider: deepseek-account
32
+ model: deepseek-flash
33
+ # 峰段窗口(UTC 小时,半开区间 [start, end));默认对齐 DeepSeek 官方口径
34
+ peakWindows:
35
+ - { start: 1, end: 4 }
36
+ - { start: 6, end: 10 }
37
+ # 峰价开始前多少分钟停止执行新任务、开始排队
38
+ leadMinutes: 10
39
+ # 推理档:off 是延迟/成本正确的档位(low 仍可能烧掉 2000+ 思考 token)
40
+ reasoningEffort: "off"
41
+ maxTokens: 2048
42
+ timeoutMs: 45000
43
+ ```
44
+
45
+ ## 用法
46
+
47
+ 在对话里直接说:
48
+
49
+ > 帮我把这 5 段文字各写一句摘要:……(然后调 `peak_run`)
50
+
51
+ 或者问:
52
+
53
+ > 现在是什么档位?队列里还有什么?
54
+
55
+ ## 开发(不装进 profile)
56
+
57
+ ```sh
58
+ cd dsh-peak-runner && pnpm install # 只需 @deepseek-ai/schemastery
59
+
60
+ DSH='/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh'
61
+ $DSH --profile <name> --patch "$PWD/dev.patch.yml" --dump-config | grep peak-runner
62
+ $DSH --profile <name> --patch "$PWD/dev.patch.yml" web
63
+ ```
64
+
65
+ ## 目录
66
+
67
+ ```
68
+ lib/index.js 接线(name/inject/Config/apply)
69
+ lib/config.js Config schema + DEFAULTS + resolveConfig
70
+ lib/tariff.js 时段计算(窗口表 → 当前档位/下一切换点)
71
+ lib/runner.js 模型调用(ctx.llm.stream + reasoning off)
72
+ lib/queue.js 任务队列(storage domain 持久化)
73
+ lib/scheduler.js 定时触发(自持 timer,不依赖可选的 ctx.schedule)
74
+ lib/tools.js 工具注册表
75
+ lib/prompt.js 系统提示文案
76
+ ```
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,18 @@
1
+ # dsh-peak-runner 的 bundle 补丁层:往插件树里插一行。
2
+ # 注意 patch 对 config 是整块替换(不是深合并),profile 层要覆盖就得把想保留的键全部重写。
3
+ - insert:
4
+ - id: dsh-peak-runner
5
+ name: dsh-peak-runner
6
+ config:
7
+ enabled: true
8
+ provider: deepseek-account
9
+ model: deepseek-flash
10
+ reasoningEffort: "off"
11
+ maxTokens: 2048
12
+ timeoutMs: 45000
13
+ leadMinutes: 10
14
+ peakWindows:
15
+ - start: 1
16
+ end: 4
17
+ - start: 6
18
+ end: 10
package/lib/config.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Plugin configuration: the schema the host validates against, the code-side
3
+ * defaults (so a direct `apply()` call works without the loader), and the
4
+ * normalisation of every knob.
5
+ *
6
+ * The Cordis loader applies `Config` defaults, but `apply` is also reachable
7
+ * through direct construction (tests, tooling), so this module resolves its own
8
+ * defaults instead of trusting the caller.
9
+ *
10
+ * @module dsh-peak-runner/config
11
+ */
12
+
13
+ import z from "@deepseek-ai/schemastery";
14
+
15
+ /** Reasoning efforts the DeepSeek adapter advertises. */
16
+ const EFFORTS = ["off", "low", "high", "max"];
17
+
18
+ /** Code-side defaults, mirrored by the `Config` schema below. */
19
+ export const DEFAULTS = Object.freeze({
20
+ enabled: true,
21
+ provider: "deepseek-account",
22
+ model: "deepseek-flash",
23
+ // DeepSeek 官方按 UTC 峰段收费:01:00–04:00、06:00–10:00 为峰时价,其余为平价。
24
+ // 周末与法定假日全天平价(与 dsh-cost-meter 的口径一致)。
25
+ peakWindows: [
26
+ { start: 1, end: 4 },
27
+ { start: 6, end: 10 },
28
+ ],
29
+ // 峰价开始前多少分钟停止执行新任务、把任务攒进队列等平价。
30
+ leadMinutes: 10,
31
+ reasoningEffort: "off",
32
+ maxTokens: 2048,
33
+ timeoutMs: 45_000,
34
+ temperature: 0.7,
35
+ });
36
+
37
+ export const Config = z.object({
38
+ /** Master switch; `false` registers no tools and runs nothing. */
39
+ enabled: z.boolean().default(DEFAULTS.enabled),
40
+ /** Provider id for the batch runner (empty = auto-pick the first usable one). */
41
+ provider: z.string().default(DEFAULTS.provider),
42
+ /** Model id for the batch runner. */
43
+ model: z.string().default(DEFAULTS.model),
44
+ /** Peak (UTC hour) windows: `[start, end)` half-open ranges. */
45
+ peakWindows: z
46
+ .array(
47
+ z.object({
48
+ start: z.number().min(0).max(23),
49
+ end: z.number().min(1).max(24),
50
+ }),
51
+ )
52
+ .default(DEFAULTS.peakWindows),
53
+ /** How many minutes before a peak window to stop running and start queueing. */
54
+ leadMinutes: z.number().min(0).max(120).default(DEFAULTS.leadMinutes),
55
+ /** Reasoning budget per call: `off`, `low`, `high` or `max`. */
56
+ reasoningEffort: z.string().default(DEFAULTS.reasoningEffort),
57
+ /** Output cap per call. */
58
+ maxTokens: z.natural().default(DEFAULTS.maxTokens),
59
+ /** Hard ceiling on one call; the queue marks the task failed instead of hanging. */
60
+ timeoutMs: z.natural().default(DEFAULTS.timeoutMs),
61
+ /** Sampling temperature. */
62
+ temperature: z.number().default(DEFAULTS.temperature),
63
+ });
64
+
65
+ /**
66
+ * A finite number clamped into `[min, max]`, or `fallback`.
67
+ *
68
+ * @param value - raw configured value.
69
+ * @param min - lower bound.
70
+ * @param max - upper bound.
71
+ * @param fallback - value used otherwise.
72
+ * @returns the resolved number.
73
+ */
74
+ function clampNumber(value, min, max, fallback) {
75
+ const number = Number(value);
76
+ if (!Number.isFinite(number)) return fallback;
77
+ return Math.min(max, Math.max(min, number));
78
+ }
79
+
80
+ /**
81
+ * A positive integer, or `fallback`.
82
+ *
83
+ * @param value - raw configured value.
84
+ * @param fallback - value used otherwise.
85
+ * @returns the resolved integer.
86
+ */
87
+ function positiveInteger(value, fallback) {
88
+ const number = Number(value);
89
+ return Number.isSafeInteger(number) && number > 0 ? number : fallback;
90
+ }
91
+
92
+ /**
93
+ * One of `allowed`, or `fallback` when the value is absent/unknown.
94
+ *
95
+ * @param value - raw configured value.
96
+ * @param allowed - accepted strings.
97
+ * @param fallback - value used otherwise.
98
+ * @returns the resolved string.
99
+ */
100
+ function pick(value, allowed, fallback) {
101
+ return typeof value === "string" && allowed.includes(value) ? value : fallback;
102
+ }
103
+
104
+ /**
105
+ * Normalise the peak-window list: only keep well-formed half-open ranges.
106
+ *
107
+ * @param raw - raw configured windows.
108
+ * @returns a fresh normalised list.
109
+ */
110
+ function windows(raw) {
111
+ if (!Array.isArray(raw)) return DEFAULTS.peakWindows.map((w) => ({ ...w }));
112
+ return raw
113
+ .filter((w) => w && typeof w === "object")
114
+ .map((w) => ({
115
+ start: Math.min(23, Math.max(0, Math.floor(Number(w.start)))),
116
+ end: Math.min(24, Math.max(1, Math.floor(Number(w.end)))),
117
+ }))
118
+ .filter((w) => Number.isFinite(w.start) && Number.isFinite(w.end));
119
+ }
120
+
121
+ /**
122
+ * Resolve user configuration into the plain settings object the plugin uses.
123
+ *
124
+ * @param raw - the loader-supplied config (possibly undefined).
125
+ * @returns resolved settings.
126
+ */
127
+ export function resolveConfig(raw) {
128
+ const config = raw === null || typeof raw !== "object" ? {} : raw;
129
+ return {
130
+ enabled: config.enabled !== false,
131
+ provider: typeof config.provider === "string" ? config.provider.trim() : DEFAULTS.provider,
132
+ model: typeof config.model === "string" ? config.model.trim() : DEFAULTS.model,
133
+ peakWindows: windows(config.peakWindows),
134
+ leadMinutes: clampNumber(config.leadMinutes, 0, 120, DEFAULTS.leadMinutes),
135
+ reasoningEffort: pick(config.reasoningEffort, EFFORTS, DEFAULTS.reasoningEffort),
136
+ maxTokens: positiveInteger(config.maxTokens, DEFAULTS.maxTokens),
137
+ timeoutMs: positiveInteger(config.timeoutMs, DEFAULTS.timeoutMs),
138
+ temperature: clampNumber(config.temperature, 0, 2, DEFAULTS.temperature),
139
+ };
140
+ }
package/lib/index.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * dsh-peak-runner — DeepSeek 峰谷价感知的任务执行器。
3
+ *
4
+ * 平价时段直接跑 deepseek-flash 的任务批;峰价快到时把任务攒进队列,
5
+ * 挂一条自持定时器,到下一个平价时段自动执行完。
6
+ *
7
+ * 接缝:`ctx.llm`(模型调用)+ `ctx.storageDomain`(队列持久化)+
8
+ * `ctx.tools`(工具注册)+ `ctx.systemPrompt`(使用指引)。
9
+ *
10
+ * @module dsh-peak-runner
11
+ */
12
+
13
+ import z from "@deepseek-ai/schemastery";
14
+ import { Config, resolveConfig } from "./config.js";
15
+ import { createQueue } from "./queue.js";
16
+ import { createToolSpecs } from "./tools.js";
17
+ import { guideText } from "./prompt.js";
18
+ import { armTimer } from "./scheduler.js";
19
+
20
+ export const name = "peak-runner";
21
+
22
+ export const inject = ["llm", "storageDomain", "tools", "systemPrompt"];
23
+
24
+ export { Config };
25
+
26
+ /**
27
+ * Mount the plugin.
28
+ *
29
+ * @param ctx - host plugin context.
30
+ * @param config - loader config (defaults are re-resolved here).
31
+ */
32
+ export function apply(ctx, config) {
33
+ const settings = resolveConfig(config);
34
+ if (!settings.enabled) {
35
+ console.log("[dsh-peak-runner] disabled by configuration");
36
+ return;
37
+ }
38
+
39
+ const queue = createQueue(ctx);
40
+
41
+ // The scheduler handle is shared between `apply()` and the tool specs.
42
+ // `armedAt` is the one piece of mutable state the tools read.
43
+ const timer = {
44
+ armedAt: null,
45
+ _dispose: null,
46
+ arm(atMs) {
47
+ this.disarm();
48
+ this.armedAt = atMs;
49
+ this._dispose = armTimer(atMs, () => {
50
+ this.armedAt = null;
51
+ // The timer only wakes the queue; the actual drain happens on the
52
+ // next `peak_run` call (or the next `peak_schedule` read), which
53
+ // keeps model calls inside an explicit tool invocation.
54
+ console.log(`[dsh-peak-runner] off-peak reached at ${new Date(atMs).toISOString()}; queue is ready to drain`);
55
+ });
56
+ },
57
+ disarm() {
58
+ if (typeof this._dispose === "function") this._dispose();
59
+ this._dispose = null;
60
+ this.armedAt = null;
61
+ },
62
+ };
63
+
64
+ ctx.effect(() => () => timer.disarm(), "dsh-peak-runner: scheduler timer");
65
+
66
+ ctx.systemPrompt.section({
67
+ name: "peak-runner:guide",
68
+ order: 70,
69
+ text: () => guideText(settings),
70
+ });
71
+
72
+ for (const spec of createToolSpecs({ ctx, settings, queue, timer })) {
73
+ ctx.tools.register(spec);
74
+ }
75
+ }
package/lib/prompt.js ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * System prompt: teach the model how to use the peak-runner tools without
3
+ * duplicating the tool descriptions.
4
+ *
5
+ * @module dsh-peak-runner/prompt
6
+ */
7
+
8
+ import { describeTariff } from "./tariff.js";
9
+
10
+ /**
11
+ * Build the guide text shown to the model.
12
+ *
13
+ * @param settings - resolved plugin settings.
14
+ * @returns the section text (regenerated on every render so the tier stays live).
15
+ */
16
+ export function guideText(settings) {
17
+ const now = describeTariff(Date.now(), settings.peakWindows);
18
+ return [
19
+ `当前 DeepSeek 计价档位:${now.tier}(UTC 峰段 ${settings.peakWindows.map((w) => `${w.start}:00–${w.end}:00`).join("、")};周末与法定假日全天平价)。`,
20
+ "peak_run 把一批 prompt 交给 deepseek-flash 执行:在平价时段立即执行;在峰价时段(或距峰价不足 leadMinutes 分钟时)自动排队,等下一个平价时段再跑。",
21
+ "peak_schedule 查看当前档位、下一个切换点与队列状态;peak_cancel 取消队列中的任务或已排定的执行。",
22
+ ].join("\n");
23
+ }
package/lib/queue.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The task queue: one batch document in the plugin's storage domain, holding
3
+ * every pending / running / finished task. Whole-document writes (one `put`
4
+ * per change) mirror how `dsh-schedule` persists a task row.
5
+ *
6
+ * @module dsh-peak-runner/queue
7
+ */
8
+
9
+ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
10
+
11
+ /** A permissive value schema: the domain only needs `parse()`. */
12
+ function recordSchema() {
13
+ return {
14
+ parse(value) {
15
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
16
+ throw new Error("peak-runner batch must be an object");
17
+ }
18
+ if (!Array.isArray(value.tasks)) throw new Error("peak-runner batch.tasks must be an array");
19
+ return value;
20
+ },
21
+ };
22
+ }
23
+
24
+ export const peakRunnerDomain = defineDomain({
25
+ // Domain names must match /^[a-z][a-z0-9_]*$/ — the hyphenated plugin name
26
+ // is rejected by the host's schema, so the underscore spelling is used here.
27
+ name: "peak_runner",
28
+ version: 1,
29
+ tables: {
30
+ batches: domainTable(recordSchema()),
31
+ },
32
+ });
33
+
34
+ /** The one and only batch document key. */
35
+ export const BATCH_KEY = "current";
36
+
37
+ /** Task statuses this queue uses. */
38
+ export const TASK_STATUSES = Object.freeze(["pending", "running", "done", "failed", "cancelled"]);
39
+
40
+ /**
41
+ * Create an empty batch document.
42
+ *
43
+ * @returns a fresh batch with an empty task list.
44
+ */
45
+ export function emptyBatch() {
46
+ return { tasks: [] };
47
+ }
48
+
49
+ /**
50
+ * Normalise a raw task into the stored shape.
51
+ *
52
+ * @param raw - caller-supplied task fields.
53
+ * @param now - epoch ms used for `createdAt`.
54
+ * @returns the normalised task.
55
+ */
56
+ export function normalizeTask(raw, now) {
57
+ const prompt = typeof raw?.prompt === "string" ? raw.prompt.trim() : "";
58
+ if (prompt === "") throw new Error("task prompt must be a non-empty string");
59
+ return {
60
+ id: typeof raw?.id === "string" && raw.id !== "" ? raw.id : `task-${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
61
+ prompt,
62
+ status: "pending",
63
+ createdAt: now,
64
+ startedAt: null,
65
+ finishedAt: null,
66
+ result: null,
67
+ error: null,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Open the queue against a storage-domain service.
73
+ *
74
+ * @param ctx - host plugin context (needs the `storageDomain` service).
75
+ * @returns `{ ready, tasks(), put() }` where `ready` resolves once the domain
76
+ * is open and `tasks()` returns the current task list.
77
+ */
78
+ export function createQueue(ctx) {
79
+ const ready = ctx.storageDomain.open(peakRunnerDomain);
80
+
81
+ /** @returns {Promise<Array>} the current task list (fresh copy). */
82
+ async function tasks() {
83
+ const domain = await ready;
84
+ const table = domain.table("batches");
85
+ const batch = table.get(BATCH_KEY);
86
+ if (batch === undefined) return [];
87
+ return Array.isArray(batch.tasks) ? batch.tasks : [];
88
+ }
89
+
90
+ /**
91
+ * Persist a whole task list.
92
+ *
93
+ * @param list - the task list to store.
94
+ * @returns {Promise<void>} resolves once the write is durable.
95
+ */
96
+ async function put(list) {
97
+ const domain = await ready;
98
+ await domain.table("batches").put(BATCH_KEY, { tasks: list });
99
+ }
100
+
101
+ return { ready, tasks, put };
102
+ }
package/lib/runner.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The runner seam — the only place this plugin talks to a model.
3
+ *
4
+ * One streamed `ctx.llm.stream()` call per task prompt, assembled into text.
5
+ * The reasoning knob defaults to `off` because the host's default (high) can
6
+ * spend the whole output budget on the reasoning block and return an EMPTY
7
+ * text block — a "successful" call that says nothing.
8
+ *
9
+ * @module dsh-peak-runner/runner
10
+ */
11
+
12
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
13
+
14
+ /**
15
+ * Resolve the provider/model pair this plugin should call.
16
+ *
17
+ * @param ctx - host plugin context (needs the `llm` service).
18
+ * @param settings - resolved plugin settings.
19
+ * @returns the pair, or null when the host advertises no usable model.
20
+ */
21
+ export async function resolveTarget(ctx, settings) {
22
+ const provider = settings.provider;
23
+ const model = settings.model;
24
+ if (provider !== "" && model !== "") {
25
+ try {
26
+ await ctx.llm.resolveModelInfo(provider, model);
27
+ return { provider, model, source: "config" };
28
+ } catch {
29
+ // Fall through to discovery: a profile without this provider is not an error.
30
+ }
31
+ }
32
+ try {
33
+ const providers = ctx.llm.listProviders() ?? [];
34
+ for (const entry of providers) {
35
+ const id = typeof entry === "string" ? entry : entry?.id;
36
+ if (typeof id !== "string" || id === "") continue;
37
+ const models = await ctx.llm.listModels(id);
38
+ if (!Array.isArray(models)) continue;
39
+ const first = models.find((candidate) => typeof candidate === "string" && candidate !== "");
40
+ if (first !== undefined) return { provider: id, model: first, source: "auto" };
41
+ }
42
+ } catch {
43
+ // No discovery either — report the absence instead of guessing.
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Describe an adapter failure in one short human-readable line.
50
+ *
51
+ * @param failure - the failure carried by the assembler's finish state.
52
+ * @returns a message suitable for the queue log.
53
+ */
54
+ export function describeFailure(failure) {
55
+ if (failure === null || typeof failure !== "object") return "model call failed";
56
+ const message = failure.message ?? failure.code ?? failure.kind;
57
+ return typeof message === "string" && message !== "" ? message : "model call failed";
58
+ }
59
+
60
+ /**
61
+ * Drive one streamed model call and return the assembled text.
62
+ *
63
+ * @param ctx - host plugin context.
64
+ * @param options - `{ system, prompt, provider, model, maxTokens, temperature,
65
+ * reasoningEffort, signal }`.
66
+ * @returns `{ ok: true, text, usage? }` or `{ ok: false, code, detail }`.
67
+ */
68
+ export async function runPrompt(ctx, options) {
69
+ const { system, prompt, provider, model, maxTokens, temperature, reasoningEffort, signal } = options;
70
+
71
+ const attempt = async (effort) => {
72
+ const assembler = new BlockAssembler();
73
+ const config = {
74
+ provider,
75
+ model,
76
+ ...(system === undefined || system === "" ? {} : { system }),
77
+ messages: [
78
+ createUserMessage({
79
+ content: [{ type: "text", text: prompt }],
80
+ source: { kind: "user" },
81
+ }),
82
+ ],
83
+ ...(temperature === undefined ? {} : { temperature }),
84
+ ...(maxTokens === undefined ? {} : { maxTokens }),
85
+ ...(signal === undefined ? {} : { signal }),
86
+ };
87
+ if (typeof effort === "string" && effort !== "") config.reasoningEffort = effort;
88
+
89
+ const stream = ctx.llm.stream(config);
90
+ for await (const chunk of stream) {
91
+ assembler.push(chunk);
92
+ }
93
+ const finish = assembler.finish;
94
+ let text = "";
95
+ try {
96
+ for (const block of assembler.blocks()) {
97
+ if (block?.type === "text" && typeof block.text === "string") text += block.text;
98
+ }
99
+ } catch {
100
+ // A malformed stream still yields whatever text was assembled above.
101
+ }
102
+ const usage = typeof assembler.usage === "object" ? assembler.usage : undefined;
103
+ if (finish !== undefined && finish !== null && (finish.kind === "error" || finish.kind === "aborted")) {
104
+ return { ok: false, code: finish.kind, detail: describeFailure(finish.failure) };
105
+ }
106
+ return { ok: true, text, ...(usage === undefined ? {} : { usage }) };
107
+ };
108
+
109
+ try {
110
+ return await attempt(reasoningEffort);
111
+ } catch (error) {
112
+ const detail = error instanceof Error ? error.message : String(error);
113
+ // A model that advertises no reasoning support rejects the knob outright;
114
+ // the call itself is still fine, so retry once without it.
115
+ if (typeof reasoningEffort === "string" && reasoningEffort !== "" && /reasoning/i.test(detail)) {
116
+ try {
117
+ return await attempt("");
118
+ } catch (retryError) {
119
+ return {
120
+ ok: false,
121
+ code: "stream-failed",
122
+ detail: retryError instanceof Error ? retryError.message : String(retryError),
123
+ };
124
+ }
125
+ }
126
+ return { ok: false, code: "stream-failed", detail };
127
+ }
128
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Scheduling: when to run the queue.
3
+ *
4
+ * In off-peak hours the queue is drained immediately. As a peak window
5
+ * approaches (within `leadMinutes`), new tasks stop running and the plugin
6
+ * arms a timer for the next off-peak start. The timer is a plain
7
+ * `setTimeout(...).unref()` — the same self-held pattern `dsh-loop` uses —
8
+ * because `ctx.schedule` is an optional facette that is not always mounted.
9
+ *
10
+ * @module dsh-peak-runner/scheduler
11
+ */
12
+
13
+ import { hourInPeak, nextSwitch } from "./tariff.js";
14
+
15
+ /** The longest `setTimeout` delay we ever ask Node for (2^31 - 1 ms). */
16
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
17
+
18
+ /**
19
+ * Should the queue run right now?
20
+ *
21
+ * @param at - epoch milliseconds.
22
+ * @param settings - resolved plugin settings.
23
+ * @returns `{ run: boolean, reason: string, nextRunAt: number|null }`.
24
+ */
25
+ export function shouldRunNow(at, settings) {
26
+ // A weekend instant is always off-peak; `isPeak` already handles that.
27
+ // For a weekday we need the *window* tier, not the calendar tier, because
28
+ // the lead window extends the "stop running" zone past the window end.
29
+ const date = new Date(at);
30
+ const weekend = Number.isNaN(date.getTime()) || date.getUTCDay() === 0 || date.getUTCDay() === 6;
31
+ if (weekend) return { run: true, reason: "off-peak", nextRunAt: null };
32
+
33
+ if (!hourInPeak(date.getUTCHours(), settings.peakWindows)) {
34
+ return { run: true, reason: "off-peak", nextRunAt: null };
35
+ }
36
+
37
+ // Inside a peak window: look for the next switch and see whether the lead
38
+ // window has already swallowed us.
39
+ const switchAt = nextSwitch(at, settings.peakWindows);
40
+ if (switchAt === null) return { run: false, reason: "peak-no-switch-found", nextRunAt: null };
41
+
42
+ const leadMs = settings.leadMinutes * 60_000;
43
+ if (at >= switchAt - leadMs) {
44
+ // We are inside the lead window that precedes the switch into off-peak.
45
+ return { run: false, reason: "lead-window", nextRunAt: switchAt };
46
+ }
47
+ // Still early inside a peak window; the next off-peak start is the switch.
48
+ return { run: false, reason: "peak", nextRunAt: switchAt };
49
+ }
50
+
51
+ /**
52
+ * Arm a one-shot timer for `atMs`, clamped to `setTimeout`'s maximum delay.
53
+ *
54
+ * @param atMs - target epoch milliseconds.
55
+ * @param fire - called when the timer elapses.
56
+ * @returns a disposer that clears the timer.
57
+ */
58
+ export function armTimer(atMs, fire) {
59
+ const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, atMs - Date.now()));
60
+ const timer = setTimeout(() => {
61
+ fire();
62
+ }, delay);
63
+ if (typeof timer.unref === "function") timer.unref();
64
+ return () => clearTimeout(timer);
65
+ }
66
+
67
+ /**
68
+ * Compute the next time the queue should be drained, whether or not it is
69
+ * currently armed.
70
+ *
71
+ * @param at - epoch milliseconds.
72
+ * @param settings - resolved plugin settings.
73
+ * @returns epoch milliseconds of the next off-peak start, or null when the
74
+ * queue should just run now.
75
+ */
76
+ export function nextDrainAt(at, settings) {
77
+ const decision = shouldRunNow(at, settings);
78
+ if (decision.run) return null;
79
+ return decision.nextRunAt;
80
+ }
package/lib/tariff.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Tariff windows: decide whether "now" is off-peak (平价), how long until the
3
+ * next switch, and when the next off-peak window opens.
4
+ *
5
+ * DeepSeek's official rule: peak price applies during UTC 01:00–04:00 and
6
+ * 06:00–10:00; every other hour is off-peak. Weekends and Chinese public
7
+ * holidays are off-peak all day. The plugin mirrors the same window shape
8
+ * `dsh-cost-meter` uses, so both agree on the current tier.
9
+ *
10
+ * This module is pure: every function takes an explicit `now` so tests can
11
+ * pin the clock.
12
+ *
13
+ * @module dsh-peak-runner/tariff
14
+ */
15
+
16
+ /** Milliseconds in one minute. */
17
+ const MINUTE = 60_000;
18
+
19
+ /**
20
+ * Whether a UTC hour falls inside any configured peak window.
21
+ *
22
+ * @param hour - UTC hour (0–23).
23
+ * @param windows - normalised `[{ start, end }]` half-open ranges.
24
+ * @returns true when the hour is a peak hour.
25
+ */
26
+ export function hourInPeak(hour, windows) {
27
+ if (!Array.isArray(windows)) return false;
28
+ return windows.some((w) => {
29
+ const start = Number(w?.start);
30
+ const end = Number(w?.end);
31
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
32
+ // A window that wraps midnight (start > end) covers the tail and the head.
33
+ if (start < end) return hour >= start && hour < end;
34
+ return hour >= start || hour < end;
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Whether `at` is inside a peak window (weekends are always off-peak).
40
+ *
41
+ * @param at - epoch milliseconds.
42
+ * @param windows - configured peak windows.
43
+ * @returns true when the instant is peak-priced.
44
+ */
45
+ export function isPeak(at, windows) {
46
+ const date = new Date(at);
47
+ if (Number.isNaN(date.getTime())) return false;
48
+ // DeepSeek charges by UTC hour; weekends (local Beijing) are off-peak all day.
49
+ if (date.getUTCDay() === 0 || date.getUTCDay() === 6) return false;
50
+ return hourInPeak(date.getUTCHours(), windows);
51
+ }
52
+
53
+ /**
54
+ * The next instant at which the tier changes (into peak or into off-peak).
55
+ *
56
+ * @param at - epoch milliseconds.
57
+ * @param windows - configured peak windows.
58
+ * @returns epoch milliseconds of the next switch, or null when none is found
59
+ * within a search horizon of 8 days.
60
+ */
61
+ export function nextSwitch(at, windows) {
62
+ if (!Array.isArray(windows) || windows.length === 0) return null;
63
+ // Collect every boundary hour across all windows, projected onto a rolling
64
+ // 8-day horizon so weekend gaps are crossed correctly.
65
+ const boundaries = new Set();
66
+ for (const w of windows) {
67
+ boundaries.add(Number(w?.start));
68
+ boundaries.add(Number(w?.end));
69
+ }
70
+ boundaries.delete(NaN);
71
+ if (boundaries.size === 0) return null;
72
+ const hours = [...boundaries].sort((a, b) => a - b);
73
+ const startHour = new Date(at).getUTCHours();
74
+ const startMs = Date.UTC(
75
+ new Date(at).getUTCFullYear(),
76
+ new Date(at).getUTCMonth(),
77
+ new Date(at).getUTCDate(),
78
+ startHour,
79
+ );
80
+ // Walk forward hour by hour (bounded) and return the first hour whose tier
81
+ // differs from the tier at `at`.
82
+ const nowTier = isPeak(at, windows);
83
+ for (let offset = 1; offset <= 8 * 24; offset += 1) {
84
+ const candidate = startMs + offset * 3_600_000;
85
+ if (isPeak(candidate, windows) !== nowTier) return candidate;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * The next instant at which the tier becomes off-peak (平价).
92
+ *
93
+ * @param at - epoch milliseconds.
94
+ * @param windows - configured peak windows.
95
+ * @returns epoch milliseconds of the next off-peak start, or null when the
96
+ * current instant is already off-peak and no upcoming switch is found.
97
+ */
98
+ export function nextOffPeak(at, windows) {
99
+ if (!isPeak(at, windows)) return null;
100
+ return nextSwitch(at, windows);
101
+ }
102
+
103
+ /**
104
+ * A one-line human summary of the current tier and the next switch.
105
+ *
106
+ * @param at - epoch milliseconds.
107
+ * @param windows - configured peak windows.
108
+ * @returns `{ tier, nextSwitchAt, minutesUntilSwitch, text }`.
109
+ */
110
+ export function describeTariff(at, windows) {
111
+ const peak = isPeak(at, windows);
112
+ const switchAt = nextSwitch(at, windows);
113
+ const minutes = switchAt === null ? null : Math.max(0, Math.round((switchAt - at) / MINUTE));
114
+ const tier = peak ? "峰价" : "平价";
115
+ const text =
116
+ switchAt === null
117
+ ? `${tier}(没有找到下一个切换点)`
118
+ : `${tier},${minutes} 分钟后切换(${new Date(switchAt).toISOString()})`;
119
+ return { tier, nextSwitchAt: switchAt, minutesUntilSwitch: minutes, text };
120
+ }
package/lib/tools.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Tool specs for the peak runner: one factory that closes over the runtime
3
+ * services (queue, runner, scheduler) and returns plain specs for `apply()`.
4
+ *
5
+ * @module dsh-peak-runner/tools
6
+ */
7
+
8
+ import { defineTool } from "@deepseek-ai/dsh-tools";
9
+ import { describeTariff, isPeak } from "./tariff.js";
10
+ import { nextDrainAt, shouldRunNow } from "./scheduler.js";
11
+ import { normalizeTask } from "./queue.js";
12
+ import { resolveTarget, runPrompt } from "./runner.js";
13
+
14
+ /** Shared output shape: every tool answers with one text block. */
15
+ const OUTPUT = {
16
+ schema: {
17
+ type: "object",
18
+ additionalProperties: false,
19
+ properties: { text: { type: "string", required: true } },
20
+ },
21
+ render: (_args, value) => [{ type: "text", text: value.text }],
22
+ };
23
+
24
+ /** Truncate a long string for the summary line. */
25
+ function clip(text, max = 120) {
26
+ return text.length > max ? `${text.slice(0, max)}…` : text;
27
+ }
28
+
29
+ /**
30
+ * Build all three tool specs bound to one plugin instance.
31
+ *
32
+ * @param deps - `{ ctx, settings, queue, timer }` where `timer` is the mutable
33
+ * scheduler handle `{ arm(atMs), disarm(), armedAt }`.
34
+ * @returns the tool specs consumed by `apply()`.
35
+ */
36
+ export function createToolSpecs({ ctx, settings, queue, timer }) {
37
+ /** Drain every pending task in order; each one is one model call. */
38
+ async function drain() {
39
+ const tasks = await queue.tasks();
40
+ const pending = tasks.filter((t) => t.status === "pending");
41
+ if (pending.length === 0) return tasks;
42
+
43
+ const target = await resolveTarget(ctx, settings);
44
+ if (target === null) {
45
+ throw new Error("no usable provider/model is registered in this harness");
46
+ }
47
+
48
+ const running = new Set();
49
+ const controller = new AbortController();
50
+ const timeout = setTimeout(() => controller.abort(), settings.timeoutMs);
51
+ if (typeof timeout.unref === "function") timeout.unref();
52
+
53
+ try {
54
+ for (const task of pending) {
55
+ task.status = "running";
56
+ task.startedAt = Date.now();
57
+ running.add(task.id);
58
+ await queue.put(tasks);
59
+
60
+ const outcome = await runPrompt(ctx, {
61
+ prompt: task.prompt,
62
+ provider: target.provider,
63
+ model: target.model,
64
+ maxTokens: settings.maxTokens,
65
+ temperature: settings.temperature,
66
+ reasoningEffort: settings.reasoningEffort,
67
+ signal: controller.signal,
68
+ });
69
+
70
+ task.finishedAt = Date.now();
71
+ running.delete(task.id);
72
+ if (outcome.ok) {
73
+ task.status = "done";
74
+ task.result = outcome.text;
75
+ } else {
76
+ task.status = "failed";
77
+ task.error = `${outcome.code}: ${outcome.detail}`;
78
+ }
79
+ await queue.put(tasks);
80
+ }
81
+ } finally {
82
+ clearTimeout(timeout);
83
+ }
84
+ return tasks;
85
+ }
86
+
87
+ /** Re-arm the scheduler for the next drain moment (no-op when none). */
88
+ function reschedule() {
89
+ const at = nextDrainAt(Date.now(), settings);
90
+ if (at === null) {
91
+ timer.disarm();
92
+ return null;
93
+ }
94
+ timer.arm(at);
95
+ return at;
96
+ }
97
+
98
+ return [
99
+ {
100
+ name: "peak_run",
101
+ description:
102
+ "Run a batch of prompts through deepseek-flash. During off-peak hours the batch runs immediately; during peak hours (or within leadMinutes of a peak window) it is queued and executed at the next off-peak start. Returns one text block with the per-task outcome.",
103
+ parameters: {
104
+ prompts: {
105
+ type: "array",
106
+ items: { type: "string" },
107
+ required: true,
108
+ description: "The prompts to run; each entry is one independent model call.",
109
+ },
110
+ },
111
+ output: OUTPUT,
112
+ async execute(args) {
113
+ const list = Array.isArray(args?.prompts) ? args.prompts : [];
114
+ const now = Date.now();
115
+ const added = [];
116
+ const tasks = await queue.tasks();
117
+ for (const prompt of list) {
118
+ const task = normalizeTask({ prompt }, now);
119
+ tasks.push(task);
120
+ added.push(task);
121
+ }
122
+ await queue.put(tasks);
123
+
124
+ const decision = shouldRunNow(now, settings);
125
+ if (!decision.run) {
126
+ const armedAt = reschedule();
127
+ const eta = armedAt === null ? "(无下一个平价点)" : new Date(armedAt).toISOString();
128
+ return {
129
+ text: [
130
+ `当前${decision.reason === "lead-window" ? "接近峰价" : "峰价"},${added.length} 个任务已排队,等待平价时段(预计 ${eta})自动执行。`,
131
+ "用 peak_schedule 查看详情,用 peak_cancel 取消。",
132
+ ].join("\n"),
133
+ };
134
+ }
135
+
136
+ const finished = await drain();
137
+ const done = finished.filter((t) => t.status === "done").length;
138
+ const failed = finished.filter((t) => t.status === "failed");
139
+ const lines = [
140
+ `平价时段执行完成:${done}/${added.length} 成功。`,
141
+ ...finished.slice(-added.length).map((t, i) => {
142
+ const body = t.status === "done" ? t.result ?? "" : t.error ?? "";
143
+ return `${i + 1}. [${t.status}] ${clip(body)}`;
144
+ }),
145
+ ];
146
+ if (failed.length > 0) lines.push(`${failed.length} 个任务失败,可用 peak_run 重试。`);
147
+ return { text: lines.join("\n") };
148
+ },
149
+ },
150
+ {
151
+ name: "peak_schedule",
152
+ description:
153
+ "Show the current DeepSeek price tier (off-peak / peak), the next switch instant, and the state of the pending task queue.",
154
+ parameters: {},
155
+ output: OUTPUT,
156
+ async execute() {
157
+ const now = Date.now();
158
+ const tariff = describeTariff(now, settings.peakWindows);
159
+ const tasks = await queue.tasks();
160
+ const pending = tasks.filter((t) => t.status === "pending");
161
+ const done = tasks.filter((t) => t.status === "done");
162
+ const failed = tasks.filter((t) => t.status === "failed");
163
+ const armedAt = timer.armedAt;
164
+ return {
165
+ text: [
166
+ `当前档位:${tariff.text}`,
167
+ `队列:${pending.length} 待执行 / ${done.length} 已完成 / ${failed.length} 失败`,
168
+ armedAt === null ? "定时器:未排定" : `定时器:已排定 ${new Date(armedAt).toISOString()}`,
169
+ ].join("\n"),
170
+ };
171
+ },
172
+ },
173
+ {
174
+ name: "peak_cancel",
175
+ description:
176
+ "Cancel pending tasks in the queue (by id, or all pending tasks when no id is given). Already-finished tasks are not affected.",
177
+ parameters: {
178
+ id: {
179
+ type: "string",
180
+ description: "Task id to cancel; omit to cancel every pending task.",
181
+ },
182
+ },
183
+ output: OUTPUT,
184
+ async execute(args) {
185
+ const id = typeof args?.id === "string" ? args.id.trim() : "";
186
+ const tasks = await queue.tasks();
187
+ let cancelled = 0;
188
+ for (const task of tasks) {
189
+ if (task.status !== "pending") continue;
190
+ if (id !== "" && task.id !== id) continue;
191
+ task.status = "cancelled";
192
+ task.finishedAt = Date.now();
193
+ cancelled += 1;
194
+ }
195
+ await queue.put(tasks);
196
+ if (cancelled === 0) return { text: "没有匹配的待执行任务被取消。" };
197
+ reschedule();
198
+ return { text: `已取消 ${cancelled} 个待执行任务。` };
199
+ },
200
+ },
201
+ ].map((spec) =>
202
+ defineTool({
203
+ ...spec,
204
+ presentCall: (args) => ({
205
+ card: "generic",
206
+ title: spec.name,
207
+ kind: "execute",
208
+ detail: args?.id ?? (Array.isArray(args?.prompts) ? `${args.prompts.length} prompts` : ""),
209
+ }),
210
+ }),
211
+ );
212
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "dsh-peak-runner",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "DeepSeek 峰谷价感知的任务执行器:平价时段跑 deepseek-flash 任务批,峰价前挂定时任务推迟到下一个平价时段执行。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "lib/index.js",
8
+ "files": [
9
+ "lib/**/*.js",
10
+ "cordis.patch.yml",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "peerDependencies": {
18
+ "@deepseek-ai/cordis": "^4.0.1",
19
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.2 || ^0.2.1-rc.1 || ^0.2.2-rc.1",
20
+ "@deepseek-ai/dsh-storage-domain": "^0.2.0-rc.2 || ^0.2.1-rc.1 || ^0.2.2-rc.1",
21
+ "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2 || ^0.2.1-rc.1 || ^0.2.2-rc.1",
22
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.2 || ^0.2.1-rc.1 || ^0.2.2-rc.1"
23
+ },
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "compatibility": {
29
+ "dshReleases": {
30
+ "0.2.0-rc.2": "compatible",
31
+ "0.2.1-rc.2": "compatible",
32
+ "0.2.2-rc.1": "compatible"
33
+ }
34
+ }
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org/"
39
+ },
40
+ "keywords": [
41
+ "dsh",
42
+ "dsh-plugin",
43
+ "deepseek-harness",
44
+ "deepseek",
45
+ "peak-offpeak",
46
+ "batch-runner",
47
+ "峰谷"
48
+ ],
49
+ "scripts": {
50
+ "test": "node --test test/*.test.mjs"
51
+ }
52
+ }