Compare commits

..

61 Commits

Author SHA1 Message Date
Peter Steinberger
300d2c9339 fix: normalize agentId casing in cron/routing (#1591) (thanks @EnzeD) 2026-01-24 13:11:52 +00:00
Nicolas Zullo
cae7e3451f feat(templates): add emoji reactions guidance to AGENTS.md
## What
Add emoji reactions guidance to the default AGENTS.md template.

## Why  
Reactions are a natural, human-like way to acknowledge messages without cluttering chat. This should be default behavior.

## Testing
- Tested locally on Discord DM 
- Tested locally on Discord guild channel 

## AI-Assisted
This change was drafted with help from my Clawdbot instance (Clawd 🦞). 
We tested the behavior together before submitting.
2026-01-24 12:50:22 +00:00
Peter Steinberger
39d8e9be0f docs: add node vs ssh faq 2026-01-24 12:48:29 +00:00
hsrvc
ac45c8b404 fix: preserve Telegram topic (message_thread_id) in sub-agent announcements
When native slash commands are executed in Telegram topics/forums, the
originating topic context was not being preserved. This caused sub-agent
announcements to be delivered to the wrong topic.

Root cause: Native slash command context did not set OriginatingChannel
and OriginatingTo, causing session delivery context to fallback to the
user's personal ID instead of the group ID + topic.

Fix: Added OriginatingChannel and OriginatingTo to native slash command
context, ensuring topic information is preserved for sub-agent announcements.

Related session fields:
- lastThreadId: preserved via MessageThreadId
- lastTo: now correctly set to group ID via OriginatingTo
- deliveryContext: includes threadId for proper routing
2026-01-24 12:26:29 +00:00
Peter Steinberger
fa746b05de fix: preserve agent id casing 2026-01-24 12:23:44 +00:00
Peter Steinberger
49c518951c fix: align bluebubbles outbound group sessions 2026-01-24 12:23:26 +00:00
Peter Steinberger
0dca8acbe2 docs: reorder 2026.1.23 changelog 2026-01-24 12:10:59 +00:00
Peter Steinberger
c42e9b1d19 fix: log discord deploy error details 2026-01-24 12:10:59 +00:00
Peter Steinberger
298901208d fix: align agent id normalization 2026-01-24 12:10:08 +00:00
Peter Steinberger
ef9ba66798 chore: tune fly deployment defaults 2026-01-24 11:58:25 +00:00
Peter Steinberger
4b6cdd1d3c fix: normalize session keys and outbound mirroring 2026-01-24 11:57:11 +00:00
Peter Steinberger
eaeb52f70a chore: update protocol artifacts 2026-01-24 11:28:24 +00:00
Luke
be1cdc9370 fix(agents): treat provider request-aborted as timeout for fallback (#1576)
* fix(agents): treat request-aborted as timeout for fallback

* test(e2e): add provider timeout fallback
2026-01-24 11:27:24 +00:00
Peter Steinberger
8002143d92 fix: guard cli session update 2026-01-24 11:21:34 +00:00
Peter Steinberger
4a9123d415 chore: suppress remaining deprecation warnings 2026-01-24 11:16:46 +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 Steinberger
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
Christof
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
Peter Steinberger
8ea8801d06 fix: show tool error fallback for tool-only replies 2026-01-24 08:17:50 +00:00
Peter Steinberger
c97bf23a4a fix: gate openai reasoning downgrade on model switches (#1562) (thanks @roshanasingh4) 2026-01-24 08:16:42 +00:00
Peter Steinberger
3fff943ba1 fix: harden gateway lock validation (#1572) (thanks @steipete) 2026-01-24 08:15:07 +00:00
Peter Steinberger
90685ef814 docs(fly): comprehensive deployment guide with real-world learnings
Based on actual Flawd deployment experience:
- Proper fly.toml configuration with all required settings
- Step-by-step guide following exe.dev doc format
- Troubleshooting section with common issues and fixes
- Config file creation via SSH
- Cost estimates
2026-01-24 08:15:07 +00:00
Peter Steinberger
a8f2ac5411 docs(fly): add configuration guidance for bind mode, memory, and troubleshooting 2026-01-24 08:15:07 +00:00
Peter Steinberger
dea96a2c3d fix: handle PID recycling in container gateway lock
In containers, PIDs can be recycled quickly after restarts. When a container
restarts, a different process might get the same PID as the previous gateway,
causing the lock check to incorrectly think the old gateway is still running.

This fix adds isGatewayProcess() which verifies on Linux that the PID actually
belongs to a clawdbot gateway by checking /proc/PID/cmdline. If the cmdline
doesn't contain 'clawdbot' or 'gateway', we assume the lock is stale.

Fixes gateway boot-loop in Docker/Fly.io deployments.
2026-01-24 08:15:07 +00:00
Peter Steinberger
90ae2f541c feat: add Fly.io deployment support
- Add fly.toml configuration for Fly.io deployment
- Add docs/platforms/fly.md with deployment guide
- Uses London (lhr) region by default
- Includes persistent volume for data storage
2026-01-24 08:15:07 +00:00
Peter Steinberger
d9a467fe3b feat: move TTS into core (#1559) (thanks @Glucksberg) 2026-01-24 08:00:44 +00:00
Glucksberg
aef88cd9f1 test(telegram-tts): add unit tests for summarizeText function
- Export summarizeText in _test for testing
- Add 8 tests covering:
  - Successful summarization with metrics
  - OpenAI API call parameters verification
  - targetLength validation (min/max boundaries)
  - Error handling (API failures, empty responses)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
104d977d12 feat(telegram-tts): add latency logging, status tracking, and unit tests
- Add latency metrics to summarizeText and textToSpeech functions
- Add /tts_status command showing config and last attempt result
- Add /tts_summary command for feature flag control
- Fix atomic write to clean up temp file on rename failure
- Add timer.unref() to prevent blocking process shutdown
- Add unit tests for validation functions (13 tests)
- Update README with new commands and features

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
4b24753be7 feat(telegram-tts): add /tts_limit command and auto-summarization
- Add /tts_limit command to configure max text length (default 1500)
- Auto-summarize long texts with gpt-4o-mini before TTS conversion
- Add truncation safeguard if summary exceeds hard limit
- Validate targetLength parameter (100-10000)
- Use conservative max_tokens for multilingual text
- Add prompt injection defense with XML delimiters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
df09e583aa feat(telegram-tts): add auto-TTS hook and provider switching
- Integrate message_sending hook into Telegram delivery path
- Send text first, then audio as voice message after
- Add /tts_provider command to switch between OpenAI and ElevenLabs
- Implement automatic fallback when primary provider fails
- Use gpt-4o-mini-tts as default OpenAI model
- Add hook integration to route-reply.ts for other channels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
46e6546bb9 feat(telegram-tts): make extension self-contained with direct API calls
- Remove sag CLI dependency
- Add direct ElevenLabs API integration via fetch
- Add OpenAI TTS as alternative provider
- Support multi-provider configuration
- Add tts.providers RPC method
- Update config schema with OpenAI options
- Bump version to 0.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Glucksberg
5428c97685 feat(extensions): add telegram-tts extension for voice responses
Add a new extension that provides automatic text-to-speech for chat
responses using ElevenLabs API.

Features:
- `speak` tool for converting text to voice messages
- RPC methods: tts.status, tts.enable, tts.disable, tts.convert
- User preferences file for persistent TTS state
- Configurable voice ID, model, and max text length

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Roshan Singh
202d7af855 Fix OpenAI Responses transcript after model switch 2026-01-24 07:58:25 +00:00
Bradley Priest
72020b37c3 fix(bird skill): gate brew install to macOS (#1569)
* fix(bird skill): gate brew install to macOS

* fix: gate bird brew install to macOS (#1569) (thanks @bradleypriest)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 07:53:29 +00:00
Peter Steinberger
b051621bd4 fix: update changelog + clawtributors (#1571) (thanks @Takhoffman) 2026-01-24 07:47:35 +00:00
Tak hoffman
ff52aec38e Agents: drop bash tool alias 2026-01-24 07:44:04 +00:00
175 changed files with 10275 additions and 3101 deletions

View File

@@ -4,11 +4,17 @@ 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.
- Docs: add emoji reaction guidance to AGENTS.md template. (#1591) Thanks @EnzeD.
- 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.
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
@@ -18,9 +24,20 @@ Docs: https://docs.clawd.bot
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
- 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)
- Routing/Cron: normalize agentId casing for bindings and cron payloads. (#1591)
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)
- Sessions: normalize session key casing to lowercase for consistent routing.
- BlueBubbles: normalize group session keys for outbound mirroring. (#1520)
- 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: 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.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
@@ -29,6 +46,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.
@@ -55,6 +74,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)

View File

@@ -964,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let sessionid: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
@@ -972,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
public init(
key: String?,
sessionid: String?,
label: String?,
agentid: String?,
spawnedby: String?,
@@ -979,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
includeunknown: Bool?
) {
self.key = key
self.sessionid = sessionid
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
@@ -987,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case sessionid = "sessionId"
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"

View File

@@ -964,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let sessionid: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
@@ -972,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
public init(
key: String?,
sessionid: String?,
label: String?,
agentid: String?,
spawnedby: String?,
@@ -979,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
includeunknown: Bool?
) {
self.key = key
self.sessionid = sessionid
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
@@ -987,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case sessionid = "sessionId"
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"

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).

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)

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",
@@ -1045,6 +1034,7 @@
"platforms/android",
"platforms/windows",
"platforms/linux",
"platforms/fly",
"platforms/hetzner",
"platforms/exe-dev"
]

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)

View File

@@ -1446,6 +1446,65 @@ active agents `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
`removeAckAfterReply` removes the bots ack reaction after a reply is sent
(Slack/Discord/Telegram only). Default: `false`.
#### `messages.tts`
Enable text-to-speech for outbound replies. When on, Clawdbot generates audio
using ElevenLabs or OpenAI and attaches it to responses. Telegram uses Opus
voice notes; other channels send MP3 audio.
```json5
{
messages: {
tts: {
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",
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",
model: "gpt-4o-mini-tts",
voice: "alloy"
}
}
}
}
```
Notes:
- `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.
- `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`
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.

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

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.

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)
Clawdbots 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": {}
}'
```

View File

@@ -7,7 +7,7 @@ read_when:
When Clawdbot misbehaves, here's how to fix it.
Start with the FAQs [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 FAQs [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 youre 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 Clawdbots 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

File diff suppressed because it is too large Load Diff

340
docs/platforms/fly.md Normal file
View File

@@ -0,0 +1,340 @@
---
title: Fly.io
description: Deploy Clawdbot on Fly.io
---
# Fly.io Deployment
**Goal:** Clawdbot Gateway running on a [Fly.io](https://fly.io) machine with persistent storage, automatic HTTPS, and Discord/channel access.
## What you need
- [flyctl CLI](https://fly.io/docs/hands-on/install-flyctl/) installed
- Fly.io account (free tier works)
- Model auth: Anthropic API key (or other provider keys)
- Channel credentials: Discord bot token, Telegram token, etc.
## Beginner quick path
1. Clone repo → customize `fly.toml`
2. Create app + volume → set secrets
3. Deploy with `fly deploy`
4. SSH in to create config or use Control UI
## 1) Create the Fly app
```bash
# Clone the repo
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
# Create a new Fly app (pick your own name)
fly apps create my-clawdbot
# Create a persistent volume (1GB is usually enough)
fly volumes create clawdbot_data --size 1 --region iad
```
**Tip:** Choose a region close to you. Common options: `lhr` (London), `iad` (Virginia), `sjc` (San Jose).
## 2) Configure fly.toml
Edit `fly.toml` to match your app name and requirements:
```toml
app = "my-clawdbot" # Your app name
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
NODE_ENV = "production"
CLAWDBOT_PREFER_PNPM = "1"
CLAWDBOT_STATE_DIR = "/data"
NODE_OPTIONS = "--max-old-space-size=1536"
[processes]
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[vm]]
size = "shared-cpu-2x"
memory = "2048mb"
[mounts]
source = "clawdbot_data"
destination = "/data"
```
**Key settings:**
| Setting | Why |
|---------|-----|
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
## 3) Set secrets
```bash
# Required: Gateway token (for non-loopback binding)
fly secrets set CLAWDBOT_GATEWAY_TOKEN=$(openssl rand -hex 32)
# Model provider API keys
fly secrets set ANTHROPIC_API_KEY=sk-ant-...
# Optional: Other providers
fly secrets set OPENAI_API_KEY=sk-...
fly secrets set GOOGLE_API_KEY=...
# Channel tokens
fly secrets set DISCORD_BOT_TOKEN=MTQ...
```
**Notes:**
- Non-loopback binds (`--bind lan`) require `CLAWDBOT_GATEWAY_TOKEN` for security.
- Treat these tokens like passwords.
## 4) Deploy
```bash
fly deploy
```
First deploy builds the Docker image (~2-3 minutes). Subsequent deploys are faster.
After deployment, verify:
```bash
fly status
fly logs
```
You should see:
```
[gateway] listening on ws://0.0.0.0:3000 (PID xxx)
[discord] logged in to discord as xxx
```
## 5) Create config file
SSH into the machine to create a proper config:
```bash
fly ssh console
```
Create the config directory and file:
```bash
mkdir -p /data
cat > /data/clawdbot.json << 'EOF'
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-opus-4-5",
"fallbacks": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
},
"maxConcurrent": 4
},
"list": [
{
"id": "main",
"default": true
}
]
},
"auth": {
"profiles": {
"anthropic:default": { "mode": "token", "provider": "anthropic" },
"openai:default": { "mode": "token", "provider": "openai" }
}
},
"bindings": [
{
"agentId": "main",
"match": { "channel": "discord" }
}
],
"channels": {
"discord": {
"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:** With `CLAWDBOT_STATE_DIR=/data`, the config path is `/data/clawdbot.json`.
**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
fly machine restart <machine-id>
```
## 6) Access the Gateway
### Control UI
Open in browser:
```bash
fly open
```
Or visit `https://my-clawdbot.fly.dev/`
Paste your gateway token (the one from `CLAWDBOT_GATEWAY_TOKEN`) to authenticate.
### Logs
```bash
fly logs # Live logs
fly logs --no-tail # Recent logs
```
### SSH Console
```bash
fly ssh console
```
## Troubleshooting
### "App is not listening on expected address"
The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
**Fix:** Add `--bind lan` to your process command in `fly.toml`.
### OOM / Memory Issues
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
**Fix:** Increase memory in `fly.toml`:
```toml
[[vm]]
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.
This happens when the container restarts but the PID lock file persists on the volume.
**Fix:** Delete the lock file:
```bash
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.
Verify the config exists:
```bash
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
# Pull latest changes
git pull
# Redeploy
fly deploy
# Check health
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)
- The Dockerfile is compatible with both architectures
- For WhatsApp/Telegram onboarding, use `fly ssh console`
- Persistent data lives on the volume at `/data`
## Cost
With the recommended config (`shared-cpu-2x`, 2GB RAM):
- ~$10-15/month depending on usage
- Free tier includes some allowance
See [Fly.io pricing](https://fly.io/docs/about/pricing/) for details.

View File

@@ -23,6 +23,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
## VPS & hosting
- Fly.io: [Fly.io](/platforms/fly)
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)

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.

View File

@@ -0,0 +1,73 @@
---
title: Outbound Session Mirroring Refactor (Issue #1520)
description: Track outbound session mirroring refactor notes, decisions, tests, and open items.
---
# Outbound Session Mirroring Refactor (Issue #1520)
## Status
- In progress.
- Core + plugin channel routing updated for outbound mirroring.
- Gateway send now derives target session when sessionKey is omitted.
## Context
Outbound sends were mirrored into the *current* agent session (tool session key) rather than the target channel session. Inbound routing uses channel/peer session keys, so outbound responses landed in the wrong session and first-contact targets often lacked session entries.
## Goals
- Mirror outbound messages into the target channel session key.
- Create session entries on outbound when missing.
- Keep thread/topic scoping aligned with inbound session keys.
- Cover core channels plus bundled extensions.
## Implementation Summary
- New outbound session routing helper:
- `src/infra/outbound/outbound-session.ts`
- `resolveOutboundSessionRoute` builds target sessionKey using `buildAgentSessionKey` (dmScope + identityLinks).
- `ensureOutboundSessionEntry` writes minimal `MsgContext` via `recordSessionMetaFromInbound`.
- `runMessageAction` (send) derives target sessionKey and passes it to `executeSendAction` for mirroring.
- `message-tool` no longer mirrors directly; it only resolves agentId from the current session key.
- Plugin send path mirrors via `appendAssistantMessageToSessionTranscript` using the derived sessionKey.
- Gateway send derives a target session key when none is provided (default agent), and ensures a session entry.
## Thread/Topic Handling
- Slack: replyTo/threadId -> `resolveThreadSessionKeys` (suffix).
- Discord: threadId/replyTo -> `resolveThreadSessionKeys` with `useSuffix=false` to match inbound (thread channel id already scopes session).
- Telegram: topic IDs map to `chatId:topic:<id>` via `buildTelegramGroupPeerId`.
## Extensions Covered
- Matrix, MS Teams, Mattermost, BlueBubbles, Nextcloud Talk, Zalo, Zalo Personal, Nostr, Tlon.
- Notes:
- Mattermost targets now strip `@` for DM session key routing.
- Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present).
- BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys.
## Decisions
- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there.
- **Session entry creation**: always use `recordSessionMetaFromInbound` with `Provider/From/To/ChatType/AccountId/Originating*` aligned to inbound formats.
- **Target normalization**: outbound routing uses resolved targets (post `resolveChannelTarget`) when available.
- **Session key casing**: canonicalize session keys to lowercase on write and during migrations.
## Tests Added/Updated
- `src/infra/outbound/outbound-session.test.ts`
- Slack thread session key.
- Telegram topic session key.
- dmScope identityLinks with Discord.
- `src/agents/tools/message-tool.test.ts`
- Derives agentId from session key (no sessionKey passed through).
- `src/gateway/server-methods/send.test.ts`
- Derives session key when omitted and creates session entry.
## Open Items / Follow-ups
- Voice-call plugin uses custom `voice:<phone>` session keys. Outbound mapping is not standardized here; if message-tool should support voice-call sends, add explicit mapping.
- Confirm if any external plugin uses non-standard `From/To` formats beyond the bundled set.
## Files Touched
- `src/infra/outbound/outbound-session.ts`
- `src/infra/outbound/outbound-send-service.ts`
- `src/infra/outbound/message-action-runner.ts`
- `src/agents/tools/message-tool.ts`
- `src/gateway/server-methods/send.ts`
- Tests in:
- `src/infra/outbound/outbound-session.test.ts`
- `src/agents/tools/message-tool.test.ts`
- `src/gateway/server-methods/send.test.ts`

View File

@@ -92,6 +92,21 @@ In group chats where you receive every message, be **smart about when to contrib
Participate, don't dominate.
### 😊 React Like a Human!
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
**React when:**
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
- Something made you laugh (😂, 💀)
- You find it interesting or thought-provoking (🤔, 💡)
- You want to acknowledge without interrupting the flow
- It's a simple yes/no or approval situation (✅, 👀)
**Why it matters:**
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
## Tools
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.

View File

@@ -48,6 +48,7 @@ Implementation:
**OpenAI / OpenAI Codex**
- Image sanitization only.
- On model switch into OpenAI Responses/Codex, drop orphaned reasoning signatures (standalone reasoning items without a following content block).
- No tool call id sanitization.
- No tool result pairing repair.
- No turn validation or reordering.

File diff suppressed because it is too large Load Diff

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.

View File

@@ -67,6 +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|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)

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
docs/tts.md Normal file
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`

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.

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:*"

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;

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"
}
}

