my-opencode-toolkit 0.0.0-stage → 0.2.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/index.ts +3 -0
  4. package/package.json +65 -4
  5. package/src/goal/command.ts +84 -0
  6. package/src/goal/continuation.ts +211 -0
  7. package/src/goal/controller.ts +107 -0
  8. package/src/goal/error.ts +94 -0
  9. package/src/goal/filter.ts +21 -0
  10. package/src/goal/index.ts +43 -0
  11. package/src/goal/parse-command.ts +31 -0
  12. package/src/goal/prompts.ts +84 -0
  13. package/src/goal/session-role.ts +37 -0
  14. package/src/goal/store.ts +91 -0
  15. package/src/goal/tools.ts +131 -0
  16. package/src/goal/types.ts +75 -0
  17. package/src/i18n/en.ts +61 -0
  18. package/src/i18n/index.ts +28 -0
  19. package/src/i18n/types.ts +57 -0
  20. package/src/i18n/zh.ts +61 -0
  21. package/src/index.ts +150 -0
  22. package/src/notification/format.ts +42 -0
  23. package/src/notification/index.ts +165 -0
  24. package/src/notification/send.ts +104 -0
  25. package/src/scheduling/command.ts +181 -0
  26. package/src/scheduling/continuation.ts +164 -0
  27. package/src/scheduling/cron.ts +309 -0
  28. package/src/scheduling/filter.ts +25 -0
  29. package/src/scheduling/index.ts +68 -0
  30. package/src/scheduling/loops.ts +235 -0
  31. package/src/scheduling/store.ts +228 -0
  32. package/src/scheduling/tools.ts +183 -0
  33. package/src/scheduling/types.ts +54 -0
  34. package/src/shared/events.ts +44 -0
  35. package/src/shared/format.ts +16 -0
  36. package/src/shared/json.ts +44 -0
  37. package/src/shared/keys.ts +26 -0
  38. package/src/shared/messages.ts +17 -0
  39. package/src/shared/rpc.ts +214 -0
  40. package/src/shared/scan.ts +21 -0
  41. package/src/shared/settings-store.ts +28 -0
  42. package/src/shared/settings.ts +133 -0
  43. package/src/shared/types.ts +4 -0
  44. package/src/stats.ts +215 -0
  45. package/src/tools/ping.ts +40 -0
  46. package/src/tui/index.tsx +274 -0
  47. package/tui.ts +3 -0
