diff --git a/CHANGELOG.md b/CHANGELOG.md index 1efdec700..75425cd7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,11 +28,13 @@ - Block streaming: preserve leading indentation in block replies (lists, indented fences). - Docs: document systemd lingering and logged-in session requirements on macOS/Windows. - Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3). +- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman ### Maintenance - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. - Skills: add CodexBar model usage helper with macOS requirement metadata. - Lint: organize imports and wrap long lines in reply commands. +- Deps: update to latest across the repo. ## 2026.1.5-3 diff --git a/docs/clawd.md b/docs/clawd.md index 77a3cdf05..21eb2c2af 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -152,7 +152,7 @@ Example: When `agent.heartbeat.every` is set to a positive interval, CLAWDBOT periodically runs a heartbeat prompt (default: `HEARTBEAT`). -- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDBOT suppresses outbound delivery for that heartbeat. +- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat. ```json5 { diff --git a/docs/configuration.md b/docs/configuration.md index 917bda69d..deb2a0eda 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -560,6 +560,7 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require - `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `imessage`, `none`). Default: `last`. - `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram). - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). +- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30). `agent.bash` configures background bash defaults: - `backgroundMs`: time before auto-background (ms, default 10000) diff --git a/docs/heartbeat.md b/docs/heartbeat.md index 4a2ab923b..fee828592 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -10,10 +10,10 @@ surface anything that needs attention without spamming the user. ## Prompt contract - Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`). -- If nothing needs attention, the model should reply **exactly** `HEARTBEAT_OK`. +- If nothing needs attention, the model should reply `HEARTBEAT_OK`. - During heartbeat runs, Clawdbot treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. Clawdbot strips the token and discards the - reply if the remaining content is **≤ 30 characters**. + reply if the remaining content is **≤ `ackMaxChars`** (default: 30). - If `HEARTBEAT_OK` is in the **middle** of a reply, it is not treated specially. - For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text. @@ -39,7 +39,8 @@ and final replies: model: "anthropic/claude-opus-4-5", target: "last", // last | whatsapp | telegram | none to: "+15551234567", // optional override for whatsapp/telegram - prompt: "HEARTBEAT" // optional override + prompt: "HEARTBEAT", // optional override + ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK } } } @@ -55,6 +56,7 @@ and final replies: - `none`: do not deliver externally; output stays in the session (WebChat-visible). - `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram). - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). +- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30). ## Behavior - Runs in the main session (`main`, or `global` when scope is global). diff --git a/package.json b/package.json index d94352b1f..23e044de8 100644 --- a/package.json +++ b/package.json @@ -111,8 +111,8 @@ "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", "tslog": "^4.10.2", - "undici": "^7.16.0", - "ws": "^8.18.3", + "undici": "^7.18.0", + "ws": "^8.19.0", "zod": "^4.3.5" }, "devDependencies": { @@ -133,7 +133,7 @@ "lucide": "^0.562.0", "markdown-it": "^14.1.0", "ollama": "^0.6.3", - "oxlint": "^1.36.0", + "oxlint": "^1.37.0", "oxlint-tsgolint": "^0.10.1", "quicktype-core": "^23.2.6", "rolldown": "1.0.0-beta.58", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54d27a002..11815c700 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,13 +27,13 @@ importers: version: 1.3.4 '@mariozechner/pi-agent-core': specifier: ^0.36.0 - version: 0.36.0(ws@8.18.3)(zod@4.3.5) + version: 0.36.0(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': specifier: ^0.36.0 - version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5) + version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': specifier: ^0.36.0 - version: 0.36.0(ws@8.18.3)(zod@4.3.5) + version: 0.36.0(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': specifier: ^0.36.0 version: 0.36.0 @@ -110,11 +110,11 @@ importers: specifier: ^4.10.2 version: 4.10.2 undici: - specifier: ^7.16.0 - version: 7.16.0 + specifier: ^7.18.0 + version: 7.18.0 ws: - specifier: ^8.18.3 - version: 8.18.3 + specifier: ^8.19.0 + version: 8.19.0 zod: specifier: ^4.3.5 version: 4.3.5 @@ -171,8 +171,8 @@ importers: specifier: ^0.6.3 version: 0.6.3 oxlint: - specifier: ^1.36.0 - version: 1.36.0(oxlint-tsgolint@0.10.1) + specifier: ^1.37.0 + version: 1.37.0(oxlint-tsgolint@0.10.1) oxlint-tsgolint: specifier: ^0.10.1 version: 0.10.1 @@ -870,43 +870,43 @@ packages: cpu: [x64] os: [win32] - '@oxlint/darwin-arm64@1.36.0': - resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==} + '@oxlint/darwin-arm64@1.37.0': + resolution: {integrity: sha512-qDa8qf4Th3sbk6P6wRbsv5paGeZ8EEOy8PtnT2IkAYSzjDHavw8nMK/lQvf6uS7LArjcmOfM1Y3KnZUFoNZZqg==} cpu: [arm64] os: [darwin] - '@oxlint/darwin-x64@1.36.0': - resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==} + '@oxlint/darwin-x64@1.37.0': + resolution: {integrity: sha512-FM0h0KyOQ4HCdhIX1ne6d80BxRra75h1ORce0jYNwQ49HT4RU8+9ywSMC7rQ79xWsmaahvkQPB7tMPyfjsQwAg==} cpu: [x64] os: [darwin] - '@oxlint/linux-arm64-gnu@1.36.0': - resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==} + '@oxlint/linux-arm64-gnu@1.37.0': + resolution: {integrity: sha512-2axK0lftGwM6Q7wOuY2sassUqa4MKrG3iemVVyEpXzJ6g5QosxhCoFPp9v81/gmLT5kAdd2gskoDcfpDJliDNw==} cpu: [arm64] os: [linux] - '@oxlint/linux-arm64-musl@1.36.0': - resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==} + '@oxlint/linux-arm64-musl@1.37.0': + resolution: {integrity: sha512-f3YROyGMIdUeXx0yD7RsAUBzBvD222D4l2GQRYF3AMxyp9mya17Rq/3wNLR4JDnAnboOul3DAEKNm+09lo3uZw==} cpu: [arm64] os: [linux] - '@oxlint/linux-x64-gnu@1.36.0': - resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==} + '@oxlint/linux-x64-gnu@1.37.0': + resolution: {integrity: sha512-FANOdOVQ2c4acYLM0dvtSoKELHSSnDBxDdm8OlXNzSRanQILrNpLgUqCXHFsfiHipFfNzz3Z417PxV6X4aBYog==} cpu: [x64] os: [linux] - '@oxlint/linux-x64-musl@1.36.0': - resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==} + '@oxlint/linux-x64-musl@1.37.0': + resolution: {integrity: sha512-eYnSKT9knXdOQ9h+6nSjEHSx0+pW8PkGwtMNGXtCYR+/ZPKYIbtZVS0nZsFy+qizP+TRVSJrgc/JY3Xr0wjcQg==} cpu: [x64] os: [linux] - '@oxlint/win32-arm64@1.36.0': - resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==} + '@oxlint/win32-arm64@1.37.0': + resolution: {integrity: sha512-2oHxNc4jcocfNWGWVVWQdEG+reZ5ncBZsmDoICJQ1rbCDx4Yimx8VUf1Ub9cCoJRcPiSLBxMqaeMaDClKixJIQ==} cpu: [arm64] os: [win32] - '@oxlint/win32-x64@1.36.0': - resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==} + '@oxlint/win32-x64@1.37.0': + resolution: {integrity: sha512-w+pBuTjGmGCGPhDjFhj/97K2tlGyq5LKAU6S7FHxROPuJRWJD6uio1L75Lsb8fKhwtw2rm54LLOX30Yi+nILxw==} cpu: [x64] os: [win32] @@ -1951,8 +1951,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hookified@1.14.0: - resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==} + hookified@1.15.0: + resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==} html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2418,8 +2418,8 @@ packages: resolution: {integrity: sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==} hasBin: true - oxlint@1.36.0: - resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==} + oxlint@1.37.0: + resolution: {integrity: sha512-MAw0JH8M5/vt9E2WxSsmJu53bVLmG6qNlVw1OXFenJYItTPbMBtW7j3n53+tgNhNuxFPundM1DR7V8E39qOOrg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2436,8 +2436,8 @@ packages: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} - p-queue@9.0.1: - resolution: {integrity: sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==} + p-queue@9.1.0: + resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==} engines: {node: '>=20'} p-retry@4.6.2: @@ -2933,8 +2933,8 @@ packages: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} engines: {node: '>=18.17'} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + undici@7.18.0: + resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==} engines: {node: '>=20.18.1'} unicode-properties@1.4.1: @@ -3073,8 +3073,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3186,13 +3186,13 @@ snapshots: dependencies: '@cacheable/utils': 2.3.3 '@keyv/bigmap': 1.3.0(keyv@5.5.5) - hookified: 1.14.0 + hookified: 1.15.0 keyv: 5.5.5 '@cacheable/node-cache@1.7.6': dependencies: cacheable: 2.3.1 - hookified: 1.14.0 + hookified: 1.15.0 keyv: 5.5.5 '@cacheable/utils@2.3.3': @@ -3290,7 +3290,7 @@ snapshots: '@vladfrangu/async_event_emitter': 2.4.7 discord-api-types: 0.38.37 tslib: 2.8.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -3397,7 +3397,7 @@ snapshots: '@google/genai@1.34.0': dependencies: google-auth-library: 10.5.0 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - supports-color @@ -3548,7 +3548,7 @@ snapshots: '@keyv/bigmap@1.3.0(keyv@5.5.5)': dependencies: hashery: 1.4.0 - hookified: 1.14.0 + hookified: 1.15.0 keyv: 5.5.5 '@keyv/serialize@1.1.1': {} @@ -3585,9 +3585,9 @@ snapshots: transitivePeerDependencies: - tailwindcss - '@mariozechner/pi-agent-core@0.36.0(ws@8.18.3)(zod@4.3.5)': + '@mariozechner/pi-agent-core@0.36.0(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5) + '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.36.0 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -3597,7 +3597,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)': + '@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@google/genai': 1.34.0 @@ -3606,7 +3606,7 @@ snapshots: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) chalk: 5.6.2 - openai: 6.10.0(ws@8.18.3)(zod@4.3.5) + openai: 6.10.0(ws@8.19.0)(zod@4.3.5) partial-json: 0.1.7 zod-to-json-schema: 3.25.1(zod@4.3.5) transitivePeerDependencies: @@ -3617,11 +3617,11 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.36.0(ws@8.18.3)(zod@4.3.5)': + '@mariozechner/pi-coding-agent@0.36.0(ws@8.19.0)(zod@4.3.5)': dependencies: '@crosscopy/clipboard': 0.2.8 - '@mariozechner/pi-agent-core': 0.36.0(ws@8.18.3)(zod@4.3.5) - '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5) + '@mariozechner/pi-agent-core': 0.36.0(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': 0.36.0 chalk: 5.6.2 cli-highlight: 2.1.11 @@ -3691,28 +3691,28 @@ snapshots: '@oxlint-tsgolint/win32-x64@0.10.1': optional: true - '@oxlint/darwin-arm64@1.36.0': + '@oxlint/darwin-arm64@1.37.0': optional: true - '@oxlint/darwin-x64@1.36.0': + '@oxlint/darwin-x64@1.37.0': optional: true - '@oxlint/linux-arm64-gnu@1.36.0': + '@oxlint/linux-arm64-gnu@1.37.0': optional: true - '@oxlint/linux-arm64-musl@1.36.0': + '@oxlint/linux-arm64-musl@1.37.0': optional: true - '@oxlint/linux-x64-gnu@1.36.0': + '@oxlint/linux-x64-gnu@1.37.0': optional: true - '@oxlint/linux-x64-musl@1.36.0': + '@oxlint/linux-x64-musl@1.37.0': optional: true - '@oxlint/win32-arm64@1.36.0': + '@oxlint/win32-arm64@1.37.0': optional: true - '@oxlint/win32-x64@1.36.0': + '@oxlint/win32-x64@1.37.0': optional: true '@pinojs/redact@0.4.0': {} @@ -3907,7 +3907,7 @@ snapshots: '@types/node': 25.0.3 '@types/ws': 8.18.1 eventemitter3: 5.0.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - debug @@ -4094,7 +4094,7 @@ snapshots: sirv: 3.0.2 tinyrainbow: 3.0.3 vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - msw @@ -4196,11 +4196,11 @@ snapshots: libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' lru-cache: 11.2.4 music-metadata: 11.10.4 - p-queue: 9.0.1 + p-queue: 9.1.0 pino: 9.14.0 protobufjs: 7.5.4 sharp: 0.34.5 - ws: 8.18.3 + ws: 8.19.0 optionalDependencies: audio-decode: 2.2.3 transitivePeerDependencies: @@ -4361,7 +4361,7 @@ snapshots: dependencies: '@cacheable/memory': 2.0.7 '@cacheable/utils': 2.3.3 - hookified: 1.14.0 + hookified: 1.15.0 keyv: 5.5.5 qified: 0.5.3 @@ -4838,7 +4838,7 @@ snapshots: hashery@1.4.0: dependencies: - hookified: 1.14.0 + hookified: 1.15.0 hasown@2.0.2: dependencies: @@ -4848,7 +4848,7 @@ snapshots: highlight.js@11.11.1: {} - hookified@1.14.0: {} + hookified@1.15.0: {} html-escaper@2.0.2: {} @@ -5256,9 +5256,9 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.10.0(ws@8.18.3)(zod@4.3.5): + openai@6.10.0(ws@8.19.0)(zod@4.3.5): optionalDependencies: - ws: 8.18.3 + ws: 8.19.0 zod: 4.3.5 opus-decoder@0.7.11: @@ -5275,16 +5275,16 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.10.1 '@oxlint-tsgolint/win32-x64': 0.10.1 - oxlint@1.36.0(oxlint-tsgolint@0.10.1): + oxlint@1.37.0(oxlint-tsgolint@0.10.1): optionalDependencies: - '@oxlint/darwin-arm64': 1.36.0 - '@oxlint/darwin-x64': 1.36.0 - '@oxlint/linux-arm64-gnu': 1.36.0 - '@oxlint/linux-arm64-musl': 1.36.0 - '@oxlint/linux-x64-gnu': 1.36.0 - '@oxlint/linux-x64-musl': 1.36.0 - '@oxlint/win32-arm64': 1.36.0 - '@oxlint/win32-x64': 1.36.0 + '@oxlint/darwin-arm64': 1.37.0 + '@oxlint/darwin-x64': 1.37.0 + '@oxlint/linux-arm64-gnu': 1.37.0 + '@oxlint/linux-arm64-musl': 1.37.0 + '@oxlint/linux-x64-gnu': 1.37.0 + '@oxlint/linux-x64-musl': 1.37.0 + '@oxlint/win32-arm64': 1.37.0 + '@oxlint/win32-x64': 1.37.0 oxlint-tsgolint: 0.10.1 p-finally@1.0.0: {} @@ -5294,7 +5294,7 @@ snapshots: eventemitter3: 4.0.7 p-timeout: 3.2.0 - p-queue@9.0.1: + p-queue@9.1.0: dependencies: eventemitter3: 5.0.1 p-timeout: 7.0.1 @@ -5453,7 +5453,7 @@ snapshots: qified@0.5.3: dependencies: - hookified: 1.14.0 + hookified: 1.15.0 qoa-format@1.0.1: dependencies: @@ -5876,7 +5876,7 @@ snapshots: undici@6.21.3: {} - undici@7.16.0: {} + undici@7.18.0: {} unicode-properties@1.4.1: dependencies: @@ -5995,7 +5995,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.3: {} + ws@8.19.0: {} y18n@5.0.8: {} diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index 1fb719c2b..124965014 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -1,14 +1,17 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; - +import type { ThinkLevel } from "../auto-reply/thinking.js"; import { isRateLimitAssistantError, pickFallbackThinkingLevel, } from "./pi-embedded-helpers.js"; -import type { ThinkLevel } from "../auto-reply/thinking.js"; const asAssistant = (overrides: Partial) => - ({ role: "assistant", stopReason: "error", ...overrides }) as AssistantMessage; + ({ + role: "assistant", + stopReason: "error", + ...overrides, + }) as AssistantMessage; describe("isRateLimitAssistantError", () => { it("detects 429 rate limit payloads", () => { @@ -57,8 +60,7 @@ describe("pickFallbackThinkingLevel", () => { it("skips already attempted levels", () => { const attempted = new Set(["low", "medium"]); const next = pickFallbackThinkingLevel({ - message: - "Supported values are: 'medium', 'high', and 'xhigh'.", + message: "Supported values are: 'medium', 'high', and 'xhigh'.", attempted, }); expect(next).toBe("high"); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 4cb5f86a2..7a03e02e8 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -6,7 +6,10 @@ import type { AgentToolResult, } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { normalizeThinkLevel, type ThinkLevel } from "../auto-reply/thinking.js"; +import { + normalizeThinkLevel, + type ThinkLevel, +} from "../auto-reply/thinking.js"; import { sanitizeContentBlocksImages } from "./tool-images.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 9b33fdda8..aec17853b 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -341,328 +341,336 @@ export async function runEmbeddedPiAgent(params: { let restoreSkillEnv: (() => void) | undefined; process.chdir(resolvedWorkspace); try { - const shouldLoadSkillEntries = - !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; - const skillEntries = shouldLoadSkillEntries - ? loadWorkspaceSkillEntries(resolvedWorkspace) - : []; - const skillsSnapshot = - params.skillsSnapshot ?? - buildWorkspaceSkillSnapshot(resolvedWorkspace, { + const shouldLoadSkillEntries = + !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; + const skillEntries = shouldLoadSkillEntries + ? loadWorkspaceSkillEntries(resolvedWorkspace) + : []; + const skillsSnapshot = + params.skillsSnapshot ?? + buildWorkspaceSkillSnapshot(resolvedWorkspace, { + config: params.config, + entries: skillEntries, + }); + const sandboxSessionKey = + params.sessionKey?.trim() || params.sessionId; + const sandbox = await resolveSandboxContext({ config: params.config, - entries: skillEntries, - }); - const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; - const sandbox = await resolveSandboxContext({ - config: params.config, - sessionKey: sandboxSessionKey, - workspaceDir: resolvedWorkspace, - }); - restoreSkillEnv = params.skillsSnapshot - ? applySkillEnvOverridesFromSnapshot({ - snapshot: params.skillsSnapshot, - config: params.config, - }) - : applySkillEnvOverrides({ - skills: skillEntries ?? [], - config: params.config, - }); - - const bootstrapFiles = - await loadWorkspaceBootstrapFiles(resolvedWorkspace); - const contextFiles = buildBootstrapContextFiles(bootstrapFiles); - const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); - // Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`). - // `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged. - const tools = createClawdbotCodingTools({ - bash: { - ...params.config?.agent?.bash, - elevated: params.bashElevated, - }, - sandbox, - surface: params.surface, - sessionKey: params.sessionKey ?? params.sessionId, - config: params.config, - }); - const machineName = await getMachineDisplayName(); - const runtimeInfo = { - host: machineName, - os: `${os.type()} ${os.release()}`, - arch: os.arch(), - node: process.version, - model: `${provider}/${modelId}`, - }; - const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); - const reasoningTagHint = provider === "ollama"; - const systemPrompt = buildSystemPrompt({ - appendPrompt: buildAgentSystemPromptAppend({ + sessionKey: sandboxSessionKey, workspaceDir: resolvedWorkspace, - defaultThinkLevel: thinkLevel, - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, - reasoningTagHint, - runtimeInfo, - sandboxInfo, - toolNames: tools.map((tool) => tool.name), - }), - contextFiles, - skills: promptSkills, - cwd: resolvedWorkspace, - tools, - }); + }); + restoreSkillEnv = params.skillsSnapshot + ? applySkillEnvOverridesFromSnapshot({ + snapshot: params.skillsSnapshot, + config: params.config, + }) + : applySkillEnvOverrides({ + skills: skillEntries ?? [], + config: params.config, + }); - const sessionManager = SessionManager.open(params.sessionFile); - const settingsManager = SettingsManager.create( - resolvedWorkspace, - agentDir, - ); - - // Split tools into built-in (recognized by pi-coding-agent SDK) and custom (clawdbot-specific) - const builtInToolNames = new Set(["read", "bash", "edit", "write"]); - const builtInTools = tools.filter((t) => builtInToolNames.has(t.name)); - const customTools = toToolDefinitions( - tools.filter((t) => !builtInToolNames.has(t.name)), - ); - - const { session } = await createAgentSession({ - cwd: resolvedWorkspace, - agentDir, - authStorage, - modelRegistry, - model, - thinkingLevel, - systemPrompt, - // Built-in tools recognized by pi-coding-agent SDK - tools: builtInTools, - // Custom clawdbot tools (browser, canvas, nodes, cron, etc.) - customTools, - sessionManager, - settingsManager, - skills: promptSkills, - contextFiles, - }); - - const prior = await sanitizeSessionMessagesImages( - session.messages, - "session:history", - ); - if (prior.length > 0) { - session.agent.replaceMessages(prior); - } - let aborted = Boolean(params.abortSignal?.aborted); - const abortRun = () => { - aborted = true; - void session.abort(); - }; - const queueHandle: EmbeddedPiQueueHandle = { - queueMessage: async (text: string) => { - await session.steer(text); - }, - isStreaming: () => session.isStreaming, - abort: abortRun, - }; - ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle); - - const { - assistantTexts, - toolMetas, - unsubscribe, - waitForCompactionRetry, - } = subscribeEmbeddedPiSession({ - session, - runId: params.runId, - verboseLevel: params.verboseLevel, - shouldEmitToolResult: params.shouldEmitToolResult, - onToolResult: params.onToolResult, - onBlockReply: params.onBlockReply, - blockReplyBreak: params.blockReplyBreak, - blockReplyChunking: params.blockReplyChunking, - onPartialReply: params.onPartialReply, - onAgentEvent: params.onAgentEvent, - enforceFinalTag: params.enforceFinalTag, - }); - - let abortWarnTimer: NodeJS.Timeout | undefined; - const abortTimer = setTimeout( - () => { - log.warn( - `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, - ); - abortRun(); - if (!abortWarnTimer) { - abortWarnTimer = setTimeout(() => { - if (!session.isStreaming) return; - log.warn( - `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, - ); - }, 10_000); - } - }, - Math.max(1, params.timeoutMs), - ); - - let messagesSnapshot: AgentMessage[] = []; - let sessionIdUsed = session.sessionId; - const onAbort = () => { - abortRun(); - }; - if (params.abortSignal) { - if (params.abortSignal.aborted) { - onAbort(); - } else { - params.abortSignal.addEventListener("abort", onAbort, { - once: true, - }); - } - } - let promptError: unknown = null; - try { - const promptStartedAt = Date.now(); - log.debug( - `embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`, + const bootstrapFiles = + await loadWorkspaceBootstrapFiles(resolvedWorkspace); + const contextFiles = buildBootstrapContextFiles(bootstrapFiles); + const promptSkills = resolvePromptSkills( + skillsSnapshot, + skillEntries, ); + // Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`). + // `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged. + const tools = createClawdbotCodingTools({ + bash: { + ...params.config?.agent?.bash, + elevated: params.bashElevated, + }, + sandbox, + surface: params.surface, + sessionKey: params.sessionKey ?? params.sessionId, + config: params.config, + }); + const machineName = await getMachineDisplayName(); + const runtimeInfo = { + host: machineName, + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + node: process.version, + model: `${provider}/${modelId}`, + }; + const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); + const reasoningTagHint = provider === "ollama"; + const systemPrompt = buildSystemPrompt({ + appendPrompt: buildAgentSystemPromptAppend({ + workspaceDir: resolvedWorkspace, + defaultThinkLevel: thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + reasoningTagHint, + runtimeInfo, + sandboxInfo, + toolNames: tools.map((tool) => tool.name), + }), + contextFiles, + skills: promptSkills, + cwd: resolvedWorkspace, + tools, + }); + + const sessionManager = SessionManager.open(params.sessionFile); + const settingsManager = SettingsManager.create( + resolvedWorkspace, + agentDir, + ); + + // Split tools into built-in (recognized by pi-coding-agent SDK) and custom (clawdbot-specific) + const builtInToolNames = new Set(["read", "bash", "edit", "write"]); + const builtInTools = tools.filter((t) => + builtInToolNames.has(t.name), + ); + const customTools = toToolDefinitions( + tools.filter((t) => !builtInToolNames.has(t.name)), + ); + + const { session } = await createAgentSession({ + cwd: resolvedWorkspace, + agentDir, + authStorage, + modelRegistry, + model, + thinkingLevel, + systemPrompt, + // Built-in tools recognized by pi-coding-agent SDK + tools: builtInTools, + // Custom clawdbot tools (browser, canvas, nodes, cron, etc.) + customTools, + sessionManager, + settingsManager, + skills: promptSkills, + contextFiles, + }); + + const prior = await sanitizeSessionMessagesImages( + session.messages, + "session:history", + ); + if (prior.length > 0) { + session.agent.replaceMessages(prior); + } + let aborted = Boolean(params.abortSignal?.aborted); + const abortRun = () => { + aborted = true; + void session.abort(); + }; + const queueHandle: EmbeddedPiQueueHandle = { + queueMessage: async (text: string) => { + await session.steer(text); + }, + isStreaming: () => session.isStreaming, + abort: abortRun, + }; + ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle); + + const { + assistantTexts, + toolMetas, + unsubscribe, + waitForCompactionRetry, + } = subscribeEmbeddedPiSession({ + session, + runId: params.runId, + verboseLevel: params.verboseLevel, + shouldEmitToolResult: params.shouldEmitToolResult, + onToolResult: params.onToolResult, + onBlockReply: params.onBlockReply, + blockReplyBreak: params.blockReplyBreak, + blockReplyChunking: params.blockReplyChunking, + onPartialReply: params.onPartialReply, + onAgentEvent: params.onAgentEvent, + enforceFinalTag: params.enforceFinalTag, + }); + + let abortWarnTimer: NodeJS.Timeout | undefined; + const abortTimer = setTimeout( + () => { + log.warn( + `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, + ); + abortRun(); + if (!abortWarnTimer) { + abortWarnTimer = setTimeout(() => { + if (!session.isStreaming) return; + log.warn( + `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, + ); + }, 10_000); + } + }, + Math.max(1, params.timeoutMs), + ); + + let messagesSnapshot: AgentMessage[] = []; + let sessionIdUsed = session.sessionId; + const onAbort = () => { + abortRun(); + }; + if (params.abortSignal) { + if (params.abortSignal.aborted) { + onAbort(); + } else { + params.abortSignal.addEventListener("abort", onAbort, { + once: true, + }); + } + } + let promptError: unknown = null; try { - await session.prompt(params.prompt); - } catch (err) { - promptError = err; - } finally { + const promptStartedAt = Date.now(); log.debug( - `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, + `embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`, ); + try { + await session.prompt(params.prompt); + } catch (err) { + promptError = err; + } finally { + log.debug( + `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, + ); + } + await waitForCompactionRetry(); + messagesSnapshot = session.messages.slice(); + sessionIdUsed = session.sessionId; + } finally { + clearTimeout(abortTimer); + if (abortWarnTimer) { + clearTimeout(abortWarnTimer); + abortWarnTimer = undefined; + } + unsubscribe(); + if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) { + ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); + notifyEmbeddedRunEnded(params.sessionId); + } + session.dispose(); + params.abortSignal?.removeEventListener?.("abort", onAbort); } - await waitForCompactionRetry(); - messagesSnapshot = session.messages.slice(); - sessionIdUsed = session.sessionId; - } finally { - clearTimeout(abortTimer); - if (abortWarnTimer) { - clearTimeout(abortWarnTimer); - abortWarnTimer = undefined; + if (promptError && !aborted) { + const fallbackThinking = pickFallbackThinkingLevel({ + message: + promptError instanceof Error + ? promptError.message + : String(promptError), + attempted: attemptedThinking, + }); + if (fallbackThinking) { + log.warn( + `unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`, + ); + thinkLevel = fallbackThinking; + continue; + } + throw promptError; } - unsubscribe(); - if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) { - ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); - notifyEmbeddedRunEnded(params.sessionId); - } - session.dispose(); - params.abortSignal?.removeEventListener?.("abort", onAbort); - } - if (promptError && !aborted) { + + const lastAssistant = messagesSnapshot + .slice() + .reverse() + .find((m) => (m as AgentMessage)?.role === "assistant") as + | AssistantMessage + | undefined; + const fallbackThinking = pickFallbackThinkingLevel({ - message: - promptError instanceof Error - ? promptError.message - : String(promptError), + message: lastAssistant?.errorMessage, attempted: attemptedThinking, }); - if (fallbackThinking) { + if (fallbackThinking && !aborted) { log.warn( `unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`, ); thinkLevel = fallbackThinking; continue; } - throw promptError; - } - const lastAssistant = messagesSnapshot - .slice() - .reverse() - .find((m) => (m as AgentMessage)?.role === "assistant") as - | AssistantMessage - | undefined; - - const fallbackThinking = pickFallbackThinkingLevel({ - message: lastAssistant?.errorMessage, - attempted: attemptedThinking, - }); - if (fallbackThinking && !aborted) { - log.warn( - `unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`, - ); - thinkLevel = fallbackThinking; - continue; - } - - const fallbackConfigured = - (params.config?.agent?.modelFallbacks?.length ?? 0) > 0; - if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) { - const message = - lastAssistant?.errorMessage?.trim() || - (lastAssistant ? formatAssistantErrorText(lastAssistant) : "") || - "LLM request rate limited."; - throw new Error(message); - } - - const usage = lastAssistant?.usage; - const agentMeta: EmbeddedPiAgentMeta = { - sessionId: sessionIdUsed, - provider: lastAssistant?.provider ?? provider, - model: lastAssistant?.model ?? model.id, - usage: usage - ? { - input: usage.input, - output: usage.output, - cacheRead: usage.cacheRead, - cacheWrite: usage.cacheWrite, - total: usage.totalTokens, - } - : undefined, - }; - - const replyItems: Array<{ text: string; media?: string[] }> = []; - - const errorText = lastAssistant - ? formatAssistantErrorText(lastAssistant) - : undefined; - if (errorText) replyItems.push({ text: errorText }); - - const inlineToolResults = - params.verboseLevel === "on" && - !params.onPartialReply && - !params.onToolResult && - toolMetas.length > 0; - if (inlineToolResults) { - for (const { toolName, meta } of toolMetas) { - const agg = formatToolAggregate(toolName, meta ? [meta] : []); - const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg); - if (cleanedText) - replyItems.push({ text: cleanedText, media: mediaUrls }); + const fallbackConfigured = + (params.config?.agent?.modelFallbacks?.length ?? 0) > 0; + if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) { + const message = + lastAssistant?.errorMessage?.trim() || + (lastAssistant ? formatAssistantErrorText(lastAssistant) : "") || + "LLM request rate limited."; + throw new Error(message); } - } - for (const text of assistantTexts.length - ? assistantTexts - : lastAssistant - ? [extractAssistantText(lastAssistant)] - : []) { - const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text); - if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) continue; - replyItems.push({ text: cleanedText, media: mediaUrls }); - } + const usage = lastAssistant?.usage; + const agentMeta: EmbeddedPiAgentMeta = { + sessionId: sessionIdUsed, + provider: lastAssistant?.provider ?? provider, + model: lastAssistant?.model ?? model.id, + usage: usage + ? { + input: usage.input, + output: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + total: usage.totalTokens, + } + : undefined, + }; - const payloads = replyItems - .map((item) => ({ - text: item.text?.trim() ? item.text.trim() : undefined, - mediaUrls: item.media?.length ? item.media : undefined, - mediaUrl: item.media?.[0], - })) - .filter( - (p) => - p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0), + const replyItems: Array<{ text: string; media?: string[] }> = []; + + const errorText = lastAssistant + ? formatAssistantErrorText(lastAssistant) + : undefined; + if (errorText) replyItems.push({ text: errorText }); + + const inlineToolResults = + params.verboseLevel === "on" && + !params.onPartialReply && + !params.onToolResult && + toolMetas.length > 0; + if (inlineToolResults) { + for (const { toolName, meta } of toolMetas) { + const agg = formatToolAggregate(toolName, meta ? [meta] : []); + const { text: cleanedText, mediaUrls } = + splitMediaFromOutput(agg); + if (cleanedText) + replyItems.push({ text: cleanedText, media: mediaUrls }); + } + } + + for (const text of assistantTexts.length + ? assistantTexts + : lastAssistant + ? [extractAssistantText(lastAssistant)] + : []) { + const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text); + if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) + continue; + replyItems.push({ text: cleanedText, media: mediaUrls }); + } + + const payloads = replyItems + .map((item) => ({ + text: item.text?.trim() ? item.text.trim() : undefined, + mediaUrls: item.media?.length ? item.media : undefined, + mediaUrl: item.media?.[0], + })) + .filter( + (p) => + p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0), + ); + + log.debug( + `embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`, ); - - log.debug( - `embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`, - ); - return { - payloads: payloads.length ? payloads : undefined, - meta: { - durationMs: Date.now() - started, - agentMeta, - aborted, - }, - }; + return { + payloads: payloads.length ? payloads : undefined, + meta: { + durationMs: Date.now() - started, + agentMeta, + aborted, + }, + }; } finally { restoreSkillEnv?.(); process.chdir(prevCwd); diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index ba0fa9868..d4b57bfe2 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -1,6 +1,7 @@ import { HEARTBEAT_TOKEN } from "./tokens.js"; export const HEARTBEAT_PROMPT = "HEARTBEAT"; +export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 30; export type StripHeartbeatMode = "heartbeat" | "message"; @@ -44,7 +45,10 @@ export function stripHeartbeatToken( if (!trimmed) return { shouldSkip: true, text: "", didStrip: false }; const mode: StripHeartbeatMode = opts.mode ?? "message"; - const maxAckChars = Math.max(0, opts.maxAckChars ?? 30); + const maxAckChars = Math.max( + 0, + opts.maxAckChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); if (!trimmed.includes(HEARTBEAT_TOKEN)) { return { shouldSkip: false, text: trimmed, didStrip: false }; diff --git a/src/config/types.ts b/src/config/types.ts index 0eec44357..ee1b4a332 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -726,6 +726,8 @@ export type ClawdbotConfig = { to?: string; /** Override the heartbeat prompt body (default: "HEARTBEAT"). */ prompt?: string; + /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ + ackMaxChars?: number; }; /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ maxConcurrent?: number; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8a598ff5c..cce97db56 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -172,6 +172,7 @@ const HeartbeatSchema = z .optional(), to: z.string().optional(), prompt: z.string().optional(), + ackMaxChars: z.number().int().nonnegative().optional(), }) .superRefine((val, ctx) => { if (!val.every) return; diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 4c93ee04b..23b29e6b2 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -553,4 +553,48 @@ describe("runCronIsolatedAgentTurn", () => { ); }); }); + + it("delivers when heartbeat ack padding exceeds configured limit", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "HEARTBEAT_OK 🦞" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const cfg = makeCfg(home, storePath); + cfg.agent = { ...cfg.agent, heartbeat: { ackMaxChars: 0 } }; + + const res = await runCronIsolatedAgentTurn({ + cfg, + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageTelegram).toHaveBeenCalled(); + }); + }); }); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index da5a95c81..93c24083a 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -18,7 +18,10 @@ import { ensureAgentWorkspace, } from "../agents/workspace.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { stripHeartbeatToken } from "../auto-reply/heartbeat.js"; +import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + stripHeartbeatToken, +} from "../auto-reply/heartbeat.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import type { CliDeps } from "../cli/deps.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -64,6 +67,7 @@ function pickSummaryFromPayloads( */ function isHeartbeatOnlyResponse( payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>, + ackMaxChars: number, ) { if (payloads.length === 0) return true; return payloads.every((payload) => { @@ -72,11 +76,13 @@ function isHeartbeatOnlyResponse( (payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl); if (hasMedia) return false; // Use heartbeat mode to check if text is just HEARTBEAT_OK or short ack. - const result = stripHeartbeatToken(payload.text, { mode: "heartbeat" }); + const result = stripHeartbeatToken(payload.text, { + mode: "heartbeat", + maxAckChars: ackMaxChars, + }); return result.shouldSkip; }); } - function resolveDeliveryTarget( cfg: ClawdbotConfig, jobPayload: { @@ -366,7 +372,10 @@ export async function runCronIsolatedAgentTurn(params: { // Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content). // This allows cron jobs to silently ack when nothing to report but still deliver // actual content when there is something to say. - const skipHeartbeatDelivery = delivery && isHeartbeatOnlyResponse(payloads); + const ackMaxChars = + params.cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS; + const skipHeartbeatDelivery = + delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars)); if (delivery && !skipHeartbeatDelivery) { if (resolvedDelivery.channel === "whatsapp") { diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index c555e237b..107c66b9e 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -181,6 +181,64 @@ describe("runHeartbeatOnce", () => { } }); + it("respects ackMaxChars for heartbeat acks", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdbotConfig = { + agent: { + heartbeat: { + every: "5m", + target: "whatsapp", + to: "+1555", + ackMaxChars: 0, + }, + }, + whatsapp: { allowFrom: ["*"] }, + session: { store: storePath }, + }; + + replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(sendWhatsApp).toHaveBeenCalled(); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("skips WhatsApp delivery when not linked or running", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index c78630ceb..9f136e3df 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,5 +1,6 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, HEARTBEAT_PROMPT, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; @@ -102,6 +103,13 @@ export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) { return trimmed || HEARTBEAT_PROMPT; } +function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) { + return Math.max( + 0, + cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); +} + function resolveHeartbeatSession(cfg: ClawdbotConfig) { const sessionCfg = cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; @@ -277,11 +285,12 @@ async function restoreHeartbeatUpdatedAt(params: { function normalizeHeartbeatReply( payload: ReplyPayload, - responsePrefix?: string, + responsePrefix: string | undefined, + ackMaxChars: number, ) { const stripped = stripHeartbeatToken(payload.text, { mode: "heartbeat", - maxAckChars: 30, + maxAckChars: ackMaxChars, }); const hasMedia = Boolean( payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0, @@ -478,9 +487,11 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } + const ackMaxChars = resolveHeartbeatAckMaxChars(cfg); const normalized = normalizeHeartbeatReply( replyPayload, cfg.messages?.responsePrefix, + ackMaxChars, ); if (normalized.shouldSkip && !normalized.hasMedia) { await restoreHeartbeatUpdatedAt({ diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 4600d6682..089723ed6 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -5,6 +5,7 @@ import { parseActivationCommand, } from "../auto-reply/group-activation.js"; import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, HEARTBEAT_PROMPT, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; @@ -369,9 +370,13 @@ export async function runWebHeartbeatOnce(opts: { const hasMedia = Boolean( replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0, ); + const ackMaxChars = Math.max( + 0, + cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); const stripped = stripHeartbeatToken(replyPayload.text, { mode: "heartbeat", - maxAckChars: 30, + maxAckChars: ackMaxChars, }); if (stripped.shouldSkip && !hasMedia) { // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works.