View File

@@ -24,7 +24,7 @@
}
},
"dependencies": {
"@urbit/aura": "^2.0.0",
"@urbit/aura": "^3.0.0",
"@urbit/http-api": "^3.0.0"
}
}

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": [

34
fly.toml Normal file
View File

@@ -0,0 +1,34 @@
# Clawdbot Fly.io deployment configuration
# See https://fly.io/docs/reference/configuration/
app = "clawdbot"
primary_region = "iad" # change to your closest region
[build]
dockerfile = "Dockerfile"
[env]
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 --allow-unconfigured --port 3000 --bind lan"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = false # Keep running for persistent connections
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[vm]]
size = "shared-cpu-2x"
memory = "2048mb"
[mounts]
source = "clawdbot_data"
destination = "/data"

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": {

1255
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

View File

@@ -29,12 +29,23 @@ const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
const WARNING_SUPPRESSION_FLAGS = [
"--disable-warning=ExperimentalWarning",
"--disable-warning=DEP0040",
"--disable-warning=DEP0060",
];
const run = (entry) =>
new Promise((resolve) => {
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
const nodeOptions = process.env.NODE_OPTIONS ?? "";
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
nodeOptions,
);
const child = spawn(pnpm, args, {
stdio: "inherit",
env: { ...process.env, VITEST_GROUP: entry.name },
env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: nextNodeOptions },
shell: process.platform === "win32",
});
children.add(child);

