feat: claude one-shot port from nanobot python codebase (v0.1.4.post4)
This commit is contained in:
146
src/heartbeat/service.ts
Normal file
146
src/heartbeat/service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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<string>;
|
||||
export type HeartbeatNotifyCallback = (content: string) => Promise<void>;
|
||||
|
||||
export class HeartbeatService {
|
||||
private _workspace: string;
|
||||
private _provider: LLMProvider;
|
||||
private _model: string;
|
||||
private _intervalMs: number;
|
||||
private _onExecute: HeartbeatExecuteCallback;
|
||||
private _onNotify: HeartbeatNotifyCallback;
|
||||
private _timer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user