Spaces:
Running
Running
| 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; | |
| } | |
| // Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- " | |
| if (/^[-*+]\s*(\[[\sXx]?\]\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(/ /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 }; | |
| } | |