diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc7610cd..4adfa6e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Breaking - Config refactor: `inbound.*` removed; use top-level `routing` (allowlists + group rules + transcription), `messages` (prefixes/timestamps), and `session` (scoping/store/mainKey). No legacy keys read. +- Heartbeat config moved to `agent.heartbeat`: set `every: "30m"` (duration string) and optional `model`. `agent.heartbeatMinutes` is removed, and heartbeats are disabled unless `agent.heartbeat.every` is set. ### Fixes - Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam. diff --git a/docs/clawd.md b/docs/clawd.md index 893a70ebb..b6979948f 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -19,7 +19,7 @@ You’re putting an agent in a position to: Start conservative: - Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac). - Use a dedicated WhatsApp number for the assistant. -- Keep heartbeats disabled until you trust the setup (`heartbeatMinutes: 0`). +- Keep heartbeats disabled until you trust the setup (omit `agent.heartbeat` or set `agent.heartbeat.every: "0m"`). ## Prerequisites @@ -122,7 +122,7 @@ Example: thinkingDefault: "high", timeoutSeconds: 1800, // Start with 0; enable later. - heartbeatMinutes: 0 + heartbeat: { every: "0m" } }, routing: { allowFrom: ["+15555550123"], @@ -148,14 +148,14 @@ Example: ## Heartbeats (proactive mode) -When `agent.heartbeatMinutes > 0`, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`). +When `agent.heartbeat.every` is set to a positive interval, CLAWDIS periodically runs a heartbeat prompt (default: `HEARTBEAT`). - If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDIS suppresses outbound delivery for that heartbeat. ```json5 { agent: { - heartbeatMinutes: 30 + heartbeat: { every: "30m" } } } ``` diff --git a/docs/configuration.md b/docs/configuration.md index 4b407feca..f044574fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -129,7 +129,9 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts). verboseDefault: "off", timeoutSeconds: 600, mediaMaxMb: 5, - heartbeatMinutes: 30, + heartbeat: { + every: "30m" + }, maxConcurrent: 3, bash: { backgroundMs: 20000, @@ -145,6 +147,11 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts). If you omit the provider, CLAWDIS currently assumes `anthropic` as a temporary deprecation fallback. +`agent.heartbeat` configures periodic heartbeat runs: +- `every`: duration string (`ms`, `s`, `m`); default unit minutes. Omit or set + `0m` to disable. +- `model`: optional override model for heartbeat runs (`provider/model`). + `agent.bash` configures background bash defaults: - `backgroundMs`: time before auto-background (ms, default 20000) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800) diff --git a/docs/heartbeat.md b/docs/heartbeat.md index 22bcb9051..39e9b1a47 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -12,8 +12,10 @@ Goal: add a simple heartbeat poll for the embedded agent that only notifies user - Keep existing WhatsApp length guidance; forbid burying the sentinel inside alerts. ## Config & defaults -- New config key: `agent.heartbeatMinutes` (number of minutes; `0` disables). -- Default: 30 minutes. +- New config key: `agent.heartbeat` with: + - `every`: duration string (`ms`, `s`, `m`; default unit minutes). `0m` disables. + - `model`: optional override model (`provider/model`) for heartbeat runs. +- Default: disabled unless `agent.heartbeat.every` is set. - New optional idle override for heartbeats: `session.heartbeatIdleMinutes` (defaults to `idleMinutes`). Heartbeat skips do **not** update the session `updatedAt` so idle expiry still works. ## Poller behavior @@ -40,7 +42,7 @@ Goal: add a simple heartbeat poll for the embedded agent that only notifies user - Unit/integration: verbose logger emits start/end lines; normal logger emits a single line. ## Documentation -- Add a short README snippet under configuration showing `heartbeatMinutes` and the sentinel rule. +- Add a short README snippet under configuration showing `agent.heartbeat` and the sentinel rule. - Expose CLI triggers: - `clawdis heartbeat` (web provider, defaults to first `routing.allowFrom`; optional `--to` override) - `--session-id ` forces resuming a specific session for that heartbeat diff --git a/docs/whatsapp.md b/docs/whatsapp.md index b2f6a743c..b34846e7d 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -86,7 +86,7 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. ## Heartbeats - **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s). -- **Reply heartbeat** asks agent on a timer (`agent.heartbeatMinutes`). +- **Reply heartbeat** asks agent on a timer (`agent.heartbeat.every`). - Uses `HEARTBEAT` prompt + `HEARTBEAT_TOKEN` skip behavior. - Skips if queue busy or last inbound was a group. - Falls back to last direct recipient if needed. @@ -104,7 +104,8 @@ Status: WhatsApp Web via Baileys only. Gateway owns the single session. - `messages.messagePrefix` (inbound prefix) - `messages.responsePrefix` (outbound prefix) - `agent.mediaMaxMb` -- `agent.heartbeatMinutes` +- `agent.heartbeat.every` +- `agent.heartbeat.model` (optional override) - `session.*` (scope, idle, store, mainKey) - `web.heartbeatSeconds` - `web.reconnect.*` diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 07d1a7a58..d5780fb40 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -101,6 +101,38 @@ describe("trigger handling", () => { }); }); + it("uses heartbeat model override for heartbeat runs", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const cfg = makeCfg(home); + cfg.agent = { + ...cfg.agent, + heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" }, + }; + + await getReplyFromConfig( + { + Body: "hello", + From: "+1002", + To: "+2000", + }, + { isHeartbeat: true }, + cfg, + ); + + const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(call?.provider).toBe("anthropic"); + expect(call?.model).toBe("claude-haiku-4-5-20251001"); + }); + }); + it("updates group activation when the owner sends /activation", async () => { await withTempHome(async (home) => { const cfg = makeCfg(home); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 7e5ba7bf3..53f47fae3 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -169,14 +169,25 @@ export async function getReplyFromConfig( const agentCfg = cfg.agent; const sessionCfg = cfg.session; - const { provider: defaultProvider, model: defaultModel } = - resolveConfiguredModelRef({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); + const mainModel = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const defaultProvider = mainModel.provider; + const defaultModel = mainModel.model; let provider = defaultProvider; let model = defaultModel; + if (opts?.isHeartbeat) { + const heartbeatRaw = agentCfg?.heartbeat?.model?.trim() ?? ""; + const heartbeatRef = heartbeatRaw + ? parseModelRef(heartbeatRaw, defaultProvider) + : null; + if (heartbeatRef) { + provider = heartbeatRef.provider; + model = heartbeatRef.model; + } + } let contextTokens = agentCfg?.contextTokens ?? lookupContextTokens(model) ?? diff --git a/src/config/config.ts b/src/config/config.ts index 1e16035c6..c615b0886 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -5,6 +5,8 @@ import path from "node:path"; import JSON5 from "json5"; import { z } from "zod"; +import { parseDurationMs } from "../cli/parse-duration.js"; + export type SessionScope = "per-sender" | "global"; export type SessionConfig = { @@ -305,7 +307,6 @@ export type ClawdisConfig = { skillsInstall?: SkillsInstallConfig; models?: ModelsConfig; agent?: { - /** Provider id, e.g. "anthropic" or "openai" (pi-ai catalog). */ /** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */ model?: string; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ @@ -322,8 +323,13 @@ export type ClawdisConfig = { /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ mediaMaxMb?: number; typingIntervalSeconds?: number; - /** Periodic background heartbeat runs (minutes). 0 disables. */ - heartbeatMinutes?: number; + /** Periodic background heartbeat runs. */ + heartbeat?: { + /** Heartbeat interval (duration string, default unit: minutes). */ + every?: string; + /** Heartbeat model override (provider/model). */ + model?: string; + }; /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ maxConcurrent?: number; /** Bash tool defaults. */ @@ -444,6 +450,25 @@ const MessagesSchema = z }) .optional(); +const HeartbeatSchema = z + .object({ + every: z.string().optional(), + model: z.string().optional(), + }) + .superRefine((val, ctx) => { + if (!val.every) return; + try { + parseDurationMs(val.every, { defaultUnit: "m" }); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["every"], + message: "invalid duration (use ms, s, m)", + }); + } + }) + .optional(); + const RoutingSchema = z .object({ allowFrom: z.array(z.string()).optional(), @@ -581,7 +606,7 @@ const ClawdisSchema = z.object({ timeoutSeconds: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(), - heartbeatMinutes: z.number().nonnegative().optional(), + heartbeat: HeartbeatSchema, maxConcurrent: z.number().int().positive().optional(), bash: z .object({ diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 58f3514df..03b2b6ebc 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -21,7 +21,7 @@ import { HEARTBEAT_TOKEN, monitorWebProvider, resolveHeartbeatRecipients, - resolveReplyHeartbeatMinutes, + resolveReplyHeartbeatIntervalMs, runWebHeartbeatOnce, SILENT_REPLY_TOKEN, stripHeartbeatToken, @@ -157,20 +157,25 @@ describe("heartbeat helpers", () => { }); }); - it("resolves heartbeat minutes with default and overrides", () => { + it("resolves reply heartbeat interval from config and overrides", () => { const cfgBase: ClawdisConfig = {}; - expect(resolveReplyHeartbeatMinutes(cfgBase)).toBe(30); + expect(resolveReplyHeartbeatIntervalMs(cfgBase)).toBeNull(); expect( - resolveReplyHeartbeatMinutes({ - agent: { heartbeatMinutes: 5 }, + resolveReplyHeartbeatIntervalMs({ + agent: { heartbeat: { every: "5m" } }, }), - ).toBe(5); + ).toBe(5 * 60_000); expect( - resolveReplyHeartbeatMinutes({ - agent: { heartbeatMinutes: 0 }, + resolveReplyHeartbeatIntervalMs({ + agent: { heartbeat: { every: "0m" } }, }), ).toBeNull(); - expect(resolveReplyHeartbeatMinutes(cfgBase, 7)).toBe(7); + expect(resolveReplyHeartbeatIntervalMs(cfgBase, "7m")).toBe(7 * 60_000); + expect( + resolveReplyHeartbeatIntervalMs({ + agent: { heartbeat: { every: "5" } }, + }), + ).toBe(5 * 60_000); }); }); @@ -506,7 +511,6 @@ describe("runWebHeartbeatOnce", () => { ); setLoadConfigMock(() => ({ - agent: { heartbeatMinutes: 0.001 }, routing: { allowFrom: ["+4367"], }, @@ -774,7 +778,7 @@ describe("web auto-reply", () => { replyResolver, runtime, controller.signal, - { replyHeartbeatMinutes: 1, replyHeartbeatNow: true }, + { replyHeartbeatEvery: "1m", replyHeartbeatNow: true }, ); try { @@ -833,7 +837,7 @@ describe("web auto-reply", () => { replyResolver, runtime, controller.signal, - { replyHeartbeatMinutes: 10_000 }, + { replyHeartbeatEvery: "10000m" }, ); try { diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 9d7764dfa..3c39088c5 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -7,6 +7,7 @@ import { import { getReplyFromConfig } from "../auto-reply/reply.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; import { @@ -72,7 +73,7 @@ type WebInboundMsg = Parameters< export type WebMonitorTuning = { reconnect?: Partial; heartbeatSeconds?: number; - replyHeartbeatMinutes?: number; + replyHeartbeatEvery?: string; replyHeartbeatNow?: boolean; sleep?: (ms: number, signal?: AbortSignal) => Promise; statusSink?: (status: WebProviderStatus) => void; @@ -81,7 +82,6 @@ export type WebMonitorTuning = { const formatDuration = (ms: number) => ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`; -const DEFAULT_REPLY_HEARTBEAT_MINUTES = 30; export const HEARTBEAT_PROMPT = "HEARTBEAT"; export { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN }; @@ -188,14 +188,22 @@ function debugMention( return { wasMentioned: result, details }; } -export function resolveReplyHeartbeatMinutes( +export function resolveReplyHeartbeatIntervalMs( cfg: ReturnType, - overrideMinutes?: number, + overrideEvery?: string, ) { - const raw = overrideMinutes ?? cfg.agent?.heartbeatMinutes; - if (raw === 0) return null; - if (typeof raw === "number" && raw > 0) return raw; - return DEFAULT_REPLY_HEARTBEAT_MINUTES; + const raw = overrideEvery ?? cfg.agent?.heartbeat?.every; + if (!raw) return null; + const trimmed = String(raw).trim(); + if (!trimmed) return null; + let ms: number; + try { + ms = parseDurationMs(trimmed, { defaultUnit: "m" }); + } catch { + return null; + } + if (ms <= 0) return null; + return ms; } export function stripHeartbeatToken(raw?: string) { @@ -767,9 +775,9 @@ export async function monitorWebProvider( cfg, tuning.heartbeatSeconds, ); - const replyHeartbeatMinutes = resolveReplyHeartbeatMinutes( + const replyHeartbeatIntervalMs = resolveReplyHeartbeatIntervalMs( cfg, - tuning.replyHeartbeatMinutes, + tuning.replyHeartbeatEvery, ); const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect); const mentionConfig = buildMentionConfig(cfg); @@ -939,7 +947,7 @@ export async function monitorWebProvider( let lastInboundMsg: WebInboundMsg | null = null; // Watchdog to detect stuck message processing (e.g., event emitter died) - // Should be significantly longer than heartbeatMinutes to avoid false positives + // Should be significantly longer than the reply heartbeat interval to avoid false positives const MESSAGE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes without any messages const WATCHDOG_CHECK_MS = 60 * 1000; // Check every minute @@ -1428,7 +1436,7 @@ export async function monitorWebProvider( } return { status: "skipped", reason: "requests-in-flight" }; } - if (!replyHeartbeatMinutes) { + if (!replyHeartbeatIntervalMs) { return { status: "skipped", reason: "disabled" }; } let heartbeatInboundMsg = lastInboundMsg; @@ -1510,7 +1518,7 @@ export async function monitorWebProvider( { connectionId, to: heartbeatInboundMsg.from, - intervalMinutes: replyHeartbeatMinutes, + intervalMs: replyHeartbeatIntervalMs, sessionKey: snapshot.key, sessionId: snapshot.entry?.sessionId ?? null, sessionFresh: snapshot.fresh, @@ -1635,8 +1643,8 @@ export async function monitorWebProvider( setReplyHeartbeatWakeHandler(async () => runReplyHeartbeat()); - if (replyHeartbeatMinutes && !replyHeartbeatTimer) { - const intervalMs = replyHeartbeatMinutes * 60_000; + if (replyHeartbeatIntervalMs && !replyHeartbeatTimer) { + const intervalMs = replyHeartbeatIntervalMs; replyHeartbeatTimer = setInterval(() => { if (!heartbeatsEnabled) return; void runReplyHeartbeat();