View File

@@ -2,7 +2,7 @@
name: bird
description: X/Twitter CLI for reading, searching, posting, and engagement via cookies.
homepage: https://bird.fast
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)"},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)","os":["darwin"]},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
---
# bird 🐦

View File

@@ -70,7 +70,8 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: C
} {
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
const sessionKey = params.sessionKey?.trim();
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined;
const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
return { defaultAgentId, sessionAgentId };
}

View File

@@ -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();

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 }> = [];

View File

@@ -17,6 +17,7 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import { createTtsTool } from "./tools/tts-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
@@ -96,6 +97,10 @@ export function createClawdbotTools(options?: {
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
}),
createTtsTool({
agentChannel: options?.agentChannel,
config: options?.config,
}),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,

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,
});
}

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 was aborted|request 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);

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

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js";
describe("downgradeOpenAIReasoningBlocks", () => {
it("keeps reasoning signatures when followed by content", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "internal reasoning",
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
},
{ type: "text", text: "answer" },
],
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
});
it("drops orphaned reasoning blocks without following content", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }),
},
],
},
{ role: "user", content: "next" },
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([
{ role: "user", content: "next" },
]);
});
it("drops object-form orphaned signatures", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinkingSignature: { id: "rs_obj", type: "reasoning" },
},
],
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]);
});
it("keeps non-reasoning thinking signatures", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "t",
thinkingSignature: "reasoning_content",
},
],
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
});
});

