import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { LLMProvider, ModelMessage } from '../provider/index.ts'; const HEARTBEAT_TOOL = [ { type: 'function' as const, function: { name: 'heartbeat_decision', description: 'Decide whether to act on this heartbeat tick.', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['skip', 'run'], description: '"skip" to do nothing, "run" to execute the tasks.', }, tasks: { type: 'array', items: { type: 'string' }, description: 'List of tasks to perform (only when action is "run").', }, reason: { type: 'string', description: 'Brief reason for the decision.' }, }, required: ['action'], }, }, }, ]; export type HeartbeatExecuteCallback = (tasks: string[]) => Promise; export type HeartbeatNotifyCallback = (content: string) => Promise; export class HeartbeatService { private _workspace: string; private _provider: LLMProvider; private _model: string; private _intervalMs: number; private _onExecute: HeartbeatExecuteCallback; private _onNotify: HeartbeatNotifyCallback; private _timer: ReturnType | null = null; private _running = false; constructor(opts: { workspace: string; provider: LLMProvider; model: string; intervalMinutes: number; onExecute: HeartbeatExecuteCallback; onNotify: HeartbeatNotifyCallback; }) { this._workspace = opts.workspace; this._provider = opts.provider; this._model = opts.model; this._intervalMs = opts.intervalMinutes * 60 * 1000; this._onExecute = opts.onExecute; this._onNotify = opts.onNotify; } start(): void { if (this._running) return; this._running = true; this._schedule(); console.info(`[heartbeat] Started (interval: ${this._intervalMs / 60000}min)`); } stop(): void { this._running = false; if (this._timer) { clearTimeout(this._timer); this._timer = null; } } async triggerNow(): Promise { await this._tick(); } private _schedule(): void { if (!this._running) return; this._timer = setTimeout(() => { void this._tick().finally(() => this._schedule()); }, this._intervalMs); } private async _tick(): Promise { const heartbeatContent = this._loadHeartbeatMd(); if (!heartbeatContent) { console.debug('[heartbeat] No HEARTBEAT.md found, skipping tick.'); return; } const now = new Date().toISOString(); const messages: ModelMessage[] = [ { role: 'system', content: 'You are a heartbeat agent. Read the HEARTBEAT.md instructions and decide whether to act on this tick. Call heartbeat_decision with action="skip" or action="run".', }, { role: 'user', content: `Current time: ${now}\n\n## HEARTBEAT.md\n\n${heartbeatContent}`, }, ]; const { response } = await this._provider.chatWithRetry({ messages, tools: HEARTBEAT_TOOL, model: this._model, toolChoice: 'required', }); const decision = response.toolCalls[0]; if (!decision) { console.debug('[heartbeat] No decision tool call returned, skipping.'); return; } const action = typeof decision.arguments['action'] === 'string' ? decision.arguments['action'] : 'skip'; if (action !== 'run') { const reason = typeof decision.arguments['reason'] === 'string' ? decision.arguments['reason'] : ''; console.debug(`[heartbeat] Decision: skip (${reason})`); return; } const tasks = Array.isArray(decision.arguments['tasks']) ? (decision.arguments['tasks'] as string[]) : []; console.info(`[heartbeat] Decision: run (${tasks.length} tasks)`); try { const result = await this._onExecute(tasks); await this._onNotify(result); } catch (err) { console.error(`[heartbeat] Execution failed: ${String(err)}`); } } private _loadHeartbeatMd(): string | null { const path = join(this._workspace, 'HEARTBEAT.md'); if (!existsSync(path)) return null; return readFileSync(path, 'utf8'); } }