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., HEARTBEAT_OK or **HEARTBEAT_OK**) still strips. const stripMarkup = (text: string) => text // Drop HTML tags. .replace(/<[^>]*>/g, " ") // Decode common nbsp variant. .replace(/ /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 }; }