View File

@@ -31,6 +31,8 @@ export {
parseImageDimensionError,
} from "./pi-embedded-helpers/errors.js";
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
export { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers/openai.js";
export {
isEmptyAssistantMessageContent,
sanitizeSessionMessagesImages,

View File

@@ -0,0 +1,118 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
type OpenAIThinkingBlock = {
type?: unknown;
thinking?: unknown;
thinkingSignature?: unknown;
};
type OpenAIReasoningSignature = {
id: string;
type: string;
};
function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
if (!value) return null;
let candidate: { id?: unknown; type?: unknown } | null = null;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
candidate = JSON.parse(trimmed) as { id?: unknown; type?: unknown };
} catch {
return null;
}
} else if (typeof value === "object") {
candidate = value as { id?: unknown; type?: unknown };
}
if (!candidate) return null;
const id = typeof candidate.id === "string" ? candidate.id : "";
const type = typeof candidate.type === "string" ? candidate.type : "";
if (!id.startsWith("rs_")) return null;
if (type === "reasoning" || type.startsWith("reasoning.")) {
return { id, type };
}
return null;
}
function hasFollowingNonThinkingBlock(
content: Extract<AgentMessage, { role: "assistant" }>["content"],
index: number,
): boolean {
for (let i = index + 1; i < content.length; i++) {
const block = content[i];
if (!block || typeof block !== "object") return true;
if ((block as { type?: unknown }).type !== "thinking") return true;
}
return false;
}
/**
* OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
* without the required following item.
*
* Clawdbot persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata
* is incomplete, drop the block to keep history usable.
*/
export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] {
const out: AgentMessage[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
out.push(msg);
continue;
}
const role = (msg as { role?: unknown }).role;
if (role !== "assistant") {
out.push(msg);
continue;
}
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
if (!Array.isArray(assistantMsg.content)) {
out.push(msg);
continue;
}
let changed = false;
type AssistantContentBlock = (typeof assistantMsg.content)[number];
const nextContent: AssistantContentBlock[] = [];
for (let i = 0; i < assistantMsg.content.length; i++) {
const block = assistantMsg.content[i];
if (!block || typeof block !== "object") {
nextContent.push(block as AssistantContentBlock);
continue;
}
const record = block as OpenAIThinkingBlock;
if (record.type !== "thinking") {
nextContent.push(block as AssistantContentBlock);
continue;
}
const signature = parseOpenAIReasoningSignature(record.thinkingSignature);
if (!signature) {
nextContent.push(block as AssistantContentBlock);
continue;
}
if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) {
nextContent.push(block as AssistantContentBlock);
continue;
}
changed = true;
}
if (!changed) {
out.push(msg);
continue;
}
if (nextContent.length === 0) {
continue;
}
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
}
return out;
}

View File

@@ -128,7 +128,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
slack: { dmHistoryLimit: 10 },
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:C1", config)).toBeUndefined();
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:c1", config)).toBeUndefined();
expect(getDmHistoryLimitFromSessionKey("telegram:slash:123", config)).toBeUndefined();
});
it("returns undefined for unknown provider", () => {

View File

@@ -126,7 +126,7 @@ describe("resolveSessionAgentIds", () => {
});
it("keeps the agent id for provider-qualified agent sessions", () => {
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: "agent:beta:slack:channel:C1",
sessionKey: "agent:beta:slack:channel:c1",
config: cfg,
});
expect(sessionAgentId).toBe("beta");

View File

@@ -161,4 +161,92 @@ describe("sanitizeSessionHistory", () => {
expect(result).toHaveLength(1);
expect(result[0]?.role).toBe("assistant");
});
it("does not downgrade openai reasoning when the model has not changed", async () => {
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
{
type: "custom",
customType: "model-snapshot",
data: {
timestamp: Date.now(),
provider: "openai",
modelApi: "openai-responses",
modelId: "gpt-5.2-codex",
},
},
];
const sessionManager = {
getEntries: vi.fn(() => sessionEntries),
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
sessionEntries.push({ type: "custom", customType, data });
}),
} as unknown as SessionManager;
const messages: AgentMessage[] = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "reasoning",
thinkingSignature: JSON.stringify({ id: "rs_test", type: "reasoning" }),
},
],
},
];
const result = await sanitizeSessionHistory({
messages,
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
sessionManager,
sessionId: "test-session",
});
expect(result).toEqual(messages);
});
it("downgrades openai reasoning only when the model changes", async () => {
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
{
type: "custom",
customType: "model-snapshot",
data: {
timestamp: Date.now(),
provider: "anthropic",
modelApi: "anthropic-messages",
modelId: "claude-3-7",
},
},
];
const sessionManager = {
getEntries: vi.fn(() => sessionEntries),
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
sessionEntries.push({ type: "custom", customType, data });
}),
} as unknown as SessionManager;
const messages: AgentMessage[] = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "reasoning",
thinkingSignature: { id: "rs_test", type: "reasoning" },
},
],
},
];
const result = await sanitizeSessionHistory({
messages,
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
sessionManager,
sessionId: "test-session",
});
expect(result).toEqual([]);
});
});

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,

