@zhin.js/core 1.0.32 → 1.0.34

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 (113) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/lib/ai/agent.d.ts.map +1 -1
  3. package/lib/ai/agent.js +15 -2
  4. package/lib/ai/agent.js.map +1 -1
  5. package/lib/ai/bootstrap.d.ts +11 -2
  6. package/lib/ai/bootstrap.d.ts.map +1 -1
  7. package/lib/ai/bootstrap.js +46 -2
  8. package/lib/ai/bootstrap.js.map +1 -1
  9. package/lib/ai/builtin-tools.d.ts +28 -6
  10. package/lib/ai/builtin-tools.d.ts.map +1 -1
  11. package/lib/ai/builtin-tools.js +265 -76
  12. package/lib/ai/builtin-tools.js.map +1 -1
  13. package/lib/ai/index.d.ts +9 -1
  14. package/lib/ai/index.d.ts.map +1 -1
  15. package/lib/ai/index.js +8 -0
  16. package/lib/ai/index.js.map +1 -1
  17. package/lib/ai/init.d.ts.map +1 -1
  18. package/lib/ai/init.js +84 -3
  19. package/lib/ai/init.js.map +1 -1
  20. package/lib/ai/providers/anthropic.d.ts +7 -0
  21. package/lib/ai/providers/anthropic.d.ts.map +1 -1
  22. package/lib/ai/providers/anthropic.js +3 -0
  23. package/lib/ai/providers/anthropic.js.map +1 -1
  24. package/lib/ai/providers/ollama.d.ts +10 -0
  25. package/lib/ai/providers/ollama.d.ts.map +1 -1
  26. package/lib/ai/providers/ollama.js +11 -3
  27. package/lib/ai/providers/ollama.js.map +1 -1
  28. package/lib/ai/providers/openai.d.ts +7 -0
  29. package/lib/ai/providers/openai.d.ts.map +1 -1
  30. package/lib/ai/providers/openai.js +3 -0
  31. package/lib/ai/providers/openai.js.map +1 -1
  32. package/lib/ai/service.d.ts +4 -0
  33. package/lib/ai/service.d.ts.map +1 -1
  34. package/lib/ai/service.js +7 -0
  35. package/lib/ai/service.js.map +1 -1
  36. package/lib/ai/subagent.d.ts +50 -0
  37. package/lib/ai/subagent.d.ts.map +1 -0
  38. package/lib/ai/subagent.js +144 -0
  39. package/lib/ai/subagent.js.map +1 -0
  40. package/lib/ai/types.d.ts +25 -5
  41. package/lib/ai/types.d.ts.map +1 -1
  42. package/lib/ai/zhin-agent-builtin-tools.d.ts +17 -0
  43. package/lib/ai/zhin-agent-builtin-tools.d.ts.map +1 -0
  44. package/lib/ai/zhin-agent-builtin-tools.js +220 -0
  45. package/lib/ai/zhin-agent-builtin-tools.js.map +1 -0
  46. package/lib/ai/zhin-agent-config.d.ts +54 -0
  47. package/lib/ai/zhin-agent-config.d.ts.map +1 -0
  48. package/lib/ai/zhin-agent-config.js +76 -0
  49. package/lib/ai/zhin-agent-config.js.map +1 -0
  50. package/lib/ai/zhin-agent-exec-policy.d.ts +20 -0
  51. package/lib/ai/zhin-agent-exec-policy.d.ts.map +1 -0
  52. package/lib/ai/zhin-agent-exec-policy.js +71 -0
  53. package/lib/ai/zhin-agent-exec-policy.js.map +1 -0
  54. package/lib/ai/zhin-agent-prompt.d.ts +21 -0
  55. package/lib/ai/zhin-agent-prompt.d.ts.map +1 -0
  56. package/lib/ai/zhin-agent-prompt.js +116 -0
  57. package/lib/ai/zhin-agent-prompt.js.map +1 -0
  58. package/lib/ai/zhin-agent-tool-collector.d.ts +22 -0
  59. package/lib/ai/zhin-agent-tool-collector.d.ts.map +1 -0
  60. package/lib/ai/zhin-agent-tool-collector.js +218 -0
  61. package/lib/ai/zhin-agent-tool-collector.js.map +1 -0
  62. package/lib/ai/zhin-agent.d.ts +11 -155
  63. package/lib/ai/zhin-agent.d.ts.map +1 -1
  64. package/lib/ai/zhin-agent.js +84 -684
  65. package/lib/ai/zhin-agent.js.map +1 -1
  66. package/lib/component.d.ts.map +1 -1
  67. package/lib/component.js +19 -19
  68. package/lib/component.js.map +1 -1
  69. package/lib/index.d.ts +1 -0
  70. package/lib/index.d.ts.map +1 -1
  71. package/lib/index.js +1 -0
  72. package/lib/index.js.map +1 -1
  73. package/lib/scheduler/index.d.ts +10 -0
  74. package/lib/scheduler/index.d.ts.map +1 -0
  75. package/lib/scheduler/index.js +12 -0
  76. package/lib/scheduler/index.js.map +1 -0
  77. package/lib/scheduler/scheduler.d.ts +49 -0
  78. package/lib/scheduler/scheduler.d.ts.map +1 -0
  79. package/lib/scheduler/scheduler.js +352 -0
  80. package/lib/scheduler/scheduler.js.map +1 -0
  81. package/lib/scheduler/types.d.ts +71 -0
  82. package/lib/scheduler/types.d.ts.map +1 -0
  83. package/lib/scheduler/types.js +8 -0
  84. package/lib/scheduler/types.js.map +1 -0
  85. package/lib/tool-zod.d.ts +28 -0
  86. package/lib/tool-zod.d.ts.map +1 -0
  87. package/lib/tool-zod.js +98 -0
  88. package/lib/tool-zod.js.map +1 -0
  89. package/package.json +9 -4
  90. package/src/ai/agent.ts +15 -2
  91. package/src/ai/bootstrap.ts +48 -2
  92. package/src/ai/builtin-tools.ts +283 -75
  93. package/src/ai/index.ts +19 -1
  94. package/src/ai/init.ts +85 -3
  95. package/src/ai/providers/anthropic.ts +3 -0
  96. package/src/ai/providers/ollama.ts +13 -3
  97. package/src/ai/providers/openai.ts +3 -0
  98. package/src/ai/service.ts +8 -0
  99. package/src/ai/subagent.ts +209 -0
  100. package/src/ai/types.ts +29 -2
  101. package/src/ai/zhin-agent-builtin-tools.ts +247 -0
  102. package/src/ai/zhin-agent-config.ts +113 -0
  103. package/src/ai/zhin-agent-exec-policy.ts +78 -0
  104. package/src/ai/zhin-agent-prompt.ts +136 -0
  105. package/src/ai/zhin-agent-tool-collector.ts +243 -0
  106. package/src/ai/zhin-agent.ts +113 -791
  107. package/src/component.ts +29 -28
  108. package/src/index.ts +1 -0
  109. package/src/scheduler/index.ts +28 -0
  110. package/src/scheduler/scheduler.ts +372 -0
  111. package/src/scheduler/types.ts +74 -0
  112. package/src/tool-zod.ts +115 -0
  113. package/tests/ai/subagent.test.ts +270 -0