@@ -0,0 +1,181 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+
4
+ import type { Plugin } from "@opencode/plugin"
5
+
6
+ import type { SessionRoles } from "../goal/session-role.ts"
7
+ import { loadSettings } from "../shared/settings-store.ts"
8
+ import type { LoopScheduler } from "./loops.ts"
9
+ import type { SchedulingStore } from "./store.ts"
10
+ import { cronToHuman } from "./cron.ts"
11
+
12
+ /** 内置维护提示词(没有 `loop.md` 时的默认)。 */
13
+ export const DEFAULT_LOOP_MAINTENANCE_PROMPT =
14
+ "Continue unfinished work in this session. Re-read AGENTS.md and progress files, pick the next concrete step, " +
15
+ "and keep going. Run cleanup passes when appropriate."
16
+
17
+ /** `/loop` 解析结果。 */
18
+ export type LoopCommand =
19
+ | { readonly kind: "start"; readonly prompt: string; readonly intervalSeconds?: number }
20
+ | { readonly kind: "stop" }
21
+
22
+ /**
23
+ * 输入:`5m` / `30s` / `2h` / `1d` 形式。
24
+ * 输出:秒数(秒向上取整到分钟,最小 60),非法返回 undefined。
25
+ */
26
+ export function parseIntervalSeconds(value: string): number | undefined {
27
+ const match = /^(\d+)([smhd])$/.exec(value)
28
+ if (match === null) return undefined
29
+ const amount = Number.parseInt(match[1] ?? "", 10)
30
+ const unit = match[2]
31
+ if (!Number.isFinite(amount) || amount <= 0) return undefined
32
+ switch (unit) {
33
+ case "s":
34
+ return Math.max(60, Math.ceil(amount / 60) * 60)
35
+ case "m":
36
+ return amount * 60
37
+ case "h":
38
+ return amount * 3600
39
+ case "d":
40
+ return amount * 86400
41
+ default:
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 输入:`/loop` 参数。
48
+ * 输出:停止,或带可选固定间隔的启动参数。
49
+ * 预期:首个 token 是合法间隔则剥离,剩余为 prompt;`stop` 单独处理。
50
+ */
51
+ export function parseLoopCommand(rawArgs: string): LoopCommand {
52
+ const trimmed = rawArgs.trim()
53
+ if (trimmed.toLowerCase() === "stop") return { kind: "stop" }
54
+ const parts = trimmed.split(/\s+/)
55
+ const first = parts[0]
56
+ if (first === undefined || first === "") return { kind: "start", prompt: "" }
57
+
58
+ const intervalSeconds = parseIntervalSeconds(first)
59
+ if (intervalSeconds === undefined) return { kind: "start", prompt: trimmed }
60
+ return { kind: "start", prompt: parts.slice(1).join(" ").trim(), intervalSeconds }
61
+ }
62
+
63
+ /** 输入:项目目录;输出:维护提示词。预期:`loop.md` 非空时优先。 */
64
+ async function loadMaintenancePrompt(directory: string | undefined): Promise<string> {
65
+ if (directory === undefined) return DEFAULT_LOOP_MAINTENANCE_PROMPT
66
+ try {
67
+ const raw = (await readFile(join(directory, "loop.md"), "utf8")).trim()
68
+ return raw.length > 0 ? raw : DEFAULT_LOOP_MAINTENANCE_PROMPT
69
+ } catch {
70
+ return DEFAULT_LOOP_MAINTENANCE_PROMPT
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 输入:`/cron` 参数。
76
+ * 输出:`{ expr, prompt }` 或错误文本。
77
+ * 预期:前 5 个 token 是 cron 表达式,剩余为 prompt。
78
+ */
79
+ function splitCron(rest: string): { expr: string; prompt: string } | null {
80
+ const parts = rest.trim().split(/\s+/)
81
+ if (parts.length < 6) return null
82
+ return { expr: parts.slice(0, 5).join(" "), prompt: parts.slice(5).join(" ") }
83
+ }
84
+
85
+ /**
86
+ * 输入:插件上下文、任务存储、循环调度器、会话角色。
87
+ * 输出:注册 `/loop` 与 `/cron` 命令。
88
+ * 预期:命令自身不进对话流;执行结果作为普通 prompt 发回当前会话。
89
+ */
90
+ export async function registerSchedulingCommands(
91
+ ctx: Plugin.Context,
92
+ store: SchedulingStore,
93
+ scheduler: LoopScheduler,
94
+ roles: SessionRoles,
95
+ ): Promise<{ dispose(): Promise<void> }> {
96
+ /** 输入:会话 ID;输出:不可用原因,可用时为 null。 */
97
+ const unavailable = async (sessionID: string): Promise<string | null> => {
98
+ const settings = await loadSettings(ctx.storage)
99
+ if (!settings.enabled || !settings.scheduling.enabled) return "Error: the scheduling feature is disabled in settings."
100
+ if (await roles.isSubagent(sessionID)) return "Error: scheduling commands are not available to subagents."
101
+ return null
102
+ }
103
+
104
+ const registration = await ctx.command.transform((editor) => {
105
+ editor.add({
106
+ name: "loop",
107
+ description: "Start or stop a recurring prompt loop in this session",
108
+ execute: async (input) => {
109
+ const blocked = await unavailable(input.sessionID)
110
+ const parsed = parseLoopCommand(input.prompt.text)
111
+ const text = await (async (): Promise<string> => {
112
+ if (blocked !== null) return blocked
113
+ if (parsed.kind === "stop") {
114
+ const count = scheduler.stopAllLoops(input.sessionID)
115
+ return count === 0 ? "No active loop." : `Stopped ${String(count)} loop(s).`
116
+ }
117
+ const prompt = parsed.prompt.length > 0 ? parsed.prompt : await loadMaintenancePrompt(ctx.location.directory)
118
+ const loop = scheduler.startLoop(input.sessionID, prompt, parsed.intervalSeconds)
119
+ return loop.mode === "fixed"
120
+ ? `Loop ${loop.id} started: every ${String(loop.intervalSeconds ?? 0)}s — ${prompt}`
121
+ : `Loop ${loop.id} started (dynamic): ${prompt}`
122
+ })()
123
+ await ctx.session.prompt({ sessionID: input.sessionID, text, delivery: input.delivery })
124
+ },
125
+ })
126
+
127
+ editor.add({
128
+ name: "cron",
129
+ description: "Create, list or delete scheduled prompts for this session",
130
+ execute: async (input) => {
131
+ const blocked = await unavailable(input.sessionID)
132
+ const raw = input.prompt.text.trim()
133
+ const text = await (async (): Promise<string> => {
134
+ if (blocked !== null) return blocked
135
+
136
+ if (raw === "" || raw.toLowerCase() === "list") {
137
+ const jobs = store.listJobs(input.sessionID)
138
+ if (jobs.length === 0) return "No scheduled jobs."
139
+ return jobs
140
+ .map((job) => {
141
+ const kind = job.recurring === true ? "recurring" : "one-shot"
142
+ return `${job.id} — ${cronToHuman(job.cron)} (${kind}): ${job.prompt}`
143
+ })
144
+ .join("\n")
145
+ }
146
+
147
+ const deleteMatch = /^delete\s+(\S+)$/i.exec(raw)
148
+ if (deleteMatch !== null) {
149
+ const id = deleteMatch[1] ?? ""
150
+ const job = store.getJob(id)
151
+ if (job === undefined) return `Error: No scheduled job with id '${id}'`
152
+ if (job.sessionID !== input.sessionID) return `Error: Cannot delete cron job '${id}': owned by another session`
153
+ await store.removeJobs([id])
154
+ return `Cancelled job ${id}.`
155
+ }
156
+
157
+ const once = /^once\s+(.+)$/is.exec(raw)
158
+ const body = once === null ? raw : (once[1] ?? "")
159
+ const split = splitCron(body)
160
+ if (split === null) return "Usage: /cron <expr> <prompt> | /cron once <expr> <prompt> | /cron list | /cron delete <id>"
161
+
162
+ const result = await store.addJob({
163
+ sessionID: input.sessionID,
164
+ cron: split.expr,
165
+ prompt: split.prompt,
166
+ recurring: once === null,
167
+ durable: true,
168
+ })
169
+ if (!result.ok) return `Error: ${result.error}`
170
+ const job = result.job
171
+ return job.recurring === true
172
+ ? `Scheduled recurring job ${job.id} (${cronToHuman(job.cron)}): ${job.prompt}`
173
+ : `Scheduled one-shot task ${job.id} (${cronToHuman(job.cron)}): ${job.prompt}`
174
+ })()
175
+ await ctx.session.prompt({ sessionID: input.sessionID, text, delivery: input.delivery })
176
+ },
177
+ })
178
+ })
179
+ await ctx.command.reload()
180
+ return registration
181
+ }
@@ -0,0 +1,164 @@
1
+ import type { Plugin } from "@opencode/plugin"
2
+
3
+ import { readEventData } from "../shared/events.ts"
4
+ import { lastMessageIsUserInput } from "../shared/messages.ts"
5
+ import type { SessionRoles } from "../goal/session-role.ts"
6
+ import type { SchedulingStore } from "./store.ts"
7
+ import { isRecurringTaskAged } from "./store.ts"
8
+ import type { ScheduledJob } from "./types.ts"
9
+
10
+ /** 注入消息的 metadata 标记。 */
11
+ const CRON_MARKER = "cronContinuation"
12
+
13
+ /** 定时任务触发控制器。 */
14
+ export type SchedulingContinuation = {
15
+ /** 输入:会话事件;输出:无。预期:idle 兜底触发,删除会话清任务。 */
16
+ handle(event: { readonly type: string; readonly data: unknown }): Promise<void>
17
+ /** 输入:无;输出:无。预期:给所有未来任务武装 timer。 */
18
+ armTimers(): void
19
+ /** 输入:无;输出:无。预期:清掉全部 timer。 */
20
+ dispose(): void
21
+ }
22
+
23
+ /**
24
+ * 输入:时间。
25
+ * 输出:`Nov 3 2:30pm` 形式的英文时间。
26
+ * 预期:只用于注入文本,固定 en-US 以免跟随系统语言变化。
27
+ */
28
+ function formatFireTime(date: Date): string {
29
+ return date
30
+ .toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
31
+ .replace(/,? at |, /, " ")
32
+ .replace(/ ([AP]M)/, (_, ampm: string) => ampm.toLowerCase())
33
+ }
34
+
35
+ /** 输入:任务 + 触发时间;输出:注入文本。 */
36
+ function buildFirePrompt(job: ScheduledJob, at: Date): string {
37
+ return `Running scheduled task (${formatFireTime(at)})\n\n${job.prompt}`
38
+ }
39
+
40
+ /**
41
+ * 输入:插件上下文、存储、会话角色、让路回调、启用判断。
42
+ * 输出:定时任务触发控制器。
43
+ * 预期:到点先无条件重排/删除,再尽力注入;子代理与让路时不注入。
44
+ */
45
+ export function createSchedulingContinuation(args: {
46
+ readonly ctx: Plugin.Context
47
+ readonly store: SchedulingStore
48
+ readonly roles: SessionRoles
49
+ readonly shouldDefer: (sessionID: string) => Promise<boolean>
50
+ readonly enabled: () => Promise<boolean>
51
+ readonly now?: () => number
52
+ }): SchedulingContinuation {
53
+ const { ctx, store, roles, shouldDefer, enabled } = args
54
+ const now = args.now ?? Date.now
55
+ const timers = new Map<string, ReturnType<typeof setTimeout>>()
56
+ const inFlight = new Set<string>()
57
+ const pending = new Set<string>()
58
+
59
+ /** 输入:会话 + 文本;输出:是否注入成功。预期:并发、上一条未完成或末条是用户输入时跳过。 */
60
+ const inject = async (sessionID: string, text: string): Promise<boolean> => {
61
+ if (inFlight.has(sessionID) || pending.has(sessionID)) return false
62
+ if (await roles.isSubagent(sessionID)) return false
63
+ if (await lastMessageIsUserInput(ctx, sessionID, CRON_MARKER)) return false
64
+ inFlight.add(sessionID)
65
+ try {
66
+ await ctx.session.prompt({ sessionID, text, delivery: "steer", metadata: { [CRON_MARKER]: true } })
67
+ pending.add(sessionID)
68
+ return true
69
+ } catch {
70
+ return false
71
+ } finally {
72
+ inFlight.delete(sessionID)
73
+ }
74
+ }
75
+
76
+ /** 输入:无;输出:无。预期:每个未来任务一个 timer,已有 timer 的不重复武装。 */
77
+ const armTimers = (): void => {
78
+ const at = now()
79
+ for (const job of store.listJobs()) {
80
+ if (job.nextRunAt === undefined || timers.has(job.id)) continue
81
+ const delay = job.nextRunAt - at
82
+ if (delay <= 0) continue
83
+ const handle = setTimeout(() => {
84
+ timers.delete(job.id)
85
+ const fresh = store.getJob(job.id)
86
+ if (fresh !== undefined) void handleCronFire(fresh)
87
+ }, delay)
88
+ handle.unref()
89
+ timers.set(job.id, handle)
90
+ }
91
+ }
92
+
93
+ /** 输入:任务;输出:无。预期:先重排/删除,再判断是否注入。 */
94
+ const handleCronFire = async (job: ScheduledJob): Promise<void> => {
95
+ const at = now()
96
+ const aged = isRecurringTaskAged(job, at)
97
+ if (aged || job.recurring !== true) {
98
+ await store.removeJobs([job.id])
99
+ } else {
100
+ await store.markFired([job.id], at)
101
+ }
102
+ armTimers()
103
+
104
+ if (!(await enabled())) return
105
+ if (await shouldDefer(job.sessionID)) return
106
+ await inject(job.sessionID, buildFirePrompt(job, new Date(at)))
107
+ }
108
+
109
+ /** 输入:会话 ID;输出:无。预期:处理该会话已到点的任务,否则补武装 timer。 */
110
+ const handleIdle = async (sessionID: string): Promise<void> => {
111
+ if (!(await enabled())) return
112
+ const at = now()
113
+ const due = store
114
+ .listJobs(sessionID)
115
+ .filter((job) => job.nextRunAt !== undefined && job.nextRunAt <= at)
116
+ .sort((a, b) => (a.nextRunAt ?? Number.POSITIVE_INFINITY) - (b.nextRunAt ?? Number.POSITIVE_INFINITY))
117
+ const job = due[0]
118
+ if (job !== undefined) {
119
+ await handleCronFire(job)
120
+ return
121
+ }
122
+ armTimers()
123
+ }
124
+
125
+ return {
126
+ async handle(event) {
127
+ const data = readEventData(event)
128
+ const sessionID = data?.["sessionID"]
129
+ if (typeof sessionID !== "string" || sessionID.length === 0) return
130
+
131
+ if (event.type === "session.idle") {
132
+ await handleIdle(sessionID)
133
+ return
134
+ }
135
+ if (event.type === "session.deleted") {
136
+ const ids = store.listJobs(sessionID).map((job) => job.id)
137
+ for (const id of ids) {
138
+ const timer = timers.get(id)
139
+ if (timer !== undefined) {
140
+ clearTimeout(timer)
141
+ timers.delete(id)
142
+ }
143
+ }
144
+ await store.removeJobs(ids)
145
+ inFlight.delete(sessionID)
146
+ pending.delete(sessionID)
147
+ roles.forget(sessionID)
148
+ return
149
+ }
150
+ if (event.type === "session.step.ended" || event.type === "session.usage.updated") {
151
+ pending.delete(sessionID)
152
+ }
153
+ },
154
+
155
+ armTimers,
156
+
157
+ dispose() {
158
+ for (const timer of timers.values()) clearTimeout(timer)
159
+ timers.clear()
160
+ inFlight.clear()
161
+ pending.clear()
162
+ },
163
+ }
164
+ }
@@ -0,0 +1,309 @@
1
+ /** 展开后的 5 个 cron 字段。 */
2
+ export type CronFields = {
3
+ minute: number[]
4
+ hour: number[]
5
+ dayOfMonth: number[]
6
+ month: number[]
7
+ dayOfWeek: number[]
8
+ }
9
+
10
+ type FieldRange = { min: number; max: number }
11
+
12
+ const FIELD_RANGES: readonly FieldRange[] = [
13
+ { min: 0, max: 59 }, // minute
14
+ { min: 0, max: 23 }, // hour
15
+ { min: 1, max: 31 }, // dayOfMonth
16
+ { min: 1, max: 12 }, // month
17
+ { min: 0, max: 6 }, // dayOfWeek(7 也接受为周日)
18
+ ]
19
+
20
+ /**
21
+ * 输入:单个 cron 字段 + 取值范围。
22
+ * 输出:升序匹配值数组,非法返回 `null`。
23
+ * 预期:支持通配、步进(`*` 后跟斜杠与数字)、单值、范围、范围步进、逗号列表;dayOfWeek 的 7 归一成 0。
24
+ */
25
+ function expandField(field: string, range: FieldRange): number[] | null {
26
+ const { min, max } = range
27
+ const out = new Set<number>()
28
+
29
+ for (const part of field.split(",")) {
30
+ const stepMatch = /^\*(?:\/(\d+))?$/.exec(part)
31
+ if (stepMatch !== null) {
32
+ const step = stepMatch[1] === undefined ? 1 : Number.parseInt(stepMatch[1], 10)
33
+ if (step < 1) return null
34
+ for (let i = min; i <= max; i += step) out.add(i)
35
+ continue
36
+ }
37
+
38
+ const rangeMatch = /^(\d+)-(\d+)(?:\/(\d+))?$/.exec(part)
39
+ if (rangeMatch !== null) {
40
+ const lo = Number.parseInt(rangeMatch[1] ?? "", 10)
41
+ const hi = Number.parseInt(rangeMatch[2] ?? "", 10)
42
+ const step = rangeMatch[3] === undefined ? 1 : Number.parseInt(rangeMatch[3], 10)
43
+ // dayOfWeek: 范围里 7 作为周日别名(5-7 → [5,6,0])
44
+ const isDow = min === 0 && max === 6
45
+ const effMax = isDow ? 7 : max
46
+ if (lo > hi || step < 1 || lo < min || hi > effMax) return null
47
+ for (let i = lo; i <= hi; i += step) out.add(isDow && i === 7 ? 0 : i)
48
+ continue
49
+ }
50
+
51
+ if (/^\d+$/.test(part)) {
52
+ let n = Number.parseInt(part, 10)
53
+ if (min === 0 && max === 6 && n === 7) n = 0
54
+ if (n < min || n > max) return null
55
+ out.add(n)
56
+ continue
57
+ }
58
+
59
+ return null
60
+ }
61
+
62
+ if (out.size === 0) return null
63
+ return Array.from(out).sort((a, b) => a - b)
64
+ }
65
+
66
+ /**
67
+ * 输入:5 字段 cron(本地时区,`minute hour dayOfMonth month dayOfWeek`)。
68
+ * 输出:展开后的字段,非法返回 `null`。
69
+ */
70
+ export function parseCronExpression(expr: string): CronFields | null {
71
+ const parts = expr.trim().split(/\s+/)
72
+ if (parts.length !== 5) return null
73
+
74
+ const expanded: number[][] = []
75
+ for (const [index, part] of parts.entries()) {
76
+ const range = FIELD_RANGES[index]
77
+ if (range === undefined) return null
78
+ const result = expandField(part, range)
79
+ if (result === null) return null
80
+ expanded.push(result)
81
+ }
82
+
83
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = expanded
84
+ if (
85
+ minute === undefined ||
86
+ hour === undefined ||
87
+ dayOfMonth === undefined ||
88
+ month === undefined ||
89
+ dayOfWeek === undefined
90
+ ) {
91
+ return null
92
+ }
93
+ return { minute, hour, dayOfMonth, month, dayOfWeek }
94
+ }
95
+
96
+ /**
97
+ * 输入:展开后的字段 + 起点时间。
98
+ * 输出:严格晚于起点、第一个匹配的本地时刻,一年内无匹配返回 `null`。
99
+ * 预期:dayOfMonth 与 dayOfWeek 都受限时任一匹配即可(OR 语义)。
100
+ */
101
+ export function computeNextCronRun(fields: CronFields, from: Date): Date | null {
102
+ const minuteSet = new Set(fields.minute)
103
+ const hourSet = new Set(fields.hour)
104
+ const domSet = new Set(fields.dayOfMonth)
105
+ const monthSet = new Set(fields.month)
106
+ const dowSet = new Set(fields.dayOfWeek)
107
+
108
+ const domWild = fields.dayOfMonth.length === 31
109
+ const dowWild = fields.dayOfWeek.length === 7
110
+
111
+ const t = new Date(from.getTime())
112
+ t.setSeconds(0, 0)
113
+ t.setMinutes(t.getMinutes() + 1)
114
+
115
+ const maxIter = 366 * 24 * 60
116
+ for (let i = 0; i < maxIter; i += 1) {
117
+ if (!monthSet.has(t.getMonth() + 1)) {
118
+ t.setMonth(t.getMonth() + 1, 1)
119
+ t.setHours(0, 0, 0, 0)
120
+ continue
121
+ }
122
+
123
+ const dayMatches =
124
+ domWild && dowWild
125
+ ? true
126
+ : domWild
127
+ ? dowSet.has(t.getDay())
128
+ : dowWild
129
+ ? domSet.has(t.getDate())
130
+ : domSet.has(t.getDate()) || dowSet.has(t.getDay())
131
+
132
+ if (!dayMatches) {
133
+ t.setDate(t.getDate() + 1)
134
+ t.setHours(0, 0, 0, 0)
135
+ continue
136
+ }
137
+
138
+ if (!hourSet.has(t.getHours())) {
139
+ t.setHours(t.getHours() + 1, 0, 0, 0)
140
+ continue
141
+ }
142
+
143
+ if (!minuteSet.has(t.getMinutes())) {
144
+ t.setMinutes(t.getMinutes() + 1)
145
+ continue
146
+ }
147
+
148
+ return t
149
+ }
150
+
151
+ return null
152
+ }
153
+
154
+ /**
155
+ * 输入:cron 字符串 + 起点毫秒。
156
+ * 输出:下一次触发毫秒,非法或无匹配返回 `null`。
157
+ */
158
+ export function nextCronRunMs(cron: string, fromMs: number): number | null {
159
+ const fields = parseCronExpression(cron)
160
+ if (fields === null) return null
161
+ return computeNextCronRun(fields, new Date(fromMs))?.getTime() ?? null
162
+ }
163
+
164
+ /** cron 的人类可读描述(英文)。 */
165
+ export type CronHuman =
166
+ | { kind: "every_minute"; n: number }
167
+ | { kind: "every_hour"; minute: number }
168
+ | { kind: "every_n_hours"; n: number; minute: number }
169
+ | { kind: "daily"; hour: number; minute: number }
170
+ | { kind: "weekly"; day: number; hour: number; minute: number }
171
+ | { kind: "weekdays"; hour: number; minute: number }
172
+ | { kind: "raw"; text: string }
173
+
174
+ const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
175
+
176
+ /**
177
+ * 输入:cron 字符串。
178
+ * 输出:常见模式的结构化描述,无法识别时回退 `raw`。
179
+ */
180
+ function parseCronHuman(cron: string): CronHuman {
181
+ const parts = cron.trim().split(/\s+/)
182
+ if (parts.length !== 5) return { kind: "raw", text: cron }
183
+ const [minute = "", hour = "", dayOfMonth = "", month = "", dayOfWeek = ""] = parts
184
+
185
+ const everyMin = /^\*\/(\d+)$/.exec(minute)
186
+ if (everyMin !== null && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
187
+ return { kind: "every_minute", n: Number.parseInt(everyMin[1] ?? "1", 10) }
188
+ }
189
+
190
+ if (/^\d+$/.test(minute) && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
191
+ return { kind: "every_hour", minute: Number.parseInt(minute, 10) }
192
+ }
193
+
194
+ const everyHour = /^\*\/(\d+)$/.exec(hour)
195
+ if (/^\d+$/.test(minute) && everyHour !== null && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
196
+ return {
197
+ kind: "every_n_hours",
198
+ n: Number.parseInt(everyHour[1] ?? "1", 10),
199
+ minute: Number.parseInt(minute, 10),
200
+ }
201
+ }
202
+
203
+ if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return { kind: "raw", text: cron }
204
+ const m = Number.parseInt(minute, 10)
205
+ const h = Number.parseInt(hour, 10)
206
+
207
+ if (dayOfMonth === "*" && month === "*" && dayOfWeek === "*") return { kind: "daily", hour: h, minute: m }
208
+ if (dayOfMonth === "*" && month === "*" && /^\d$/.test(dayOfWeek)) {
209
+ return { kind: "weekly", day: Number.parseInt(dayOfWeek, 10) % 7, hour: h, minute: m }
210
+ }
211
+ if (dayOfMonth === "*" && month === "*" && dayOfWeek === "1-5") return { kind: "weekdays", hour: h, minute: m }
212
+
213
+ return { kind: "raw", text: cron }
214
+ }
215
+
216
+ /** 输入:小时 + 分钟;输出:`HH:MM`。预期:固定两位,便于工具输出。 */
217
+ function formatTime(hour: number, minute: number): string {
218
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`
219
+ }
220
+
221
+ /**
222
+ * 输入:cron 字符串。
223
+ * 输出:英文人类可读描述(给模型看的工具输出)。
224
+ */
225
+ export function cronToHuman(cron: string): string {
226
+ const human = parseCronHuman(cron)
227
+ switch (human.kind) {
228
+ case "every_minute":
229
+ return human.n === 1 ? "every minute" : `every ${String(human.n)} minutes`
230
+ case "every_hour":
231
+ return human.minute === 0 ? "every hour" : `every hour at :${String(human.minute).padStart(2, "0")}`
232
+ case "every_n_hours":
233
+ return `every ${String(human.n)} hours at :${String(human.minute).padStart(2, "0")}`
234
+ case "daily":
235
+ return `daily at ${formatTime(human.hour, human.minute)}`
236
+ case "weekly":
237
+ return `weekly on ${DAY_NAMES[human.day] ?? String(human.day)} at ${formatTime(human.hour, human.minute)}`
238
+ case "weekdays":
239
+ return `weekdays at ${formatTime(human.hour, human.minute)}`
240
+ case "raw":
241
+ return human.text
242
+ }
243
+ }
244
+
245
+ /** jitter 参数。 */
246
+ export type CronJitterConfig = {
247
+ readonly recurringFrac: number
248
+ readonly recurringCapMs: number
249
+ readonly oneShotMaxMs: number
250
+ readonly oneShotFloorMs: number
251
+ readonly oneShotMinuteMod: number
252
+ readonly recurringMaxAgeMs: number
253
+ }
254
+
255
+ export const DEFAULT_CRON_JITTER_CONFIG: CronJitterConfig = {
256
+ recurringFrac: 0.1,
257
+ recurringCapMs: 15 * 60 * 1000,
258
+ oneShotMaxMs: 90 * 1000,
259
+ oneShotFloorMs: 0,
260
+ oneShotMinuteMod: 30,
261
+ recurringMaxAgeMs: 7 * 24 * 60 * 60 * 1000,
262
+ }
263
+
264
+ /**
265
+ * 输入:任务 id。
266
+ * 输出:`[0,1)` 的稳定分数。
267
+ * 预期:非 hex id 回退 0(无抖动)。
268
+ */
269
+ function jitterFrac(taskId: string): number {
270
+ const frac = Number.parseInt(taskId.slice(0, 8), 16) / 0x1_0000_0000
271
+ return Number.isFinite(frac) ? frac : 0
272
+ }
273
+
274
+ /**
275
+ * 输入:cron + 起点 + 任务 id。
276
+ * 输出:带确定性正向延迟的下一次触发时刻(recurring 用)。
277
+ * 预期:延迟不超过间隔的 10%,且不超过 15 分钟;没有第二跳时不抖。
278
+ */
279
+ export function jitteredNextCronRunMs(
280
+ cron: string,
281
+ fromMs: number,
282
+ taskId: string,
283
+ cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG,
284
+ ): number | null {
285
+ const first = nextCronRunMs(cron, fromMs)
286
+ if (first === null) return null
287
+ const second = nextCronRunMs(cron, first)
288
+ if (second === null) return first
289
+ const jitter = Math.min(jitterFrac(taskId) * cfg.recurringFrac * (second - first), cfg.recurringCapMs)
290
+ return first + jitter
291
+ }
292
+
293
+ /**
294
+ * 输入:cron + 起点 + 任务 id。
295
+ * 输出:带确定性提前量的下一次触发时刻(one-shot 用)。
296
+ * 预期:只在分钟命中 `oneShotMinuteMod` 时提前,且不早于起点。
297
+ */
298
+ export function oneShotJitteredNextCronRunMs(
299
+ cron: string,
300
+ fromMs: number,
301
+ taskId: string,
302
+ cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG,
303
+ ): number | null {
304
+ const first = nextCronRunMs(cron, fromMs)
305
+ if (first === null) return null
306
+ if (new Date(first).getMinutes() % cfg.oneShotMinuteMod !== 0) return first
307
+ const lead = cfg.oneShotFloorMs + jitterFrac(taskId) * (cfg.oneShotMaxMs - cfg.oneShotFloorMs)
308
+ return Math.max(first - lead, fromMs)
309
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 输入:即将发给模型的工具表(会被原地修改)。
3
+ * 输出:实际删掉的工具名。
4
+ * 预期:只删 scheduling 工具,其余工具原样保留;不存在时跳过。
5
+ */
6
+ export function hideSchedulingTools(tools: Record<string, unknown>): string[] {
7
+ const removed: string[] = []
8
+ if ("cron_create" in tools) {
9
+ delete tools["cron_create"]
10
+ removed.push("cron_create")
11
+ }
12
+ if ("cron_list" in tools) {
13
+ delete tools["cron_list"]
14
+ removed.push("cron_list")
15
+ }
16
+ if ("cron_delete" in tools) {
17
+ delete tools["cron_delete"]
18
+ removed.push("cron_delete")
19
+ }
20
+ if ("schedule_wakeup" in tools) {
21
+ delete tools["schedule_wakeup"]
22
+ removed.push("schedule_wakeup")
23
+ }
24
+ return removed
25
+ }