View File

@@ -6,6 +6,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
import {
downgradeOpenAIReasoningBlocks,
isCompactionFailureError,
isGoogleModelApi,
sanitizeGoogleTurnOrdering,
@@ -211,7 +212,50 @@ registerUnhandledRejectionHandler((reason) => {
return true;
});
type CustomEntryLike = { type?: unknown; customType?: unknown };
type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown };
type ModelSnapshotEntry = {
timestamp: number;
provider?: string;
modelApi?: string | null;
modelId?: string;
};
const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot";
function readLastModelSnapshot(sessionManager: SessionManager): ModelSnapshotEntry | null {
try {
const entries = sessionManager.getEntries();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i] as CustomEntryLike;
if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) continue;
const data = entry?.data as ModelSnapshotEntry | undefined;
if (data && typeof data === "object") {
return data;
}
}
} catch {
return null;
}
return null;
}
function appendModelSnapshot(sessionManager: SessionManager, data: ModelSnapshotEntry): void {
try {
sessionManager.appendCustomEntry(MODEL_SNAPSHOT_CUSTOM_TYPE, data);
} catch {
// ignore persistence failures
}
}
function isSameModelSnapshot(a: ModelSnapshotEntry, b: ModelSnapshotEntry): boolean {
const normalize = (value?: string | null) => value ?? "";
return (
normalize(a.provider) === normalize(b.provider) &&
normalize(a.modelApi) === normalize(b.modelApi) &&
normalize(a.modelId) === normalize(b.modelId)
);
}
function hasGoogleTurnOrderingMarker(sessionManager: SessionManager): boolean {
try {
@@ -292,12 +336,38 @@ export async function sanitizeSessionHistory(params: {
? sanitizeToolUseResultPairing(sanitizedThinking)
: sanitizedThinking;
const isOpenAIResponsesApi =
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
const modelChanged = priorSnapshot
? !isSameModelSnapshot(priorSnapshot, {
timestamp: 0,
provider: params.provider,
modelApi: params.modelApi,
modelId: params.modelId,
})
: false;
const sanitizedOpenAI =
isOpenAIResponsesApi && modelChanged
? downgradeOpenAIReasoningBlocks(repairedTools)
: repairedTools;
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
appendModelSnapshot(params.sessionManager, {
timestamp: Date.now(),
provider: params.provider,
modelApi: params.modelApi,
modelId: params.modelId,
});
}
if (!policy.applyGoogleTurnOrdering) {
return repairedTools;
return sanitizedOpenAI;
}
return applyGoogleTurnOrderingFix({
messages: repairedTools,
messages: sanitizedOpenAI,
modelApi: params.modelApi,
sessionManager: params.sessionManager,
sessionId: params.sessionId,

View File

@@ -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,

View File

@@ -148,6 +148,35 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads[0]?.text).toBe("All good");
});
it("adds tool error fallback when the assistant only invoked tools", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: {
stopReason: "toolUse",
content: [
{
type: "toolCall",
id: "toolu_01",
name: "exec",
arguments: { command: "echo hi" },
},
],
} as AssistantMessage,
lastToolError: { toolName: "exec", error: "Command exited with code 1" },
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBe(true);
expect(payloads[0]?.text).toContain("Exec");
expect(payloads[0]?.text).toContain("code 1");
});
it("suppresses recoverable tool errors containing 'required'", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: [],

View File

@@ -169,7 +169,16 @@ export function buildEmbeddedRunPayloads(params: {
}
if (params.lastToolError) {
const hasUserFacingReply = replyItems.length > 0;
const lastAssistantHasToolCalls =
Array.isArray(params.lastAssistant?.content) &&
params.lastAssistant?.content.some((block) =>
block && typeof block === "object"
? (block as { type?: unknown }).type === "toolCall"
: false,
);
const lastAssistantWasToolUse = params.lastAssistant?.stopReason === "toolUse";
const hasUserFacingReply =
replyItems.length > 0 && !lastAssistantHasToolCalls && !lastAssistantWasToolUse;
// Check if this is a recoverable/internal tool error that shouldn't be shown to users
// when there's already a user-facing reply (the model should have retried).
const errorLower = (params.lastToolError.error ?? "").toLowerCase();

View File

@@ -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,

View File

@@ -106,7 +106,7 @@ describe("sandbox explain helpers", () => {
const msg = formatSandboxToolPolicyBlockedMessage({
cfg,
sessionKey: "agent:main:whatsapp:group:G1",
sessionKey: "agent:main:whatsapp:group:g1",
toolName: "browser",
});
expect(msg).toBeTruthy();

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",

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) {

View File

@@ -8,7 +8,6 @@ import { createMessageTool } from "./message-tool.js";
const mocks = vi.hoisted(() => ({
runMessageAction: vi.fn(),
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
}));
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
@@ -21,47 +20,9 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
};
});
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
};
});
describe("message tool mirroring", () => {
it("mirrors media filename for plugin-handled sends", async () => {
mocks.appendAssistantMessageToSessionTranscript.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
handledBy: "plugin",
payload: {},
dryRun: false,
} satisfies MessageActionRunResult);
const tool = createMessageTool({
agentSessionKey: "agent:main:main",
config: {} as never,
});
await tool.execute("1", {
action: "send",
target: "telegram:123",
message: "",
media: "https://example.com/files/report.pdf?sig=1",
});
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
expect.objectContaining({ text: "report.pdf" }),
);
});
it("does not mirror on dry-run", async () => {
mocks.appendAssistantMessageToSessionTranscript.mockClear();
describe("message tool agent routing", () => {
it("derives agentId from the session key", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
@@ -72,7 +33,7 @@ describe("message tool mirroring", () => {
} satisfies MessageActionRunResult);
const tool = createMessageTool({
agentSessionKey: "agent:main:main",
agentSessionKey: "agent:alpha:main",
config: {} as never,
});
@@ -82,7 +43,9 @@ describe("message tool mirroring", () => {
message: "hi",
});
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.agentId).toBe("alpha");
expect(call?.sessionKey).toBeUndefined();
});
});

View File

