Compare commits

...
Author SHA1 Message Date
Peter Steinberger 618efd8a71 fix: land timeout fallback fixes (#1576) (thanks @lc0rp) 2026-01-24 11:15:10 +00:00
Peter Steinberger e405ddcc20 chore: carry timeout fallback changes 2026-01-24 11:10:23 +00:00
Peter Steinberger dbf139d14e test: cover explicit mention gating across channels 2026-01-24 11:09:33 +00:00
Peter Steinberger d905ca0e02 fix: enforce explicit mention gating across channels 2026-01-24 11:09:33 +00:00
Peter Steinberger ab000398be fix: resolve session ids in session tools 2026-01-24 11:09:11 +00:00
Peter Steinberger 1bbbb10abf fix: persist session usage metadata on suppressed replies 2026-01-24 11:05:02 +00:00
Peter Steinberger c02204fd1e chore: update fly config defaults 2026-01-24 10:58:55 +00:00
Peter Steinberger 5482803547 chore: filter noisy warnings 2026-01-24 10:48:33 +00:00
Peter Steinberger 3dcaa70531 chore: update deps and test timeout 2026-01-24 10:30:30 +00:00
Peter Steinberger a6ddd82a14 feat: add TTS hint to system prompt 2026-01-24 10:25:42 +00:00
Peter Steinberger 585e20b72e docs: fix redirects and help links 2026-01-24 10:21:05 +00:00
Peter Steinberger d8a6317dfc fix: show voice mode in status 2026-01-24 10:03:19 +00:00
Peter Steinberger c8c58c0537 fix: avoid Discord /tts conflict 2026-01-24 09:58:06 +00:00
Peter Steinberger cfdd5a8c2e docs: consolidate faq under help 2026-01-24 09:49:38 +00:00
Peter SteinbergerandGlucksberg 6765fd15eb feat: default TTS model overrides on (#1559) (thanks @Glucksberg)
Co-authored-by: Glucksberg <80581902+Glucksberg@users.noreply.github.com>
2026-01-24 09:42:32 +00:00
Peter Steinberger 4074fa0471 docs: restore faq and fix redirect 2026-01-24 09:39:24 +00:00
Peter Steinberger ea2ccd8ae6 docs(fly): update guide with deployment lessons
- Increase recommended memory to 2GB (512MB/1GB OOM)
- Add OOM symptoms (SIGABRT, v8 allocation errors)
- Fix lock file path (/data/gateway.*.lock)
- Add complete config example with failover, auth, bindings
- Document Discord token from env var vs config
- Add machine update commands for command/memory changes
- Add config writing tips (echo+tee, sftp caveats)

Learned from FLAWD deployment debugging.
2026-01-24 09:36:54 +00:00
Peter Steinberger b1ac7e0501 docs: move cross-context faq to troubleshooting 2026-01-24 09:36:44 +00:00
Peter Steinberger b4a2dc81a2 docs: expand heartbeat visibility config examples 2026-01-24 09:31:04 +00:00
Peter Steinberger d73e8ecca3 fix: document tools invoke + honor main session key (#1575) (thanks @vignesh07) 2026-01-24 09:29:32 +00:00
Vignesh Natarajan faa90fc206 docs(lobster): document clawd.invoke tool allowlisting 2026-01-24 09:29:32 +00:00
Vignesh Natarajan f1083cd52c gateway: add /tools/invoke HTTP endpoint 2026-01-24 09:29:32 +00:00
Peter Steinberger 7f7550e53c docs: add cross-context messaging faq 2026-01-24 09:28:59 +00:00
Peter Steinberger d4d17025cf docs: add oauth refresh troubleshooting 2026-01-24 09:21:15 +00:00
Peter Steinberger 7b76db2841 fix: document heartbeat visibility controls (#1452) (thanks @dlauer) 2026-01-24 09:07:03 +00:00
Dave Lauer f9cf508cff feat(heartbeat): add configurable visibility for heartbeat responses
Add per-channel and per-account heartbeat visibility settings:
- showOk: hide/show HEARTBEAT_OK messages (default: false)
- showAlerts: hide/show alert messages (default: true)
- useIndicator: emit typing indicator events (default: true)

Config precedence: per-account > per-channel > channel-defaults > global

This allows silencing routine heartbeat acks while still surfacing
alerts when something needs attention.
2026-01-24 09:07:03 +00:00
Peter Steinberger 9b12275fe1 fix(hooks): emit message_received metadata 2026-01-24 08:56:16 +00:00
Peter Steinberger f70ac0c7c2 fix: harden discord rate-limit handling 2026-01-24 08:43:28 +00:00
Peter Steinberger 09a72f1ede docs: changelog msteams probe (#1574) (thanks @Evizero) 2026-01-24 08:35:10 +00:00
ChristofandChristof Salis 2b8b3c4b10 fix(msteams): remove remaining /.default postfix (#1574)
This fixes the msteams probe which otherwise incorrectly assumes teams is not working.

The @microsoft/agents-hosting SDK's MsalTokenProvider automatically appends /.default to all scope strings in its token acquisition methods (acquireAccessTokenViaSecret, acquireAccessTokenViaFIC, acquireAccessTokenViaWID, acquireTokenWithCertificate in msalTokenProvider.ts). This is consistent SDK behavior, not a recent change.

The current code is including .default in scope URLs, resulting in invalid double suffixes like https://graph.microsoft.com/.default/.default. I am not sure how the .default postfixed worked in the past for you if I am honest.

This was confirmed to cause Graph API authentication errors. Removing the .default suffix from our scope strings allows the SDK to append it correctly, resolving the issue. I confirmed it manually on my teams setup

Before: we pass .default -> SDK appends -> double .default (broken)
After: we pass base URL -> SDK appends -> single .default (works)

Co-authored-by: Christof Salis <c.salis@vertifymed.com>
2026-01-24 08:30:34 +00:00
111 changed files with 6658 additions and 3207 deletions

No files matched your search

+10
View File
@@ -4,10 +4,15 @@ Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
### Highlights
- TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).
### Changes
- Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
- Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
@@ -21,9 +26,11 @@ Docs: https://docs.clawd.bot
- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.
### Fixes
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Agents: treat request-aborted AbortError timeouts as fallback triggers. (#1576) Thanks @lc0rp.
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
@@ -35,6 +42,8 @@ Docs: https://docs.clawd.bot
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
@@ -61,6 +70,7 @@ Docs: https://docs.clawd.bot
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
- MS Teams (plugin): remove `.default` suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
-8
View File
@@ -1,8 +0,0 @@
---
summary: "Alias for compaction docs"
read_when:
- You looked for /compaction; canonical doc lives in /concepts/compaction
---
# Compaction
Canonical compaction docs live in [Compaction](/concepts/compaction).
+3 -2
View File
@@ -56,19 +56,20 @@ Row shape (JSON):
Fetch transcript for one session.
Parameters:
- `sessionKey` (required)
- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`)
- `limit?: number` max messages (server clamps)
- `includeTools?: boolean` (default false)
Behavior:
- `includeTools=false` filters `role: "toolResult"` messages.
- Returns messages array in the raw transcript format.
- When given a `sessionId`, Clawdbot resolves it to the corresponding session key (missing ids error).
## sessions_send
Send a message into another session.
Parameters:
- `sessionKey` (required)
- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`)
- `message` (required)
- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget)
+4 -15
View File
@@ -387,7 +387,7 @@
},
{
"source": "/faq",
"destination": "/start/faq"
"destination": "/help/faq"
},
{
"source": "/gateway-lock",
@@ -449,10 +449,6 @@
"source": "/location-command",
"destination": "/nodes/location-command"
},
{
"source": "/logging",
"destination": "/gateway/logging"
},
{
"source": "/lore",
"destination": "/start/lore"
@@ -761,21 +757,13 @@
"source": "/wizard",
"destination": "/start/wizard"
},
{
"source": "/install/node",
"destination": "/install#nodejs--npm-path-sanity"
},
{
"source": "/install/node/",
"destination": "/install#nodejs--npm-path-sanity"
},
{
"source": "/start/faq",
"destination": "/help"
"destination": "/help/faq"
},
{
"source": "/start/faq/",
"destination": "/help"
"destination": "/help/faq"
},
{
"source": "/oauth",
@@ -916,6 +904,7 @@
"gateway/configuration-examples",
"gateway/authentication",
"gateway/openai-http-api",
"gateway/tools-invoke-http-api",
"gateway/cli-backends",
"gateway/local-models",
"gateway/background-process",
+1 -1
View File
@@ -74,5 +74,5 @@ See [Configuration: Env var substitution](/gateway/configuration#env-var-substit
## Related
- [Gateway configuration](/gateway/configuration)
- [FAQ: env vars and .env loading](/start/faq#env-vars-and-env-loading)
- [FAQ: env vars and .env loading](/help/faq#env-vars-and-env-loading)
- [Models overview](/concepts/models)
+24 -3
View File
@@ -1459,13 +1459,28 @@ voice notes; other channels send MP3 audio.
enabled: true,
mode: "final", // final | all (include tool/block replies)
provider: "elevenlabs",
summaryModel: "openai/gpt-4.1-mini",
modelOverrides: {
enabled: true
},
maxTextLength: 4000,
timeoutMs: 30000,
prefsPath: "~/.clawdbot/settings/tts.json",
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2"
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0
}
},
openai: {
apiKey: "openai_api_key",
@@ -1478,11 +1493,17 @@ voice notes; other channels send MP3 audio.
```
Notes:
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts_on`, `/tts_off`).
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
- `/tts_limit` and `/tts_summary` control per-user summarization settings.
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
- Accepts `provider/model` or an alias from `agents.defaults.models`.
- `modelOverrides` enables model-driven overrides like `[[tts:...]]` tags (on by default).
- `/tts limit` and `/tts summary` control per-user summarization settings.
- `apiKey` values fall back to `ELEVENLABS_API_KEY`/`XI_API_KEY` and `OPENAI_API_KEY`.
- `elevenlabs.baseUrl` overrides the ElevenLabs API base URL.
- `elevenlabs.voiceSettings` supports `stability`/`similarityBoost`/`style` (0..1),
`useSpeakerBoost`, and `speed` (0.5..2.0).
### `talk`
+70
View File
@@ -92,6 +92,14 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
}
```
### Scope and precedence
- `agents.defaults.heartbeat` sets global heartbeat behavior.
- `agents.list[].heartbeat` merges on top; if any agent has a `heartbeat` block, **only those agents** run heartbeats.
- `channels.defaults.heartbeat` sets visibility defaults for all channels.
- `channels.<channel>.heartbeat` overrides channel defaults.
- `channels.<channel>.accounts.<id>.heartbeat` (multi-account channels) overrides per-channel settings.
### Per-agent heartbeats
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
@@ -156,6 +164,68 @@ Example: two agents, only the second agent runs heartbeats.
- Heartbeat-only replies do **not** keep the session alive; the last `updatedAt`
is restored so idle expiry behaves normally.
## Visibility controls
By default, `HEARTBEAT_OK` acknowledgments are suppressed while alert content is
delivered. You can adjust this per channel or per account:
```yaml
channels:
defaults:
heartbeat:
showOk: false # Hide HEARTBEAT_OK (default)
showAlerts: true # Show alert messages (default)
useIndicator: true # Emit indicator events (default)
telegram:
heartbeat:
showOk: true # Show OK acknowledgments on Telegram
whatsapp:
accounts:
work:
heartbeat:
showAlerts: false # Suppress alert delivery for this account
```
Precedence: per-account → per-channel → channel defaults → built-in defaults.
### What each flag does
- `showOk`: sends a `HEARTBEAT_OK` acknowledgment when the model returns an OK-only reply.
- `showAlerts`: sends the alert content when the model returns a non-OK reply.
- `useIndicator`: emits indicator events for UI status surfaces.
If **all three** are false, Clawdbot skips the heartbeat run entirely (no model call).
### Per-channel vs per-account examples
```yaml
channels:
defaults:
heartbeat:
showOk: false
showAlerts: true
useIndicator: true
slack:
heartbeat:
showOk: true # all Slack accounts
accounts:
ops:
heartbeat:
showAlerts: false # suppress alerts for the ops account only
telegram:
heartbeat:
showOk: true
```
### Common patterns
| Goal | Config |
| --- | --- |
| Default behavior (silent OKs, alerts on) | *(no config needed)* |
| Fully silent (no messages, no indicator) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: false }` |
| Indicator-only (no messages) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: true }` |
| OKs in one channel only | `channels.telegram.heartbeat: { showOk: true }` |
## HEARTBEAT.md (optional)
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
+1
View File
@@ -30,6 +30,7 @@ pnpm gateway:watch
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
- OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api).
- OpenResponses (HTTP): [`/v1/responses`](/gateway/openresponses-http-api).
- Tools Invoke (HTTP): [`/tools/invoke`](/gateway/tools-invoke-http-api).
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdbot__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`.
- Logs to stdout; use launchd/systemd to keep it alive and rotate logs.
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
+79
View File
@@ -0,0 +1,79 @@
---
summary: "Invoke a single tool directly via the Gateway HTTP endpoint"
read_when:
- Calling tools without running a full agent turn
- Building automations that need tool policy enforcement
---
# Tools Invoke (HTTP)
Clawdbot’s Gateway exposes a simple HTTP endpoint for invoking a single tool directly. It is always enabled, but gated by Gateway auth and tool policy.
- `POST /tools/invoke`
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/tools/invoke`
Default max payload size is 2 MB.
## Authentication
Uses the Gateway auth configuration. Send a bearer token:
- `Authorization: Bearer <token>`
Notes:
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `CLAWDBOT_GATEWAY_PASSWORD`).
## Request body
```json
{
"tool": "sessions_list",
"action": "json",
"args": {},
"sessionKey": "main",
"dryRun": false
}
```
Fields:
- `tool` (string, required): tool name to invoke.
- `action` (string, optional): mapped into args if the tool schema supports `action` and the args payload omitted it.
- `args` (object, optional): tool-specific arguments.
- `sessionKey` (string, optional): target session key. If omitted or `"main"`, the Gateway uses the configured main session key (honors `session.mainKey` and default agent, or `global` in global scope).
- `dryRun` (boolean, optional): reserved for future use; currently ignored.
## Policy + routing behavior
Tool availability is filtered through the same policy chain used by Gateway agents:
- `tools.profile` / `tools.byProvider.profile`
- `tools.allow` / `tools.byProvider.allow`
- `agents.<id>.tools.allow` / `agents.<id>.tools.byProvider.allow`
- group policies (if the session key maps to a group or channel)
- subagent policy (when invoking with a subagent session key)
If a tool is not allowed by policy, the endpoint returns **404**.
To help group policies resolve context, you can optionally set:
- `x-clawdbot-message-channel: <channel>` (example: `slack`, `telegram`)
- `x-clawdbot-account-id: <accountId>` (when multiple accounts exist)
## Responses
- `200` → `{ ok: true, result }`
- `400` → `{ ok: false, error: { type, message } }` (invalid request or tool error)
- `401` → unauthorized
- `404` → tool not available (not found or not allowlisted)
- `405` → method not allowed
## Example
```bash
curl -sS http://127.0.0.1:18789/tools/invoke \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"tool": "sessions_list",
"action": "json",
"args": {}
}'
```
+29 -1
View File
@@ -7,7 +7,7 @@ read_when:
When Clawdbot misbehaves, here's how to fix it.
Start with the FAQ’s [First 60 seconds](/start/faq#first-60-seconds-if-somethings-broken) if you just want a quick triage recipe. This page goes deeper on runtime failures and diagnostics.
Start with the FAQ’s [First 60 seconds](/help/faq#first-60-seconds-if-somethings-broken) if you just want a quick triage recipe. This page goes deeper on runtime failures and diagnostics.
Provider-specific shortcuts: [/channels/troubleshooting](/channels/troubleshooting)
@@ -31,6 +31,34 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
## Common Issues
### OAuth token refresh failed (Anthropic Claude subscription)
This means the stored Anthropic OAuth token expired and the refresh failed.
If you’re on a Claude subscription (no API key), the most reliable fix is to
switch to a **Claude Code setup-token** or re-sync Claude Code CLI OAuth on the
**gateway host**.
**Recommended (setup-token):**
```bash
# Run on the gateway host (runs Claude Code CLI)
clawdbot models auth setup-token --provider anthropic
clawdbot models status
```
If you generated the token elsewhere:
```bash
clawdbot models auth paste-token --provider anthropic
clawdbot models status
```
**If you want to keep OAuth reuse:**
log in with Claude Code CLI on the gateway host, then run `clawdbot models status`
to sync the refreshed token into Clawdbot’s auth store.
More detail: [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
### Control UI fails on HTTP ("device identity required" / "connect failed")
If you open the dashboard over plain HTTP (e.g. `http://<lan-ip>:18789/` or
+1660 -12
View File
File diff suppressed because it is too large. Load diff
+81 -10
View File
@@ -140,11 +140,8 @@ cat > /data/.clawdbot/clawdbot.json << 'EOF'
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-opus-4-5"
},
"models": {
"anthropic/claude-opus-4-5": {},
"anthropic/claude-sonnet-4-5": {}
"primary": "anthropic/claude-opus-4-5",
"failover": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
},
"maxConcurrent": 4
},
@@ -155,15 +152,47 @@ cat > /data/.clawdbot/clawdbot.json << 'EOF'
}
]
},
"auth": {
"profiles": {
"anthropic:default": { "mode": "token", "provider": "anthropic" },
"openai:default": { "mode": "token", "provider": "openai" }
}
},
"bindings": [
{
"agentId": "main",
"match": { "channel": "discord" }
}
],
"channels": {
"discord": {
"enabled": true
"enabled": true,
"groupPolicy": "allowlist",
"guilds": {
"YOUR_GUILD_ID": {
"channels": { "general": { "allow": true } },
"requireMention": false
}
}
}
},
"gateway": {
"mode": "local",
"bind": "auto"
},
"meta": {
"lastTouchedVersion": "2026.1.23"
}
}
EOF
```
**Note:** The Discord token can come from either:
- Environment variable: `DISCORD_BOT_TOKEN` (recommended for secrets)
- Config file: `channels.discord.token`
If using env var, no need to add token to config. The gateway reads `DISCORD_BOT_TOKEN` automatically.
Restart to apply:
```bash
exit
@@ -206,7 +235,7 @@ The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
### OOM / Memory Issues
Container keeps restarting or getting killed.
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
**Fix:** Increase memory in `fly.toml`:
```toml
@@ -214,6 +243,13 @@ Container keeps restarting or getting killed.
memory = "2048mb"
```
Or update an existing machine:
```bash
fly machine update <machine-id> --vm-memory 2048 -y
```
**Note:** 512MB is too small. 1GB may work but can OOM under load or with verbose logging. **2GB is recommended.**
### Gateway Lock Issues
Gateway refuses to start with "already running" errors.
@@ -222,12 +258,12 @@ This happens when the container restarts but the PID lock file persists on the v
**Fix:** Delete the lock file:
```bash
fly ssh console
rm /data/.clawdbot/run/gateway.*.lock
exit
fly ssh console --command "rm -f /data/gateway.*.lock"
fly machine restart <machine-id>
```
The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
### Config Not Being Read
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
@@ -237,6 +273,24 @@ Verify the config exists:
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
```
### Writing Config via SSH
The `fly ssh console -C` command doesn't support shell redirection. To write a config file:
```bash
# Use echo + tee (pipe from local to remote)
echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
# Or use sftp
fly sftp shell
> put /local/path/config.json /data/.clawdbot/clawdbot.json
```
**Note:** `fly sftp` may fail if the file already exists. Delete first:
```bash
fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
```
## Updates
```bash
@@ -251,6 +305,23 @@ fly status
fly logs
```
### Updating Machine Command
If you need to change the startup command without a full redeploy:
```bash
# Get machine ID
fly machines list
# Update command
fly machine update <machine-id> --command "node dist/index.js gateway --port 3000 --bind lan" -y
# Or with memory increase
fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js gateway --port 3000 --bind lan" -y
```
**Note:** After `fly deploy`, the machine command may reset to what's in `fly.toml`. If you made manual changes, re-apply them after deploy.
## Notes
- Fly.io uses **x86 architecture** (not ARM)
+1
View File
@@ -100,6 +100,7 @@ clawdbot onboard --auth-choice claude-cli
## Notes
- Generate the setup-token with `claude setup-token` and paste it, or run `clawdbot models auth setup-token` on the gateway host.
- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token or resync Claude Code CLI OAuth on the gateway host. See [/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription](/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription).
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
auto-migrated on load.
-1652
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -379,10 +379,10 @@ List sessions, inspect transcript history, or send to another session.
Core parameters:
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
- `session_status`: `sessionKey?` (default current), `model?` (`default` clears override)
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
+2 -7
View File
@@ -67,13 +67,8 @@ Text + native (when enabled):
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
- `/tts_on` (enable TTS replies)
- `/tts_off` (disable TTS replies)
- `/tts_provider [openai|elevenlabs]` (set or show TTS provider)
- `/tts_limit <chars>` (max chars before TTS summarization)
- `/tts_summary on|off` (toggle TTS auto-summary)
- `/tts_status` (show TTS status)
- `/audio <text>` (convert text to a TTS audio reply)
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
+1 -1
View File
@@ -84,7 +84,7 @@ current limits and pricing.
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
environment. For a gateway install, put it in `~/.clawdbot/.env` (or your
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
service environment). See [Env vars](/help/faq#how-does-clawdbot-load-environment-variables).
## Using Perplexity (direct or via OpenRouter)
+296
View File
@@ -0,0 +1,296 @@
---
summary: "Text-to-speech (TTS) for outbound replies"
read_when:
- Enabling text-to-speech for replies
- Configuring TTS providers or limits
- Using /tts commands
---
# Text-to-speech (TTS)
Clawdbot can convert outbound replies into audio using ElevenLabs or OpenAI.
It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubble.
## Supported services
- **ElevenLabs** (primary or fallback provider)
- **OpenAI** (primary or fallback provider; also used for summaries)
## Required keys
At least one of:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY`
If both are configured, the selected provider is used first and the other is a fallback.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
so that provider must also be authenticated if you enable summaries.
## Service links
- [OpenAI Text-to-Speech guide](https://platform.openai.com/docs/guides/text-to-speech)
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
## Is it enabled by default?
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
which writes a local preference override.
## Config
TTS config lives under `messages.tts` in `clawdbot.json`.
Full schema is in [Gateway configuration](/gateway/configuration).
### Minimal config (enable + provider)
```json5
{
messages: {
tts: {
enabled: true,
provider: "elevenlabs"
}
}
}
```
### OpenAI primary with ElevenLabs fallback
```json5
{
messages: {
tts: {
enabled: true,
provider: "openai",
summaryModel: "openai/gpt-4.1-mini",
modelOverrides: {
enabled: true
},
openai: {
apiKey: "openai_api_key",
model: "gpt-4o-mini-tts",
voice: "alloy"
},
elevenlabs: {
apiKey: "elevenlabs_api_key",
baseUrl: "https://api.elevenlabs.io",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0
}
}
}
}
}
```
### Custom limits + prefs path
```json5
{
messages: {
tts: {
enabled: true,
maxTextLength: 4000,
timeoutMs: 30000,
prefsPath: "~/.clawdbot/settings/tts.json"
}
}
}
```
### Disable auto-summary for long replies
```json5
{
messages: {
tts: {
enabled: true
}
}
}
```
Then run:
```
/tts summary off
```
### Notes on fields
- `enabled`: master toggle (default `false`; local prefs can override).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: `"elevenlabs"` or `"openai"` (fallback is automatic).
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default).
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path.
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `elevenlabs.voiceSettings`:
- `stability`, `similarityBoost`, `style`: `0..1`
- `useSpeakerBoost`: `true|false`
- `speed`: `0.5..2.0` (1.0 = normal)
- `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
## Model-driven overrides (default on)
By default, the model **can** emit TTS directives for a single reply.
When enabled, the model can emit `[[tts:...]]` directives to override the voice
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
provide expressive tags (laughter, singing cues, etc) that should only appear in
the audio.
Example reply payload:
```
Here you go.
[[tts:provider=elevenlabs voiceId=pMsXgVXv3BLzUgSXRplE model=eleven_v3 speed=1.1]]
[[tts:text]](laughs) Read the song once more.[[/tts:text]]
```
Available directive keys (when enabled):
- `provider` (`openai` | `elevenlabs`)
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs)
- `model` (OpenAI TTS model or ElevenLabs model id)
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
- `applyTextNormalization` (`auto|on|off`)
- `languageCode` (ISO 639-1)
- `seed`
Disable all model overrides:
```json5
{
messages: {
tts: {
modelOverrides: {
enabled: false
}
}
}
}
```
Optional allowlist (disable specific overrides while keeping tags enabled):
```json5
{
messages: {
tts: {
modelOverrides: {
enabled: true,
allowProvider: false,
allowSeed: false
}
}
}
}
```
## Per-user preferences
Slash commands write local overrides to `prefsPath` (default:
`~/.clawdbot/settings/tts.json`, override with `CLAWDBOT_TTS_PREFS` or
`messages.tts.prefsPath`).
Stored fields:
- `enabled`
- `provider`
- `maxLength` (summary threshold; default 1500 chars)
- `summarize` (default `true`)
These override `messages.tts.*` for that host.
## Output formats (fixed)
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
- 44.1kHz / 128kbps is the default balance for speech clarity.
This is not configurable; Telegram expects Opus for voice-note UX.
## Auto-TTS behavior
When enabled, Clawdbot:
- skips TTS if the reply already contains media or a `MEDIA:` directive.
- skips very short replies (< 10 chars).
- summarizes long replies when enabled using `agents.defaults.model.primary` (or `summaryModel`).
- attaches the generated audio to the reply.
If the reply exceeds `maxLength` and summary is off (or no API key for the
summary model), audio
is skipped and the normal text reply is sent.
## Flow diagram
```
Reply -> TTS enabled?
no -> send text
yes -> has media / MEDIA: / short?
yes -> send text
no -> length > limit?
no -> TTS -> attach audio
yes -> summary enabled?
no -> send text
yes -> summarize (summaryModel or agents.defaults.model.primary)
-> TTS -> attach audio
```
## Slash command usage
There is a single command: `/tts`.
See [Slash commands](/tools/slash-commands) for enablement details.
Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
`/voice` as the native command there. Text `/tts ...` still works.
```
/tts on
/tts off
/tts status
/tts provider openai
/tts limit 2000
/tts summary off
/tts audio Hello from Clawdbot
```
Notes:
- Commands require an authorized sender (allowlist/owner rules still apply).
- `commands.text` or native command registration must be enabled.
- `limit` and `summary` are stored in local prefs, not the main config.
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
## Agent tool
The `tts` tool converts text to speech and returns a `MEDIA:` path. When the
result is Telegram-compatible, the tool includes `[[audio_as_voice]]` so
Telegram sends a voice bubble.
## Gateway RPC
Gateway methods:
- `tts.status`
- `tts.enable`
- `tts.disable`
- `tts.convert`
- `tts.setProvider`
- `tts.providers`
+42
View File
@@ -30,6 +30,48 @@ Enable it in an agent allowlist:
}
```
## Using `clawd.invoke` (Lobster → Clawdbot tools)
Some Lobster pipelines may include a `clawd.invoke` step to call back into Clawdbot tools/plugins (for example: `gog` for Google Workspace, `gh` for GitHub, `message.send`, etc.).
For this to work, the Clawdbot Gateway must expose the tool bridge endpoint and the target tool must be allowed by policy:
- Clawdbot provides an HTTP endpoint: `POST /tools/invoke`.
- The request is gated by **gateway auth** (e.g. `Authorization: Bearer …` when token auth is enabled).
- The invoked tool is gated by **tool policy** (global + per-agent + provider + group policy). If the tool is not allowed, Clawdbot returns `404 Tool not available`.
### Allowlisting recommended
To avoid letting workflows call arbitrary tools, set a tight allowlist on the agent that will be used by `clawd.invoke`.
Example (allow only a small set of tools):
```jsonc
{
"agents": {
"list": [
{
"id": "main",
"tools": {
"allow": [
"lobster",
"web_fetch",
"web_search",
"gog",
"gh"
],
"deny": ["gateway"]
}
}
]
}
}
```
Notes:
- If `tools.allow` is omitted or empty, it behaves like "allow everything (except denied)". For a real allowlist, set a **non-empty** `allow`.
- Tool names depend on which plugins you have installed/enabled.
## Security
- Runs the `lobster` executable as a local subprocess.
+1 -1
View File
@@ -28,7 +28,7 @@
"markdown-it": "14.1.0",
"matrix-bot-sdk": "0.8.0",
"music-metadata": "^11.10.6",
"zod": "^4.3.5"
"zod": "^4.3.6"
},
"devDependencies": {
"clawdbot": "workspace:*"
+1 -1
View File
@@ -65,7 +65,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
try {
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
await tokenProvider.getAccessToken("https://api.botframework.com");
let graph:
| {
ok: boolean;
+2 -2
View File
@@ -25,7 +25,7 @@
},
"dependencies": {
"clawdbot": "workspace:*",
"nostr-tools": "^2.19.4",
"zod": "^4.3.5"
"nostr-tools": "^2.20.0",
"zod": "^4.3.6"
}
}
+1 -1
View File
@@ -24,7 +24,7 @@
}
},
"dependencies": {
"@urbit/aura": "^2.0.0",
"@urbit/aura": "^3.0.0",
"@urbit/http-api": "^3.0.0"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"dependencies": {
"@sinclair/typebox": "0.34.47",
"ws": "^8.19.0",
"zod": "^4.3.5"
"zod": "^4.3.6"
},
"clawdbot": {
"extensions": [
+9 -3
View File
@@ -2,7 +2,7 @@
# See https://fly.io/docs/reference/configuration/
app = "clawdbot"
primary_region = "lhr" # London
primary_region = "iad" # change to your closest region
[build]
dockerfile = "Dockerfile"
@@ -11,6 +11,11 @@ primary_region = "lhr" # London
NODE_ENV = "production"
# Fly uses x86, but keep this for consistency
CLAWDBOT_PREFER_PNPM = "1"
CLAWDBOT_STATE_DIR = "/data"
NODE_OPTIONS = "--max-old-space-size=1536"
[processes]
app = "node dist/index.js gateway --port 3000 --bind lan"
[http_service]
internal_port = 3000
@@ -18,10 +23,11 @@ primary_region = "lhr" # London
auto_stop_machines = false # Keep running for persistent connections
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
size = "shared-cpu-2x"
memory = "2048mb"
[mounts]
source = "clawdbot_data"
+8 -8
View File
@@ -146,7 +146,7 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.0",
"@agentclientprotocol/sdk": "0.13.1",
"@aws-sdk/client-bedrock": "^3.975.0",
"@buape/carbon": "0.14.0",
"@clack/prompts": "^0.11.0",
@@ -186,7 +186,7 @@
"markdown-it": "^14.1.0",
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.4.530",
"playwright-core": "1.57.0",
"playwright-core": "1.58.0",
"proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
@@ -196,7 +196,7 @@
"undici": "^7.19.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.5"
"zod": "^4.3.6"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.88",
@@ -214,21 +214,21 @@
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"@vitest/coverage-v8": "^4.0.17",
"@typescript/native-preview": "7.0.0-dev.20260124.1",
"@vitest/coverage-v8": "^4.0.18",
"docx-preview": "^0.3.7",
"lit": "^3.3.2",
"lucide": "^0.562.0",
"lucide": "^0.563.0",
"ollama": "^0.6.3",
"oxfmt": "0.26.0",
"oxlint": "^1.41.0",
"oxlint-tsgolint": "^0.11.1",
"quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.60",
"rolldown": "1.0.0-rc.1",
"signal-utils": "^0.21.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.17",
"vitest": "^4.0.18",
"wireit": "^0.14.12"
},
"pnpm": {
+403 -854
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -6,11 +6,13 @@ WORKDIR /app
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts ./
COPY src ./src
COPY test ./test
COPY scripts ./scripts
COPY docs ./docs
COPY skills ./skills
COPY patches ./patches
COPY extensions/memory-core ./extensions/memory-core
RUN pnpm install --frozen-lockfile
@@ -17,7 +17,8 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
updateSessionStoreMock(storePath, store);
return store;
},
resolveStorePath: () => "/tmp/sessions.json",
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
};
});
@@ -117,11 +118,118 @@ describe("session_status tool", () => {
if (!tool) throw new Error("missing session_status tool");
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
"Unknown sessionKey",
"Unknown sessionId",
);
expect(updateSessionStoreMock).not.toHaveBeenCalled();
});
it("resolves sessionId inputs", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
const sessionId = "sess-main";
loadSessionStoreMock.mockReturnValue({
"agent:main:main": {
sessionId,
updatedAt: 10,
},
});
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
const result = await tool.execute("call3", { sessionKey: sessionId });
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("agent:main:main");
});
it("uses non-standard session keys without sessionId resolution", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
"temp:slug-generator": {
sessionId: "sess-temp",
updatedAt: 10,
},
});
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
const result = await tool.execute("call4", { sessionKey: "temp:slug-generator" });
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("temp:slug-generator");
});
it("blocks cross-agent session_status without agent-to-agent access", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
loadSessionStoreMock.mockReturnValue({
"agent:other:main": {
sessionId: "s2",
updatedAt: 10,
},
});
const tool = createClawdbotTools({ agentSessionKey: "agent:main:main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
await expect(tool.execute("call5", { sessionKey: "agent:other:main" })).rejects.toThrow(
"Agent-to-agent status is disabled",
);
});
it("scopes bare session keys to the requester agent", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
const stores = new Map<string, Record<string, unknown>>([
[
"/tmp/main/sessions.json",
{
"agent:main:main": { sessionId: "s-main", updatedAt: 10 },
},
],
[
"/tmp/support/sessions.json",
{
main: { sessionId: "s-support", updatedAt: 20 },
},
],
]);
loadSessionStoreMock.mockImplementation((storePath: string) => {
return stores.get(storePath) ?? {};
});
updateSessionStoreMock.mockImplementation(
(_storePath: string, store: Record<string, unknown>) => {
// Keep map in sync for resolveSessionEntry fallbacks if needed.
if (_storePath) {
stores.set(_storePath, store);
}
},
);
const tool = createClawdbotTools({ agentSessionKey: "agent:support:main" }).find(
(candidate) => candidate.name === "session_status",
);
expect(tool).toBeDefined();
if (!tool) throw new Error("missing session_status tool");
const result = await tool.execute("call6", { sessionKey: "main" });
const details = result.details as { ok?: boolean; sessionKey?: string };
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("main");
});
it("resets per-session model override via model=default", async () => {
loadSessionStoreMock.mockReset();
updateSessionStoreMock.mockReset();
+100
View File
@@ -172,6 +172,62 @@ describe("sessions tools", () => {
expect(withToolsDetails.messages).toHaveLength(2);
});
it("sessions_history resolves sessionId inputs", async () => {
callGatewayMock.mockReset();
const sessionId = "sess-group";
const targetKey = "agent:main:discord:channel:1457165743010611293";
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: Record<string, unknown> };
if (request.method === "sessions.resolve") {
return {
key: targetKey,
};
}
if (request.method === "chat.history") {
return {
messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }],
};
}
return {};
});
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_history tool");
const result = await tool.execute("call5", { sessionKey: sessionId });
const details = result.details as { messages?: unknown[] };
expect(details.messages).toHaveLength(1);
const historyCall = callGatewayMock.mock.calls.find(
(call) => (call[0] as { method?: string }).method === "chat.history",
);
expect(historyCall?.[0]).toMatchObject({
method: "chat.history",
params: { sessionKey: targetKey },
});
});
it("sessions_history errors on missing sessionId", async () => {
callGatewayMock.mockReset();
const sessionId = "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa";
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "sessions.resolve") {
throw new Error("No session found");
}
return {};
});
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_history tool");
const result = await tool.execute("call6", { sessionKey: sessionId });
const details = result.details as { status?: string; error?: string };
expect(details.status).toBe("error");
expect(details.error).toMatch(/Session not found|No session found/);
});
it("sessions_send supports fire-and-forget and wait", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
@@ -313,6 +369,50 @@ describe("sessions tools", () => {
expect(sendCallCount).toBe(0);
});
it("sessions_send resolves sessionId inputs", async () => {
callGatewayMock.mockReset();
const sessionId = "sess-send";
const targetKey = "agent:main:discord:channel:123";
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: Record<string, unknown> };
if (request.method === "sessions.resolve") {
return { key: targetKey };
}
if (request.method === "agent") {
return { runId: "run-1", acceptedAt: 123 };
}
if (request.method === "agent.wait") {
return { status: "ok" };
}
if (request.method === "chat.history") {
return { messages: [] };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "main",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
const result = await tool.execute("call7", {
sessionKey: sessionId,
message: "ping",
timeoutSeconds: 0,
});
const details = result.details as { status?: string };
expect(details.status).toBe("accepted");
const agentCall = callGatewayMock.mock.calls.find(
(call) => (call[0] as { method?: string }).method === "agent",
);
expect(agentCall?.[0]).toMatchObject({
method: "agent",
params: { sessionKey: targetKey },
});
});
it("sessions_send runs ping-pong then announces", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
+3
View File
@@ -13,6 +13,7 @@ import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildSystemPromptParams } from "../system-prompt-params.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
@@ -194,6 +195,7 @@ export function buildSystemPrompt(params: {
defaultModel: defaultModelLabel,
},
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
@@ -209,6 +211,7 @@ export function buildSystemPrompt(params: {
userTime,
userTimeFormat,
contextFiles: params.contextFiles,
ttsHint,
});
}
+3
View File
@@ -1,6 +1,7 @@
import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js";
const TIMEOUT_HINT_RE = /timeout|timed out|deadline exceeded|context deadline exceeded/i;
const ABORT_TIMEOUT_RE = /request(?:\s+was)?[- ]aborted/i;
export class FailoverError extends Error {
readonly reason: FailoverReason;
@@ -104,6 +105,8 @@ export function isTimeoutError(err: unknown): boolean {
if (hasTimeoutHint(err)) return true;
if (!err || typeof err !== "object") return false;
if (getErrorName(err) !== "AbortError") return false;
const message = getErrorMessage(err);
if (message && ABORT_TIMEOUT_RE.test(message)) return true;
const cause = "cause" in err ? (err as { cause?: unknown }).cause : undefined;
const reason = "reason" in err ? (err as { reason?: unknown }).reason : undefined;
return hasTimeoutHint(cause) || hasTimeoutHint(reason);
+22
View File
@@ -346,6 +346,28 @@ describe("runWithModelFallback", () => {
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on provider abort errors with request-aborted messages", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("Request was aborted"), { name: "AbortError" }),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("does not fall back on user aborts", async () => {
const cfg = makeCfg();
const run = vi
+3
View File
@@ -66,6 +66,7 @@ import { splitSdkTools } from "./tool-split.js";
import type { EmbeddedPiCompactResult } from "./types.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
export async function compactEmbeddedPiSession(params: {
sessionId: string;
@@ -298,6 +299,7 @@ export async function compactEmbeddedPiSession(params: {
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
@@ -310,6 +312,7 @@ export async function compactEmbeddedPiSession(params: {
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
runtimeInfo,
messageToolHints,
@@ -78,6 +78,7 @@ import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
import { buildSystemPromptParams } from "../../system-prompt-params.js";
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import { buildTtsSystemPromptHint } from "../../../tts/tts.js";
import { isTimeoutError } from "../../failover-error.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
@@ -315,6 +316,7 @@ export async function runEmbeddedAttempt(
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
@@ -328,6 +330,7 @@ export async function runEmbeddedAttempt(
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
workspaceNotes,
reactionGuidance,
promptMode,
@@ -16,6 +16,7 @@ export function buildEmbeddedSystemPrompt(params: {
heartbeatPrompt?: string;
skillsPrompt?: string;
docsPath?: string;
ttsHint?: string;
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
@@ -55,6 +56,7 @@ export function buildEmbeddedSystemPrompt(params: {
heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt,
docsPath: params.docsPath,
ttsHint: params.ttsHint,
workspaceNotes: params.workspaceNotes,
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
+12
View File
@@ -34,6 +34,7 @@ describe("buildAgentSystemPrompt", () => {
toolNames: ["message", "memory_search"],
docsPath: "/tmp/clawd/docs",
extraSystemPrompt: "Subagent details",
ttsHint: "Voice (TTS) is enabled.",
});
expect(prompt).not.toContain("## User Identity");
@@ -42,6 +43,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).not.toContain("## Documentation");
expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Voice (TTS)");
expect(prompt).not.toContain("## Silent Replies");
expect(prompt).not.toContain("## Heartbeats");
expect(prompt).toContain("## Subagent Context");
@@ -49,6 +51,16 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Subagent details");
});
it("includes voice hint when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
ttsHint: "Voice (TTS) is enabled.",
});
expect(prompt).toContain("## Voice (TTS)");
expect(prompt).toContain("Voice (TTS) is enabled.");
});
it("adds reasoning tag hint when enabled", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
+9
View File
@@ -103,6 +103,13 @@ function buildMessagingSection(params: {
];
}
function buildVoiceSection(params: { isMinimal: boolean; ttsHint?: string }) {
if (params.isMinimal) return [];
const hint = params.ttsHint?.trim();
if (!hint) return [];
return ["## Voice (TTS)", hint, ""];
}
function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) {
const docsPath = params.docsPath?.trim();
if (!docsPath || params.isMinimal) return [];
@@ -137,6 +144,7 @@ export function buildAgentSystemPrompt(params: {
heartbeatPrompt?: string;
docsPath?: string;
workspaceNotes?: string[];
ttsHint?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo?: {
@@ -464,6 +472,7 @@ export function buildAgentSystemPrompt(params: {
runtimeChannel,
messageToolHints: params.messageToolHints,
}),
...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }),
];
if (extraSystemPrompt) {
+79 -7
View File
@@ -40,7 +40,13 @@ import {
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
import {
shouldResolveSessionIdInput,
resolveInternalSessionKey,
resolveMainSessionAlias,
createAgentToAgentPolicy,
} from "./sessions-helpers.js";
import { loadCombinedSessionStoreForGateway } from "../../gateway/session-utils.js";
const SessionStatusToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
@@ -149,6 +155,22 @@ function resolveSessionEntry(params: {
return null;
}
function resolveSessionKeyFromSessionId(params: {
cfg: ClawdbotConfig;
sessionId: string;
agentId?: string;
}): string | null {
const trimmed = params.sessionId.trim();
if (!trimmed) return null;
const { store } = loadCombinedSessionStoreForGateway(params.cfg);
const match = Object.entries(store).find(([key, entry]) => {
if (entry?.sessionId !== trimmed) return false;
if (!params.agentId) return true;
return resolveAgentIdFromSessionKey(key) === params.agentId;
});
return match?.[0] ?? null;
}
async function resolveModelOverride(params: {
cfg: ClawdbotConfig;
raw: string;
@@ -222,24 +244,74 @@ export function createSessionStatusTool(opts?: {
const params = args as Record<string, unknown>;
const cfg = opts?.config ?? loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const a2aPolicy = createAgentToAgentPolicy(cfg);
const requestedKeyRaw = readStringParam(params, "sessionKey") ?? opts?.agentSessionKey;
const requestedKeyParam = readStringParam(params, "sessionKey");
let requestedKeyRaw = requestedKeyParam ?? opts?.agentSessionKey;
if (!requestedKeyRaw?.trim()) {
throw new Error("sessionKey required");
}
const agentId = resolveAgentIdFromSessionKey(opts?.agentSessionKey ?? requestedKeyRaw);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const requesterAgentId = resolveAgentIdFromSessionKey(
opts?.agentSessionKey ?? requestedKeyRaw,
);
const ensureAgentAccess = (targetAgentId: string) => {
if (targetAgentId === requesterAgentId) return;
// Gate cross-agent access behind tools.agentToAgent settings.
if (!a2aPolicy.enabled) {
throw new Error(
"Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
);
}
if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) {
throw new Error("Agent-to-agent session status denied by tools.agentToAgent.allow.");
}
};
const resolved = resolveSessionEntry({
if (requestedKeyRaw.startsWith("agent:")) {
ensureAgentAccess(resolveAgentIdFromSessionKey(requestedKeyRaw));
}
const isExplicitAgentKey = requestedKeyRaw.startsWith("agent:");
let agentId = isExplicitAgentKey
? resolveAgentIdFromSessionKey(requestedKeyRaw)
: requesterAgentId;
let storePath = resolveStorePath(cfg.session?.store, { agentId });
let store = loadSessionStore(storePath);
// Resolve against the requester-scoped store first to avoid leaking default agent data.
let resolved = resolveSessionEntry({
store,
keyRaw: requestedKeyRaw,
alias,
mainKey,
});
if (!resolved && shouldResolveSessionIdInput(requestedKeyRaw)) {
const resolvedKey = resolveSessionKeyFromSessionId({
cfg,
sessionId: requestedKeyRaw,
agentId: a2aPolicy.enabled ? undefined : requesterAgentId,
});
if (resolvedKey) {
// If resolution points at another agent, enforce A2A policy before switching stores.
ensureAgentAccess(resolveAgentIdFromSessionKey(resolvedKey));
requestedKeyRaw = resolvedKey;
agentId = resolveAgentIdFromSessionKey(resolvedKey);
storePath = resolveStorePath(cfg.session?.store, { agentId });
store = loadSessionStore(storePath);
resolved = resolveSessionEntry({
store,
keyRaw: requestedKeyRaw,
alias,
mainKey,
});
}
}
if (!resolved) {
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
const kind = shouldResolveSessionIdInput(requestedKeyRaw) ? "sessionId" : "sessionKey";
throw new Error(`Unknown ${kind}: ${requestedKeyRaw}`);
}
const configured = resolveDefaultModelForAgent({ cfg, agentId });
+191 -1
View File
@@ -1,11 +1,12 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { sanitizeUserFacingText } from "../pi-embedded-helpers.js";
import {
stripDowngradedToolCallText,
stripMinimaxToolCallXml,
stripThinkingTagsFromText,
} from "../pi-embedded-utils.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js";
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
@@ -62,6 +63,195 @@ export function resolveInternalSessionKey(params: { key: string; alias: string;
return params.key;
}
export type AgentToAgentPolicy = {
enabled: boolean;
matchesAllow: (agentId: string) => boolean;
isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean;
};
export function createAgentToAgentPolicy(cfg: ClawdbotConfig): AgentToAgentPolicy {
const routingA2A = cfg.tools?.agentToAgent;
const enabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const isAllowed = (requesterAgentId: string, targetAgentId: string) => {
if (requesterAgentId === targetAgentId) return true;
if (!enabled) return false;
return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId);
};
return { enabled, matchesAllow, isAllowed };
}
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function looksLikeSessionId(value: string): boolean {
return SESSION_ID_RE.test(value.trim());
}
export function looksLikeSessionKey(value: string): boolean {
const raw = value.trim();
if (!raw) return false;
// These are canonical key shapes that should never be treated as sessionIds.
if (raw === "main" || raw === "global" || raw === "unknown") return true;
if (isAcpSessionKey(raw)) return true;
if (raw.startsWith("agent:")) return true;
if (raw.startsWith("cron:") || raw.startsWith("hook:")) return true;
if (raw.startsWith("node-") || raw.startsWith("node:")) return true;
if (raw.includes(":group:") || raw.includes(":channel:")) return true;
return false;
}
export function shouldResolveSessionIdInput(value: string): boolean {
// Treat anything that doesn't look like a well-formed key as a sessionId candidate.
return looksLikeSessionId(value) || !looksLikeSessionKey(value);
}
export type SessionReferenceResolution =
| {
ok: true;
key: string;
displayKey: string;
resolvedViaSessionId: boolean;
}
| { ok: false; status: "error" | "forbidden"; error: string };
async function resolveSessionKeyFromSessionId(params: {
sessionId: string;
alias: string;
mainKey: string;
requesterInternalKey?: string;
restrictToSpawned: boolean;
}): Promise<SessionReferenceResolution> {
try {
// Resolve via gateway so we respect store routing and visibility rules.
const result = (await callGateway({
method: "sessions.resolve",
params: {
sessionId: params.sessionId,
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
includeGlobal: !params.restrictToSpawned,
includeUnknown: !params.restrictToSpawned,
},
})) as { key?: unknown };
const key = typeof result?.key === "string" ? result.key.trim() : "";
if (!key) {
throw new Error(
`Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`,
);
}
return {
ok: true,
key,
displayKey: resolveDisplaySessionKey({
key,
alias: params.alias,
mainKey: params.mainKey,
}),
resolvedViaSessionId: true,
};
} catch (err) {
if (params.restrictToSpawned) {
return {
ok: false,
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${params.sessionId}`,
};
}
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
status: "error",
error:
message ||
`Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`,
};
}
}
async function resolveSessionKeyFromKey(params: {
key: string;
alias: string;
mainKey: string;
requesterInternalKey?: string;
restrictToSpawned: boolean;
}): Promise<SessionReferenceResolution | null> {
try {
// Try key-based resolution first so non-standard keys keep working.
const result = (await callGateway({
method: "sessions.resolve",
params: {
key: params.key,
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
},
})) as { key?: unknown };
const key = typeof result?.key === "string" ? result.key.trim() : "";
if (!key) return null;
return {
ok: true,
key,
displayKey: resolveDisplaySessionKey({
key,
alias: params.alias,
mainKey: params.mainKey,
}),
resolvedViaSessionId: false,
};
} catch {
return null;
}
}
export async function resolveSessionReference(params: {
sessionKey: string;
alias: string;
mainKey: string;
requesterInternalKey?: string;
restrictToSpawned: boolean;
}): Promise<SessionReferenceResolution> {
const raw = params.sessionKey.trim();
if (shouldResolveSessionIdInput(raw)) {
// Prefer key resolution to avoid misclassifying custom keys as sessionIds.
const resolvedByKey = await resolveSessionKeyFromKey({
key: raw,
alias: params.alias,
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
restrictToSpawned: params.restrictToSpawned,
});
if (resolvedByKey) return resolvedByKey;
return await resolveSessionKeyFromSessionId({
sessionId: raw,
alias: params.alias,
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
restrictToSpawned: params.restrictToSpawned,
});
}
const resolvedKey = resolveInternalSessionKey({
key: raw,
alias: params.alias,
mainKey: params.mainKey,
});
const displayKey = resolveDisplaySessionKey({
key: resolvedKey,
alias: params.alias,
mainKey: params.mainKey,
});
return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false };
}
export function classifySessionKind(params: {
key: string;
gatewayKind?: string | null;
+28 -42
View File
@@ -2,17 +2,14 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import {
resolveDisplaySessionKey,
resolveInternalSessionKey,
createAgentToAgentPolicy,
resolveSessionReference,
resolveMainSessionAlias,
resolveInternalSessionKey,
stripToolMessages,
} from "./sessions-helpers.js";
@@ -58,7 +55,7 @@ export function createSessionsHistoryTool(opts?: {
parameters: SessionsHistoryToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
const sessionKeyParam = readStringParam(params, "sessionKey", {
required: true,
});
const cfg = loadConfig();
@@ -72,17 +69,26 @@ export function createSessionsHistoryTool(opts?: {
mainKey,
})
: undefined;
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!!requesterInternalKey &&
!isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) {
const resolvedSession = await resolveSessionReference({
sessionKey: sessionKeyParam,
alias,
mainKey,
requesterInternalKey,
restrictToSpawned,
});
if (!resolvedSession.ok) {
return jsonResult({ status: resolvedSession.status, error: resolvedSession.error });
}
// From here on, use the canonical key (sessionId inputs already resolved).
const resolvedKey = resolvedSession.key;
const displayKey = resolvedSession.displayKey;
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
if (restrictToSpawned && !resolvedViaSessionId) {
const ok = await isSpawnedSessionAllowed({
requesterSessionKey: requesterInternalKey,
targetSessionKey: resolvedKey,
@@ -90,40 +96,24 @@ export function createSessionsHistoryTool(opts?: {
if (!ok) {
return jsonResult({
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
error: `Session not visible from this sandboxed agent session: ${sessionKeyParam}`,
});
}
}
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(parseAgentSessionKey(resolvedKey)?.agentId);
const a2aPolicy = createAgentToAgentPolicy(cfg);
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aEnabled) {
if (!a2aPolicy.enabled) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) {
return jsonResult({
status: "forbidden",
error: "Agent-to-agent history denied by tools.agentToAgent.allow.",
@@ -143,11 +133,7 @@ export function createSessionsHistoryTool(opts?: {
const rawMessages = Array.isArray(result?.messages) ? result.messages : [];
const messages = includeTools ? rawMessages : stripToolMessages(rawMessages);
return jsonResult({
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
sessionKey: displayKey,
messages,
});
},
+6 -28
View File
@@ -4,14 +4,11 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringArrayParam } from "./common.js";
import {
createAgentToAgentPolicy,
classifySessionKind,
deriveChannel,
resolveDisplaySessionKey,
@@ -98,24 +95,8 @@ export function createSessionsListTool(opts?: {
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const a2aPolicy = createAgentToAgentPolicy(cfg);
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
const rows: SessionListRow[] = [];
for (const entry of sessions) {
@@ -123,12 +104,9 @@ export function createSessionsListTool(opts?: {
const key = typeof entry.key === "string" ? entry.key : "";
if (!key) continue;
const entryAgentId = normalizeAgentId(parseAgentSessionKey(key)?.agentId);
const entryAgentId = resolveAgentIdFromSessionKey(key);
const crossAgent = entryAgentId !== requesterAgentId;
if (crossAgent) {
if (!a2aEnabled) continue;
if (!matchesAllow(requesterAgentId) || !matchesAllow(entryAgentId)) continue;
}
if (crossAgent && !a2aPolicy.isAllowed(requesterAgentId, entryAgentId)) continue;
if (key === "unknown") continue;
if (key === "global" && alias !== "global") continue;
+29 -41
View File
@@ -7,7 +7,7 @@ import { callGateway } from "../../gateway/call.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
import {
@@ -18,10 +18,11 @@ import { AGENT_LANE_NESTED } from "../lanes.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import {
createAgentToAgentPolicy,
extractAssistantText,
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
resolveSessionReference,
stripToolMessages,
} from "./sessions-helpers.js";
import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./sessions-send-helpers.js";
@@ -63,24 +64,10 @@ export function createSessionsSendTool(opts?: {
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!!requesterInternalKey &&
!isSubagentSessionKey(requesterInternalKey);
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const a2aPolicy = createAgentToAgentPolicy(cfg);
const sessionKeyParam = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label")?.trim() || undefined;
@@ -105,7 +92,7 @@ export function createSessionsSendTool(opts?: {
let sessionKey = sessionKeyParam;
if (!sessionKey && labelParam) {
const requesterAgentId = requesterInternalKey
? normalizeAgentId(parseAgentSessionKey(requesterInternalKey)?.agentId)
? resolveAgentIdFromSessionKey(requesterInternalKey)
: undefined;
const requestedAgentId = labelAgentIdParam
? normalizeAgentId(labelAgentIdParam)
@@ -125,7 +112,7 @@ export function createSessionsSendTool(opts?: {
}
if (requesterAgentId && requestedAgentId && requestedAgentId !== requesterAgentId) {
if (!a2aEnabled) {
if (!a2aPolicy.enabled) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
@@ -133,7 +120,7 @@ export function createSessionsSendTool(opts?: {
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(requestedAgentId)) {
if (!a2aPolicy.isAllowed(requesterAgentId, requestedAgentId)) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
@@ -195,14 +182,26 @@ export function createSessionsSendTool(opts?: {
error: "Either sessionKey or label is required",
});
}
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
const resolvedSession = await resolveSessionReference({
sessionKey,
alias,
mainKey,
requesterInternalKey,
restrictToSpawned,
});
if (!resolvedSession.ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: resolvedSession.status,
error: resolvedSession.error,
});
}
// Normalize sessionKey/sessionId input into a canonical session key.
const resolvedKey = resolvedSession.key;
const displayKey = resolvedSession.displayKey;
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
if (restrictToSpawned) {
if (restrictToSpawned && !resolvedViaSessionId) {
const sessions = await listSessions({
includeGlobal: false,
includeUnknown: false,
@@ -215,11 +214,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
sessionKey: displayKey,
});
}
}
@@ -231,18 +226,11 @@ export function createSessionsSendTool(opts?: {
const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs;
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
const displayKey = resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
});
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(parseAgentSessionKey(resolvedKey)?.agentId);
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aEnabled) {
if (!a2aPolicy.enabled) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
@@ -251,7 +239,7 @@ export function createSessionsSendTool(opts?: {
sessionKey: displayKey,
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
+12 -66
View File
@@ -273,80 +273,26 @@ function buildChatCommands(): ChatCommandDefinition[] {
argsMenu: "auto",
}),
defineChatCommand({
key: "audio",
nativeName: "audio",
description: "Convert text to a TTS audio reply.",
textAlias: "/audio",
key: "tts",
nativeName: "tts",
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
args: [
{
name: "text",
description: "Text to speak",
name: "action",
description: "on | off | status | provider | limit | summary | audio | help",
type: "string",
choices: ["on", "off", "status", "provider", "limit", "summary", "audio", "help"],
},
{
name: "value",
description: "Provider, limit, or text",
type: "string",
captureRemaining: true,
},
],
}),
defineChatCommand({
key: "tts_on",
nativeName: "tts_on",
description: "Enable text-to-speech for replies.",
textAlias: "/tts_on",
}),
defineChatCommand({
key: "tts_off",
nativeName: "tts_off",
description: "Disable text-to-speech for replies.",
textAlias: "/tts_off",
}),
defineChatCommand({
key: "tts_provider",
nativeName: "tts_provider",
description: "Set or show the TTS provider.",
textAlias: "/tts_provider",
args: [
{
name: "provider",
description: "openai or elevenlabs",
type: "string",
choices: ["openai", "elevenlabs"],
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "tts_limit",
nativeName: "tts_limit",
description: "Set or show the max TTS text length.",
textAlias: "/tts_limit",
args: [
{
name: "maxLength",
description: "Max chars before summarizing",
type: "number",
},
],
}),
defineChatCommand({
key: "tts_summary",
nativeName: "tts_summary",
description: "Enable or disable TTS auto-summary.",
textAlias: "/tts_summary",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["on", "off"],
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "tts_status",
nativeName: "tts_status",
description: "Show TTS status and last attempt.",
textAlias: "/tts_status",
}),
defineChatCommand({
key: "stop",
nativeName: "stop",
+11
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildCommandText,
buildCommandTextFromArgs,
findCommandByNativeName,
getCommandDetection,
listChatCommands,
listChatCommandsForConfig,
@@ -85,6 +86,16 @@ describe("commands registry", () => {
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("applies provider-specific native names", () => {
const native = listNativeCommandSpecsForConfig(
{ commands: { native: true } },
{ provider: "discord" },
);
expect(native.find((spec) => spec.name === "voice")).toBeTruthy();
expect(findCommandByNativeName("voice", "discord")?.key).toBe("tts");
expect(findCommandByNativeName("tts", "discord")).toBeUndefined();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);
+26 -5
View File
@@ -105,13 +105,29 @@ export function listChatCommandsForConfig(
return [...base, ...buildSkillCommandDefinitions(params.skillCommands)];
}
const NATIVE_NAME_OVERRIDES: Record<string, Record<string, string>> = {
discord: {
tts: "voice",
},
};
function resolveNativeName(command: ChatCommandDefinition, provider?: string): string | undefined {
if (!command.nativeName) return undefined;
if (provider) {
const override = NATIVE_NAME_OVERRIDES[provider]?.[command.key];
if (override) return override;
}
return command.nativeName;
}
export function listNativeCommandSpecs(params?: {
skillCommands?: SkillCommandSpec[];
provider?: string;
}): NativeCommandSpec[] {
return listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
name: resolveNativeName(command, params?.provider) ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
@@ -120,22 +136,27 @@ export function listNativeCommandSpecs(params?: {
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
params?: { skillCommands?: SkillCommandSpec[]; provider?: string },
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg, params)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
name: resolveNativeName(command, params?.provider) ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}));
}
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {
export function findCommandByNativeName(
name: string,
provider?: string,
): ChatCommandDefinition | undefined {
const normalized = name.trim().toLowerCase();
return getChatCommands().find(
(command) => command.scope !== "text" && command.nativeName?.toLowerCase() === normalized,
(command) =>
command.scope !== "text" &&
resolveNativeName(command, provider)?.toLowerCase() === normalized,
);
}
@@ -1,6 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
@@ -38,8 +42,12 @@ vi.mock("./queue.js", async () => {
import { runReplyAgent } from "./agent-runner.js";
function createRun(messageProvider = "slack") {
function createRun(
messageProvider = "slack",
opts: { storePath?: string; sessionKey?: string } = {},
) {
const typing = createMockTypingController();
const sessionKey = opts.sessionKey ?? "main";
const sessionCtx = {
Provider: messageProvider,
OriginatingTo: "channel:C1",
@@ -53,7 +61,7 @@ function createRun(messageProvider = "slack") {
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
sessionKey,
messageProvider,
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
@@ -85,6 +93,8 @@ function createRun(messageProvider = "slack") {
isStreaming: false,
typing,
sessionCtx,
sessionKey,
storePath: opts.storePath,
defaultModel: "anthropic/claude-opus-4-5",
resolvedVerboseLevel: "off",
isNewSession: false,
@@ -141,4 +151,34 @@ describe("runReplyAgent messaging tool suppression", () => {
expect(result).toMatchObject({ text: "hello world!" });
});
it("persists usage even when replies are suppressed", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-store-")),
"sessions.json",
);
const sessionKey = "main";
const entry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
await saveSessionStore(storePath, { [sessionKey]: entry });
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
meta: {
agentMeta: {
usage: { input: 10, output: 5 },
model: "claude-opus-4-5",
provider: "anthropic",
},
},
});
const result = await createRun("slack", { storePath, sessionKey });
expect(result).toBeUndefined();
const store = loadSessionStore(storePath, { skipCache: true });
expect(store[sessionKey]?.totalTokens ?? 0).toBeGreaterThan(0);
expect(store[sessionKey]?.model).toBe("claude-opus-4-5");
});
});
+25 -81
View File
@@ -1,6 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
@@ -16,7 +15,6 @@ import {
updateSessionStoreEntry,
} from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
@@ -38,6 +36,7 @@ import { resolveBlockStreamingCoalescing } from "./block-streaming.js";
import { createFollowupRunner } from "./followup-runner.js";
import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
import { persistSessionUsageUpdate } from "./session-usage.js";
import { incrementCompactionCount } from "./session-updates.js";
import type { TypingController } from "./typing.js";
import { createTypingSignaler } from "./typing-mode.js";
@@ -365,6 +364,30 @@ export async function runReplyAgent(params: {
await Promise.allSettled(pendingToolTasks);
}
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
const cliSessionId = isCliProvider(providerUsed, cfg)
? runResult.meta.agentMeta?.sessionId?.trim()
: undefined;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
activeSessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage,
modelUsed,
providerUsed,
contextTokensUsed,
systemPromptReport: runResult.meta.systemPromptReport,
cliSessionId,
});
// Drain any late tool/block deliveries before deciding there's "nothing to send".
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
// keep the typing indicator stuck.
@@ -395,19 +418,6 @@ export async function runReplyAgent(params: {
await signalTypingIfNeeded(replyPayloads, typingSignals);
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
const cliSessionId = isCliProvider(providerUsed, cfg)
? runResult.meta.agentMeta?.sessionId?.trim()
: undefined;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
activeSessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (isDiagnosticsEnabled(cfg) && hasNonzeroUsage(usage)) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
@@ -445,72 +455,6 @@ export async function runReplyAgent(params: {
});
}
if (storePath && sessionKey) {
if (hasNonzeroUsage(usage)) {
try {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async (entry) => {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
const patch: Partial<SessionEntry> = {
inputTokens: input,
outputTokens: output,
totalTokens: promptTokens > 0 ? promptTokens : (usage.total ?? input),
modelProvider: providerUsed,
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
systemPromptReport: runResult.meta.systemPromptReport ?? entry.systemPromptReport,
updatedAt: Date.now(),
};
if (cliSessionId) {
const nextEntry = { ...entry, ...patch };
setCliSessionId(nextEntry, providerUsed, cliSessionId);
return {
...patch,
cliSessionIds: nextEntry.cliSessionIds,
claudeCliSessionId: nextEntry.claudeCliSessionId,
};
}
return patch;
},
});
} catch (err) {
logVerbose(`failed to persist usage update: ${String(err)}`);
}
} else if (modelUsed || contextTokensUsed) {
try {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async (entry) => {
const patch: Partial<SessionEntry> = {
modelProvider: providerUsed ?? entry.modelProvider,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
systemPromptReport: runResult.meta.systemPromptReport ?? entry.systemPromptReport,
updatedAt: Date.now(),
};
if (cliSessionId) {
const nextEntry = { ...entry, ...patch };
setCliSessionId(nextEntry, providerUsed, cliSessionId);
return {
...patch,
cliSessionIds: nextEntry.cliSessionIds,
claudeCliSessionId: nextEntry.claudeCliSessionId,
};
}
return patch;
},
});
} catch (err) {
logVerbose(`failed to persist model/context update: ${String(err)}`);
}
}
}
const responseUsageRaw =
activeSessionEntry?.responseUsage ??
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined);
@@ -12,6 +12,7 @@ import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
import type { ReplyPayload } from "../types.js";
import type { HandleCommandsParams } from "./commands-types.js";
@@ -128,6 +129,7 @@ async function resolveContextReport(
},
}
: { enabled: false };
const ttsHint = params.cfg ? buildTtsSystemPromptHint(params.cfg) : undefined;
const systemPrompt = buildAgentSystemPrompt({
workspaceDir,
@@ -145,6 +147,7 @@ async function resolveContextReport(
contextFiles: injectedFiles,
skillsPrompt,
heartbeatPrompt: undefined,
ttsHint,
runtimeInfo,
sandboxInfo,
});
+60 -45
View File
@@ -18,22 +18,39 @@ import {
textToSpeech,
} from "../../tts/tts.js";
function parseCommandArg(normalized: string, command: string): string | null {
if (normalized === command) return "";
if (normalized.startsWith(`${command} `)) return normalized.slice(command.length).trim();
return null;
type ParsedTtsCommand = {
action: string;
args: string;
};
function parseTtsCommand(normalized: string): ParsedTtsCommand | null {
// Accept `/tts` and `/tts <action> [args]` as a single control surface.
if (normalized === "/tts") return { action: "status", args: "" };
if (!normalized.startsWith("/tts ")) return null;
const rest = normalized.slice(5).trim();
if (!rest) return { action: "status", args: "" };
const [action, ...tail] = rest.split(/\s+/);
return { action: action.toLowerCase(), args: tail.join(" ").trim() };
}
function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
text:
"⚙️ Usage: /tts <on|off|status|provider|limit|summary|audio> [value]" +
"\nExamples:\n" +
"/tts on\n" +
"/tts provider openai\n" +
"/tts limit 2000\n" +
"/tts summary off\n" +
"/tts audio Hello from Clawdbot",
};
}
export const handleTtsCommands: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const normalized = params.command.commandBodyNormalized;
if (
!normalized.startsWith("/tts_") &&
normalized !== "/audio" &&
!normalized.startsWith("/audio ")
) {
return null;
}
const parsed = parseTtsCommand(params.command.commandBodyNormalized);
if (!parsed) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
@@ -44,36 +61,42 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
const config = resolveTtsConfig(params.cfg);
const prefsPath = resolveTtsPrefsPath(config);
const action = parsed.action;
const args = parsed.args;
if (normalized === "/tts_on") {
if (action === "help") {
return { shouldContinue: false, reply: ttsUsage() };
}
if (action === "on") {
setTtsEnabled(prefsPath, true);
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
}
if (normalized === "/tts_off") {
if (action === "off") {
setTtsEnabled(prefsPath, false);
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
}
const audioArg = parseCommandArg(normalized, "/audio");
if (audioArg !== null) {
if (!audioArg.trim()) {
return { shouldContinue: false, reply: { text: "⚙️ Usage: /audio <text>" } };
if (action === "audio") {
if (!args.trim()) {
return { shouldContinue: false, reply: ttsUsage() };
}
const start = Date.now();
const result = await textToSpeech({
text: audioArg,
text: args,
cfg: params.cfg,
channel: params.command.channel,
prefsPath,
});
if (result.success && result.audioPath) {
// Store last attempt for `/tts status`.
setLastTtsAttempt({
timestamp: Date.now(),
success: true,
textLength: audioArg.length,
textLength: args.length,
summarized: false,
provider: result.provider,
latencyMs: result.latencyMs,
@@ -85,10 +108,11 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: payload };
}
// Store failure details for `/tts status`.
setLastTtsAttempt({
timestamp: Date.now(),
success: false,
textLength: audioArg.length,
textLength: args.length,
summarized: false,
error: result.error,
latencyMs: Date.now() - start,
@@ -99,10 +123,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
};
}
const providerArg = parseCommandArg(normalized, "/tts_provider");
if (providerArg !== null) {
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!providerArg.trim()) {
if (!args.trim()) {
const fallback = currentProvider === "openai" ? "elevenlabs" : "openai";
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
@@ -115,17 +138,14 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
`Fallback: ${fallback}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Usage: /tts_provider openai | elevenlabs`,
`Usage: /tts provider openai | elevenlabs`,
},
};
}
const requested = providerArg.trim().toLowerCase();
const requested = args.trim().toLowerCase();
if (requested !== "openai" && requested !== "elevenlabs") {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /tts_provider openai | elevenlabs" },
};
return { shouldContinue: false, reply: ttsUsage() };
}
setTtsProvider(prefsPath, requested);
@@ -136,21 +156,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
};
}
const limitArg = parseCommandArg(normalized, "/tts_limit");
if (limitArg !== null) {
if (!limitArg.trim()) {
if (action === "limit") {
if (!args.trim()) {
const currentLimit = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
};
}
const next = Number.parseInt(limitArg.trim(), 10);
const next = Number.parseInt(args.trim(), 10);
if (!Number.isFinite(next) || next < 100 || next > 10_000) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /tts_limit <100-10000>" },
};
return { shouldContinue: false, reply: ttsUsage() };
}
setTtsMaxLength(prefsPath, next);
return {
@@ -159,18 +175,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
};
}
const summaryArg = parseCommandArg(normalized, "/tts_summary");
if (summaryArg !== null) {
if (!summaryArg.trim()) {
if (action === "summary") {
if (!args.trim()) {
const enabled = isSummarizationEnabled(prefsPath);
return {
shouldContinue: false,
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
};
}
const requested = summaryArg.trim().toLowerCase();
const requested = args.trim().toLowerCase();
if (requested !== "on" && requested !== "off") {
return { shouldContinue: false, reply: { text: "⚙️ Usage: /tts_summary on|off" } };
return { shouldContinue: false, reply: ttsUsage() };
}
setSummarizationEnabled(prefsPath, requested === "on");
return {
@@ -181,7 +196,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
};
}
if (normalized === "/tts_status") {
if (action === "status") {
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = Boolean(resolveTtsApiKey(config, provider));
@@ -210,5 +225,5 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
return null;
return { shouldContinue: false, reply: ttsUsage() };
};
@@ -18,6 +18,12 @@ const diagnosticMocks = vi.hoisted(() => ({
logMessageProcessed: vi.fn(),
logSessionStateChange: vi.fn(),
}));
const hookMocks = vi.hoisted(() => ({
runner: {
hasHooks: vi.fn(() => false),
runMessageReceived: vi.fn(async () => {}),
},
}));
vi.mock("./route-reply.js", () => ({
isRoutableChannel: (channel: string | undefined) =>
@@ -45,6 +51,10 @@ vi.mock("../../logging/diagnostic.js", () => ({
logSessionStateChange: diagnosticMocks.logSessionStateChange,
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => hookMocks.runner,
}));
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
@@ -64,6 +74,9 @@ describe("dispatchReplyFromConfig", () => {
diagnosticMocks.logMessageQueued.mockReset();
diagnosticMocks.logMessageProcessed.mockReset();
diagnosticMocks.logSessionStateChange.mockReset();
hookMocks.runner.hasHooks.mockReset();
hookMocks.runner.hasHooks.mockReturnValue(false);
hookMocks.runner.runMessageReceived.mockReset();
});
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
@@ -201,6 +214,57 @@ describe("dispatchReplyFromConfig", () => {
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("emits message_received hook with originating channel metadata", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
aborted: false,
});
hookMocks.runner.hasHooks.mockReturnValue(true);
const cfg = {} as ClawdbotConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "slack",
Surface: "slack",
OriginatingChannel: "Telegram",
OriginatingTo: "telegram:999",
CommandBody: "/search hello",
RawBody: "raw text",
Body: "body text",
Timestamp: 1710000000000,
MessageSidFull: "sid-full",
SenderId: "user-1",
SenderName: "Alice",
SenderUsername: "alice",
SenderE164: "+15555550123",
AccountId: "acc-1",
});
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(hookMocks.runner.runMessageReceived).toHaveBeenCalledWith(
expect.objectContaining({
from: ctx.From,
content: "/search hello",
timestamp: 1710000000000,
metadata: expect.objectContaining({
originatingChannel: "Telegram",
originatingTo: "telegram:999",
messageId: "sid-full",
senderId: "user-1",
senderName: "Alice",
senderUsername: "alice",
senderE164: "+15555550123",
}),
}),
expect.objectContaining({
channelId: "telegram",
accountId: "acc-1",
conversationId: "telegram:999",
}),
);
});
it("emits diagnostics when enabled", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false,
@@ -6,6 +6,7 @@ import {
logMessageQueued,
logSessionStateChange,
} from "../../logging/diagnostic.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { getReplyFromConfig } from "../reply.js";
import type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -80,6 +81,56 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_received")) {
const timestamp =
typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp)
? ctx.Timestamp
: undefined;
const messageIdForHook =
ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
const content =
typeof ctx.BodyForCommands === "string"
? ctx.BodyForCommands
: typeof ctx.RawBody === "string"
? ctx.RawBody
: typeof ctx.Body === "string"
? ctx.Body
: "";
const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase();
const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined;
void hookRunner
.runMessageReceived(
{
from: ctx.From ?? "",
content,
timestamp,
metadata: {
to: ctx.To,
provider: ctx.Provider,
surface: ctx.Surface,
threadId: ctx.MessageThreadId,
originatingChannel: ctx.OriginatingChannel,
originatingTo: ctx.OriginatingTo,
messageId: messageIdForHook,
senderId: ctx.SenderId,
senderName: ctx.SenderName,
senderUsername: ctx.SenderUsername,
senderE164: ctx.SenderE164,
},
},
{
channelId,
accountId: ctx.AccountId,
conversationId,
},
)
.catch((err) => {
logVerbose(`dispatch-from-config: message_received hook failed: ${String(err)}`);
});
}
// Check if we should route replies to originating channel instead of dispatcher.
// Only route when the originating channel is DIFFERENT from the current surface.
// This handles cross-provider routing (e.g., message from Telegram being processed
+44 -1
View File
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js";
import type { FollowupRun } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
@@ -195,4 +195,47 @@ describe("createFollowupRunner messaging tool dedupe", () => {
expect(onBlockReply).not.toHaveBeenCalled();
});
it("persists usage even when replies are suppressed", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-followup-usage-")),
"sessions.json",
);
const sessionKey = "main";
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await saveSessionStore(storePath, sessionStore);
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
meta: {
agentMeta: {
usage: { input: 10, output: 5 },
model: "claude-opus-4-5",
provider: "anthropic",
},
},
});
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createMockTypingController(),
typingMode: "instant",
sessionEntry,
sessionStore,
sessionKey,
storePath,
defaultModel: "anthropic/claude-opus-4-5",
});
await runner(baseQueuedRun("slack"));
expect(onBlockReply).not.toHaveBeenCalled();
const store = loadSessionStore(storePath, { skipCache: true });
expect(store[sessionKey]?.totalTokens ?? 0).toBeGreaterThan(0);
expect(store[sessionKey]?.model).toBe("claude-opus-4-5");
});
});
+22 -56
View File
@@ -4,12 +4,7 @@ import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import {
resolveAgentIdFromSessionKey,
type SessionEntry,
updateSessionStoreEntry,
} from "../../config/sessions.js";
import { resolveAgentIdFromSessionKey, type SessionEntry } from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
@@ -26,6 +21,7 @@ import {
} from "./reply-payloads.js";
import { resolveReplyToMode } from "./reply-threading.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { persistSessionUsageUpdate } from "./session-usage.js";
import { incrementCompactionCount } from "./session-updates.js";
import type { TypingController } from "./typing.js";
import { createTypingSignaler } from "./typing-mode.js";
@@ -190,6 +186,26 @@ export function createFollowupRunner(params: {
return;
}
if (storePath && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage,
modelUsed,
providerUsed: fallbackProvider,
contextTokensUsed,
logLabel: "followup",
});
}
const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return;
const sanitizedPayloads = payloadArray.flatMap((payload) => {
@@ -245,56 +261,6 @@ export function createFollowupRunner(params: {
}
}
if (storePath && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (hasNonzeroUsage(usage)) {
try {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async (entry) => {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens = input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
return {
inputTokens: input,
outputTokens: output,
totalTokens: promptTokens > 0 ? promptTokens : (usage.total ?? input),
modelProvider: fallbackProvider ?? entry.modelProvider,
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
};
},
});
} catch (err) {
logVerbose(`failed to persist followup usage update: ${String(err)}`);
}
} else if (modelUsed || contextTokensUsed) {
try {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async (entry) => ({
modelProvider: fallbackProvider ?? entry.modelProvider,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
}),
});
} catch (err) {
logVerbose(`failed to persist followup model/context update: ${String(err)}`);
}
}
}
await sendFollowupPayloads(finalPayloads, queued);
} finally {
typing.markRunComplete();
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { matchesMentionWithExplicit } from "./mentions.js";
describe("matchesMentionWithExplicit", () => {
const mentionRegexes = [/\bclawd\b/i];
it("prefers explicit mentions when other mentions are present", () => {
const result = matchesMentionWithExplicit({
text: "@clawd hello",
mentionRegexes,
explicit: {
hasAnyMention: true,
isExplicitlyMentioned: false,
canResolveExplicit: true,
},
});
expect(result).toBe(false);
});
it("returns true when explicitly mentioned even if regexes do not match", () => {
const result = matchesMentionWithExplicit({
text: "<@123456>",
mentionRegexes: [],
explicit: {
hasAnyMention: true,
isExplicitlyMentioned: true,
canResolveExplicit: true,
},
});
expect(result).toBe(true);
});
it("falls back to regex matching when explicit mention cannot be resolved", () => {
const result = matchesMentionWithExplicit({
text: "clawd please",
mentionRegexes,
explicit: {
hasAnyMention: true,
isExplicitlyMentioned: false,
canResolveExplicit: false,
},
});
expect(result).toBe(true);
});
});
+20
View File
@@ -75,6 +75,26 @@ export function matchesMentionPatterns(text: string, mentionRegexes: RegExp[]):
return mentionRegexes.some((re) => re.test(cleaned));
}
export type ExplicitMentionSignal = {
hasAnyMention: boolean;
isExplicitlyMentioned: boolean;
canResolveExplicit: boolean;
};
export function matchesMentionWithExplicit(params: {
text: string;
mentionRegexes: RegExp[];
explicit?: ExplicitMentionSignal;
}): boolean {
const cleaned = normalizeMentionText(params.text ?? "");
const explicit = params.explicit?.isExplicitlyMentioned === true;
const explicitAvailable = params.explicit?.canResolveExplicit === true;
const hasAnyMention = params.explicit?.hasAnyMention === true;
if (hasAnyMention && explicitAvailable) return explicit;
if (!cleaned) return explicit;
return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
}
export function stripStructuralPrefixes(text: string): string {
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
// detection still works in group batches that include history/context.
+96
View File
@@ -0,0 +1,96 @@
import { setCliSessionId } from "../../agents/cli-session.js";
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
import {
type SessionSystemPromptReport,
type SessionEntry,
updateSessionStoreEntry,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
export async function persistSessionUsageUpdate(params: {
storePath?: string;
sessionKey?: string;
usage?: NormalizedUsage;
modelUsed?: string;
providerUsed?: string;
contextTokensUsed?: number;
systemPromptReport?: SessionSystemPromptReport;
cliSessionId?: string;
logLabel?: string;
}): Promise<void> {
const { storePath, sessionKey } = params;
if (!storePath || !sessionKey) return;
const label = params.logLabel ? `${params.logLabel} ` : "";
if (hasNonzeroUsage(params.usage)) {
try {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async (entry) => {
const input = params.usage?.input ?? 0;
const output = params.usage?.output ?? 0;
const promptTokens =
input + (params.usage?.cacheRead ?? 0) + (params.usage?.cacheWrite ?? 0);
const patch: Partial<SessionEntry> = {
inputTokens: input,
outputTokens: output,
totalTokens: promptTokens > 0 ? promptTokens : (params.usage?.total ?? input),
modelProvider: params.providerUsed ?? entry.modelProvider,
model: params.modelUsed ?? entry.model,
contextTokens: params.contextTokensUsed ?? entry.contextTokens,
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
updatedAt: Date.now(),
};
if (params.cliSessionId) {
const nextEntry = { ...entry, ...patch };
const provider = params.providerUsed ?? entry.modelProvider;
if (!provider) return patch;
setCliSessionId(nextEntry, provider, params.cliSessionId);
return {
...patch,
cliSessionIds: nextEntry.cliSessionIds,
claudeCliSessionId: nextEntry.claudeCliSessionId,
};
}
return patch;
},
});
} catch (err) {
logVerbose(`failed to persist ${label}usage update: ${String(err)}`);
}
return;
}
if (params.modelUsed || params.contextTokensUsed) {
try {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async (entry) => {
const patch: Partial<SessionEntry> = {
modelProvider: params.providerUsed ?? entry.modelProvider,
model: params.modelUsed ?? entry.model,
contextTokens: params.contextTokensUsed ?? entry.contextTokens,
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
updatedAt: Date.now(),
};
if (params.cliSessionId) {
const nextEntry = { ...entry, ...patch };
const provider = params.providerUsed ?? entry.modelProvider;
if (!provider) return patch;
setCliSessionId(nextEntry, provider, params.cliSessionId);
return {
...patch,
cliSessionIds: nextEntry.cliSessionIds,
claudeCliSessionId: nextEntry.claudeCliSessionId,
};
}
return patch;
},
});
} catch (err) {
logVerbose(`failed to persist ${label}model/context update: ${String(err)}`);
}
}
}
+21
View File
@@ -13,6 +13,14 @@ import {
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
resolveTtsConfig,
resolveTtsPrefsPath,
} from "../tts/tts.js";
import { resolveCommitHash } from "../infra/git-commit.js";
import {
estimateUsageCost,
@@ -244,6 +252,17 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
return `📎 Media: ${parts.join(" · ")}`;
};
const formatVoiceModeLine = (config?: ClawdbotConfig): string | null => {
if (!config) return null;
const ttsConfig = resolveTtsConfig(config);
const prefsPath = resolveTtsPrefsPath(ttsConfig);
if (!isTtsEnabled(ttsConfig, prefsPath)) return null;
const provider = getTtsProvider(ttsConfig, prefsPath);
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
return `🔊 Voice: on · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
};
export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry;
@@ -379,6 +398,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const usageCostLine =
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
const voiceLine = formatVoiceModeLine(args.config);
return [
versionLine,
@@ -391,6 +411,7 @@ export function buildStatusMessage(args: StatusArgs): string {
`🧵 ${sessionLine}`,
args.subagentsLine,
`⚙️ ${optionsLine}`,
voiceLine,
activationLine,
]
.filter(Boolean)
+1
View File
@@ -87,6 +87,7 @@ export type MsgContext = {
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
Timestamp?: number;
/** Provider label (e.g. whatsapp, telegram). */
Provider?: string;
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */
+11
View File
@@ -7,8 +7,19 @@ import type { TelegramConfig } from "./types.telegram.js";
import type { WhatsAppConfig } from "./types.whatsapp.js";
import type { GroupPolicy } from "./types.base.js";
export type ChannelHeartbeatVisibilityConfig = {
/** Show HEARTBEAT_OK acknowledgments in chat (default: false). */
showOk?: boolean;
/** Show heartbeat alerts with actual content (default: true). */
showAlerts?: boolean;
/** Emit indicator events for UI status display (default: true). */
useIndicator?: boolean;
};
export type ChannelDefaultsConfig = {
groupPolicy?: GroupPolicy;
/** Default heartbeat visibility for all channels. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type ChannelsConfig = {
+3
View File
@@ -6,6 +6,7 @@ import type {
OutboundRetryConfig,
ReplyToMode,
} from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
@@ -121,6 +122,8 @@ export type DiscordAccountConfig = {
dm?: DiscordDmConfig;
/** New per-guild config keyed by guild id or slug. */
guilds?: Record<string, DiscordGuildEntry>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type DiscordConfig = {
+3
View File
@@ -4,6 +4,7 @@ import type {
GroupPolicy,
MarkdownConfig,
} from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
@@ -63,6 +64,8 @@ export type IMessageAccountConfig = {
tools?: GroupToolPolicyConfig;
}
>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type IMessageConfig = {
+3
View File
@@ -4,6 +4,7 @@ import type {
GroupPolicy,
MarkdownConfig,
} from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
@@ -94,4 +95,6 @@ export type MSTeamsConfig = {
mediaMaxMb?: number;
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2"). */
sharePointSiteId?: string;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
+3
View File
@@ -4,6 +4,7 @@ import type {
GroupPolicy,
MarkdownConfig,
} from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
@@ -63,6 +64,8 @@ export type SignalAccountConfig = {
reactionNotifications?: SignalReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type SignalConfig = {
+3
View File
@@ -5,6 +5,7 @@ import type {
MarkdownConfig,
ReplyToMode,
} from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
@@ -136,6 +137,8 @@ export type SlackAccountConfig = {
slashCommand?: SlackSlashCommandConfig;
dm?: SlackDmConfig;
channels?: Record<string, SlackChannelConfig>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type SlackConfig = {
+3
View File
@@ -7,6 +7,7 @@ import type {
OutboundRetryConfig,
ReplyToMode,
} from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
@@ -113,6 +114,8 @@ export type TelegramAccountConfig = {
* - "extensive": agent can react liberally when appropriate
*/
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type TelegramTopicConfig = {
+34
View File
@@ -2,6 +2,25 @@ export type TtsProvider = "elevenlabs" | "openai";
export type TtsMode = "final" | "all";
export type TtsModelOverrideConfig = {
/** Enable model-provided overrides for TTS. */
enabled?: boolean;
/** Allow model-provided TTS text blocks. */
allowText?: boolean;
/** Allow model-provided provider override. */
allowProvider?: boolean;
/** Allow model-provided voice/voiceId override. */
allowVoice?: boolean;
/** Allow model-provided modelId override. */
allowModelId?: boolean;
/** Allow model-provided voice settings override. */
allowVoiceSettings?: boolean;
/** Allow model-provided normalization or language overrides. */
allowNormalization?: boolean;
/** Allow model-provided seed override. */
allowSeed?: boolean;
};
export type TtsConfig = {
/** Enable auto-TTS (can be overridden by local prefs). */
enabled?: boolean;
@@ -9,11 +28,26 @@ export type TtsConfig = {
mode?: TtsMode;
/** Primary TTS provider (fallbacks are automatic). */
provider?: TtsProvider;
/** Optional model override for TTS auto-summary (provider/model or alias). */
summaryModel?: string;
/** Allow the model to override TTS parameters. */
modelOverrides?: TtsModelOverrideConfig;
/** ElevenLabs configuration. */
elevenlabs?: {
apiKey?: string;
baseUrl?: string;
voiceId?: string;
modelId?: string;
seed?: number;
applyTextNormalization?: "auto" | "on" | "off";
languageCode?: string;
voiceSettings?: {
stability?: number;
similarityBoost?: number;
style?: number;
useSpeakerBoost?: boolean;
speed?: number;
};
};
/** OpenAI configuration. */
openai?: {
+5
View File
@@ -4,6 +4,7 @@ import type {
GroupPolicy,
MarkdownConfig,
} from "./types.base.js";
import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js";
import type { GroupToolPolicyConfig } from "./types.tools.js";
@@ -86,6 +87,8 @@ export type WhatsAppConfig = {
};
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
export type WhatsAppAccountConfig = {
@@ -147,4 +150,6 @@ export type WhatsAppAccountConfig = {
};
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
/** Heartbeat visibility settings for this account. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
export const ChannelHeartbeatVisibilitySchema = z
.object({
showOk: z.boolean().optional(),
showAlerts: z.boolean().optional(),
useIndicator: z.boolean().optional(),
})
.strict()
.optional();
+28
View File
@@ -162,11 +162,39 @@ export const TtsConfigSchema = z
enabled: z.boolean().optional(),
mode: TtsModeSchema.optional(),
provider: TtsProviderSchema.optional(),
summaryModel: z.string().optional(),
modelOverrides: z
.object({
enabled: z.boolean().optional(),
allowText: z.boolean().optional(),
allowProvider: z.boolean().optional(),
allowVoice: z.boolean().optional(),
allowModelId: z.boolean().optional(),
allowVoiceSettings: z.boolean().optional(),
allowNormalization: z.boolean().optional(),
allowSeed: z.boolean().optional(),
})
.strict()
.optional(),
elevenlabs: z
.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
seed: z.number().int().min(0).max(4294967295).optional(),
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
languageCode: z.string().optional(),
voiceSettings: z
.object({
stability: z.number().min(0).max(1).optional(),
similarityBoost: z.number().min(0).max(1).optional(),
style: z.number().min(0).max(1).optional(),
useSpeakerBoost: z.boolean().optional(),
speed: z.number().min(0.5).max(2).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
+8
View File
@@ -15,6 +15,7 @@ import {
requireOpenAllowFrom,
} from "./zod-schema.core.js";
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
import {
normalizeTelegramCommandDescription,
normalizeTelegramCommandName,
@@ -122,6 +123,7 @@ export const TelegramAccountSchemaBase = z
.optional(),
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();
@@ -241,6 +243,7 @@ export const DiscordAccountSchema = z
replyToMode: ReplyToModeSchema.optional(),
dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();
@@ -351,6 +354,7 @@ export const SlackAccountSchema = z
.optional(),
dm: SlackDmSchema.optional(),
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();
@@ -416,6 +420,7 @@ export const SignalAccountSchemaBase = z
mediaMaxMb: z.number().int().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();
@@ -477,6 +482,7 @@ export const IMessageAccountSchemaBase = z
.optional(),
)
.optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();
@@ -553,6 +559,7 @@ export const BlueBubblesAccountSchemaBase = z
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();
@@ -630,6 +637,7 @@ export const MSTeamsConfigSchema = z
mediaMaxMb: z.number().positive().optional(),
/** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */
sharePointSiteId: z.string().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict()
.superRefine((value, ctx) => {
@@ -8,6 +8,7 @@ import {
MarkdownConfigSchema,
} from "./zod-schema.core.js";
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
export const WhatsAppAccountSchema = z
.object({
@@ -53,6 +54,7 @@ export const WhatsAppAccountSchema = z
.strict()
.optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict()
.superRefine((value, ctx) => {
@@ -115,6 +117,7 @@ export const WhatsAppConfigSchema = z
.strict()
.optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict()
.superRefine((value, ctx) => {
+3
View File
@@ -11,15 +11,18 @@ import {
} from "./zod-schema.providers-core.js";
import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js";
import { GroupPolicySchema } from "./zod-schema.core.js";
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
export * from "./zod-schema.providers-core.js";
export * from "./zod-schema.providers-whatsapp.js";
export { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
export const ChannelsSchema = z
.object({
defaults: z
.object({
groupPolicy: GroupPolicySchema.optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict()
.optional(),
+34 -2
View File
@@ -20,7 +20,9 @@ describe("fetchDiscord", () => {
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch);
await fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
@@ -36,7 +38,37 @@ describe("fetchDiscord", () => {
it("preserves non-JSON error text", async () => {
const fetcher = async () => new Response("Not Found", { status: 404 });
await expect(
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch),
fetchDiscord("/users/@me/guilds", "test", fetcher as typeof fetch, {
retry: { attempts: 1 },
}),
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
});
it("retries rate limits before succeeding", async () => {
let calls = 0;
const fetcher = async () => {
calls += 1;
if (calls === 1) {
return jsonResponse(
{
message: "You are being rate limited.",
retry_after: 0,
global: false,
},
429,
);
}
return jsonResponse([{ id: "1", name: "Guild" }], 200);
};
const result = await fetchDiscord<Array<{ id: string; name: string }>>(
"/users/@me/guilds",
"test",
fetcher as typeof fetch,
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } },
);
expect(result).toHaveLength(1);
expect(calls).toBe(2);
});
});
+67 -10
View File
@@ -1,6 +1,13 @@
import { resolveFetch } from "../infra/fetch.js";
import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const DISCORD_API_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30_000,
jitter: 0.1,
};
type DiscordApiErrorPayload = {
message?: string;
@@ -21,6 +28,19 @@ function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | nul
return null;
}
function parseRetryAfterSeconds(text: string, response: Response): number | undefined {
const payload = parseDiscordApiErrorPayload(text);
const retryAfter =
payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after)
? payload.retry_after
: undefined;
if (retryAfter !== undefined) return retryAfter;
const header = response.headers.get("Retry-After");
if (!header) return undefined;
const parsed = Number(header);
return Number.isFinite(parsed) ? parsed : undefined;
}
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
if (value === undefined || !Number.isFinite(value) || value < 0) return undefined;
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
@@ -45,23 +65,60 @@ function formatDiscordApiErrorText(text: string): string | undefined {
return retryAfter ? `${message} (retry after ${retryAfter})` : message;
}
export class DiscordApiError extends Error {
status: number;
retryAfter?: number;
constructor(message: string, status: number, retryAfter?: number) {
super(message);
this.status = status;
this.retryAfter = retryAfter;
}
}
export type DiscordFetchOptions = {
retry?: RetryConfig;
label?: string;
};
export async function fetchDiscord<T>(
path: string,
token: string,
fetcher: typeof fetch = fetch,
options?: DiscordFetchOptions,
): Promise<T> {
const fetchImpl = resolveFetch(fetcher);
if (!fetchImpl) {
throw new Error("fetch is not available");
}
const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const detail = formatDiscordApiErrorText(text);
const suffix = detail ? `: ${detail}` : "";
throw new Error(`Discord API ${path} failed (${res.status})${suffix}`);
}
return (await res.json()) as T;
const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry);
return retryAsync(
async () => {
const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const detail = formatDiscordApiErrorText(text);
const suffix = detail ? `: ${detail}` : "";
const retryAfter = res.status === 429 ? parseRetryAfterSeconds(text, res) : undefined;
throw new DiscordApiError(
`Discord API ${path} failed (${res.status})${suffix}`,
res.status,
retryAfter,
);
}
return (await res.json()) as T;
},
{
...retryConfig,
label: options?.label ?? path,
shouldRetry: (err) => err instanceof DiscordApiError && err.status === 429,
retryAfterMs: (err) =>
err instanceof DiscordApiError && typeof err.retryAfter === "number"
? err.retryAfter * 1000
: undefined,
},
);
}
@@ -135,6 +135,86 @@ describe("discord tool result dispatch", () => {
expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000);
it("skips guild messages when another user is explicitly mentioned", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
channels: {
discord: {
dm: { enabled: true, policy: "open" },
groupPolicy: "open",
guilds: { "*": { requireMention: true } },
},
},
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
},
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.channels.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: true } },
});
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
}),
} as unknown as Client;
await handler(
{
message: {
id: "m2",
content: "clawd: hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [{ id: "u2", bot: false, username: "Bea" }],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada" },
},
author: { id: "u1", bot: false, username: "Ada" },
member: { nickname: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
expect(dispatchMock).not.toHaveBeenCalled();
expect(sendMock).not.toHaveBeenCalled();
}, 20_000);
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
@@ -6,7 +6,10 @@ import {
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import {
buildMentionRegexes,
matchesMentionWithExplicit,
} from "../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { recordChannelActivity } from "../../infra/channel-activity.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
@@ -174,10 +177,26 @@ export async function preflightDiscordMessage(
},
});
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
const explicitlyMentioned = Boolean(
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
);
const hasAnyMention = Boolean(
!isDirectMessage &&
(message.mentionedEveryone ||
(message.mentionedUsers?.length ?? 0) > 0 ||
(message.mentionedRoles?.length ?? 0) > 0),
);
const wasMentioned =
!isDirectMessage &&
(Boolean(botId && message.mentionedUsers?.some((user: User) => user.id === botId)) ||
matchesMentionPatterns(baseText, mentionRegexes));
matchesMentionWithExplicit({
text: baseText,
mentionRegexes,
explicit: {
hasAnyMention,
isExplicitlyMentioned: explicitlyMentioned,
canResolveExplicit: Boolean(botId),
},
});
const implicitMention = Boolean(
!isDirectMessage &&
botId &&
@@ -341,12 +360,6 @@ export async function preflightDiscordMessage(
channelConfig,
guildInfo,
});
const hasAnyMention = Boolean(
!isDirectMessage &&
(message.mentionedEveryone ||
(message.mentionedUsers?.length ?? 0) > 0 ||
(message.mentionedRoles?.length ?? 0) > 0),
);
const allowTextCommands = shouldHandleTextCommands({
cfg: params.cfg,
surface: "discord",
+2 -2
View File
@@ -254,7 +254,7 @@ async function handleDiscordCommandArgInteraction(
return;
}
const commandDefinition =
findCommandByNativeName(parsed.command) ??
findCommandByNativeName(parsed.command, "discord") ??
listChatCommands().find((entry) => entry.key === parsed.command);
if (!commandDefinition) {
await safeDiscordInteractionCall("command arg update", () =>
@@ -395,7 +395,7 @@ export function createDiscordNativeCommand(params: {
}) {
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
const commandDefinition =
findCommandByNativeName(command.name) ??
findCommandByNativeName(command.name, "discord") ??
({
key: command.name,
nativeName: command.name,
+24 -3
View File
@@ -15,6 +15,7 @@ import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveDiscordAccount } from "../accounts.js";
@@ -62,6 +63,22 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
return `${sample.join(", ")}${suffix}`;
}
async function deployDiscordCommands(params: {
client: Client;
runtime: RuntimeEnv;
enabled: boolean;
}) {
if (!params.enabled) return;
const runWithRetry = createDiscordRetryRunner({ verbose: shouldLogVerbose() });
try {
await runWithRetry(() => params.client.handleDeployRequest(), "command deploy");
} catch (err) {
params.runtime.error?.(
danger(`discord: failed to deploy native commands: ${formatErrorMessage(err)}`),
);
}
}
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveDiscordAccount({
@@ -329,11 +346,13 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const maxDiscordCommands = 100;
let skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
let commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : [];
let commandSpecs = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "discord" })
: [];
const initialCommandCount = commandSpecs.length;
if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) {
skillCommands = [];
commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [] });
commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [], provider: "discord" });
runtime.log?.(
warn(
`discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`,
@@ -365,7 +384,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
clientId: applicationId,
publicKey: "a",
token,
autoDeploy: nativeEnabled,
autoDeploy: false,
},
{
commands,
@@ -396,6 +415,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
],
);
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
const logger = createSubsystemLogger("discord/monitor");
const guildHistories = new Map<string, HistoryEntry[]>();
let botUserId: string | undefined;
+2
View File
@@ -5,9 +5,11 @@ import process from "node:process";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
import { isTruthyEnvValue } from "./infra/env.js";
import { installProcessWarningFilter } from "./infra/warnings.js";
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
process.title = "clawdbot";
installProcessWarningFilter();
if (process.argv.includes("--no-color")) {
process.env.NO_COLOR = "1";
+1
View File
@@ -38,6 +38,7 @@ export const SessionsPreviewParamsSchema = Type.Object(
export const SessionsResolveParamsSchema = Type.Object(
{
key: Type.Optional(NonEmptyString),
sessionId: Type.Optional(NonEmptyString),
label: Type.Optional(SessionLabelString),
agentId: Type.Optional(NonEmptyString),
spawnedBy: Type.Optional(NonEmptyString),
+2
View File
@@ -29,6 +29,7 @@ import {
import { applyHookMappings } from "./hooks-mapping.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
@@ -229,6 +230,7 @@ export function createGatewayHttpServer(opts: {
if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth })) return;
if (openResponsesEnabled) {
if (
await handleOpenResponsesHttpRequest(req, res, {
@@ -142,6 +142,12 @@ describe("gateway server sessions", () => {
expect(resolvedByKey.ok).toBe(true);
expect(resolvedByKey.payload?.key).toBe("agent:main:main");
const resolvedBySessionId = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
sessionId: "sess-group",
});
expect(resolvedBySessionId.ok).toBe(true);
expect(resolvedBySessionId.payload?.key).toBe("agent:main:discord:group:dev");
const list1 = await rpcReq<{
path: string;
defaults?: { model?: string | null; modelProvider?: string | null };
+47 -4
View File
@@ -23,17 +23,23 @@ export function resolveSessionKeyFromResolveParams(params: {
const key = typeof p.key === "string" ? p.key.trim() : "";
const hasKey = key.length > 0;
const sessionId = typeof p.sessionId === "string" ? p.sessionId.trim() : "";
const hasSessionId = sessionId.length > 0;
const hasLabel = typeof p.label === "string" && p.label.trim().length > 0;
if (hasKey && hasLabel) {
const selectionCount = [hasKey, hasSessionId, hasLabel].filter(Boolean).length;
if (selectionCount > 1) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "Provide either key or label (not both)"),
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"Provide either key, sessionId, or label (not multiple)",
),
};
}
if (!hasKey && !hasLabel) {
if (selectionCount === 0) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, "Either key or label is required"),
error: errorShape(ErrorCodes.INVALID_REQUEST, "Either key, sessionId, or label is required"),
};
}
@@ -50,6 +56,43 @@ export function resolveSessionKeyFromResolveParams(params: {
return { ok: true, key: target.canonicalKey };
}
if (hasSessionId) {
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const list = listSessionsFromStore({
cfg,
storePath,
store,
opts: {
includeGlobal: p.includeGlobal === true,
includeUnknown: p.includeUnknown === true,
spawnedBy: p.spawnedBy,
agentId: p.agentId,
search: sessionId,
limit: 8,
},
});
const matches = list.sessions.filter(
(session) => session.sessionId === sessionId || session.key === sessionId,
);
if (matches.length === 0) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${sessionId}`),
};
}
if (matches.length > 1) {
const keys = matches.map((session) => session.key).join(", ");
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`Multiple sessions found for sessionId: ${sessionId} (${keys})`,
),
};
}
return { ok: true, key: String(matches[0]?.key ?? "") };
}
const parsedLabel = parseSessionLabel(p.label);
if (!parsedLabel.ok) {
return {
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, it } from "vitest";
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
import { testState } from "./test-helpers.mocks.js";
installGatewayTestHooks({ scope: "suite" });
describe("POST /tools/invoke", () => {
it("invokes a tool and returns {ok:true,result}", async () => {
// Allow the sessions_list tool for main agent.
testState.agentsConfig = {
list: [
{
id: "main",
tools: {
allow: ["sessions_list"],
},
},
],
} as any;
const port = await getFreePort();
const server = await startGatewayServer(port, {
bind: "loopback",
});
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body).toHaveProperty("result");
await server.close();
});
it("accepts password auth when bearer token matches", async () => {
testState.agentsConfig = {
list: [
{
id: "main",
tools: {
allow: ["sessions_list"],
},
},
],
} as any;
const port = await getFreePort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "password", password: "secret" },
});
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer secret",
},
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(200);
await server.close();
});
it("rejects unauthorized when auth mode is token and header is missing", async () => {
testState.agentsConfig = {
list: [
{
id: "main",
tools: {
allow: ["sessions_list"],
},
},
],
} as any;
const port = await getFreePort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token: "t" },
});
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(401);
await server.close();
});
it("returns 404 when tool is not allowlisted", async () => {
testState.agentsConfig = {
list: [
{
id: "main",
tools: {
deny: ["sessions_list"],
},
},
],
} as any;
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(404);
await server.close();
});
it("respects tools.profile allowlist", async () => {
testState.agentsConfig = {
list: [
{
id: "main",
tools: {
allow: ["sessions_list"],
},
},
],
} as any;
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
tools: { profile: "minimal" },
} as any);
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
expect(res.status).toBe(404);
await server.close();
});
it("uses the configured main session key when sessionKey is missing or main", async () => {
testState.agentsConfig = {
list: [
{
id: "main",
tools: {
deny: ["sessions_list"],
},
},
{
id: "ops",
default: true,
tools: {
allow: ["sessions_list"],
},
},
],
} as any;
testState.sessionConfig = { mainKey: "primary" };
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const payload = { tool: "sessions_list", action: "json", args: {} };
const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
expect(resDefault.status).toBe(200);
const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...payload, sessionKey: "main" }),
});
expect(resMain.status).toBe(200);
await server.close();
});
});
+255
View File
@@ -0,0 +1,255 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { createClawdbotTools } from "../agents/clawdbot-tools.js";
import {
filterToolsByPolicy,
resolveEffectiveToolPolicy,
resolveGroupToolPolicy,
resolveSubagentToolPolicy,
} from "../agents/pi-tools.policy.js";
import {
buildPluginToolGroups,
collectExplicitAllowlist,
expandPolicyWithPluginGroups,
normalizeToolName,
resolveToolProfilePolicy,
stripPluginOnlyAllowlist,
} from "../agents/tool-policy.js";
import { loadConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
import { getBearerToken, getHeader } from "./http-utils.js";
import {
readJsonBodyOrError,
sendInvalidRequest,
sendJson,
sendMethodNotAllowed,
sendUnauthorized,
} from "./http-common.js";
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
type ToolsInvokeBody = {
tool?: unknown;
action?: unknown;
args?: unknown;
sessionKey?: unknown;
dryRun?: unknown;
};
function resolveSessionKeyFromBody(body: ToolsInvokeBody): string | undefined {
if (typeof body.sessionKey === "string" && body.sessionKey.trim()) return body.sessionKey.trim();
return undefined;
}
function mergeActionIntoArgsIfSupported(params: {
toolSchema: unknown;
action: string | undefined;
args: Record<string, unknown>;
}): Record<string, unknown> {
const { toolSchema, action, args } = params;
if (!action) return args;
if (args.action !== undefined) return args;
// TypeBox schemas are plain objects; many tools define an `action` property.
const schemaObj = toolSchema as { properties?: Record<string, unknown> } | null;
const hasAction = Boolean(
schemaObj &&
typeof schemaObj === "object" &&
schemaObj.properties &&
"action" in schemaObj.properties,
);
if (!hasAction) return args;
return { ...args, action };
}
export async function handleToolsInvokeHttpRequest(
req: IncomingMessage,
res: ServerResponse,
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number },
): Promise<boolean> {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
if (url.pathname !== "/tools/invoke") return false;
if (req.method !== "POST") {
sendMethodNotAllowed(res, "POST");
return true;
}
const token = getBearerToken(req);
const authResult = await authorizeGatewayConnect({
auth: opts.auth,
connectAuth: token ? { token, password: token } : null,
req,
});
if (!authResult.ok) {
sendUnauthorized(res);
return true;
}
const bodyUnknown = await readJsonBodyOrError(req, res, opts.maxBodyBytes ?? DEFAULT_BODY_BYTES);
if (bodyUnknown === undefined) return true;
const body = (bodyUnknown ?? {}) as ToolsInvokeBody;
const toolName = typeof body.tool === "string" ? body.tool.trim() : "";
if (!toolName) {
sendInvalidRequest(res, "tools.invoke requires body.tool");
return true;
}
const action = typeof body.action === "string" ? body.action.trim() : undefined;
const argsRaw = body.args;
const args = (
argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw)
? (argsRaw as Record<string, unknown>)
: {}
) as Record<string, unknown>;
const cfg = loadConfig();
const rawSessionKey = resolveSessionKeyFromBody(body);
const sessionKey =
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;
// Resolve message channel/account hints (optional headers) for policy inheritance.
const messageChannel = normalizeMessageChannel(
getHeader(req, "x-clawdbot-message-channel") ?? "",
);
const accountId = getHeader(req, "x-clawdbot-account-id")?.trim() || undefined;
const {
agentId,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
} = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const groupPolicy = resolveGroupToolPolicy({
config: cfg,
sessionKey,
messageProvider: messageChannel ?? undefined,
accountId: accountId ?? null,
});
const subagentPolicy = isSubagentSessionKey(sessionKey)
? resolveSubagentToolPolicy(cfg)
: undefined;
// Build tool list (core + plugin tools).
const allTools = createClawdbotTools({
agentSessionKey: sessionKey,
agentChannel: messageChannel ?? undefined,
agentAccountId: accountId,
config: cfg,
pluginToolAllowlist: collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
groupPolicy,
subagentPolicy,
]),
});
const coreToolNames = new Set(
allTools
.filter((tool) => !getPluginToolMeta(tool as any))
.map((tool) => normalizeToolName(tool.name))
.filter(Boolean),
);
const pluginGroups = buildPluginToolGroups({
tools: allTools,
toolMeta: (tool) => getPluginToolMeta(tool as any),
});
const resolvePolicy = (policy: typeof profilePolicy, label: string) => {
const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames);
if (resolved.unknownAllowlist.length > 0) {
const entries = resolved.unknownAllowlist.join(", ");
const suffix = resolved.strippedAllowlist
? "Ignoring allowlist so core tools remain available."
: "These entries won't match any tool unless the plugin is enabled.";
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
}
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
};
const profilePolicyExpanded = resolvePolicy(
profilePolicy,
profile ? `tools.profile (${profile})` : "tools.profile",
);
const providerProfileExpanded = resolvePolicy(
providerProfilePolicy,
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
);
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
const globalProviderExpanded = resolvePolicy(globalProviderPolicy, "tools.byProvider.allow");
const agentPolicyExpanded = resolvePolicy(
agentPolicy,
agentId ? `agents.${agentId}.tools.allow` : "agent tools.allow",
);
const agentProviderExpanded = resolvePolicy(
agentProviderPolicy,
agentId ? `agents.${agentId}.tools.byProvider.allow` : "agent tools.byProvider.allow",
);
const groupPolicyExpanded = resolvePolicy(groupPolicy, "group tools.allow");
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
const toolsFiltered = profilePolicyExpanded
? filterToolsByPolicy(allTools, profilePolicyExpanded)
: allTools;
const providerProfileFiltered = providerProfileExpanded
? filterToolsByPolicy(toolsFiltered, providerProfileExpanded)
: toolsFiltered;
const globalFiltered = globalPolicyExpanded
? filterToolsByPolicy(providerProfileFiltered, globalPolicyExpanded)
: providerProfileFiltered;
const globalProviderFiltered = globalProviderExpanded
? filterToolsByPolicy(globalFiltered, globalProviderExpanded)
: globalFiltered;
const agentFiltered = agentPolicyExpanded
? filterToolsByPolicy(globalProviderFiltered, agentPolicyExpanded)
: globalProviderFiltered;
const agentProviderFiltered = agentProviderExpanded
? filterToolsByPolicy(agentFiltered, agentProviderExpanded)
: agentFiltered;
const groupFiltered = groupPolicyExpanded
? filterToolsByPolicy(agentProviderFiltered, groupPolicyExpanded)
: agentProviderFiltered;
const subagentFiltered = subagentPolicyExpanded
? filterToolsByPolicy(groupFiltered, subagentPolicyExpanded)
: groupFiltered;
const tool = subagentFiltered.find((t) => t.name === toolName);
if (!tool) {
sendJson(res, 404, {
ok: false,
error: { type: "not_found", message: `Tool not available: ${toolName}` },
});
return true;
}
try {
const toolArgs = mergeActionIntoArgsIfSupported({
toolSchema: (tool as any).parameters,
action,
args,
});
const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs);
sendJson(res, 200, { ok: true, result });
} catch (err) {
sendJson(res, 400, {
ok: false,
error: { type: "tool_error", message: err instanceof Error ? err.message : String(err) },
});
}
return true;
}
+24
View File
@@ -1,3 +1,5 @@
export type HeartbeatIndicatorType = "ok" | "alert" | "error";
export type HeartbeatEventPayload = {
ts: number;
status: "sent" | "ok-empty" | "ok-token" | "skipped" | "failed";
@@ -6,8 +8,30 @@ export type HeartbeatEventPayload = {
durationMs?: number;
hasMedia?: boolean;
reason?: string;
/** The channel this heartbeat was sent to. */
channel?: string;
/** Whether the message was silently suppressed (showOk: false). */
silent?: boolean;
/** Indicator type for UI status display. */
indicatorType?: HeartbeatIndicatorType;
};
export function resolveIndicatorType(
status: HeartbeatEventPayload["status"],
): HeartbeatIndicatorType | undefined {
switch (status) {
case "ok-empty":
case "ok-token":
return "ok";
case "sent":
return "alert";
case "failed":
return "error";
case "skipped":
return undefined;
}
}
let lastHeartbeat: HeartbeatEventPayload | null = null;
const listeners = new Set<(evt: HeartbeatEventPayload) => void>();
@@ -92,6 +92,135 @@ describe("resolveHeartbeatIntervalMs", () => {
}
});
it("sends HEARTBEAT_OK when visibility.showOk is true", 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 {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
},
},
},
channels: { whatsapp: { allowFrom: ["*"], heartbeat: { showOk: true } } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
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).toHaveBeenCalledTimes(1);
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "HEARTBEAT_OK", expect.any(Object));
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("skips heartbeat LLM calls when visibility disables all output", 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 {
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
},
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
heartbeat: { showOk: false, showAlerts: false, useIndicator: false },
},
},
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastProvider: "whatsapp",
lastTo: "+1555",
},
},
null,
2,
),
);
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
const result = await runHeartbeatOnce({
cfg,
deps: {
sendWhatsApp,
getQueueSize: () => 0,
nowMs: () => 0,
webAuthExists: async () => true,
hasActiveWebListener: () => true,
},
});
expect(replySpy).not.toHaveBeenCalled();
expect(sendWhatsApp).not.toHaveBeenCalled();
expect(result).toEqual({ status: "skipped", reason: "alerts-disabled" });
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("skips delivery for markup-wrapped HEARTBEAT_OK", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
+78 -2
View File
@@ -16,6 +16,7 @@ import {
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
stripHeartbeatToken,
} from "../auto-reply/heartbeat.js";
import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
@@ -39,7 +40,8 @@ import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
import { emitHeartbeatEvent } from "./heartbeat-events.js";
import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
import {
type HeartbeatRunResult,
type HeartbeatWakeHandler,
@@ -471,7 +473,16 @@ export async function runHeartbeatOnce(opts: {
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
const previousUpdatedAt = entry?.updatedAt;
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
const visibility =
delivery.channel !== "none"
? resolveHeartbeatVisibility({
cfg,
channel: delivery.channel,
accountId: delivery.accountId,
})
: { showOk: false, showAlerts: true, useIndicator: true };
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: prompt,
@@ -480,6 +491,43 @@ export async function runHeartbeatOnce(opts: {
Provider: "heartbeat",
SessionKey: sessionKey,
};
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
emitHeartbeatEvent({
status: "skipped",
reason: "alerts-disabled",
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
});
return { status: "skipped", reason: "alerts-disabled" };
}
const heartbeatOkText = responsePrefix
? `${responsePrefix} ${HEARTBEAT_TOKEN}`
: HEARTBEAT_TOKEN;
const canAttemptHeartbeatOk = Boolean(
visibility.showOk && delivery.channel !== "none" && delivery.to,
);
const maybeSendHeartbeatOk = async () => {
if (!canAttemptHeartbeatOk || delivery.channel === "none" || !delivery.to) return false;
const heartbeatPlugin = getChannelPlugin(delivery.channel);
if (heartbeatPlugin?.heartbeat?.checkReady) {
const readiness = await heartbeatPlugin.heartbeat.checkReady({
cfg,
accountId: delivery.accountId,
deps: opts.deps,
});
if (!readiness.ok) return false;
}
await deliverOutboundPayloads({
cfg,
channel: delivery.channel,
to: delivery.to,
accountId: delivery.accountId,
payloads: [{ text: heartbeatOkText }],
deps: opts.deps,
});
return true;
};
try {
const replyResult = await getReplyFromConfig(ctx, { isHeartbeat: true }, cfg);
@@ -498,10 +546,14 @@ export async function runHeartbeatOnce(opts: {
sessionKey,
updatedAt: previousUpdatedAt,
});
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-empty",
reason: opts.reason,
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-empty") : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -509,7 +561,7 @@ export async function runHeartbeatOnce(opts: {
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const normalized = normalizeHeartbeatReply(
replyPayload,
resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
responsePrefix,
ackMaxChars,
);
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
@@ -519,10 +571,14 @@ export async function runHeartbeatOnce(opts: {
sessionKey,
updatedAt: previousUpdatedAt,
});
const okSent = await maybeSendHeartbeatOk();
emitHeartbeatEvent({
status: "ok-token",
reason: opts.reason,
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
silent: !okSent,
indicatorType: visibility.useIndicator ? resolveIndicatorType("ok-token") : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -556,6 +612,7 @@ export async function runHeartbeatOnce(opts: {
preview: normalized.text.slice(0, 200),
durationMs: Date.now() - startedAt,
hasMedia: false,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
}
@@ -579,6 +636,20 @@ export async function runHeartbeatOnce(opts: {
return { status: "ran", durationMs: Date.now() - startedAt };
}
if (!visibility.showAlerts) {
await restoreHeartbeatUpdatedAt({ storePath, sessionKey, updatedAt: previousUpdatedAt });
emitHeartbeatEvent({
status: "skipped",
reason: "alerts-disabled",
preview: previewText?.slice(0, 200),
durationMs: Date.now() - startedAt,
channel: delivery.channel,
hasMedia: mediaUrls.length > 0,
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
}
const deliveryAccountId = delivery.accountId;
const heartbeatPlugin = getChannelPlugin(delivery.channel);
if (heartbeatPlugin?.heartbeat?.checkReady) {
@@ -594,6 +665,7 @@ export async function runHeartbeatOnce(opts: {
preview: previewText?.slice(0, 200),
durationMs: Date.now() - startedAt,
hasMedia: mediaUrls.length > 0,
channel: delivery.channel,
});
log.info("heartbeat: channel not ready", {
channel: delivery.channel,
@@ -642,6 +714,8 @@ export async function runHeartbeatOnce(opts: {
preview: previewText?.slice(0, 200),
durationMs: Date.now() - startedAt,
hasMedia: mediaUrls.length > 0,
channel: delivery.channel,
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
});
return { status: "ran", durationMs: Date.now() - startedAt };
} catch (err) {
@@ -650,6 +724,8 @@ export async function runHeartbeatOnce(opts: {
status: "failed",
reason,
durationMs: Date.now() - startedAt,
channel: delivery.channel !== "none" ? delivery.channel : undefined,
indicatorType: visibility.useIndicator ? resolveIndicatorType("failed") : undefined,
});
log.error(`heartbeat failed: ${reason}`, { error: reason });
return { status: "failed", reason };
+250
View File
@@ -0,0 +1,250 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js";
describe("resolveHeartbeatVisibility", () => {
it("returns default values when no config is provided", () => {
const cfg = {} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result).toEqual({
showOk: false,
showAlerts: true,
useIndicator: true,
});
});
it("uses channel defaults when provided", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: true,
showAlerts: false,
useIndicator: false,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result).toEqual({
showOk: true,
showAlerts: false,
useIndicator: false,
});
});
it("per-channel config overrides channel defaults", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
telegram: {
heartbeat: {
showOk: true,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result).toEqual({
showOk: true,
showAlerts: true,
useIndicator: true,
});
});
it("per-account config overrides per-channel config", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: false,
showAlerts: true,
useIndicator: true,
},
},
telegram: {
heartbeat: {
showOk: false,
showAlerts: false,
},
accounts: {
primary: {
heartbeat: {
showOk: true,
showAlerts: true,
},
},
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({
cfg,
channel: "telegram",
accountId: "primary",
});
expect(result).toEqual({
showOk: true,
showAlerts: true,
useIndicator: true,
});
});
it("falls through to defaults when account has no heartbeat config", () => {
const cfg = {
channels: {
defaults: {
heartbeat: {
showOk: false,
},
},
telegram: {
heartbeat: {
showAlerts: false,
},
accounts: {
primary: {},
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({
cfg,
channel: "telegram",
accountId: "primary",
});
expect(result).toEqual({
showOk: false,
showAlerts: false,
useIndicator: true,
});
});
it("handles missing accountId gracefully", () => {
const cfg = {
channels: {
telegram: {
heartbeat: {
showOk: true,
},
accounts: {
primary: {
heartbeat: {
showOk: false,
},
},
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "telegram" });
expect(result.showOk).toBe(true);
});
it("handles non-existent account gracefully", () => {
const cfg = {
channels: {
telegram: {
heartbeat: {
showOk: true,
},
accounts: {
primary: {
heartbeat: {
showOk: false,
},
},
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({
cfg,
channel: "telegram",
accountId: "nonexistent",
});
expect(result.showOk).toBe(true);
});
it("works with whatsapp channel", () => {
const cfg = {
channels: {
whatsapp: {
heartbeat: {
showOk: true,
showAlerts: false,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" });
expect(result).toEqual({
showOk: true,
showAlerts: false,
useIndicator: true,
});
});
it("works with discord channel", () => {
const cfg = {
channels: {
discord: {
heartbeat: {
useIndicator: false,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "discord" });
expect(result).toEqual({
showOk: false,
showAlerts: true,
useIndicator: false,
});
});
it("works with slack channel", () => {
const cfg = {
channels: {
slack: {
heartbeat: {
showOk: true,
showAlerts: true,
useIndicator: true,
},
},
},
} as ClawdbotConfig;
const result = resolveHeartbeatVisibility({ cfg, channel: "slack" });
expect(result).toEqual({
showOk: true,
showAlerts: true,
useIndicator: true,
});
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js";
import type { DeliverableMessageChannel } from "../utils/message-channel.js";
export type ResolvedHeartbeatVisibility = {
showOk: boolean;
showAlerts: boolean;
useIndicator: boolean;
};
const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = {
showOk: false, // Silent by default
showAlerts: true, // Show content messages
useIndicator: true, // Emit indicator events
};
export function resolveHeartbeatVisibility(params: {
cfg: ClawdbotConfig;
channel: DeliverableMessageChannel;
accountId?: string;
}): ResolvedHeartbeatVisibility {
const { cfg, channel, accountId } = params;
// Layer 1: Global channel defaults
const channelDefaults = cfg.channels?.defaults?.heartbeat;
// Layer 2: Per-channel config (at channel root level)
const channelCfg = cfg.channels?.[channel] as
| {
heartbeat?: ChannelHeartbeatVisibilityConfig;
accounts?: Record<string, { heartbeat?: ChannelHeartbeatVisibilityConfig }>;
}
| undefined;
const perChannel = channelCfg?.heartbeat;
// Layer 3: Per-account config (most specific)
const accountCfg = accountId ? channelCfg?.accounts?.[accountId] : undefined;
const perAccount = accountCfg?.heartbeat;
// Precedence: per-account > per-channel > channel-defaults > global defaults
return {
showOk:
perAccount?.showOk ??
perChannel?.showOk ??
channelDefaults?.showOk ??
DEFAULT_VISIBILITY.showOk,
showAlerts:
perAccount?.showAlerts ??
perChannel?.showAlerts ??
channelDefaults?.showAlerts ??
DEFAULT_VISIBILITY.showAlerts,
useIndicator:
perAccount?.useIndicator ??
perChannel?.useIndicator ??
channelDefaults?.useIndicator ??
DEFAULT_VISIBILITY.useIndicator,
};
}
+33
View File
@@ -0,0 +1,33 @@
const warningFilterKey = Symbol.for("clawdbot.warning-filter");
type Warning = Error & {
code?: string;
name?: string;
message?: string;
};
function shouldIgnoreWarning(warning: Warning): boolean {
if (warning.code === "DEP0040" && warning.message?.includes("punycode")) {
return true;
}
if (
warning.name === "ExperimentalWarning" &&
warning.message?.includes("SQLite is an experimental feature")
) {
return true;
}
return false;
}
export function installProcessWarningFilter(): void {
const globalState = globalThis as typeof globalThis & {
[warningFilterKey]?: { installed: boolean };
};
if (globalState[warningFilterKey]?.installed) return;
globalState[warningFilterKey] = { installed: true };
process.on("warning", (warning: Warning) => {
if (shouldIgnoreWarning(warning)) return;
process.stderr.write(`${warning.stack ?? warning.toString()}\n`);
});
}
+4 -16
View File
@@ -1,22 +1,10 @@
import { createRequire } from "node:module";
import { installProcessWarningFilter } from "../infra/warnings.js";
const require = createRequire(import.meta.url);
export function requireNodeSqlite(): typeof import("node:sqlite") {
const onWarning = (warning: Error & { name?: string; message?: string }) => {
if (
warning.name === "ExperimentalWarning" &&
warning.message?.includes("SQLite is an experimental feature")
) {
return;
}
process.stderr.write(`${warning.stack ?? warning.toString()}\n`);
};
process.on("warning", onWarning);
try {
return require("node:sqlite") as typeof import("node:sqlite");
} finally {
process.off("warning", onWarning);
}
installProcessWarningFilter();
return require("node:sqlite") as typeof import("node:sqlite");
}
+6 -1
View File
@@ -17,7 +17,11 @@ import {
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
} from "../../auto-reply/reply/mentions.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
@@ -200,6 +204,7 @@ export function createPluginRuntime(): PluginRuntime {
mentions: {
buildMentionRegexes,
matchesMentionPatterns,
matchesMentionWithExplicit,
},
reactions: {
shouldAckReaction,
+3
View File
@@ -19,6 +19,8 @@ type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer;
type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes;
type MatchesMentionPatterns =
typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns;
type MatchesMentionWithExplicit =
typeof import("../../auto-reply/reply/mentions.js").matchesMentionWithExplicit;
type ShouldAckReaction = typeof import("../../channels/ack-reactions.js").shouldAckReaction;
type RemoveAckReactionAfterReply =
typeof import("../../channels/ack-reactions.js").removeAckReactionAfterReply;
@@ -215,6 +217,7 @@ export type PluginRuntime = {
mentions: {
buildMentionRegexes: BuildMentionRegexes;
matchesMentionPatterns: MatchesMentionPatterns;
matchesMentionWithExplicit: MatchesMentionWithExplicit;
};
reactions: {
shouldAckReaction: ShouldAckReaction;
+1 -1
View File
@@ -170,7 +170,7 @@ export type PluginCommandHandler = (
* Definition for a plugin-registered command.
*/
export type ClawdbotPluginCommandDefinition = {
/** Command name without leading slash (e.g., "tts_on") */
/** Command name without leading slash (e.g., "tts") */
name: string;
/** Description shown in /help and command menus */
description: string;
@@ -400,6 +400,51 @@ describe("monitorSlackProvider tool results", () => {
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
});
it("skips channel messages when another user is explicitly mentioned", async () => {
slackTestState.config = {
messages: {
responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bclawd\\b"] },
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
channels: { C1: { allow: true, requireMention: true } },
},
},
};
replyMock.mockResolvedValue({ text: "hi" });
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: "bot-token",
appToken: "app-token",
abortSignal: controller.signal,
});
await waitForSlackEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
await handler({
event: {
type: "message",
user: "U1",
text: "clawd: hello <@U2>",
ts: "123",
channel: "C1",
channel_type: "channel",
},
});
await flush();
controller.abort();
await run;
expect(replyMock).not.toHaveBeenCalled();
expect(sendMock).not.toHaveBeenCalled();
});
it("treats replies to bot threads as implicit mentions", async () => {
slackTestState.config = {
channels: {
+17 -4
View File
@@ -12,7 +12,10 @@ import {
recordPendingHistoryEntryIfEnabled,
} from "../../../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../../auto-reply/reply/mentions.js";
import {
buildMentionRegexes,
matchesMentionWithExplicit,
} from "../../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import { enqueueSystemEvent } from "../../../infra/system-events.js";
import { buildPairingReply } from "../../../pairing/pairing-messages.js";
@@ -204,11 +207,22 @@ export async function prepareSlackMessage(params: {
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
const explicitlyMentioned = Boolean(
ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`),
);
const wasMentioned =
opts.wasMentioned ??
(!isDirectMessage &&
(Boolean(ctx.botUserId && message.text?.includes(`<@${ctx.botUserId}>`)) ||
matchesMentionPatterns(message.text ?? "", mentionRegexes)));
matchesMentionWithExplicit({
text: message.text ?? "",
mentionRegexes,
explicit: {
hasAnyMention,
isExplicitlyMentioned: explicitlyMentioned,
canResolveExplicit: Boolean(ctx.botUserId),
},
}));
const implicitMention = Boolean(
!isDirectMessage &&
ctx.botUserId &&
@@ -232,7 +246,6 @@ export async function prepareSlackMessage(params: {
return null;
}
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: "slack",
+3 -3
View File
@@ -476,14 +476,14 @@ export function registerSlackMonitorSlashCommands(params: {
const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "slack" })
: [];
if (nativeCommands.length > 0) {
for (const command of nativeCommands) {
ctx.app.command(
`/${command.name}`,
async ({ command: cmd, ack, respond }: SlackCommandMiddlewareArgs) => {
const commandDefinition = findCommandByNativeName(command.name);
const commandDefinition = findCommandByNativeName(command.name, "slack");
const rawText = cmd.text?.trim() ?? "";
const commandArgs = commandDefinition
? parseCommandArgs(commandDefinition, rawText)
@@ -557,7 +557,7 @@ export function registerSlackMonitorSlashCommands(params: {
});
return;
}
const commandDefinition = findCommandByNativeName(parsed.command);
const commandDefinition = findCommandByNativeName(parsed.command, "slack");
const commandArgs: CommandArgs = {
values: { [parsed.arg]: parsed.value },
};
+12 -5
View File
@@ -10,7 +10,7 @@ import {
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
import { buildMentionRegexes, matchesMentionWithExplicit } from "../auto-reply/reply/mentions.js";
import { formatLocationText, toLocationContext } from "../channels/location.js";
import { recordInboundSession } from "../channels/session.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -299,13 +299,20 @@ export const buildTelegramMessageContext = async ({
if (!bodyText && allMedia.length > 0) {
bodyText = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
const computedWasMentioned =
(botUsername ? hasBotMention(msg, botUsername) : false) ||
matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes);
const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned;
const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some(
(ent) => ent.type === "mention",
);
const explicitlyMentioned = botUsername ? hasBotMention(msg, botUsername) : false;
const computedWasMentioned = matchesMentionWithExplicit({
text: msg.text ?? msg.caption ?? "",
mentionRegexes,
explicit: {
hasAnyMention,
isExplicitlyMentioned: explicitlyMentioned,
canResolveExplicit: Boolean(botUsername),
},
});
const wasMentioned = options?.forceWasMentioned === true ? true : computedWasMentioned;
if (isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: logVerbose,
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more