Files
clawdbot/src/auto-reply/heartbeat.ts
JustYannicc dd06028827 feat(heartbeat): skip API calls when HEARTBEAT.md is effectively empty (#1535)
* feat: skip heartbeat API calls when HEARTBEAT.md is effectively empty

- Added isHeartbeatContentEffectivelyEmpty() to detect files with only headers/comments
- Modified runHeartbeatOnce() to check HEARTBEAT.md content before polling the LLM
- Returns early with 'empty-heartbeat-file' reason when no actionable tasks exist
- Preserves existing behavior when file is missing (lets LLM decide)
- Added comprehensive test coverage for empty file detection
- Saves API calls/costs when heartbeat file has no meaningful content

* chore: update HEARTBEAT.md template to be effectively empty by default

Changed instruction text to comment format so new workspaces benefit from
heartbeat optimization immediately. Users still get clear guidance on usage.

* fix: only treat markdown headers (# followed by space) as comments, not #TODO etc

* refactor: simplify regex per code review suggestion

* docs: clarify heartbeat empty file behavior (#1535) (thanks @JustYannicc)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 04:19:01 +00:00

138 lines
4.8 KiB
TypeScript

import { HEARTBEAT_TOKEN } from "./tokens.js";
// Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset).
// Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context.
export const HEARTBEAT_PROMPT =
"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.";
export const DEFAULT_HEARTBEAT_EVERY = "30m";
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
/**
* Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks.
* This allows skipping heartbeat API calls when no tasks are configured.
*
* A file is considered effectively empty if it contains only:
* - Whitespace
* - Comment lines (lines starting with #)
* - Empty lines
*
* Note: A missing file returns false (not effectively empty) so the LLM can still
* decide what to do. This function is only for when the file exists but has no content.
*/
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
if (content === undefined || content === null) return false;
if (typeof content !== "string") return false;
const lines = content.split("\n");
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines
if (!trimmed) continue;
// Skip markdown header lines (# followed by space or EOL, ## etc)
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
// (Those aren't valid markdown headers - ATX headers require space after #)
if (/^#+(\s|$)/.test(trimmed)) continue;
// Found a non-empty, non-comment line - there's actionable content
return false;
}
// All lines were either empty or comments
return true;
}
export function resolveHeartbeatPrompt(raw?: string): string {
const trimmed = typeof raw === "string" ? raw.trim() : "";
return trimmed || HEARTBEAT_PROMPT;
}
export type StripHeartbeatMode = "heartbeat" | "message";
function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
let text = raw.trim();
if (!text) return { text: "", didStrip: false };
const token = HEARTBEAT_TOKEN;
if (!text.includes(token)) return { text, didStrip: false };
let didStrip = false;
let changed = true;
while (changed) {
changed = false;
const next = text.trim();
if (next.startsWith(token)) {
const after = next.slice(token.length).trimStart();
text = after;
didStrip = true;
changed = true;
continue;
}
if (next.endsWith(token)) {
const before = next.slice(0, Math.max(0, next.length - token.length));
text = before.trimEnd();
didStrip = true;
changed = true;
}
}
const collapsed = text.replace(/\s+/g, " ").trim();
return { text: collapsed, didStrip };
}
export function stripHeartbeatToken(
raw?: string,
opts: { mode?: StripHeartbeatMode; maxAckChars?: number } = {},
) {
if (!raw) return { shouldSkip: true, text: "", didStrip: false };
const trimmed = raw.trim();
if (!trimmed) return { shouldSkip: true, text: "", didStrip: false };
const mode: StripHeartbeatMode = opts.mode ?? "message";
const maxAckCharsRaw = opts.maxAckChars;
const parsedAckChars =
typeof maxAckCharsRaw === "string" ? Number(maxAckCharsRaw) : maxAckCharsRaw;
const maxAckChars = Math.max(
0,
typeof parsedAckChars === "number" && Number.isFinite(parsedAckChars)
? parsedAckChars
: DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
);
// Normalize lightweight markup so HEARTBEAT_OK wrapped in HTML/Markdown
// (e.g., <b>HEARTBEAT_OK</b> or **HEARTBEAT_OK**) still strips.
const stripMarkup = (text: string) =>
text
// Drop HTML tags.
.replace(/<[^>]*>/g, " ")
// Decode common nbsp variant.
.replace(/&nbsp;/gi, " ")
// Remove markdown-ish wrappers at the edges.
.replace(/^[*`~_]+/, "")
.replace(/[*`~_]+$/, "");
const trimmedNormalized = stripMarkup(trimmed);
const hasToken = trimmed.includes(HEARTBEAT_TOKEN) || trimmedNormalized.includes(HEARTBEAT_TOKEN);
if (!hasToken) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
const strippedOriginal = stripTokenAtEdges(trimmed);
const strippedNormalized = stripTokenAtEdges(trimmedNormalized);
const picked =
strippedOriginal.didStrip && strippedOriginal.text ? strippedOriginal : strippedNormalized;
if (!picked.didStrip) {
return { shouldSkip: false, text: trimmed, didStrip: false };
}
if (!picked.text) {
return { shouldSkip: true, text: "", didStrip: true };
}
const rest = picked.text.trim();
if (mode === "heartbeat") {
if (rest.length <= maxAckChars) {
return { shouldSkip: true, text: "", didStrip: true };
}
}
return { shouldSkip: false, text: rest, didStrip: true };
}