@@ -11,10 +11,6 @@ import {
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
appendAssistantMessageToSessionTranscript,
resolveMirroredTranscriptText,
} from "../../config/sessions.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
@@ -377,36 +373,11 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
defaultAccountId: accountId ?? undefined,
gateway,
toolContext,
sessionKey: options?.agentSessionKey,
agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined,
});
if (
action === "send" &&
options?.agentSessionKey &&
!result.dryRun &&
result.handledBy === "plugin"
) {
const mediaUrl = typeof params.media === "string" ? params.media : undefined;
const mirrorText = resolveMirroredTranscriptText({
text: typeof params.message === "string" ? params.message : undefined,
mediaUrls: mediaUrl ? [mediaUrl] : undefined,
});
if (mirrorText) {
const agentId = resolveSessionAgentId({
sessionKey: options.agentSessionKey,
config: cfg,
});
await appendAssistantMessageToSessionTranscript({
agentId,
sessionKey: options.agentSessionKey,
text: mirrorText,
});
}
}
const toolResult = getToolResult(result);
if (toolResult) return toolResult;
return jsonResult(result.payload);

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 });

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;

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,
});
},

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;

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",

View File

@@ -0,0 +1,60 @@
import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { textToSpeech } from "../../tts/tts.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
const TtsToolSchema = Type.Object({
text: Type.String({ description: "Text to convert to speech." }),
channel: Type.Optional(
Type.String({ description: "Optional channel id to pick output format (e.g. telegram)." }),
),
});
export function createTtsTool(opts?: {
config?: ClawdbotConfig;
agentChannel?: GatewayMessageChannel;
}): AnyAgentTool {
return {
label: "TTS",
name: "tts",
description:
"Convert text to speech and return a MEDIA: path. Use when the user requests audio or TTS is enabled. Copy the MEDIA line exactly.",
parameters: TtsToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const text = readStringParam(params, "text", { required: true });
const channel = readStringParam(params, "channel");
const cfg = opts?.config ?? loadConfig();
const result = await textToSpeech({
text,
cfg,
channel: channel ?? opts?.agentChannel,
});
if (result.success && result.audioPath) {
const lines: string[] = [];
// Tag Telegram Opus output as a voice bubble instead of a file attachment.
if (result.voiceCompatible) lines.push("[[audio_as_voice]]");
lines.push(`MEDIA:${result.audioPath}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { audioPath: result.audioPath, provider: result.provider },
};
}
return {
content: [
{
type: "text",
text: result.error ?? "TTS conversion failed",
},
],
details: { error: result.error },
};
},
};
}

View File

@@ -272,6 +272,27 @@ function buildChatCommands(): ChatCommandDefinition[] {
],
argsMenu: "auto",
}),
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
args: [
{
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,
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "stop",
nativeName: "stop",

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);

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,
);
}

View File

@@ -159,7 +159,7 @@ describe("RawBody directive parsing", () => {
ChatType: "group",
From: "+1222",
To: "+1222",
SessionKey: "agent:main:whatsapp:group:G1",
SessionKey: "agent:main:whatsapp:group:g1",
Provider: "whatsapp",
Surface: "whatsapp",
SenderE164: "+1222",
@@ -182,7 +182,7 @@ describe("RawBody directive parsing", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Session: agent:main:whatsapp:group:G1");
expect(text).toContain("Session: agent:main:whatsapp:group:g1");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});

View File

@@ -37,7 +37,7 @@ describe("abort detection", () => {
Body: `[Context]\nJake: /stop\n[from: Jake]`,
RawBody: "/stop",
ChatType: "group",
SessionKey: "agent:main:whatsapp:group:G1",
SessionKey: "agent:main:whatsapp:group:g1",
};
const result = await initSessionState({

View File

@@ -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");
});
});

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);

View File

@@ -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,
});

View File

@@ -16,6 +16,7 @@ import {
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleModelsCommand } from "./commands-models.js";
import { handleTtsCommands } from "./commands-tts.js";
import {
handleAbortTrigger,
handleActivationCommand,
@@ -39,6 +40,7 @@ const HANDLERS: CommandHandler[] = [
handleSendPolicyCommand,
handleUsageCommand,
handleRestartCommand,
handleTtsCommands,
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,

View File

@@ -0,0 +1,229 @@
import { logVerbose } from "../../globals.js";
import type { ReplyPayload } from "../types.js";
import type { CommandHandler } from "./commands-types.js";
import {
getLastTtsAttempt,
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
setLastTtsAttempt,
setSummarizationEnabled,
setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
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 parsed = parseTtsCommand(params.command.commandBodyNormalized);
if (!parsed) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring TTS command from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const config = resolveTtsConfig(params.cfg);
const prefsPath = resolveTtsPrefsPath(config);
const action = parsed.action;
const args = parsed.args;
if (action === "help") {
return { shouldContinue: false, reply: ttsUsage() };
}
if (action === "on") {
setTtsEnabled(prefsPath, true);
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
}
if (action === "off") {
setTtsEnabled(prefsPath, false);
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
}
if (action === "audio") {
if (!args.trim()) {
return { shouldContinue: false, reply: ttsUsage() };
}
const start = Date.now();
const result = await textToSpeech({
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: args.length,
summarized: false,
provider: result.provider,
latencyMs: result.latencyMs,
});
const payload: ReplyPayload = {
mediaUrl: result.audioPath,
audioAsVoice: result.voiceCompatible === true,
};
return { shouldContinue: false, reply: payload };
}
// Store failure details for `/tts status`.
setLastTtsAttempt({
timestamp: Date.now(),
success: false,
textLength: args.length,
summarized: false,
error: result.error,
latencyMs: Date.now() - start,
});
return {
shouldContinue: false,
reply: { text: `❌ Error generating audio: ${result.error ?? "unknown error"}` },
};
}
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
const fallback = currentProvider === "openai" ? "elevenlabs" : "openai";
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
return {
shouldContinue: false,
reply: {
text:
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
`Fallback: ${fallback}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Usage: /tts provider openai | elevenlabs`,
},
};
}
const requested = args.trim().toLowerCase();
if (requested !== "openai" && requested !== "elevenlabs") {
return { shouldContinue: false, reply: ttsUsage() };
}
setTtsProvider(prefsPath, requested);
const fallback = requested === "openai" ? "elevenlabs" : "openai";
return {
shouldContinue: false,
reply: { text: `✅ TTS provider set to ${requested} (fallback: ${fallback}).` },
};
}
if (action === "limit") {
if (!args.trim()) {
const currentLimit = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
};
}
const next = Number.parseInt(args.trim(), 10);
if (!Number.isFinite(next) || next < 100 || next > 10_000) {
return { shouldContinue: false, reply: ttsUsage() };
}
setTtsMaxLength(prefsPath, next);
return {
shouldContinue: false,
reply: { text: `✅ TTS limit set to ${next} characters.` },
};
}
if (action === "summary") {
if (!args.trim()) {
const enabled = isSummarizationEnabled(prefsPath);
return {
shouldContinue: false,
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
};
}
const requested = args.trim().toLowerCase();
if (requested !== "on" && requested !== "off") {
return { shouldContinue: false, reply: ttsUsage() };
}
setSummarizationEnabled(prefsPath, requested === "on");
return {
shouldContinue: false,
reply: {
text: requested === "on" ? "✅ TTS auto-summary enabled." : "❌ TTS auto-summary disabled.",
},
};
}
if (action === "status") {
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = Boolean(resolveTtsApiKey(config, provider));
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
const lines = [
"📊 TTS status",
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Provider: ${provider} (${hasKey ? "✅ key" : "❌ no key"})`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];
if (last) {
const timeAgo = Math.round((Date.now() - last.timestamp) / 1000);
lines.push("");
lines.push(`Last attempt (${timeAgo}s ago): ${last.success ? "✅" : "❌"}`);
lines.push(`Text: ${last.textLength} chars${last.summarized ? " (summarized)" : ""}`);
if (last.success) {
lines.push(`Provider: ${last.provider ?? "unknown"}`);
lines.push(`Latency: ${last.latencyMs ?? 0}ms`);
} else if (last.error) {
lines.push(`Error: ${last.error}`);
}
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
return { shouldContinue: false, reply: ttsUsage() };
};

View File

@@ -235,8 +235,8 @@ describe("handleCommands subagents", () => {
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:slack:slash:U1",
requesterDisplayKey: "agent:main:slack:slash:U1",
requesterSessionKey: "agent:main:slack:slash:u1",
requesterDisplayKey: "agent:main:slack:slash:u1",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
@@ -250,7 +250,7 @@ describe("handleCommands subagents", () => {
CommandSource: "native",
CommandTargetSessionKey: "agent:main:main",
});
params.sessionKey = "agent:main:slack:slash:U1";
params.sessionKey = "agent:main:slack:slash:u1";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Subagents (current session)");

View File

@@ -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,

View File

@@ -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";
@@ -13,6 +14,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { maybeApplyTtsToPayload } from "../../tts/tts.js";
export type DispatchFromConfigResult = {
queuedFinal: boolean;
@@ -79,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
@@ -91,6 +143,7 @@ export async function dispatchReplyFromConfig(params: {
const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase();
const shouldRouteToOriginating =
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface;
const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface;
/**
* Helper to send a payload via route-reply (async).
@@ -164,22 +217,36 @@ export async function dispatchReplyFromConfig(params: {
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
if (shouldRouteToOriginating) {
// Fire-and-forget for streaming tool results when routing.
void sendPayloadAsync(payload);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendToolResult(payload);
}
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
channel: ttsChannel,
kind: "tool",
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload);
} else {
dispatcher.sendToolResult(ttsPayload);
}
};
return run();
},
onBlockReply: (payload: ReplyPayload, context) => {
if (shouldRouteToOriginating) {
// Await routed sends so upstream can enforce ordering/timeouts.
return sendPayloadAsync(payload, context?.abortSignal);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendBlockReply(payload);
}
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
channel: ttsChannel,
kind: "block",
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload, context?.abortSignal);
} else {
dispatcher.sendBlockReply(ttsPayload);
}
};
return run();
},
},
cfg,
@@ -190,10 +257,16 @@ export async function dispatchReplyFromConfig(params: {
let queuedFinal = false;
let routedFinalCount = 0;
for (const reply of replies) {
const ttsReply = await maybeApplyTtsToPayload({
payload: reply,
cfg,
channel: ttsChannel,
kind: "final",
});
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
// Route final reply to originating channel.
const result = await routeReply({
payload: reply,
payload: ttsReply,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
@@ -209,7 +282,7 @@ export async function dispatchReplyFromConfig(params: {
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
} else {
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
}
}
await dispatcher.waitForIdle();

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");
});
});

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();

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);
});
});

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.

View File

@@ -45,8 +45,8 @@ async function resolveState(params: {
describe("createModelSelectionState parent inheritance", () => {
it("inherits parent override from explicit parentSessionKey", async () => {
const cfg = {} as ClawdbotConfig;
const parentKey = "agent:main:discord:channel:C1";
const sessionKey = "agent:main:discord:channel:C1:thread:123";
const parentKey = "agent:main:discord:channel:c1";
const sessionKey = "agent:main:discord:channel:c1:thread:123";
const parentEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
@@ -132,8 +132,8 @@ describe("createModelSelectionState parent inheritance", () => {
},
},
} as ClawdbotConfig;
const parentKey = "agent:main:slack:channel:C1";
const sessionKey = "agent:main:slack:channel:C1:thread:123";
const parentKey = "agent:main:slack:channel:c1";
const sessionKey = "agent:main:slack:channel:c1:thread:123";
const parentEntry = makeEntry({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-5",

View File

@@ -72,8 +72,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
});
if (!normalized) return { ok: true };
const text = normalized.text ?? "";
const mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
let text = normalized.text ?? "";
let mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
? (normalized.mediaUrls?.filter(Boolean) as string[])
: normalized.mediaUrl
? [normalized.mediaUrl]

View File

@@ -136,7 +136,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
it("Reset trigger works when RawBody is clean but Body has wrapped context", async () => {
const storePath = await createStorePath("clawdbot-group-rawbody-");
const sessionKey = "agent:main:whatsapp:group:G1";
const sessionKey = "agent:main:whatsapp:group:g1";
const existingSessionId = "existing-session-123";
await seedSessionStore({
storePath,

View File

@@ -0,0 +1,94 @@
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(),
};
const cliProvider = params.providerUsed ?? entry.modelProvider;
if (params.cliSessionId && cliProvider) {
const nextEntry = { ...entry, ...patch };
setCliSessionId(nextEntry, cliProvider, 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(),
};
const cliProvider = params.providerUsed ?? entry.modelProvider;
if (params.cliSessionId && cliProvider) {
const nextEntry = { ...entry, ...patch };
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);
return {
...patch,
cliSessionIds: nextEntry.cliSessionIds,
claudeCliSessionId: nextEntry.claudeCliSessionId,
};
}
return patch;
},
});
} catch (err) {
logVerbose(`failed to persist ${label}model/context update: ${String(err)}`);
}
}
}

View File

@@ -37,7 +37,7 @@ describe("initSessionState thread forking", () => {
);
const storePath = path.join(root, "sessions.json");
const parentSessionKey = "agent:main:slack:channel:C1";
const parentSessionKey = "agent:main:slack:channel:c1";
await saveSessionStore(storePath, {
[parentSessionKey]: {
sessionId: parentSessionId,
@@ -50,7 +50,7 @@ describe("initSessionState thread forking", () => {
session: { store: storePath },
} as ClawdbotConfig;
const threadSessionKey = "agent:main:slack:channel:C1:thread:123";
const threadSessionKey = "agent:main:slack:channel:c1:thread:123";
const threadLabel = "Slack thread #general: starter";
const result = await initSessionState({
ctx: {
@@ -117,7 +117,7 @@ describe("initSessionState RawBody", () => {
Body: `[Chat messages since your last reply - for context]\n[WhatsApp ...] Someone: hello\n\n[Current message - respond to this]\n[WhatsApp ...] Jake: /status\n[from: Jake McInteer (+6421807830)]`,
RawBody: "/status",
ChatType: "group",
SessionKey: "agent:main:whatsapp:group:G1",
SessionKey: "agent:main:whatsapp:group:g1",
};
const result = await initSessionState({
@@ -138,7 +138,7 @@ describe("initSessionState RawBody", () => {
Body: `[Context]\nJake: /new\n[from: Jake]`,
RawBody: "/new",
ChatType: "group",
SessionKey: "agent:main:whatsapp:group:G1",
SessionKey: "agent:main:whatsapp:group:g1",
};
const result = await initSessionState({
@@ -165,7 +165,7 @@ describe("initSessionState RawBody", () => {
const ctx = {
RawBody: "/NEW KeepThisCase",
ChatType: "direct",
SessionKey: "agent:main:whatsapp:dm:S1",
SessionKey: "agent:main:whatsapp:dm:s1",
};
const result = await initSessionState({
@@ -186,7 +186,7 @@ describe("initSessionState RawBody", () => {
const ctx = {
Body: "/status",
SessionKey: "agent:main:whatsapp:dm:S1",
SessionKey: "agent:main:whatsapp:dm:s1",
};
const result = await initSessionState({
@@ -206,7 +206,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S1";
const sessionKey = "agent:main:whatsapp:dm:s1";
const existingSessionId = "daily-session-id";
await saveSessionStore(storePath, {
@@ -236,7 +236,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-daily-edge-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S-edge";
const sessionKey = "agent:main:whatsapp:dm:s-edge";
const existingSessionId = "daily-edge-session";
await saveSessionStore(storePath, {
@@ -266,7 +266,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-idle-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S2";
const sessionKey = "agent:main:whatsapp:dm:s2";
const existingSessionId = "idle-session-id";
await saveSessionStore(storePath, {
@@ -301,7 +301,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:slack:channel:C1:thread:123";
const sessionKey = "agent:main:slack:channel:c1:thread:123";
const existingSessionId = "thread-session-id";
await saveSessionStore(storePath, {
@@ -337,7 +337,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-thread-nosuffix-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:discord:channel:C1";
const sessionKey = "agent:main:discord:channel:c1";
const existingSessionId = "thread-nosuffix";
await saveSessionStore(storePath, {
@@ -372,7 +372,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-type-default-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S4";
const sessionKey = "agent:main:whatsapp:dm:s4";
const existingSessionId = "type-default-session";
await saveSessionStore(storePath, {
@@ -407,7 +407,7 @@ describe("initSessionState reset policy", () => {
try {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reset-legacy-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:whatsapp:dm:S3";
const sessionKey = "agent:main:whatsapp:dm:s3";
const existingSessionId = "legacy-session-id";
await saveSessionStore(storePath, {

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)

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. */

View File

@@ -162,7 +162,7 @@ describe("cron cli", () => {
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
const patch = updateCall?.[2] as { patch?: { agentId?: unknown } };
expect(patch?.patch?.agentId).toBe("Ops");
expect(patch?.patch?.agentId).toBe("ops");
callGatewayFromCli.mockClear();
await program.parseAsync(["cron", "edit", "job-2", "--clear-agent"], {

View File

@@ -1,8 +1,8 @@
import type { Command } from "commander";
import type { CronJob } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js";

View File

@@ -1,7 +1,7 @@
import type { Command } from "commander";
import { danger } from "../../globals.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import {
getCronChannelOptions,

View File

@@ -72,7 +72,7 @@ describe("doctor legacy state migrations", () => {
expect(store["agent:main:+1666"]?.sessionId).toBe("b");
expect(store["+1555"]).toBeUndefined();
expect(store["+1666"]).toBeUndefined();
expect(store["agent:main:slack:channel:C123"]?.sessionId).toBe("c");
expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("c");
expect(store["agent:main:unknown:group:abc"]?.sessionId).toBe("d");
expect(store["agent:main:subagent:xyz"]?.sessionId).toBe("e");
});
@@ -278,6 +278,27 @@ describe("doctor legacy state migrations", () => {
expect(store["agent:main:main"]).toBeUndefined();
});
it("lowercases agent session keys during canonicalization", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};
const targetDir = path.join(root, "agents", "main", "sessions");
writeJson5(path.join(targetDir, "sessions.json"), {
"agent:main:slack:channel:C123": { sessionId: "legacy", updatedAt: 10 },
});
const detected = await detectLegacyStateMigrations({
cfg,
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
});
await runLegacyStateMigrations({ detected, now: () => 123 });
const store = JSON.parse(
fs.readFileSync(path.join(targetDir, "sessions.json"), "utf-8"),
) as Record<string, { sessionId: string }>;
expect(store["agent:main:slack:channel:c123"]?.sessionId).toBe("legacy");
expect(store["agent:main:slack:channel:C123"]).toBeUndefined();
});
it("auto-migrates when only target sessions contain legacy keys", async () => {
const root = await makeTempRoot();
const cfg: ClawdbotConfig = {};

View File

@@ -88,7 +88,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
? parts.slice(2).join(":")
: parts.slice(1).join(":")
: from;
const finalId = id.trim();
const finalId = id.trim().toLowerCase();
if (!finalId) return null;
return {

View File

@@ -23,7 +23,7 @@ export function deriveSessionKey(scope: SessionScope, ctx: MsgContext) {
*/
export function resolveSessionKey(scope: SessionScope, ctx: MsgContext, mainKey?: string) {
const explicit = ctx.SessionKey?.trim();
if (explicit) return explicit;
if (explicit) return explicit.toLowerCase();
const raw = deriveSessionKey(scope, ctx);
if (scope === "global") return raw;
const canonicalMainKey = normalizeMainKey(mainKey);

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 = {

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 = {

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 = {

View File

@@ -1,4 +1,5 @@
import type { QueueDropPolicy, QueueMode, QueueModeByProvider } from "./types.queue.js";
import type { TtsConfig } from "./types.tts.js";
export type GroupChatConfig = {
mentionPatterns?: string[];
@@ -81,6 +82,8 @@ export type MessagesConfig = {
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
/** Remove ack reaction after reply is sent (default: false). */
removeAckAfterReply?: boolean;
/** Text-to-speech settings for outbound replies. */
tts?: TtsConfig;
};
export type NativeCommandsSetting = boolean | "auto";

Some files were not shown because too many files have changed in this diff Show More