Merge branch 'pr-335-merge'
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
|
||||
### Fixes
|
||||
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
|
||||
- Heartbeat: default interval now 30m with a new default prompt + HEARTBEAT.md template.
|
||||
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
|
||||
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
|
||||
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
|
||||
- Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows.
|
||||
@@ -26,6 +26,7 @@
|
||||
- Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307.
|
||||
- Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324.
|
||||
- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322.
|
||||
- Telegram: include sender identity in group envelope headers. (#336)
|
||||
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
|
||||
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
|
||||
- Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331.
|
||||
@@ -120,6 +121,7 @@
|
||||
- Models: extend `clawdbot models` status output with a masked auth overview (profiles, env sources, and OAuth counts).
|
||||
|
||||
### Maintenance
|
||||
- Skills: add Himalaya email CLI skill. Thanks @dantelex for PR #335.
|
||||
- Agent: add `skipBootstrap` config option. Thanks @onutc for PR #292.
|
||||
- UI: add favicon.ico derived from the macOS app icon. Thanks @jeffersonwarrior for PR #305.
|
||||
- Tooling: replace tsx with bun for TypeScript execution. Thanks @obviyus for PR #278.
|
||||
|
||||
@@ -453,5 +453,5 @@ Thanks to all clawtributors:
|
||||
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
||||
<a href="https://github.com/adamgall"><img src="https://avatars.githubusercontent.com/u/706929?v=4&s=48" width="48" height="48" alt="adamgall" title="adamgall"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/regenrek"><img src="https://avatars.githubusercontent.com/u/5182020?v=4&s=48" width="48" height="48" alt="regenrek" title="regenrek"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="tobiasbischoff" title="tobiasbischoff"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="Iamadig" title="Iamadig"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a>
|
||||
<a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="ManuelHettich" title="ManuelHettich"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="AbhisekBasu1" title="AbhisekBasu1"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a>
|
||||
<a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="ManuelHettich" title="ManuelHettich"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="AbhisekBasu1" title="AbhisekBasu1"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a>
|
||||
</p>
|
||||
|
||||
@@ -166,6 +166,7 @@ By default, CLAWDBOT runs a heartbeat every 30 minutes with the prompt:
|
||||
Set `agent.heartbeat.every: "0m"` to disable.
|
||||
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat.
|
||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -205,7 +206,7 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`).
|
||||
|
||||
- WebChat: [WebChat](https://docs.clawd.bot/webchat)
|
||||
- Gateway ops: [Gateway runbook](https://docs.clawd.bot/gateway)
|
||||
- Cron + wakeups: [Cron + wakeups](https://docs.clawd.bot/cron)
|
||||
- Cron + wakeups: [Cron jobs](https://docs.clawd.bot/cron-jobs)
|
||||
- macOS menu bar companion: [Clawdbot macOS app](https://docs.clawd.bot/macos)
|
||||
- iOS node app: [iOS app](https://docs.clawd.bot/ios)
|
||||
- Android node app: [Android app](https://docs.clawd.bot/android)
|
||||
|
||||
@@ -791,11 +791,11 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
- `model`: optional override model for heartbeat runs (`provider/model`).
|
||||
- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (provider-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`).
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
|
||||
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens; adjust `every`
|
||||
and/or `model` accordingly.
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
|
||||
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
|
||||
|
||||
`agent.bash` configures background bash defaults:
|
||||
- `backgroundMs`: time before auto-background (ms, default 10000)
|
||||
@@ -1482,7 +1482,7 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any
|
||||
|
||||
## Cron (Gateway scheduler)
|
||||
|
||||
Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wakeups](https://docs.clawd.bot/cron) for the full RFC and CLI examples.
|
||||
Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron jobs](https://docs.clawd.bot/cron-jobs) for the feature overview and CLI examples.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
132
docs/cron-jobs.md
Normal file
132
docs/cron-jobs.md
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
summary: "Cron jobs + wakeups for the Gateway scheduler"
|
||||
read_when:
|
||||
- Scheduling background jobs or wakeups
|
||||
- Wiring automation that should run with or alongside heartbeats
|
||||
---
|
||||
# Cron jobs (Gateway scheduler)
|
||||
|
||||
Cron runs inside the Gateway and schedules background work so Clawdbot can
|
||||
wake itself up, run isolated agent jobs, and deliver reminders on time.
|
||||
|
||||
## Update checklist (internal)
|
||||
- [x] Audit cron + heartbeat behavior in code
|
||||
- [x] Rewrite cron doc as user-facing feature
|
||||
- [x] Update heartbeat docs + templates
|
||||
- [x] Update cron links in docs
|
||||
- [x] Update changelog
|
||||
- [x] Run full gate (lint/build/test/docs)
|
||||
|
||||
## What cron is
|
||||
- **Gateway-owned scheduler** that persists jobs under `~/.clawdbot/cron/`.
|
||||
- **Two execution modes**:
|
||||
- **Main session jobs** enqueue `System:` events and rely on the heartbeat runner.
|
||||
- **Isolated jobs** run a dedicated agent turn in `cron:<jobId>` sessions.
|
||||
- **Wakeups** are first-class: a job can trigger the next heartbeat or run it now.
|
||||
|
||||
## When to use it
|
||||
- Recurring reminders: “every weekday at 7:30” or “every 2h.”
|
||||
- Background chores: summarize inboxes, check dashboards, watch logs.
|
||||
- Automation that should not pollute the main chat history.
|
||||
- Scheduled wakeups that drive the heartbeat pipeline.
|
||||
|
||||
## Schedules
|
||||
Cron supports three schedule kinds:
|
||||
- `at`: one-shot timestamp in ms.
|
||||
- `every`: fixed interval (ms).
|
||||
- `cron`: 5-field cron expression, optional IANA timezone.
|
||||
|
||||
Cron expressions use `croner` under the hood. If a timezone is omitted, the
|
||||
server’s local timezone is used.
|
||||
|
||||
## Job types
|
||||
|
||||
### Main session jobs
|
||||
Main jobs enqueue a system event and optionally wake the heartbeat runner.
|
||||
They **must** use `payload.kind = "systemEvent"`.
|
||||
|
||||
- **`wakeMode: "next-heartbeat"`** (default): the event waits for the next
|
||||
scheduled heartbeat.
|
||||
- **`wakeMode: "now"`**: the event triggers an immediate heartbeat run.
|
||||
|
||||
### Isolated jobs
|
||||
Isolated jobs run a dedicated agent turn in session `cron:<jobId>` and can
|
||||
optionally deliver a message.
|
||||
|
||||
Key behaviors:
|
||||
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
|
||||
- A summary is posted to the main session with prefix `Cron` (or
|
||||
`isolation.postToMainPrefix`).
|
||||
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
|
||||
- `payload.deliver: true` sends output to a provider; otherwise it stays internal.
|
||||
|
||||
## Storage & history
|
||||
- Job store: `~/.clawdbot/cron/jobs.json` (JSON, Gateway-managed).
|
||||
- Run history: `~/.clawdbot/cron/runs/<jobId>.jsonl` (JSONL, auto-pruned).
|
||||
- Override store path: `cron.store` in config.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json5
|
||||
{
|
||||
cron: {
|
||||
enabled: true, // default true
|
||||
store: "~/.clawdbot/cron/jobs.json",
|
||||
maxConcurrentRuns: 1 // default 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Disable cron entirely:
|
||||
- `cron.enabled: false` (config)
|
||||
- or `CLAWDBOT_SKIP_CRON=1` (env)
|
||||
|
||||
## CLI quickstart
|
||||
|
||||
One-shot reminder (main session, wake immediately):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Calendar check" \
|
||||
--at "20m" \
|
||||
--session main \
|
||||
--system-event "Next heartbeat: check calendar." \
|
||||
--wake now
|
||||
```
|
||||
|
||||
Recurring isolated job (deliver to WhatsApp):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Morning status" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize inbox + calendar for today." \
|
||||
--deliver \
|
||||
--provider whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
|
||||
Manual run (debug):
|
||||
```bash
|
||||
clawdbot cron run <jobId> --force
|
||||
```
|
||||
|
||||
Run history:
|
||||
```bash
|
||||
clawdbot cron runs --id <jobId> --limit 50
|
||||
```
|
||||
|
||||
Immediate wake without creating a job:
|
||||
```bash
|
||||
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
||||
```
|
||||
|
||||
## API surface (Gateway)
|
||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||
- `cron.run` (force or due), `cron.runs`
|
||||
- `wake` (enqueue system event + optional heartbeat)
|
||||
|
||||
## Tips
|
||||
- Use **main session jobs** when you want the heartbeat prompt + existing context.
|
||||
- Use **isolated jobs** for noisy, frequent, or long-running work.
|
||||
- Keep messages short; cron turns are full agent runs and can burn tokens.
|
||||
385
docs/cron.md
385
docs/cron.md
@@ -1,385 +0,0 @@
|
||||
---
|
||||
summary: "RFC: Cron jobs + wakeups for Clawd/Clawdbot (main vs isolated sessions)"
|
||||
read_when:
|
||||
- Designing scheduled jobs, alarms, or wakeups
|
||||
- Adding Gateway methods or CLI commands for automation
|
||||
- Adjusting heartbeat behavior or session routing
|
||||
---
|
||||
|
||||
# RFC: Cron jobs + wakeups for Clawd
|
||||
|
||||
Status: Draft
|
||||
Last updated: 2025-12-13
|
||||
|
||||
## Context
|
||||
|
||||
Clawdbot already has:
|
||||
- A **gateway heartbeat runner** that runs the agent with the configured heartbeat prompt (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`) and suppresses `HEARTBEAT_OK` ([`src/infra/heartbeat-runner.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/heartbeat-runner.ts)).
|
||||
- A lightweight, in-memory **system event queue** (`enqueueSystemEvent`) that is injected into the next **main session** turn (`drainSystemEvents` in [`src/auto-reply/reply.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/auto-reply/reply.ts)).
|
||||
- A WebSocket **Gateway** daemon that is intended to be always-on ([`docs/gateway.md`](https://docs.clawd.bot/gateway)).
|
||||
|
||||
This RFC adds a small “cron job system” so Clawd can schedule future work and reliably wake itself up:
|
||||
- **Delayed**: run on the *next* normal heartbeat tick
|
||||
- **Immediate**: run *now* (trigger a heartbeat immediately)
|
||||
- **Isolated jobs**: optionally run in their own session that does not pollute the main session and can run concurrently (within configured limits).
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a **persistent job store** and an **in-process scheduler** owned by the Gateway.
|
||||
- Allow each job to target either:
|
||||
- `sessionTarget: "main"`: inject as `System:` lines and rely on the main heartbeat (or trigger it immediately).
|
||||
- `sessionTarget: "isolated"`: run an agent turn in a dedicated session key (job session), optionally delivering a message and/or posting a summary back to main.
|
||||
- Expose a stable control surface:
|
||||
- **Gateway methods** (`cron.*`, `wake`) for programmatic usage (mac app, CLI, agents).
|
||||
- **CLI commands** (`clawdbot cron ...`) to add/remove/edit/list and to debug `run`.
|
||||
- Produce clear, structured **logs** for job lifecycle and execution outcomes.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Multi-host distributed scheduling.
|
||||
- Exactly-once semantics across crashes (we aim for “at-least-once with idempotency hooks”).
|
||||
- A full Unix-cron parser as the only schedule format (we can support it, but v1 should not require complex cron features to be useful).
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Wake**: a request to ensure the agent gets a turn soon (either right now or next heartbeat).
|
||||
- **Main session**: the canonical session bucket (default key `"main"`) that receives `System:` events.
|
||||
- **Isolated session**: a per-job session key (e.g. `cron:<jobId>`) with its own session id / session file.
|
||||
|
||||
## User stories
|
||||
|
||||
- “Remind me in 20 minutes” → add a one-shot job that triggers an immediate heartbeat at T+20m.
|
||||
- “Every weekday at 7:30, wake me up and start music” → recurring job, isolated session, deliver to WhatsApp.
|
||||
- “Every hour, check battery; only interrupt me if < 20%” → isolated job that decides whether to deliver; may also post a brief status to main.
|
||||
- “Next heartbeat, please check calendar” → delayed wake targeting main session.
|
||||
|
||||
## Job model
|
||||
|
||||
### Storage schema (v1)
|
||||
|
||||
Each job is a JSON object with stable keys (unknown keys ignored for forward compatibility):
|
||||
|
||||
- `id: string` (UUID)
|
||||
- `name: string` (required)
|
||||
- `description?: string` (optional)
|
||||
- `enabled: boolean`
|
||||
- `createdAtMs: number`
|
||||
- `updatedAtMs: number`
|
||||
- `schedule` (one of)
|
||||
- `{"kind":"at","atMs":number}` (one-shot)
|
||||
- `{"kind":"every","everyMs":number,"anchorMs"?:number}` (simple interval)
|
||||
- `{"kind":"cron","expr":string,"tz"?:string}` (optional; see “Schedule parsing”)
|
||||
- `sessionTarget: "main" | "isolated"`
|
||||
- `wakeMode: "next-heartbeat" | "now"`
|
||||
- For `sessionTarget:"isolated"`, `wakeMode:"now"` means “run immediately when due”.
|
||||
- For `sessionTarget:"main"`, `wakeMode` controls whether we trigger the heartbeat immediately or just enqueue and wait.
|
||||
- `payload` (one of)
|
||||
- `{"kind":"systemEvent","text":string}` (enqueue as `System:`)
|
||||
- `{"kind":"agentTurn","message":string,"deliver"?:boolean,"provider"?: "last"|"whatsapp"|"telegram"|"discord"|"signal"|"imessage","to"?:string,"timeoutSeconds"?:number}`
|
||||
- `isolation` (optional; only meaningful for isolated jobs)
|
||||
- `{"postToMainPrefix"?: string}`
|
||||
- `runtime` (optional)
|
||||
- `{"maxAttempts"?:number,"retryBackoffMs"?:number}` (best-effort retries; defaults off)
|
||||
- `state` (runtime-maintained)
|
||||
- `{"nextRunAtMs":number,"lastRunAtMs"?:number,"lastStatus"?: "ok"|"error"|"skipped","lastError"?:string,"lastDurationMs"?:number}`
|
||||
|
||||
### Key behavior
|
||||
|
||||
- `sessionTarget:"main"` jobs always enqueue `payload.kind:"systemEvent"` (directly or derived from `agentTurn` results; see below).
|
||||
- `sessionTarget:"isolated"` jobs create/use a stable session key: `cron:<jobId>`.
|
||||
|
||||
## Storage location
|
||||
|
||||
Cron persists everything under `~/.clawdbot/cron/`:
|
||||
- Job store: `~/.clawdbot/cron/jobs.json`
|
||||
- Run history: `~/.clawdbot/cron/runs/<jobId>.jsonl`
|
||||
|
||||
You can override the job store path via `cron.store` in config.
|
||||
|
||||
The scheduler should never require additional configuration for the base directory (Clawdbot already treats `~/.clawdbot` as fixed).
|
||||
|
||||
## Enabling
|
||||
|
||||
Cron execution is enabled by default inside the Gateway.
|
||||
|
||||
To disable it, set:
|
||||
|
||||
```json5
|
||||
{
|
||||
cron: {
|
||||
enabled: false,
|
||||
// optional:
|
||||
store: "~/.clawdbot/cron/jobs.json",
|
||||
maxConcurrentRuns: 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also disable scheduling via the environment variable `CLAWDBOT_SKIP_CRON=1`.
|
||||
|
||||
## Scheduler design
|
||||
|
||||
### Ownership
|
||||
|
||||
The Gateway owns:
|
||||
- the scheduler timer,
|
||||
- job store reads/writes,
|
||||
- job execution (enqueue system events and/or agent turns).
|
||||
|
||||
This keeps scheduling unified with the always-on process and prevents “two schedulers” when multiple CLIs run.
|
||||
|
||||
### Timer strategy
|
||||
|
||||
- Maintain an in-memory heap/array of enabled jobs keyed by `state.nextRunAtMs`.
|
||||
- Use a **single `setTimeout`** to wake at the earliest next run.
|
||||
- On wake:
|
||||
- compute all due jobs (now >= nextRunAtMs),
|
||||
- mark them “in flight” (in memory),
|
||||
- persist updated `state` (at least bump `nextRunAtMs` / `lastRunAtMs`) before starting execution to minimize duplicate runs on crash,
|
||||
- execute jobs (with concurrency limits),
|
||||
- persist final `lastStatus/lastError/lastDurationMs`,
|
||||
- re-arm timer for the next earliest run.
|
||||
|
||||
### Schedule parsing
|
||||
|
||||
V1 can ship with `at` + `every` without extra deps.
|
||||
|
||||
If we add `"kind":"cron"`:
|
||||
- Use a well-maintained parser (we use `croner`) and support:
|
||||
- 5-field cron (`min hour dom mon dow`) at minimum
|
||||
- optional `tz`
|
||||
- Store `nextRunAtMs` computed by the parser; re-compute after each run.
|
||||
|
||||
## Execution semantics
|
||||
|
||||
### Main session jobs
|
||||
|
||||
Main session jobs do not run the agent directly by default.
|
||||
|
||||
When due:
|
||||
1) `enqueueSystemEvent(job.payload.text)` (or a derived message)
|
||||
2) If `wakeMode:"now"`, trigger an immediate heartbeat run (see “Heartbeat wake hook”).
|
||||
3) Otherwise do nothing else (the next scheduled heartbeat will pick up the system event).
|
||||
|
||||
Why: This keeps the main session’s “proactive” behavior centralized in the heartbeat rules and avoids ad-hoc agent turns that might fight with inbound message processing.
|
||||
|
||||
### Isolated session jobs
|
||||
|
||||
Isolated jobs run an agent turn in a dedicated session key, intended to be separate from main.
|
||||
|
||||
When due:
|
||||
- Build a message body that includes schedule metadata, e.g.:
|
||||
- `"[cron:<jobId>] <job.name>: <payload.message>"`
|
||||
- Execute via the same agent runner path as other command-mode runs, but pinned to:
|
||||
- `sessionKey = cron:<jobId>`
|
||||
- `sessionId = store[sessionKey].sessionId` (create if missing)
|
||||
- Optionally deliver output (`payload.deliver === true`) to the configured provider/to.
|
||||
- Isolated jobs always enqueue a summary system event to the main session when they finish (derived from the last agent text output).
|
||||
- Prefix defaults to `Cron`, and can be customized via `isolation.postToMainPrefix`.
|
||||
- If `deliver` is omitted/false, nothing is sent to external providers; you still get the main-session summary and can inspect the full isolated transcript in `cron:<jobId>`.
|
||||
|
||||
### “Run in parallel to main”
|
||||
|
||||
Clawdbot currently serializes command execution through a global in-process queue ([`src/process/command-queue.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/process/command-queue.ts)) to avoid collisions.
|
||||
|
||||
To support isolated cron jobs running “in parallel”, we should introduce **lanes** (keyed queues) plus a global concurrency cap:
|
||||
- Lane `"main"`: inbound auto-replies + main heartbeat.
|
||||
- Lane `"cron"` (or `cron:<jobId>`): isolated jobs.
|
||||
- Configurable `cron.maxConcurrentRuns` (default 1 or 2).
|
||||
|
||||
This yields:
|
||||
- isolated jobs can overlap with the main lane (up to cap),
|
||||
- each lane still preserves ordering for its own work (optional),
|
||||
- we retain safety knobs to prevent runaway resource contention.
|
||||
|
||||
## Heartbeat wake hook (immediate vs next heartbeat)
|
||||
|
||||
We need a way for the Gateway (or the scheduler) to request an immediate heartbeat without duplicating heartbeat logic.
|
||||
|
||||
Design:
|
||||
- `startHeartbeatRunner` owns the real heartbeat execution and installs a wake handler.
|
||||
- Wake hook lives in [`src/infra/heartbeat-wake.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/heartbeat-wake.ts):
|
||||
- `setHeartbeatWakeHandler(fn | null)` installed by the heartbeat runner
|
||||
- `requestHeartbeatNow({ reason, coalesceMs? })`
|
||||
- If the handler is absent, the request is stored as “pending”; the next time the handler is installed, it runs once.
|
||||
- Coalesce rapid calls and respect the “skip when queue busy” behavior (retry soon vs dropping).
|
||||
|
||||
## Run history log (JSONL)
|
||||
|
||||
In addition to normal structured logs, the Gateway writes an append-only run history “ledger” (JSONL) whenever a job finishes. This is intended for quick debugging (“did the job run, when, and what happened?”).
|
||||
|
||||
Path rules:
|
||||
- Run logs are stored per job next to the store: `.../runs/<jobId>.jsonl`.
|
||||
|
||||
Retention:
|
||||
- Best-effort pruning when the file grows beyond ~2MB; keep the newest ~2000 lines.
|
||||
|
||||
Each log line includes (at minimum) job id, status/error, timing, and a `summary` string (systemEvent text for main jobs, and the last agent text output for isolated jobs).
|
||||
|
||||
## Compatibility policy (cron.add/cron.update)
|
||||
|
||||
To keep older clients working, the Gateway applies **best-effort normalization** for `cron.add` and `cron.update`:
|
||||
- Accepts wrapped payloads under `data` or `job` and unwraps them.
|
||||
- Infers `schedule.kind` from `atMs`, `everyMs`, or `expr` if missing.
|
||||
- Infers `payload.kind` from `text` (systemEvent) or `message` (agentTurn) if missing.
|
||||
- Defaults `wakeMode` to `"next-heartbeat"` when omitted.
|
||||
- Defaults `sessionTarget` based on payload kind (`systemEvent` → `"main"`, `agentTurn` → `"isolated"`).
|
||||
|
||||
Normalization is **compat-only**. New clients should send the full schema (including `kind`, `sessionTarget`, and `wakeMode`) to avoid ambiguity. Unknown fields are still rejected by schema validation.
|
||||
|
||||
## Gateway API
|
||||
|
||||
New methods (names can be bikeshed; `cron.*` is suggested):
|
||||
|
||||
- `wake`
|
||||
- params: `{ mode: "now" | "next-heartbeat", text: string }`
|
||||
- effect: `enqueueSystemEvent(text)`, plus optional immediate heartbeat trigger
|
||||
|
||||
- `cron.list`
|
||||
- params: optional `{ includeDisabled?: boolean }`
|
||||
- returns: `{ jobs: CronJob[] }`
|
||||
|
||||
- `cron.add`
|
||||
- params: job payload without `id/state` (server generates and returns created job)
|
||||
|
||||
- `cron.update`
|
||||
- params: `{ id: string, patch: Partial<CronJobWritableFields> }`
|
||||
|
||||
- `cron.remove`
|
||||
- params: `{ id: string }`
|
||||
|
||||
- `cron.run`
|
||||
- params: `{ id: string, mode?: "due" | "force" }` (debugging; does not change schedule unless `force` requires it)
|
||||
|
||||
- `cron.runs`
|
||||
- params: `{ id: string, limit?: number }`
|
||||
- returns: `{ entries: CronRunLogEntry[] }`
|
||||
- note: `id` is required (runs are stored per-job).
|
||||
|
||||
The Gateway should broadcast a `cron` event for UI/debug:
|
||||
- event: `cron`
|
||||
- payload: `{ jobId, action: "added"|"updated"|"removed"|"started"|"finished", status?, error?, nextRunAtMs? }`
|
||||
|
||||
## CLI surface
|
||||
|
||||
Add a `cron` command group (all commands should also support `--json` where sensible):
|
||||
|
||||
- `clawdbot cron list [--json] [--all]`
|
||||
- `clawdbot cron add ...`
|
||||
- schedule flags:
|
||||
- `--at <iso8601|ms|relative>` (one-shot)
|
||||
- `--every <duration>` (e.g. `10m`, `1h`)
|
||||
- `--cron "<expr>" [--tz "<tz>"]`
|
||||
- target flags:
|
||||
- `--session main|isolated`
|
||||
- `--wake now|next-heartbeat`
|
||||
- payload flags (choose one):
|
||||
- `--system-event "<text>"`
|
||||
- `--message "<agent message>" [--deliver] [--provider last|whatsapp|telegram|discord|slack|signal|imessage] [--to <dest>]`
|
||||
|
||||
- `clawdbot cron edit <id> ...` (patch-by-flags, non-interactive)
|
||||
- `clawdbot cron rm <id>`
|
||||
- `clawdbot cron enable <id>` / `clawdbot cron disable <id>`
|
||||
- `clawdbot cron run <id> [--force]` (debug)
|
||||
- `clawdbot cron runs --id <id> [--limit <n>]` (run history)
|
||||
- `clawdbot cron status` (scheduler enabled + next wake)
|
||||
|
||||
Additionally:
|
||||
- `clawdbot wake --mode now|next-heartbeat --text "<text>"` as a thin wrapper around `wake` for agents to call.
|
||||
|
||||
## Examples
|
||||
|
||||
### Run once at a specific time
|
||||
|
||||
One-shot reminder that targets the main session and triggers a heartbeat immediately at the scheduled time:
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--at "2025-12-14T07:00:00-08:00" \
|
||||
--session main \
|
||||
--wake now \
|
||||
--system-event "Alarm: wake up (meeting in 30 minutes)."
|
||||
```
|
||||
|
||||
### Run daily (calendar-accurate)
|
||||
|
||||
Daily at 07:00 in a specific timezone (preferred over “every 24h” to avoid DST drift):
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--wake now \
|
||||
--message "Daily check: scan calendar + inbox; deliver only if urgent." \
|
||||
--deliver \
|
||||
--provider last
|
||||
```
|
||||
|
||||
### Run weekly (every Wednesday)
|
||||
|
||||
Every Wednesday at 09:00:
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--cron "0 9 * * 3" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--wake now \
|
||||
--message "Weekly: summarize status and remind me of goals." \
|
||||
--deliver \
|
||||
--provider last
|
||||
```
|
||||
|
||||
### “Next heartbeat”
|
||||
|
||||
Enqueue a note for the main session but let the existing heartbeat cadence pick it up:
|
||||
|
||||
```bash
|
||||
clawdbot wake --mode next-heartbeat --text "Next heartbeat: check battery + upcoming meetings."
|
||||
```
|
||||
|
||||
## Logging & observability
|
||||
|
||||
Logging requirements:
|
||||
- Use `getChildLogger({ module: "cron", jobId, runId, name })` for every run.
|
||||
- Log lifecycle:
|
||||
- store load/save (debug; include job count)
|
||||
- schedule recompute (debug; include nextRunAt)
|
||||
- job start/end (info)
|
||||
- job skipped (info; include reason)
|
||||
- job error (warn; include error + stack where available)
|
||||
- Emit a concise user-facing line to stdout when running in CLI mode (similar to heartbeat logs).
|
||||
|
||||
Suggested log events:
|
||||
- `cron: scheduler started` (jobCount, nextWakeAt)
|
||||
- `cron: job started` (jobId, scheduleKind, sessionTarget, wakeMode)
|
||||
- `cron: job finished` (status, durationMs, nextRunAtMs)
|
||||
- When `cron.enabled` is false, the Gateway logs `cron: disabled` and jobs will not run automatically (the CLI warns on `cron add`/`cron edit`).
|
||||
- Use `clawdbot cron status` to confirm the scheduler is enabled and see the next wake time.
|
||||
|
||||
## Safety & security
|
||||
|
||||
- Respect existing allowlists/routing rules: delivery defaults should not send to arbitrary destinations unless explicitly configured.
|
||||
- Provide a global “kill switch”:
|
||||
- `cron.enabled: boolean` (default `true`).
|
||||
- `gateway method set-heartbeats` already exists; cron should have similar.
|
||||
- Avoid persistence of sensitive payloads unless requested; job text may contain private content.
|
||||
|
||||
## Testing plan (v1)
|
||||
|
||||
- Unit tests:
|
||||
- schedule computation for `at` and `every`
|
||||
- job store read/write + migration behavior
|
||||
- lane concurrency: main vs cron overlap is bounded
|
||||
- “wake now” coalescing and pending behavior when provider not ready
|
||||
- Integration tests:
|
||||
- start Gateway with `CLAWDBOT_SKIP_PROVIDERS=1`, add jobs, list/edit/remove
|
||||
- simulate due jobs and assert `enqueueSystemEvent` called + cron events broadcast
|
||||
|
||||
## Rollout plan
|
||||
|
||||
1) Add the `wake` primitive + heartbeat wake hook (no persistent jobs yet).
|
||||
2) Add `cron.*` API and CLI wrappers with `at` + `every`.
|
||||
3) Add optional cron expression parsing (`kind:"cron"`) if needed.
|
||||
4) Add UI surfacing in WebChat/macOS app (optional).
|
||||
@@ -20,6 +20,16 @@
|
||||
"url": "https://github.com/clawdbot/clawdbot/releases"
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/cron",
|
||||
"destination": "/cron-jobs"
|
||||
},
|
||||
{
|
||||
"source": "/cron/",
|
||||
"destination": "/cron-jobs"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"groups": [
|
||||
{
|
||||
|
||||
@@ -8,17 +8,28 @@ read_when:
|
||||
Heartbeat runs periodic agent turns in the **main session** so the model can
|
||||
surface anything that needs attention without spamming the user.
|
||||
|
||||
## Defaults
|
||||
- Interval: `30m` (set `agent.heartbeat.every` to change, `0m` disables).
|
||||
- Prompt body (configurable via `agent.heartbeat.prompt`):
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
- Heartbeat prompt text is sent **verbatim** as the user message. Clawdbot does
|
||||
not append extra body text. The system prompt includes a Heartbeats section
|
||||
and the run is flagged as a heartbeat internally.
|
||||
|
||||
## Prompt contract
|
||||
- Heartbeat body defaults to: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.` (configurable via `agent.heartbeat.prompt`).
|
||||
- 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 **≤ `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.
|
||||
- Heartbeat prompt text is sent **verbatim** as the user message. Clawdbot does
|
||||
not append extra body text. The system prompt includes a Heartbeats section
|
||||
and the run is flagged as a heartbeat internally.
|
||||
|
||||
## Prompt overrides
|
||||
- Overriding `agent.heartbeat.prompt` **replaces** the default body. Nothing is
|
||||
merged for you.
|
||||
- If you still want `HEARTBEAT.md` instructions, keep a line like
|
||||
`Read HEARTBEAT.md if exists` in your custom prompt.
|
||||
- `HEARTBEAT_OK` handling stays the same; changing the prompt won’t break acks.
|
||||
|
||||
### Stray `HEARTBEAT_OK` outside heartbeats
|
||||
If the model accidentally includes `HEARTBEAT_OK` at the start or end of a
|
||||
@@ -63,9 +74,9 @@ and final replies:
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).
|
||||
|
||||
## Cost awareness
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens. If you
|
||||
don’t need frequent checks, increase `every`, pick a cheaper `model`, or set
|
||||
`target: "none"` to keep results internal.
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens. Be
|
||||
intentional about `every`, keep `HEARTBEAT.md` tiny, and consider a cheaper
|
||||
`model` or `target: "none"` if you only want internal state updates.
|
||||
|
||||
## HEARTBEAT.md (optional)
|
||||
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
||||
@@ -85,6 +96,8 @@ bloat.
|
||||
- Handle mundane tasks (triage inboxes, summarize queues, refresh notes).
|
||||
- Nudge on open loops or reminders.
|
||||
- Background monitoring (health checks, status polling, low-priority alerts).
|
||||
- Scheduled routines (use [Cron jobs](https://docs.clawd.bot/cron-jobs) when you
|
||||
need exact schedules or isolated runs).
|
||||
|
||||
## Wake hook
|
||||
- The gateway exposes a heartbeat wake hook so cron/jobs/webhooks can request an
|
||||
|
||||
@@ -80,7 +80,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Tools surface](https://docs.clawd.bot/tools)
|
||||
- [Bash tool](https://docs.clawd.bot/bash)
|
||||
- [Elevated mode](https://docs.clawd.bot/elevated)
|
||||
- [Cron + wakeups](https://docs.clawd.bot/cron)
|
||||
- [Cron jobs](https://docs.clawd.bot/cron-jobs)
|
||||
- [Thinking + verbose](https://docs.clawd.bot/thinking)
|
||||
- [Models](https://docs.clawd.bot/models)
|
||||
- [Agent send CLI](https://docs.clawd.bot/agent-send)
|
||||
|
||||
@@ -171,7 +171,7 @@ Example:
|
||||
- [Linux app](https://docs.clawd.bot/linux)
|
||||
- Ops and safety:
|
||||
- [Sessions](https://docs.clawd.bot/session)
|
||||
- [Cron + wakeups](https://docs.clawd.bot/cron)
|
||||
- [Cron jobs](https://docs.clawd.bot/cron-jobs)
|
||||
- [Security](https://docs.clawd.bot/security)
|
||||
- [Troubleshooting](https://docs.clawd.bot/troubleshooting)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ Recent gateway logs show repeated `cron.add` failures with invalid parameters (m
|
||||
- [x] Fix UI CronStatus type to match gateway (`jobs` instead of `jobCount`).
|
||||
- [x] Update cron UI provider select to include Discord/Slack/Signal/iMessage.
|
||||
- [x] Update macOS CronJobEditor provider picker + enum to include Slack/Signal/iMessage.
|
||||
- [x] Document cron compatibility normalization policy in [`docs/cron.md`](https://docs.clawd.bot/cron).
|
||||
- [x] Document cron compatibility normalization policy in [`docs/cron-jobs.md`](https://docs.clawd.bot/cron-jobs).
|
||||
|
||||
### Phase 2 — Input normalization + tooling hardening
|
||||
- [x] Add shared cron input normalization helpers (`normalizeCronJobCreate`/`normalizeCronJobPatch`).
|
||||
|
||||
2
docs/templates/AGENTS.md
vendored
2
docs/templates/AGENTS.md
vendored
@@ -120,7 +120,7 @@ When you receive a heartbeat poll (message matches the configured heartbeat prom
|
||||
Default heartbeat prompt:
|
||||
`Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`
|
||||
|
||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small.
|
||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||
|
||||
**Things to check (rotate through these, 2-4 times per day):**
|
||||
- **Emails** - Any urgent unread messages?
|
||||
|
||||
2
docs/templates/HEARTBEAT.md
vendored
2
docs/templates/HEARTBEAT.md
vendored
@@ -5,4 +5,4 @@ read_when:
|
||||
---
|
||||
# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist for heartbeat runs. Keep it small.
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
|
||||
224
skills/himalaya/SKILL.md
Normal file
224
skills/himalaya/SKILL.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
name: himalaya
|
||||
description: "CLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language)."
|
||||
homepage: https://github.com/pimalaya/himalaya
|
||||
metadata: {"clawdbot":{"emoji":"📧","requires":{"bins":["himalaya"]},"install":[{"id":"brew","kind":"brew","formula":"himalaya","bins":["himalaya"],"label":"Install Himalaya (brew)"}]}}
|
||||
---
|
||||
|
||||
# Himalaya Email CLI
|
||||
|
||||
Himalaya is a CLI email client that lets you manage emails from the terminal using IMAP, SMTP, Notmuch, or Sendmail backends.
|
||||
|
||||
## References
|
||||
|
||||
- `references/configuration.md` (config file setup + IMAP/SMTP authentication)
|
||||
- `references/message-composition.md` (MML syntax for composing emails)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Himalaya CLI installed (`himalaya --version` to verify)
|
||||
2. A configuration file at `~/.config/himalaya/config.toml`
|
||||
3. IMAP/SMTP credentials configured (password stored securely)
|
||||
|
||||
## Configuration Setup
|
||||
|
||||
Run the interactive wizard to set up an account:
|
||||
```bash
|
||||
himalaya account configure
|
||||
```
|
||||
|
||||
Or create `~/.config/himalaya/config.toml` manually:
|
||||
```toml
|
||||
[accounts.personal]
|
||||
email = "you@example.com"
|
||||
display-name = "Your Name"
|
||||
default = true
|
||||
|
||||
backend.type = "imap"
|
||||
backend.host = "imap.example.com"
|
||||
backend.port = 993
|
||||
backend.encryption.type = "tls"
|
||||
backend.login = "you@example.com"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.cmd = "pass show email/imap" # or use keyring
|
||||
|
||||
message.send.backend.type = "smtp"
|
||||
message.send.backend.host = "smtp.example.com"
|
||||
message.send.backend.port = 587
|
||||
message.send.backend.encryption.type = "start-tls"
|
||||
message.send.backend.login = "you@example.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.cmd = "pass show email/smtp"
|
||||
```
|
||||
|
||||
## Common Operations
|
||||
|
||||
### List Folders
|
||||
|
||||
```bash
|
||||
himalaya folder list
|
||||
```
|
||||
|
||||
### List Emails
|
||||
|
||||
List emails in INBOX (default):
|
||||
```bash
|
||||
himalaya envelope list
|
||||
```
|
||||
|
||||
List emails in a specific folder:
|
||||
```bash
|
||||
himalaya envelope list --folder "Sent"
|
||||
```
|
||||
|
||||
List with pagination:
|
||||
```bash
|
||||
himalaya envelope list --page 1 --page-size 20
|
||||
```
|
||||
|
||||
### Search Emails
|
||||
|
||||
```bash
|
||||
himalaya envelope list --query "from john@example.com subject meeting"
|
||||
```
|
||||
|
||||
### Read an Email
|
||||
|
||||
Read email by ID (shows plain text):
|
||||
```bash
|
||||
himalaya message read 42
|
||||
```
|
||||
|
||||
Export raw MIME:
|
||||
```bash
|
||||
himalaya message export 42 --full
|
||||
```
|
||||
|
||||
### Reply to an Email
|
||||
|
||||
Interactive reply (opens $EDITOR):
|
||||
```bash
|
||||
himalaya message reply 42
|
||||
```
|
||||
|
||||
Reply-all:
|
||||
```bash
|
||||
himalaya message reply 42 --all
|
||||
```
|
||||
|
||||
### Forward an Email
|
||||
|
||||
```bash
|
||||
himalaya message forward 42
|
||||
```
|
||||
|
||||
### Write a New Email
|
||||
|
||||
Interactive compose (opens $EDITOR):
|
||||
```bash
|
||||
himalaya message write
|
||||
```
|
||||
|
||||
Send directly using template:
|
||||
```bash
|
||||
cat << 'EOF' | himalaya template send
|
||||
From: you@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Test Message
|
||||
|
||||
Hello from Himalaya!
|
||||
EOF
|
||||
```
|
||||
|
||||
Or with headers flag:
|
||||
```bash
|
||||
himalaya message write -H "To:recipient@example.com" -H "Subject:Test" "Message body here"
|
||||
```
|
||||
|
||||
### Move/Copy Emails
|
||||
|
||||
Move to folder:
|
||||
```bash
|
||||
himalaya message move 42 "Archive"
|
||||
```
|
||||
|
||||
Copy to folder:
|
||||
```bash
|
||||
himalaya message copy 42 "Important"
|
||||
```
|
||||
|
||||
### Delete an Email
|
||||
|
||||
```bash
|
||||
himalaya message delete 42
|
||||
```
|
||||
|
||||
### Manage Flags
|
||||
|
||||
Add flag:
|
||||
```bash
|
||||
himalaya flag add 42 --flag seen
|
||||
```
|
||||
|
||||
Remove flag:
|
||||
```bash
|
||||
himalaya flag remove 42 --flag seen
|
||||
```
|
||||
|
||||
## Multiple Accounts
|
||||
|
||||
List accounts:
|
||||
```bash
|
||||
himalaya account list
|
||||
```
|
||||
|
||||
Use a specific account:
|
||||
```bash
|
||||
himalaya --account work envelope list
|
||||
```
|
||||
|
||||
## Attachments
|
||||
|
||||
Save attachments from a message:
|
||||
```bash
|
||||
himalaya attachment download 42
|
||||
```
|
||||
|
||||
Save to specific directory:
|
||||
```bash
|
||||
himalaya attachment download 42 --dir ~/Downloads
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
Most commands support `--output` for structured output:
|
||||
```bash
|
||||
himalaya envelope list --output json
|
||||
himalaya envelope list --output plain
|
||||
```
|
||||
|
||||
## Sync Mode
|
||||
|
||||
For faster access, enable local caching:
|
||||
```bash
|
||||
himalaya account sync personal
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
Enable debug logging:
|
||||
```bash
|
||||
RUST_LOG=debug himalaya envelope list
|
||||
```
|
||||
|
||||
Full trace with backtrace:
|
||||
```bash
|
||||
RUST_LOG=trace RUST_BACKTRACE=1 himalaya envelope list
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `himalaya --help` or `himalaya <command> --help` for detailed usage.
|
||||
- Message IDs are relative to the current folder; re-list after folder changes.
|
||||
- For composing rich emails with attachments, use MML syntax (see `references/message-composition.md`).
|
||||
- Store passwords securely using `pass`, system keyring, or a command that outputs the password.
|
||||
174
skills/himalaya/references/configuration.md
Normal file
174
skills/himalaya/references/configuration.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Himalaya Configuration Reference
|
||||
|
||||
Configuration file location: `~/.config/himalaya/config.toml`
|
||||
|
||||
## Minimal IMAP + SMTP Setup
|
||||
|
||||
```toml
|
||||
[accounts.default]
|
||||
email = "user@example.com"
|
||||
display-name = "Your Name"
|
||||
default = true
|
||||
|
||||
# IMAP backend for reading emails
|
||||
backend.type = "imap"
|
||||
backend.host = "imap.example.com"
|
||||
backend.port = 993
|
||||
backend.encryption.type = "tls"
|
||||
backend.login = "user@example.com"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.raw = "your-password"
|
||||
|
||||
# SMTP backend for sending emails
|
||||
message.send.backend.type = "smtp"
|
||||
message.send.backend.host = "smtp.example.com"
|
||||
message.send.backend.port = 587
|
||||
message.send.backend.encryption.type = "start-tls"
|
||||
message.send.backend.login = "user@example.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.raw = "your-password"
|
||||
```
|
||||
|
||||
## Password Options
|
||||
|
||||
### Raw password (testing only, not recommended)
|
||||
```toml
|
||||
backend.auth.raw = "your-password"
|
||||
```
|
||||
|
||||
### Password from command (recommended)
|
||||
```toml
|
||||
backend.auth.cmd = "pass show email/imap"
|
||||
# backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w"
|
||||
```
|
||||
|
||||
### System keyring (requires keyring feature)
|
||||
```toml
|
||||
backend.auth.keyring = "imap-example"
|
||||
```
|
||||
Then run `himalaya account configure <account>` to store the password.
|
||||
|
||||
## Gmail Configuration
|
||||
|
||||
```toml
|
||||
[accounts.gmail]
|
||||
email = "you@gmail.com"
|
||||
display-name = "Your Name"
|
||||
default = true
|
||||
|
||||
backend.type = "imap"
|
||||
backend.host = "imap.gmail.com"
|
||||
backend.port = 993
|
||||
backend.encryption.type = "tls"
|
||||
backend.login = "you@gmail.com"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.cmd = "pass show google/app-password"
|
||||
|
||||
message.send.backend.type = "smtp"
|
||||
message.send.backend.host = "smtp.gmail.com"
|
||||
message.send.backend.port = 587
|
||||
message.send.backend.encryption.type = "start-tls"
|
||||
message.send.backend.login = "you@gmail.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.cmd = "pass show google/app-password"
|
||||
```
|
||||
|
||||
**Note:** Gmail requires an App Password if 2FA is enabled.
|
||||
|
||||
## iCloud Configuration
|
||||
|
||||
```toml
|
||||
[accounts.icloud]
|
||||
email = "you@icloud.com"
|
||||
display-name = "Your Name"
|
||||
|
||||
backend.type = "imap"
|
||||
backend.host = "imap.mail.me.com"
|
||||
backend.port = 993
|
||||
backend.encryption.type = "tls"
|
||||
backend.login = "you@icloud.com"
|
||||
backend.auth.type = "password"
|
||||
backend.auth.cmd = "pass show icloud/app-password"
|
||||
|
||||
message.send.backend.type = "smtp"
|
||||
message.send.backend.host = "smtp.mail.me.com"
|
||||
message.send.backend.port = 587
|
||||
message.send.backend.encryption.type = "start-tls"
|
||||
message.send.backend.login = "you@icloud.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.cmd = "pass show icloud/app-password"
|
||||
```
|
||||
|
||||
**Note:** Generate an app-specific password at appleid.apple.com
|
||||
|
||||
## Folder Aliases
|
||||
|
||||
Map custom folder names:
|
||||
```toml
|
||||
[accounts.default.folder.alias]
|
||||
inbox = "INBOX"
|
||||
sent = "Sent"
|
||||
drafts = "Drafts"
|
||||
trash = "Trash"
|
||||
```
|
||||
|
||||
## Multiple Accounts
|
||||
|
||||
```toml
|
||||
[accounts.personal]
|
||||
email = "personal@example.com"
|
||||
default = true
|
||||
# ... backend config ...
|
||||
|
||||
[accounts.work]
|
||||
email = "work@company.com"
|
||||
# ... backend config ...
|
||||
```
|
||||
|
||||
Switch accounts with `--account`:
|
||||
```bash
|
||||
himalaya --account work envelope list
|
||||
```
|
||||
|
||||
## Notmuch Backend (local mail)
|
||||
|
||||
```toml
|
||||
[accounts.local]
|
||||
email = "user@example.com"
|
||||
|
||||
backend.type = "notmuch"
|
||||
backend.db-path = "~/.mail/.notmuch"
|
||||
```
|
||||
|
||||
## OAuth2 Authentication (for providers that support it)
|
||||
|
||||
```toml
|
||||
backend.auth.type = "oauth2"
|
||||
backend.auth.client-id = "your-client-id"
|
||||
backend.auth.client-secret.cmd = "pass show oauth/client-secret"
|
||||
backend.auth.access-token.cmd = "pass show oauth/access-token"
|
||||
backend.auth.refresh-token.cmd = "pass show oauth/refresh-token"
|
||||
backend.auth.auth-url = "https://provider.com/oauth/authorize"
|
||||
backend.auth.token-url = "https://provider.com/oauth/token"
|
||||
```
|
||||
|
||||
## Additional Options
|
||||
|
||||
### Signature
|
||||
```toml
|
||||
[accounts.default]
|
||||
signature = "Best regards,\nYour Name"
|
||||
signature-delim = "-- \n"
|
||||
```
|
||||
|
||||
### Downloads directory
|
||||
```toml
|
||||
[accounts.default]
|
||||
downloads-dir = "~/Downloads/himalaya"
|
||||
```
|
||||
|
||||
### Editor for composing
|
||||
Set via environment variable:
|
||||
```bash
|
||||
export EDITOR="vim"
|
||||
```
|
||||
182
skills/himalaya/references/message-composition.md
Normal file
182
skills/himalaya/references/message-composition.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Message Composition with MML (MIME Meta Language)
|
||||
|
||||
Himalaya uses MML for composing emails. MML is a simple XML-based syntax that compiles to MIME messages.
|
||||
|
||||
## Basic Message Structure
|
||||
|
||||
An email message is a list of **headers** followed by a **body**, separated by a blank line:
|
||||
|
||||
```
|
||||
From: sender@example.com
|
||||
To: recipient@example.com
|
||||
Subject: Hello World
|
||||
|
||||
This is the message body.
|
||||
```
|
||||
|
||||
## Headers
|
||||
|
||||
Common headers:
|
||||
- `From`: Sender address
|
||||
- `To`: Primary recipient(s)
|
||||
- `Cc`: Carbon copy recipients
|
||||
- `Bcc`: Blind carbon copy recipients
|
||||
- `Subject`: Message subject
|
||||
- `Reply-To`: Address for replies (if different from From)
|
||||
- `In-Reply-To`: Message ID being replied to
|
||||
|
||||
### Address Formats
|
||||
|
||||
```
|
||||
To: user@example.com
|
||||
To: John Doe <john@example.com>
|
||||
To: "John Doe" <john@example.com>
|
||||
To: user1@example.com, user2@example.com, "Jane" <jane@example.com>
|
||||
```
|
||||
|
||||
## Plain Text Body
|
||||
|
||||
Simple plain text email:
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: Plain Text Example
|
||||
|
||||
Hello, this is a plain text email.
|
||||
No special formatting needed.
|
||||
|
||||
Best,
|
||||
Alice
|
||||
```
|
||||
|
||||
## MML for Rich Emails
|
||||
|
||||
### Multipart Messages
|
||||
|
||||
Alternative text/html parts:
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: Multipart Example
|
||||
|
||||
<#multipart type=alternative>
|
||||
This is the plain text version.
|
||||
<#part type=text/html>
|
||||
<html><body><h1>This is the HTML version</h1></body></html>
|
||||
<#/multipart>
|
||||
```
|
||||
|
||||
### Attachments
|
||||
|
||||
Attach a file:
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: With Attachment
|
||||
|
||||
Here is the document you requested.
|
||||
|
||||
<#part filename=/path/to/document.pdf><#/part>
|
||||
```
|
||||
|
||||
Attachment with custom name:
|
||||
```
|
||||
<#part filename=/path/to/file.pdf name=report.pdf><#/part>
|
||||
```
|
||||
|
||||
Multiple attachments:
|
||||
```
|
||||
<#part filename=/path/to/doc1.pdf><#/part>
|
||||
<#part filename=/path/to/doc2.pdf><#/part>
|
||||
```
|
||||
|
||||
### Inline Images
|
||||
|
||||
Embed an image inline:
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: Inline Image
|
||||
|
||||
<#multipart type=related>
|
||||
<#part type=text/html>
|
||||
<html><body>
|
||||
<p>Check out this image:</p>
|
||||
<img src="cid:image1">
|
||||
</body></html>
|
||||
<#part disposition=inline id=image1 filename=/path/to/image.png><#/part>
|
||||
<#/multipart>
|
||||
```
|
||||
|
||||
### Mixed Content (Text + Attachments)
|
||||
|
||||
```
|
||||
From: alice@localhost
|
||||
To: bob@localhost
|
||||
Subject: Mixed Content
|
||||
|
||||
<#multipart type=mixed>
|
||||
<#part type=text/plain>
|
||||
Please find the attached files.
|
||||
|
||||
Best,
|
||||
Alice
|
||||
<#part filename=/path/to/file1.pdf><#/part>
|
||||
<#part filename=/path/to/file2.zip><#/part>
|
||||
<#/multipart>
|
||||
```
|
||||
|
||||
## MML Tag Reference
|
||||
|
||||
### `<#multipart>`
|
||||
Groups multiple parts together.
|
||||
- `type=alternative`: Different representations of same content
|
||||
- `type=mixed`: Independent parts (text + attachments)
|
||||
- `type=related`: Parts that reference each other (HTML + images)
|
||||
|
||||
### `<#part>`
|
||||
Defines a message part.
|
||||
- `type=<mime-type>`: Content type (e.g., `text/html`, `application/pdf`)
|
||||
- `filename=<path>`: File to attach
|
||||
- `name=<name>`: Display name for attachment
|
||||
- `disposition=inline`: Display inline instead of as attachment
|
||||
- `id=<cid>`: Content ID for referencing in HTML
|
||||
|
||||
## Composing from CLI
|
||||
|
||||
### Interactive compose
|
||||
Opens your `$EDITOR`:
|
||||
```bash
|
||||
himalaya message write
|
||||
```
|
||||
|
||||
### Reply (opens editor with quoted message)
|
||||
```bash
|
||||
himalaya message reply 42
|
||||
himalaya message reply 42 --all # reply-all
|
||||
```
|
||||
|
||||
### Forward
|
||||
```bash
|
||||
himalaya message forward 42
|
||||
```
|
||||
|
||||
### Send from stdin
|
||||
```bash
|
||||
cat message.txt | himalaya template send
|
||||
```
|
||||
|
||||
### Prefill headers from CLI
|
||||
```bash
|
||||
himalaya message write \
|
||||
-H "To:recipient@example.com" \
|
||||
-H "Subject:Quick Message" \
|
||||
"Message body here"
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- The editor opens with a template; fill in headers and body.
|
||||
- Save and exit the editor to send; exit without saving to cancel.
|
||||
- MML parts are compiled to proper MIME when sending.
|
||||
- Use `himalaya message export --full` to inspect the raw MIME structure of received emails.
|
||||
@@ -87,10 +87,9 @@ It does not define which tools exist; Clawdbot provides built-in tools internall
|
||||
Add whatever else you want the assistant to know about your local toolchain.
|
||||
`;
|
||||
|
||||
const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md - Optional heartbeat notes
|
||||
const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md
|
||||
|
||||
Keep this file small. Leave it empty unless you want a short checklist or reminders
|
||||
to follow during heartbeat runs.
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
`;
|
||||
|
||||
const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after)
|
||||
|
||||
@@ -241,6 +241,49 @@ describe("createTelegramBot", () => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.WasMentioned).toBe(true);
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Test Group id:7 from Ada id:9 2025-01-09T00:00Z\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes sender identity in group envelope headers", async () => {
|
||||
onSpy.mockReset();
|
||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
replySpy.mockReset();
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
telegram: { groups: { "*": { requireMention: false } } },
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = onSpy.mock.calls[0][1] as (
|
||||
ctx: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
},
|
||||
me: { username: "clawdbot_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.Body).toMatch(
|
||||
/^\[Telegram Ops id:42 from Ada Lovelace \(@ada\) id:99 2025-01-09T00:00Z\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||
|
||||
@@ -375,8 +375,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const body = formatAgentEnvelope({
|
||||
provider: "Telegram",
|
||||
from: isGroup
|
||||
? buildGroupLabel(msg, chatId)
|
||||
: buildSenderLabel(msg, chatId),
|
||||
? buildGroupFromLabel(msg, chatId, senderId)
|
||||
: buildSenderLabel(msg, senderId || chatId),
|
||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||
body: `${bodyText}${replySuffix}`,
|
||||
});
|
||||
@@ -874,7 +874,10 @@ function buildSenderName(msg: TelegramMessage) {
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
function buildSenderLabel(msg: TelegramMessage, chatId: number | string) {
|
||||
function buildSenderLabel(
|
||||
msg: TelegramMessage,
|
||||
senderId?: number | string,
|
||||
) {
|
||||
const name = buildSenderName(msg);
|
||||
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
|
||||
let label = name;
|
||||
@@ -883,8 +886,17 @@ function buildSenderLabel(msg: TelegramMessage, chatId: number | string) {
|
||||
} else if (!name && username) {
|
||||
label = username;
|
||||
}
|
||||
const idPart = `id:${chatId}`;
|
||||
return label ? `${label} ${idPart}` : idPart;
|
||||
const normalizedSenderId =
|
||||
senderId != null && `${senderId}`.trim()
|
||||
? `${senderId}`.trim()
|
||||
: undefined;
|
||||
const fallbackId =
|
||||
normalizedSenderId ??
|
||||
(msg.from?.id != null ? String(msg.from.id) : undefined);
|
||||
const idPart = fallbackId ? `id:${fallbackId}` : undefined;
|
||||
if (label && idPart) return `${label} ${idPart}`;
|
||||
if (label) return label;
|
||||
return idPart ?? "id:unknown";
|
||||
}
|
||||
|
||||
function buildGroupLabel(msg: TelegramMessage, chatId: number | string) {
|
||||
@@ -893,6 +905,16 @@ function buildGroupLabel(msg: TelegramMessage, chatId: number | string) {
|
||||
return `group:${chatId}`;
|
||||
}
|
||||
|
||||
function buildGroupFromLabel(
|
||||
msg: TelegramMessage,
|
||||
chatId: number | string,
|
||||
senderId?: number | string,
|
||||
) {
|
||||
const groupLabel = buildGroupLabel(msg, chatId);
|
||||
const senderLabel = buildSenderLabel(msg, senderId);
|
||||
return `${groupLabel} from ${senderLabel}`;
|
||||
}
|
||||
|
||||
function hasBotMention(msg: TelegramMessage, botUsername: string) {
|
||||
const text = (msg.text ?? msg.caption ?? "").toLowerCase();
|
||||
if (text.includes(`@${botUsername}`)) return true;
|
||||
|
||||
Reference in New Issue
Block a user