package/src/component.ts CHANGED
@@ -470,34 +470,38 @@ export async function renderComponent<P = any>(component: Component<P>, template
470
470
  const props = getProps(component, template, context);
471
471
  return component(props, context);
472
472
  }
473
- // 渲染函数 - 支持新的组件系统
473
+ // 渲染函数 - 支持新的组件系统;无组件时仍对内容执行 ${...} 模板编译,与有组件时行为一致
474
474
  export async function renderComponents(
475
475
  componentMap: Map<string, Component>,
476
476
  options: SendOptions,
477
477
  customContext?: ComponentContext
478
478
  ): Promise<SendOptions> {
479
- if (!componentMap.size) return options;
479
+ const template = typeof options.content === 'string'
480
+ ? options.content
481
+ : segment.toString(options.content as MessageElement);
480
482
 
481
- const components = [...Array.from(componentMap.values()), Fetch, Fragment];
482
-
483
- // 创建根上下文
484
483
  const rootContext = customContext || createComponentContext(
485
484
  options,
486
485
  undefined,
487
- typeof options.content === 'string' ? options.content : segment.toString(options.content as MessageElement)
486
+ template
488
487
  );
489
488
 
490
- // 实现渲染逻辑
491
- const renderWithContext = async (template: string, context: ComponentContext): Promise<SendContent> => {
492
- let result = template;
489
+ if (!componentMap.size) {
490
+ const compiled = rootContext.compile(template);
491
+ return {
492
+ ...options,
493
+ content: typeof compiled === 'string' ? segment.from(compiled) : (compiled as MessageElement[]),
494
+ };
495
+ }
496
+
497
+ const components = [...Array.from(componentMap.values()), Fetch, Fragment];
498
+
499
+ const renderWithContext = async (tpl: string, context: ComponentContext): Promise<SendContent> => {
500
+ let result = context.compile(tpl);
493
501
  let hasChanges = true;
494
502
  let iterations = 0;
495
- const maxIterations = 10; // 防止无限循环
503
+ const maxIterations = 10;
496
504
 
497
- // 编译模板
498
- result = context.compile(result);
499
-
500
- // 递归处理所有组件,直到没有更多组件需要渲染
501
505
  while (hasChanges && iterations < maxIterations) {
502
506
  hasChanges = false;
503
507
  iterations++;
@@ -505,22 +509,21 @@ export async function renderComponents(
505
509
  for (const comp of components) {
506
510
  const match = matchComponent(comp, result);
507
511
  if (match) {
508
- // 创建组件特定的上下文
509
512
  const componentContext = createComponentContext(
510
513
  context.props,
511
514
  context,
512
515
  result
513
516
  );
514
517
  let SendContent;
515
- try{
518
+ try {
516
519
  SendContent = await renderComponent(comp, match, componentContext);
517
- }catch(error){
518
- SendContent = `[${comp.name} Error: ${(error as Error)?.message||String(error)}]`
520
+ } catch (error) {
521
+ SendContent = `[${comp.name} Error: ${(error as Error)?.message || String(error)}]`;
519
522
  }
520
523
  const renderedString = typeof SendContent === 'string' ? SendContent : segment.toString(SendContent as MessageElement);
521
524
  result = result.replace(match, renderedString);
522
525
  hasChanges = true;
523
- break; // 处理一个组件后重新开始循环
526
+ break;
524
527
  }
525
528
  }
526
529
  }
@@ -528,20 +531,18 @@ export async function renderComponents(
528
531
  return result;
529
532
  };
530
533
 
531
- // 更新根上下文的渲染函数
532
- rootContext.render = async (template: string, context?: Partial<ComponentContext>) => {
533
- return await renderWithContext(template, rootContext);
534
+ rootContext.render = async (tpl: string, context?: Partial<ComponentContext>) => {
535
+ return await renderWithContext(tpl, rootContext);
534
536
  };
535
537
 
536
- // 渲染模板
537
538
  const output = await renderWithContext(rootContext.root, rootContext);
538
539
  const content = typeof output === 'string' ? segment.from(output) : output as MessageElement[];
539
540
 
540
- return {
541
- ...options,
542
- content
543
- };
544
- }
541
+ return {
542
+ ...options,
543
+ content,
544
+ };
545
+ }
545
546
 
546
547
  // 内置组件
547
548
  export const Fragment = defineComponent(async (props: { children?: SendContent }, context: ComponentContext) => {
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ export * from './types.js'
33
33
  export * from './utils.js'
34
34
  export * from './errors.js' // 导出错误处理系统
35
35
  export * from './cron.js'
36
+ export * from './scheduler/index.js'
36
37
  export * from '@zhin.js/database'
37
38
  export * from '@zhin.js/logger'
38
39
  // 只导出 Schema 类,避免与 utils.js 的 isEmpty 冲突
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Scheduler module — at / every / cron + heartbeat
3
+ */
4
+
5
+ export type {
6
+ Schedule,
7
+ JobPayload,
8
+ JobState,
9
+ ScheduledJob,
10
+ JobStore,
11
+ JobCallback,
12
+ AddJobOptions,
13
+ IScheduler,
14
+ } from './types.js';
15
+ export { Scheduler } from './scheduler.js';
16
+ export type { SchedulerOptions } from './scheduler.js';
17
+
18
+ import type { Scheduler } from './scheduler.js';
19
+
20
+ let schedulerInstance: Scheduler | null = null;
21
+
22
+ export function getScheduler(): Scheduler | null {
23
+ return schedulerInstance;
24
+ }
25
+
26
+ export function setScheduler(s: Scheduler | null): void {
27
+ schedulerInstance = s;
28
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Unified scheduler — at / every / cron + heartbeat
3
+ *
4
+ * 持久化到 data/scheduler-jobs.json,支持单次 at、间隔 every、cron 表达式,
5
+ * 以及可选的 HEARTBEAT.md 周期检查。
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { randomUUID } from 'crypto';
11
+ import { Cron as Croner } from 'croner';
12
+ import type {
13
+ Schedule,
14
+ JobPayload,
15
+ ScheduledJob,
16
+ JobStore,
17
+ JobCallback,
18
+ AddJobOptions,
19
+ IScheduler,
20
+ } from './types.js';
21
+ import { Logger } from '@zhin.js/logger';
22
+
23
+ const logger = new Logger(null, 'scheduler');
24
+
25
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30 * 60 * 1000;
26
+
27
+ const HEARTBEAT_PROMPT = `Read HEARTBEAT.md in your workspace (if it exists).
28
+ Follow any instructions or tasks listed there.
29
+ If nothing needs attention, reply with just: HEARTBEAT_OK`;
30
+
31
+ function nowMs(): number {
32
+ return Date.now();
33
+ }
34
+
35
+ function computeNextRun(schedule: Schedule, currentMs: number): number | undefined {
36
+ if (schedule.kind === 'at') {
37
+ return schedule.atMs != null && schedule.atMs > currentMs ? schedule.atMs : undefined;
38
+ }
39
+ if (schedule.kind === 'every') {
40
+ if (schedule.everyMs == null || schedule.everyMs <= 0) return undefined;
41
+ return currentMs + schedule.everyMs;
42
+ }
43
+ if (schedule.kind === 'cron' && schedule.expr) {
44
+ try {
45
+ const job = new Croner(schedule.expr, { paused: true, timezone: schedule.tz });
46
+ const next = job.nextRun();
47
+ job.stop();
48
+ return next ? next.getTime() : undefined;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ function createStore(): JobStore {
57
+ return { version: 1, jobs: [] };
58
+ }
59
+
60
+ function isHeartbeatEmpty(content: string | null): boolean {
61
+ if (!content) return true;
62
+ const skipPatterns = new Set(['- [ ]', '* [ ]', '- [x]', '* [x]']);
63
+ for (const line of content.split('\n')) {
64
+ const trimmed = line.trim();
65
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('<!--') || skipPatterns.has(trimmed)) continue;
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+
71
+ export interface SchedulerOptions {
72
+ storePath: string;
73
+ workspace: string;
74
+ onJob?: JobCallback;
75
+ heartbeatEnabled?: boolean;
76
+ heartbeatIntervalMs?: number;
77
+ }
78
+
79
+ export class Scheduler implements IScheduler {
80
+ private storePath: string;
81
+ private workspace: string;
82
+ private onJob: JobCallback | null = null;
83
+ private store: JobStore | null = null;
84
+ private timerTimeout: ReturnType<typeof setTimeout> | null = null;
85
+ private _running = false;
86
+ private heartbeatEnabled: boolean;
87
+ private heartbeatIntervalMs: number;
88
+ private heartbeatJobId: string | null = null;
89
+
90
+ constructor(options: SchedulerOptions) {
91
+ this.storePath = options.storePath;
92
+ this.workspace = options.workspace;
93
+ this.onJob = options.onJob ?? null;
94
+ this.heartbeatEnabled = options.heartbeatEnabled ?? true;
95
+ this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
96
+ }
97
+
98
+ private loadStore(): JobStore {
99
+ if (this.store) return this.store;
100
+ if (fs.existsSync(this.storePath)) {
101
+ try {
102
+ const data = JSON.parse(fs.readFileSync(this.storePath, 'utf-8'));
103
+ const jobs: ScheduledJob[] = (data.jobs || []).map((j: any) => ({
104
+ id: j.id,
105
+ name: j.name,
106
+ enabled: j.enabled ?? true,
107
+ schedule: {
108
+ kind: j.schedule?.kind ?? 'cron',
109
+ atMs: j.schedule?.atMs,
110
+ everyMs: j.schedule?.everyMs,
111
+ expr: j.schedule?.expr,
112
+ tz: j.schedule?.tz,
113
+ },
114
+ payload: {
115
+ kind: j.payload?.kind ?? 'agent_turn',
116
+ message: j.payload?.message ?? '',
117
+ deliver: j.payload?.deliver ?? false,
118
+ channel: j.payload?.channel,
119
+ to: j.payload?.to,
120
+ },
121
+ state: {
122
+ nextRunAtMs: j.state?.nextRunAtMs,
123
+ lastRunAtMs: j.state?.lastRunAtMs,
124
+ lastStatus: j.state?.lastStatus,
125
+ lastError: j.state?.lastError,
126
+ },
127
+ createdAtMs: j.createdAtMs ?? 0,
128
+ updatedAtMs: j.updatedAtMs ?? 0,
129
+ deleteAfterRun: j.deleteAfterRun ?? false,
130
+ }));
131
+ this.store = { version: data.version ?? 1, jobs };
132
+ } catch (e) {
133
+ logger.warn('Failed to load scheduler store', e);
134
+ this.store = createStore();
135
+ }
136
+ } else {
137
+ this.store = createStore();
138
+ }
139
+ return this.store;
140
+ }
141
+
142
+ private saveStore(): void {
143
+ if (!this.store) return;
144
+ const dir = path.dirname(this.storePath);
145
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
146
+ const persistJobs = this.store.jobs.filter(j => j.id !== this.heartbeatJobId);
147
+ const data = {
148
+ version: this.store.version,
149
+ jobs: persistJobs.map(j => ({
150
+ id: j.id,
151
+ name: j.name,
152
+ enabled: j.enabled,
153
+ schedule: j.schedule,
154
+ payload: j.payload,
155
+ state: j.state,
156
+ createdAtMs: j.createdAtMs,
157
+ updatedAtMs: j.updatedAtMs,
158
+ deleteAfterRun: j.deleteAfterRun,
159
+ })),
160
+ };
161
+ fs.writeFileSync(this.storePath, JSON.stringify(data, null, 2));
162
+ }
163
+
164
+ async start(): Promise<void> {
165
+ this._running = true;
166
+ this.loadStore();
167
+ if (this.heartbeatEnabled) this.addHeartbeatJob();
168
+ this.recomputeNextRuns();
169
+ this.saveStore();
170
+ this.armTimer();
171
+ logger.info({ jobs: this.store?.jobs.length ?? 0 }, 'Scheduler started');
172
+ }
173
+
174
+ stop(): void {
175
+ this._running = false;
176
+ if (this.timerTimeout) {
177
+ clearTimeout(this.timerTimeout);
178
+ this.timerTimeout = null;
179
+ }
180
+ }
181
+
182
+ private addHeartbeatJob(): void {
183
+ if (!this.store) return;
184
+ const existing = this.store.jobs.find(j => j.payload.kind === 'heartbeat');
185
+ if (existing) {
186
+ this.heartbeatJobId = existing.id;
187
+ return;
188
+ }
189
+ const now = nowMs();
190
+ const job: ScheduledJob = {
191
+ id: `heartbeat-${randomUUID().slice(0, 8)}`,
192
+ name: 'Heartbeat',
193
+ enabled: true,
194
+ schedule: { kind: 'every', everyMs: this.heartbeatIntervalMs },
195
+ payload: { kind: 'heartbeat', message: HEARTBEAT_PROMPT, deliver: false },
196
+ state: { nextRunAtMs: now + this.heartbeatIntervalMs },
197
+ createdAtMs: now,
198
+ updatedAtMs: now,
199
+ deleteAfterRun: false,
200
+ };
201
+ this.heartbeatJobId = job.id;
202
+ this.store.jobs.push(job);
203
+ logger.info({ intervalMs: this.heartbeatIntervalMs }, 'Heartbeat job added');
204
+ }
205
+
206
+ private recomputeNextRuns(): void {
207
+ if (!this.store) return;
208
+ const now = nowMs();
209
+ for (const job of this.store.jobs) {
210
+ if (job.enabled) job.state.nextRunAtMs = computeNextRun(job.schedule, now);
211
+ }
212
+ }
213
+
214
+ private getNextWakeMs(): number | undefined {
215
+ if (!this.store) return undefined;
216
+ const times = this.store.jobs
217
+ .filter(j => j.enabled && j.state.nextRunAtMs != null)
218
+ .map(j => j.state.nextRunAtMs!);
219
+ return times.length > 0 ? Math.min(...times) : undefined;
220
+ }
221
+
222
+ private armTimer(): void {
223
+ if (this.timerTimeout) {
224
+ clearTimeout(this.timerTimeout);
225
+ this.timerTimeout = null;
226
+ }
227
+ const nextWake = this.getNextWakeMs();
228
+ if (nextWake == null || !this._running) return;
229
+ const delayMs = Math.max(0, nextWake - nowMs());
230
+ this.timerTimeout = setTimeout(async () => {
231
+ if (this._running) await this.onTimer();
232
+ }, delayMs);
233
+ }
234
+
235
+ private async onTimer(): Promise<void> {
236
+ if (!this.store) return;
237
+ const now = nowMs();
238
+ const dueJobs = this.store.jobs.filter(
239
+ j => j.enabled && j.state.nextRunAtMs != null && now >= j.state.nextRunAtMs!
240
+ );
241
+ for (const job of dueJobs) await this.executeJob(job);
242
+ this.saveStore();
243
+ this.armTimer();
244
+ }
245
+
246
+ private async executeJob(job: ScheduledJob): Promise<void> {
247
+ const startMs = nowMs();
248
+ if (job.payload.kind === 'heartbeat') {
249
+ const shouldRun = this.checkHeartbeatFile();
250
+ if (!shouldRun) {
251
+ job.state.lastStatus = 'skipped';
252
+ job.state.lastRunAtMs = startMs;
253
+ job.updatedAtMs = nowMs();
254
+ job.state.nextRunAtMs = computeNextRun(job.schedule, nowMs());
255
+ return;
256
+ }
257
+ }
258
+ logger.info({ jobId: job.id, name: job.name }, 'Scheduler: executing job');
259
+ try {
260
+ if (this.onJob) await this.onJob(job);
261
+ job.state.lastStatus = 'ok';
262
+ job.state.lastError = undefined;
263
+ logger.info({ jobId: job.id, name: job.name }, 'Scheduler: job completed');
264
+ } catch (error) {
265
+ job.state.lastStatus = 'error';
266
+ job.state.lastError = String(error);
267
+ logger.error({ jobId: job.id, name: job.name, lastError: String(error) }, 'Scheduler: job failed');
268
+ }
269
+ job.state.lastRunAtMs = startMs;
270
+ job.updatedAtMs = nowMs();
271
+ if (job.schedule.kind === 'at') {
272
+ if (job.deleteAfterRun && this.store) {
273
+ this.store.jobs = this.store.jobs.filter(j => j.id !== job.id);
274
+ } else {
275
+ job.enabled = false;
276
+ job.state.nextRunAtMs = undefined;
277
+ }
278
+ } else {
279
+ job.state.nextRunAtMs = computeNextRun(job.schedule, nowMs());
280
+ }
281
+ }
282
+
283
+ private checkHeartbeatFile(): boolean {
284
+ const heartbeatPath = path.join(this.workspace, 'HEARTBEAT.md');
285
+ if (!fs.existsSync(heartbeatPath)) return false;
286
+ try {
287
+ const content = fs.readFileSync(heartbeatPath, 'utf-8');
288
+ return !isHeartbeatEmpty(content);
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+
294
+ listJobs(): ScheduledJob[] {
295
+ const store = this.loadStore();
296
+ return store.jobs
297
+ .filter(j => j.id !== this.heartbeatJobId)
298
+ .sort((a, b) => (a.state.nextRunAtMs ?? Infinity) - (b.state.nextRunAtMs ?? Infinity));
299
+ }
300
+
301
+ addJob(options: AddJobOptions): ScheduledJob {
302
+ const store = this.loadStore();
303
+ const now = nowMs();
304
+ const job: ScheduledJob = {
305
+ id: randomUUID().slice(0, 8),
306
+ name: options.name,
307
+ enabled: options.enabled ?? true,
308
+ schedule: options.schedule,
309
+ payload: options.payload,
310
+ state: { nextRunAtMs: computeNextRun(options.schedule, now) },
311
+ createdAtMs: now,
312
+ updatedAtMs: now,
313
+ deleteAfterRun: options.deleteAfterRun ?? false,
314
+ };
315
+ store.jobs.push(job);
316
+ this.saveStore();
317
+ this.armTimer();
318
+ logger.info({ jobId: job.id, name: job.name }, 'Scheduler: added job');
319
+ return job;
320
+ }
321
+
322
+ removeJob(jobId: string): boolean {
323
+ const store = this.loadStore();
324
+ const before = store.jobs.length;
325
+ store.jobs = store.jobs.filter(j => j.id !== jobId);
326
+ const removed = store.jobs.length < before;
327
+ if (removed) {
328
+ this.saveStore();
329
+ this.armTimer();
330
+ logger.info({ jobId }, 'Scheduler: removed job');
331
+ }
332
+ return removed;
333
+ }
334
+
335
+ enableJob(jobId: string, enabled: boolean = true): boolean {
336
+ const store = this.loadStore();
337
+ const job = store.jobs.find(j => j.id === jobId);
338
+ if (!job) return false;
339
+ job.enabled = enabled;
340
+ job.updatedAtMs = nowMs();
341
+ job.state.nextRunAtMs = enabled ? computeNextRun(job.schedule, nowMs()) : undefined;
342
+ this.saveStore();
343
+ this.armTimer();
344
+ return true;
345
+ }
346
+
347
+ async runJob(jobId: string): Promise<void> {
348
+ const store = this.loadStore();
349
+ const job = store.jobs.find(j => j.id === jobId);
350
+ if (job) {
351
+ await this.executeJob(job);
352
+ this.saveStore();
353
+ this.armTimer();
354
+ }
355
+ }
356
+
357
+ status(): { running: boolean; jobCount: number; nextWakeAt?: number } {
358
+ const store = this.loadStore();
359
+ return {
360
+ running: this._running,
361
+ jobCount: store.jobs.filter(j => j.id !== this.heartbeatJobId).length,
362
+ nextWakeAt: this.getNextWakeMs(),
363
+ };
364
+ }
365
+
366
+ async triggerHeartbeat(): Promise<void> {
367
+ if (this.heartbeatJobId && this.store) {
368
+ const job = this.store.jobs.find(j => j.id === this.heartbeatJobId);
369
+ if (job && this.onJob) await this.onJob(job);
370
+ }
371
+ }
372
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Scheduler types
3
+ *
4
+ * 支持三种调度:at(单次指定时间)、every(固定间隔)、cron(表达式)
5
+ * Payload 支持 agent_turn(到点执行 prompt)、heartbeat(读 HEARTBEAT.md)、system_event
6
+ */
7
+
8
+ export interface Schedule {
9
+ kind: 'at' | 'every' | 'cron';
10
+ /** 单次执行时间戳(kind=at) */
11
+ atMs?: number;
12
+ /** 间隔毫秒(kind=every) */
13
+ everyMs?: number;
14
+ /** Cron 表达式(kind=cron) */
15
+ expr?: string;
16
+ /** 时区(kind=cron 可选) */
17
+ tz?: string;
18
+ }
19
+
20
+ export interface JobPayload {
21
+ kind: 'system_event' | 'agent_turn' | 'heartbeat';
22
+ /** 触发时发给 AI 的 prompt(agent_turn)或 heartbeat 说明 */
23
+ message: string;
24
+ /** 是否投递到指定 channel/user */
25
+ deliver: boolean;
26
+ channel?: string;
27
+ to?: string;
28
+ }
29
+
30
+ export interface JobState {
31
+ nextRunAtMs?: number;
32
+ lastRunAtMs?: number;
33
+ lastStatus?: 'ok' | 'error' | 'skipped';
34
+ lastError?: string;
35
+ }
36
+
37
+ export interface ScheduledJob {
38
+ id: string;
39
+ name: string;
40
+ enabled: boolean;
41
+ schedule: Schedule;
42
+ payload: JobPayload;
43
+ state: JobState;
44
+ createdAtMs: number;
45
+ updatedAtMs: number;
46
+ /** 单次任务执行后是否删除 */
47
+ deleteAfterRun: boolean;
48
+ }
49
+
50
+ export interface JobStore {
51
+ version: number;
52
+ jobs: ScheduledJob[];
53
+ }
54
+
55
+ export type JobCallback = (job: ScheduledJob) => Promise<void>;
56
+
57
+ export interface AddJobOptions {
58
+ name: string;
59
+ schedule: Schedule;
60
+ payload: JobPayload;
61
+ enabled?: boolean;
62
+ deleteAfterRun?: boolean;
63
+ }
64
+
65
+ export interface IScheduler {
66
+ start(): Promise<void>;
67
+ stop(): void;
68
+ addJob(options: AddJobOptions): ScheduledJob;
69
+ removeJob(jobId: string): boolean;
70
+ enableJob(jobId: string, enabled: boolean): boolean;
71
+ runJob(jobId: string): Promise<void>;
72
+ listJobs(): ScheduledJob[];
73
+ status(): { running: boolean; jobCount: number; nextWakeAt?: number };
74
+ }