feat: claude one-shot port from nanobot python codebase (v0.1.4.post4)

This commit is contained in:
Joe Fleming
2026-03-13 08:58:43 -06:00
parent 37c66a1bbf
commit a857bf95cd
53 changed files with 5002 additions and 8 deletions

146
src/heartbeat/service.ts Normal file
View 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');
}
}