Compare commits

..
Author SHA1 Message Date
Peter Steinberger 557db51b8a fix: wire venice onboarding + docs (#1666) (thanks @jonisjongithub) 2026-01-25 01:11:40 +00:00
jonisjongithub 41c98942d8 docs: remove rate limits claim from Venice docs 2026-01-25 00:57:11 +00:00
jonisjongithub 4c4ca12e08 feat: add Venice AI provider integration
Venice AI is a privacy-focused AI inference provider with support for
uncensored models and access to major proprietary models via their
anonymized proxy.

This integration adds:

- Complete model catalog with 25 models:
  - 15 private models (Llama, Qwen, DeepSeek, Venice Uncensored, etc.)
  - 10 anonymized models (Claude, GPT-5.2, Gemini, Grok, Kimi, MiniMax)
- Auto-discovery from Venice API with fallback to static catalog
- VENICE_API_KEY environment variable support
- Interactive onboarding via 'venice-api-key' auth choice
- Model selection prompt showing all available Venice models
- Provider auto-registration when API key is detected
- Comprehensive documentation covering:
  - Privacy modes (private vs anonymized)
  - All 25 models with context windows and features
  - Streaming, function calling, and vision support
  - Model selection recommendations

Privacy modes:
- Private: Fully private, no logging (open-source models)
- Anonymized: Proxied through Venice (proprietary models)

Default model: venice/llama-3.3-70b (good balance of capability + privacy)
Venice API: https://api.venice.ai/api/v1 (OpenAI-compatible)
2026-01-25 00:57:11 +00:00
218 changed files with 882 additions and 5850 deletions

No files matched your search

-1
View File
@@ -18,7 +18,6 @@
- Docs are hosted on Mintlify (docs.clawd.bot). - Docs are hosted on Mintlify (docs.clawd.bot).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`). - Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`). - Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative). - When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced. - When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub. - README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
+1 -22
View File
@@ -6,52 +6,31 @@ Docs: https://docs.clawd.bot
### Highlights ### Highlights
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama - Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
### Changes ### Changes
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts - Models: add Venice AI provider support (onboarding, non-interactive auth, docs). (#1666) Thanks @jonisjongithub. https://docs.clawd.bot/providers/venice
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround). - Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance. - Docs: add verbose installer troubleshooting guidance.
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
- Docs: update Fly.io guide notes. - Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock - Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands - Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. - Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
### Fixes ### Fixes
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
- Auto-reply: don't treat `/models` as a `/model` directive. (#1753) Thanks @uos-status.
- Heartbeat: normalize target identifiers for consistent routing. - Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663) - TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) - Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. - Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz. - Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery. - Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted. - Models: default missing custom provider fields so minimal configs are accepted.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) - Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. - Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix. - Google Chat: normalize space targets without double `spaces/` prefix.
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
## 2026.1.23-1 ## 2026.1.23-1
+7 -7
View File
@@ -498,11 +498,11 @@ Thanks to all clawtributors:
<a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a>
<a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a>
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
@@ -1167,29 +1167,17 @@ public struct ConfigApplyParams: Codable, Sendable {
public struct ConfigPatchParams: Codable, Sendable { public struct ConfigPatchParams: Codable, Sendable {
public let raw: String public let raw: String
public let basehash: String? public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init( public init(
raw: String, raw: String,
basehash: String?, basehash: String?
sessionkey: String?,
note: String?,
restartdelayms: Int?
) { ) {
self.raw = raw self.raw = raw
self.basehash = basehash self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case raw case raw
case basehash = "baseHash" case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
} }
} }
@@ -1167,29 +1167,17 @@ public struct ConfigApplyParams: Codable, Sendable {
public struct ConfigPatchParams: Codable, Sendable { public struct ConfigPatchParams: Codable, Sendable {
public let raw: String public let raw: String
public let basehash: String? public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init( public init(
raw: String, raw: String,
basehash: String?, basehash: String?
sessionkey: String?,
note: String?,
restartdelayms: Int?
) { ) {
self.raw = raw self.raw = raw
self.basehash = basehash self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case raw case raw
case basehash = "baseHash" case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
} }
} }
-2
View File
@@ -205,7 +205,6 @@ Notes:
## Capabilities & limits ## Capabilities & limits
- DMs and guild text channels (threads are treated as separate channels; voice not supported). - DMs and guild text channels (threads are treated as separate channels; voice not supported).
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17). - Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking.
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB). - File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
- Mention-gated guild replies by default to avoid noisy bots. - Mention-gated guild replies by default to avoid noisy bots.
- Reply context is injected when a message references another message (quoted content + ids). - Reply context is injected when a message references another message (quoted content + ids).
@@ -307,7 +306,6 @@ ack reaction after the bot replies.
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel). - `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`). - `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000. - `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking.
- `maxLinesPerMessage`: soft max line count per message. Default: 17. - `maxLinesPerMessage`: soft max line count per message. Default: 17.
- `mediaMaxMb`: clamp inbound media saved to disk. - `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables). - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
+1 -1
View File
@@ -32,7 +32,7 @@ Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
- Under **Connection settings**, select **HTTP endpoint URL**. - Under **Connection settings**, select **HTTP endpoint URL**.
- Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`. - Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`.
- *Tip: Run `clawdbot status` to find your gateway's public URL.* - *Tip: Run `clawdbot status` to find your gateway's public URL.*
- Under **Visibility**, check **Make this Chat app available to specific people and groups in &lt;Your Domain&gt;**. - Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**.
- Enter your email address (e.g. `user@example.com`) in the text box. - Enter your email address (e.g. `user@example.com`) in the text box.
- Click **Save** at the bottom. - Click **Save** at the bottom.
6) **Enable the app status**: 6) **Enable the app status**:
+1 -1
View File
@@ -17,7 +17,7 @@ read_when:
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls). - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel. - **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`. - **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming. - **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
-2
View File
@@ -219,7 +219,6 @@ This is useful when you want an isolated personality/model for a specific thread
## Limits ## Limits
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000). - Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking.
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16). - Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
## Addressing / delivery targets ## Addressing / delivery targets
@@ -254,7 +253,6 @@ Provider options:
- `channels.imessage.includeAttachments`: ingest attachments into context. - `channels.imessage.includeAttachments`: ingest attachments into context.
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.imessage.textChunkLimit`: outbound chunk size (chars). - `channels.imessage.textChunkLimit`: outbound chunk size (chars).
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
Related global options: Related global options:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`). - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
-2
View File
@@ -32,8 +32,6 @@ Text is supported everywhere; media and reactions vary by channel.
## Notes ## Notes
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat. - Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
stores more state on disk.
- Group behavior varies by channel; see [Groups](/concepts/groups). - Group behavior varies by channel; see [Groups](/concepts/groups).
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security). - DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
- Telegram internals: [grammY notes](/channels/grammy). - Telegram internals: [grammY notes](/channels/grammy).
-1
View File
@@ -215,7 +215,6 @@ Provider options:
- `channels.matrix.initialSyncLimit`: initial sync limit. - `channels.matrix.initialSyncLimit`: initial sync limit.
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). - `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars). - `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible. - `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). - `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
-1
View File
@@ -415,7 +415,6 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. - `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
- `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.textChunkLimit`: outbound text chunk size.
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
- `channels.msteams.requireMention`: require @mention in channels/groups (default true). - `channels.msteams.requireMention`: require @mention in channels/groups (default true).
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)). - `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
-1
View File
@@ -114,7 +114,6 @@ Provider options:
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables). - `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit). - `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars). - `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel. - `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning. - `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB). - `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
-40
View File
@@ -74,22 +74,6 @@ Example:
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## External daemon mode (httpUrl)
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
```json5
{
channels: {
signal: {
httpUrl: "http://127.0.0.1:8080",
autoStart: false
}
}
}
```
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
## Access control (DMs + groups) ## Access control (DMs + groups)
DMs: DMs:
- Default: `channels.signal.dmPolicy = "pairing"`. - Default: `channels.signal.dmPolicy = "pairing"`.
@@ -111,7 +95,6 @@ Groups:
## Media + limits ## Media + limits
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000). - Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking.
- Attachments supported (base64 fetched from `signal-cli`). - Attachments supported (base64 fetched from `signal-cli`).
- Default media cap: `channels.signal.mediaMaxMb` (default 8). - Default media cap: `channels.signal.mediaMaxMb` (default 8).
- Use `channels.signal.ignoreAttachments` to skip downloading media. - Use `channels.signal.ignoreAttachments` to skip downloading media.
@@ -122,29 +105,8 @@ Groups:
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs. - **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
- Signal-cli does not expose read receipts for groups. - Signal-cli does not expose read receipts for groups.
## Reactions (message tool)
- Use `message action=react` with `channel=signal`.
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
- `messageId` is the Signal timestamp for the message you’re reacting to.
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
Examples:
```
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
```
Config:
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
- `off`/`ack` disables agent reactions (message tool `react` will error).
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
## Delivery targets (CLI/cron) ## Delivery targets (CLI/cron)
- DMs: `signal:+15551234567` (or plain E.164). - DMs: `signal:+15551234567` (or plain E.164).
- UUID DMs: `uuid:<id>` (or bare UUID).
- Groups: `signal:group:<groupId>`. - Groups: `signal:group:<groupId>`.
- Usernames: `username:<name>` (if supported by your Signal account). - Usernames: `username:<name>` (if supported by your Signal account).
@@ -158,7 +120,6 @@ Provider options:
- `channels.signal.httpUrl`: full daemon URL (overrides host/port). - `channels.signal.httpUrl`: full daemon URL (overrides host/port).
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080). - `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset). - `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
- `channels.signal.receiveMode`: `on-start | manual`. - `channels.signal.receiveMode`: `on-start | manual`.
- `channels.signal.ignoreAttachments`: skip attachment downloads. - `channels.signal.ignoreAttachments`: skip attachment downloads.
- `channels.signal.ignoreStories`: ignore stories from the daemon. - `channels.signal.ignoreStories`: ignore stories from the daemon.
@@ -170,7 +131,6 @@ Provider options:
- `channels.signal.historyLimit`: max group messages to include as context (0 disables). - `channels.signal.historyLimit`: max group messages to include as context (0 disables).
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`. - `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
- `channels.signal.textChunkLimit`: outbound chunk size (chars). - `channels.signal.textChunkLimit`: outbound chunk size (chars).
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
Related global options: Related global options:
-1
View File
@@ -349,7 +349,6 @@ ack reaction after the bot replies.
## Limits ## Limits
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000). - Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on each line before length chunking.
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20). - Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
## Reply threading ## Reply threading
-10
View File
@@ -120,13 +120,6 @@ You can add custom commands to the menu via config:
} }
``` ```
## Troubleshooting
- `setMyCommands failed` in logs usually means outbound HTTPS/DNS is blocked to `api.telegram.org`.
- If you see `sendMessage` or `sendChatAction` failures, check IPv6 routing and DNS.
More help: [Channel troubleshooting](/channels/troubleshooting).
Notes: Notes:
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere. - Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars). - Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
@@ -135,7 +128,6 @@ Notes:
## Limits ## Limits
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on each line before length chunking.
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). - Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs. - Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
@@ -524,8 +516,6 @@ Provider options:
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override. - `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
-1
View File
@@ -22,4 +22,3 @@ clawdbot channels status --probe
## Telegram quick fixes ## Telegram quick fixes
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting). - Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
+1 -2
View File
@@ -271,13 +271,12 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
## Limits ## Limits
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000). - Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on each line before length chunking.
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB). - Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB). - Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
## Outbound send (text + media) ## Outbound send (text + media)
- Uses active web listener; error if gateway not running. - Uses active web listener; error if gateway not running.
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`). - Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
- Media: - Media:
- Image/video/audio/document supported. - Image/video/audio/document supported.
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`. - Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
+2 -10
View File
@@ -66,12 +66,11 @@ Name lookup:
- Discord only: `--poll-duration-hours`, `--message` - Discord only: `--poll-duration-hours`, `--message`
- `react` - `react`
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal - Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--target` - Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid` - Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions) - Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me` - WhatsApp only: `--participant`, `--from-me`
- Signal group reactions: `--target-author` or `--target-author-uuid` required
- `reactions` - `reactions`
- Channels: Discord/Google Chat/Slack - Channels: Discord/Google Chat/Slack
@@ -214,13 +213,6 @@ clawdbot message react --channel slack \
--target C123 --message-id 456 --emoji "✅" --target C123 --message-id 456 --emoji "✅"
``` ```
React in a Signal group:
```
clawdbot message react --channel signal \
--target signal:group:abc123 --message-id 1737630212345 \
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
```
Send Telegram inline buttons: Send Telegram inline buttons:
``` ```
clawdbot message send --channel telegram --target @mychat --message "Choose:" \ clawdbot message send --channel telegram --target @mychat --message "Choose:" \
-2
View File
@@ -31,8 +31,6 @@ These files live under the workspace (`agents.defaults.workspace`, default
- Decisions, preferences, and durable facts go to `MEMORY.md`. - Decisions, preferences, and durable facts go to `MEMORY.md`.
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`. - Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
- If someone says "remember this," write it down (do not keep it in RAM). - If someone says "remember this," write it down (do not keep it in RAM).
- This area is still evolving. It helps to remind the model to store memories; it will know what to do.
- If you want something to stick, **ask the bot to write it** into memory.
## Automatic memory flush (pre-compaction ping) ## Automatic memory flush (pre-compaction ping)
-2
View File
@@ -89,8 +89,6 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default). - Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-gemini-cli-auth` - Enable: `clawdbot plugins enable google-gemini-cli-auth`
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default` - Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
- Note: you do **not** paste a client id or secret into `clawdbot.json`. The CLI login flow stores
tokens in auth profiles on the gateway host.
### Z.AI (GLM) ### Z.AI (GLM)
-1
View File
@@ -38,7 +38,6 @@ Legend:
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`. - `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send). - `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`). - Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on each line before length chunking).
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping. - Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
**Boundary semantics:** **Boundary semantics:**
-1
View File
@@ -1048,7 +1048,6 @@
"pages": [ "pages": [
"platforms", "platforms",
"platforms/macos", "platforms/macos",
"platforms/macos-vm",
"platforms/ios", "platforms/ios",
"platforms/android", "platforms/android",
"platforms/windows", "platforms/windows",
+5 -20
View File
@@ -46,9 +46,6 @@ better forms without hard-coding config knowledge.
Use `config.apply` to validate + write the full config and restart the Gateway in one step. Use `config.apply` to validate + write the full config and restart the Gateway in one step.
It writes a restart sentinel and pings the last active session after the Gateway comes back. It writes a restart sentinel and pings the last active session after the Gateway comes back.
Warning: `config.apply` replaces the **entire config**. If you want to change only a few keys,
use `config.patch` or `clawdbot config set`. Keep a backup of `~/.clawdbot/clawdbot.json`.
Params: Params:
- `raw` (string) — JSON5 payload for the entire config - `raw` (string) — JSON5 payload for the entire config
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists) - `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
@@ -507,7 +504,6 @@ For groups, use `channels.whatsapp.groupPolicy` + `channels.whatsapp.groupAllowF
dmPolicy: "pairing", // pairing | allowlist | open | disabled dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "+447700900123"], allowFrom: ["+15555550123", "+447700900123"],
textChunkLimit: 4000, // optional outbound chunk size (chars) textChunkLimit: 4000, // optional outbound chunk size (chars)
chunkMode: "length", // optional chunking mode (length | newline)
mediaMaxMb: 50 // optional inbound media cap (MB) mediaMaxMb: 50 // optional inbound media cap (MB)
} }
} }
@@ -1021,7 +1017,6 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
], ],
historyLimit: 50, // include last N group messages as context (0 disables) historyLimit: 50, // include last N group messages as context (0 disables)
replyToMode: "first", // off | first | all replyToMode: "first", // off | first | all
linkPreview: true, // toggle outbound link previews
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming) streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
draftChunk: { // optional; only for streamMode=block draftChunk: { // optional; only for streamMode=block
minChars: 200, minChars: 200,
@@ -1110,7 +1105,6 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
}, },
historyLimit: 20, // include last N guild messages as context historyLimit: 20, // include last N guild messages as context
textChunkLimit: 2000, // optional outbound text chunk size (chars) textChunkLimit: 2000, // optional outbound text chunk size (chars)
chunkMode: "length", // optional chunking mode (length | newline)
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping) maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
retry: { // outbound retry policy retry: { // outbound retry policy
attempts: 3, attempts: 3,
@@ -1131,7 +1125,7 @@ Reaction notification modes:
- `own`: reactions on the bot's own messages (default). - `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages. - `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables). - `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on line boundaries before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars. Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry). Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
### `channels.googlechat` (Chat API webhook) ### `channels.googlechat` (Chat API webhook)
@@ -1224,7 +1218,6 @@ Slack runs in Socket Mode and requires both a bot token and app token:
ephemeral: true ephemeral: true
}, },
textChunkLimit: 4000, textChunkLimit: 4000,
chunkMode: "length",
mediaMaxMb: 20 mediaMaxMb: 20
} }
} }
@@ -1274,8 +1267,7 @@ Mattermost requires a bot token plus the base URL for your server:
dmPolicy: "pairing", dmPolicy: "pairing",
chatmode: "oncall", // oncall | onmessage | onchar chatmode: "oncall", // oncall | onmessage | onchar
oncharPrefixes: [">", "!"], oncharPrefixes: [">", "!"],
textChunkLimit: 4000, textChunkLimit: 4000
chunkMode: "length"
} }
} }
} }
@@ -1510,7 +1502,7 @@ voice notes; other channels send MP3 audio.
{ {
messages: { messages: {
tts: { tts: {
auto: "always", // off | always | inbound | tagged enabled: true,
mode: "final", // final | all (include tool/block replies) mode: "final", // final | all (include tool/block replies)
provider: "elevenlabs", provider: "elevenlabs",
summaryModel: "openai/gpt-4.1-mini", summaryModel: "openai/gpt-4.1-mini",
@@ -1547,10 +1539,8 @@ voice notes; other channels send MP3 audio.
``` ```
Notes: Notes:
- `messages.tts.auto` controls auto‑TTS (`off`, `always`, `inbound`, `tagged`). - `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
- `/tts off|always|inbound|tagged` sets the per‑session auto mode (overrides config). - `prefsPath` stores local overrides (enabled/provider/limit/summarize).
- `messages.tts.enabled` is legacy; doctor migrates it to `messages.tts.auto`.
- `prefsPath` stores local overrides (provider/limit/summarize).
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit. - `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary. - `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
- Accepts `provider/model` or an alias from `agents.defaults.models`. - Accepts `provider/model` or an alias from `agents.defaults.models`.
@@ -2856,11 +2846,6 @@ Related docs:
- [Tailscale](/gateway/tailscale) - [Tailscale](/gateway/tailscale)
- [Remote access](/gateway/remote) - [Remote access](/gateway/remote)
Trusted proxies:
- `gateway.trustedProxies`: list of reverse proxy IPs that terminate TLS in front of the Gateway.
- When a connection comes from one of these IPs, Clawdbot uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks.
- Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`.
Notes: Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
-7
View File
@@ -29,8 +29,6 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh
- where the bot is allowed to act - where the bot is allowed to act
- what the bot can touch - what the bot can touch
Start with the smallest access that still works, then widen it as you gain confidence.
### What the audit checks (high level) ### What the audit checks (high level)
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot? - **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
@@ -324,11 +322,6 @@ Tailscale.
you terminate TLS or proxy in front of the gateway, disable you terminate TLS or proxy in front of the gateway, disable
`gateway.auth.allowTailscale` and use token/password auth instead. `gateway.auth.allowTailscale` and use token/password auth instead.
Trusted proxies:
- If you terminate TLS in front of the Gateway, set `gateway.trustedProxies` to your proxy IPs.
- Clawdbot will trust `x-forwarded-for` (or `x-real-ip`) from those IPs to determine the client IP for local pairing checks and HTTP auth/local checks.
- Ensure your proxy **overwrites** `x-forwarded-for` and blocks direct access to the Gateway port.
See [Tailscale](/gateway/tailscale) and [Web overview](/web). See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 0.6.1) Browser control server over Tailscale (recommended) ### 0.6.1) Browser control server over Tailscale (recommended)
+204 -785
View File
File diff suppressed because it is too large. Load diff
-6
View File
@@ -114,9 +114,3 @@ Git requirement:
If you choose `-InstallMethod git` and Git is missing, the installer will print the If you choose `-InstallMethod git` and Git is missing, the installer will print the
Git for Windows link (`https://git-scm.com/download/win`) and exit. Git for Windows link (`https://git-scm.com/download/win`) and exit.
Common Windows issues:
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
-275
View File
@@ -1,275 +0,0 @@
---
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
read_when:
- You want Clawdbot isolated from your main macOS environment
- You want iMessage integration (BlueBubbles) in a sandbox
- You want a resettable macOS environment you can clone
- You want to compare local vs hosted macOS VM options
---
# Clawdbot on macOS VMs (Sandboxing)
## Recommended default (most users)
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
## macOS VM options
### Local VM on your Apple Silicon Mac (Lume)
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
This gives you:
- Full macOS environment in isolation (your host stays clean)
- iMessage support via BlueBubbles (impossible on Linux/Windows)
- Instant reset by cloning VMs
- No extra hardware or cloud costs
### Hosted Mac providers (cloud)
If you want macOS in the cloud, hosted Mac providers work too:
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
- Other hosted Mac vendors also work; follow their VM + SSH docs
Once you have SSH access to a macOS VM, continue at step 6 below.
---
## Quick path (Lume, experienced users)
1. Install Lume
2. `lume create clawdbot --os macos --ipsw latest`
3. Complete Setup Assistant, enable Remote Login (SSH)
4. `lume run clawdbot --no-display`
5. SSH in, install Clawdbot, configure channels
6. Done
---
## What you need (Lume)
- Apple Silicon Mac (M1/M2/M3/M4)
- macOS Sequoia or later on the host
- ~60 GB free disk space per VM
- ~20 minutes
---
## 1) Install Lume
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
```
If `~/.local/bin` isn't in your PATH:
```bash
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
```
Verify:
```bash
lume --version
```
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
---
## 2) Create the macOS VM
```bash
lume create clawdbot --os macos --ipsw latest
```
This downloads macOS and creates the VM. A VNC window opens automatically.
Note: The download can take a while depending on your connection.
---
## 3) Complete Setup Assistant
In the VNC window:
1. Select language and region
2. Skip Apple ID (or sign in if you want iMessage later)
3. Create a user account (remember the username and password)
4. Skip all optional features
After setup completes, enable SSH:
1. Open System Settings → General → Sharing
2. Enable "Remote Login"
---
## 4) Get the VM's IP address
```bash
lume get clawdbot
```
Look for the IP address (usually `192.168.64.x`).
---
## 5) SSH into the VM
```bash
ssh youruser@192.168.64.X
```
Replace `youruser` with the account you created, and the IP with your VM's IP.
---
## 6) Install Clawdbot
Inside the VM:
```bash
npm install -g clawdbot@latest
clawdbot onboard --install-daemon
```
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
---
## 7) Configure channels
Edit the config file:
```bash
nano ~/.clawdbot/clawdbot.json
```
Add your channels:
```json
{
"channels": {
"whatsapp": {
"dmPolicy": "allowlist",
"allowFrom": ["+15551234567"]
},
"telegram": {
"botToken": "YOUR_BOT_TOKEN"
}
}
}
```
Then login to WhatsApp (scan QR):
```bash
clawdbot channels login
```
---
## 8) Run the VM headlessly
Stop the VM and restart without display:
```bash
lume stop clawdbot
lume run clawdbot --no-display
```
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
To check status:
```bash
ssh youruser@192.168.64.X "clawdbot status"
```
---
## Bonus: iMessage integration
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
Inside the VM:
1. Download BlueBubbles from bluebubbles.app
2. Sign in with your Apple ID
3. Enable the Web API and set a password
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
Add to your Clawdbot config:
```json
{
"channels": {
"bluebubbles": {
"serverUrl": "http://localhost:1234",
"password": "your-api-password",
"webhookPath": "/bluebubbles-webhook"
}
}
}
```
Restart the gateway. Now your agent can send and receive iMessages.
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
---
## Save a golden image
Before customizing further, snapshot your clean state:
```bash
lume stop clawdbot
lume clone clawdbot clawdbot-golden
```
Reset anytime:
```bash
lume stop clawdbot && lume delete clawdbot
lume clone clawdbot-golden clawdbot
lume run clawdbot --no-display
```
---
## Running 24/7
Keep the VM running by:
- Keeping your Mac plugged in
- Disabling sleep in System Settings → Energy Saver
- Using `caffeinate` if needed
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
| Lume command not found | Add `~/.local/bin` to your PATH |
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
---
## Related docs
- [VPS hosting](/vps)
- [Nodes](/nodes)
- [Gateway remote](/gateway/remote)
- [BlueBubbles channel](/channels/bluebubbles)
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
+1 -2
View File
@@ -7,8 +7,7 @@ read_when:
# Windows (WSL2) # Windows (WSL2)
Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The
CLI + Gateway run inside Linux, which keeps the runtime consistent and makes CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
tooling far more compatible (Node/Bun/pnpm, Linux binaries, skills). Native
Windows installs are untested and more problematic. Windows installs are untested and more problematic.
Native Windows companion apps are planned. Native Windows companion apps are planned.
-16
View File
@@ -67,22 +67,6 @@ Plugins can register:
Plugins run **in‑process** with the Gateway, so treat them as trusted code. Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Runtime helpers
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
```ts
const result = await api.runtime.tts.textToSpeechTelephony({
text: "Hello from Clawdbot",
cfg: api.config,
});
```
Notes:
- Uses core `messages.tts` configuration (OpenAI or ElevenLabs).
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
- Edge TTS is not supported for telephony.
## Discovery & precedence ## Discovery & precedence
Clawdbot scans, in order: Clawdbot scans, in order:
-81
View File
@@ -104,87 +104,6 @@ Notes:
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only. - `skipSignatureVerification` is for local testing only.
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
**same shape** — it deep‑merges with `messages.tts`.
```json5
{
tts: {
provider: "elevenlabs",
elevenlabs: {
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
```
Notes:
- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
### More examples
Use core TTS only (no override):
```json5
{
messages: {
tts: {
provider: "openai",
openai: { voice: "alloy" }
}
}
}
```
Override to ElevenLabs just for calls (keep core default elsewhere):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
provider: "elevenlabs",
elevenlabs: {
apiKey: "elevenlabs_key",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
}
}
}
}
```
Override only the OpenAI model for calls (deep‑merge example):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
openai: {
model: "gpt-4o-mini-tts",
voice: "marin"
}
}
}
}
}
}
}
```
## Inbound calls ## Inbound calls
Inbound policy defaults to `disabled`. To enable inbound calls, set: Inbound policy defaults to `disabled`. To enable inbound calls, set:
+1 -10
View File
@@ -11,15 +11,6 @@ default model as `provider/model`.
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels). Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
## Highlight: Venius (Venice AI)
Venius is our recommended Venice AI setup for privacy-first inference with an option to use Opus for hard tasks.
- Default: `venice/llama-3.3-70b`
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
See [Venice AI](/providers/venice).
## Quick start ## Quick start
1) Authenticate with the provider (usually via `clawdbot onboard`). 1) Authenticate with the provider (usually via `clawdbot onboard`).
@@ -44,7 +35,7 @@ See [Venice AI](/providers/venice).
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
- [GLM models](/providers/glm) - [GLM models](/providers/glm)
- [MiniMax](/providers/minimax) - [MiniMax](/providers/minimax)
- [Venius (Venice AI, privacy-focused)](/providers/venice) - [Venice AI (privacy-focused)](/providers/venice)
- [Ollama (local models)](/providers/ollama) - [Ollama (local models)](/providers/ollama)
## Transcription providers ## Transcription providers
-10
View File
@@ -9,15 +9,6 @@ read_when:
Clawdbot can use many LLM providers. Pick one, authenticate, then set the default Clawdbot can use many LLM providers. Pick one, authenticate, then set the default
model as `provider/model`. model as `provider/model`.
## Highlight: Venius (Venice AI)
Venius is our recommended Venice AI setup for privacy-first inference with an option to use Opus for the hardest tasks.
- Default: `venice/llama-3.3-70b`
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
See [Venice AI](/providers/venice).
## Quick start (two steps) ## Quick start (two steps)
1) Authenticate with the provider (usually via `clawdbot onboard`). 1) Authenticate with the provider (usually via `clawdbot onboard`).
@@ -41,7 +32,6 @@ See [Venice AI](/providers/venice).
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
- [GLM models](/providers/glm) - [GLM models](/providers/glm)
- [MiniMax](/providers/minimax) - [MiniMax](/providers/minimax)
- [Venius (Venice AI)](/providers/venice)
- [Amazon Bedrock](/bedrock) - [Amazon Bedrock](/bedrock)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration, For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
+3 -15
View File
@@ -4,19 +4,10 @@ read_when:
- You want privacy-focused inference in Clawdbot - You want privacy-focused inference in Clawdbot
- You want Venice AI setup guidance - You want Venice AI setup guidance
--- ---
# Venice AI (Venius highlight) # Venice AI Provider
**Venius** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging. Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.
## Why Venice in Clawdbot
- **Private inference** for open-source models (no logging).
- **Uncensored models** when you need them.
- **Anonymized access** to proprietary models (Opus/GPT/Gemini) when quality matters.
- OpenAI-compatible `/v1` endpoints.
## Privacy Modes ## Privacy Modes
Venice offers two privacy levels — understanding this is key to choosing your model: Venice offers two privacy levels — understanding this is key to choosing your model:
@@ -83,10 +74,8 @@ clawdbot chat --model venice/llama-3.3-70b "Hello, are you working?"
After setup, Clawdbot shows all available Venice models. Pick based on your needs: After setup, Clawdbot shows all available Venice models. Pick based on your needs:
- **Default (our pick)**: `venice/llama-3.3-70b` for private, balanced performance. - **Privacy**: Choose "private" models for fully private inference
- **Best overall quality**: `venice/claude-opus-45` for hard jobs (Opus remains the strongest). - **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy
- **Privacy**: Choose "private" models for fully private inference.
- **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy.
Change your default model anytime: Change your default model anytime:
@@ -112,7 +101,6 @@ clawdbot models list | grep venice
| Use Case | Recommended Model | Why | | Use Case | Recommended Model | Why |
|----------|-------------------|-----| |----------|-------------------|-----|
| **General chat** | `llama-3.3-70b` | Good all-around, fully private | | **General chat** | `llama-3.3-70b` | Good all-around, fully private |
| **Best overall quality** | `claude-opus-45` | Opus remains the strongest for hard tasks |
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy | | **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context | | **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model | | **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
+3 -5
View File
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
## One-click deploy ## One-click deploy
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a> <a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
After deploy, find your public URL in **Railway → your service → Settings → Domains**. After deploy, find your public URL in **Railway → your service → Settings → Domains**.
@@ -55,7 +55,6 @@ Attach a volume mounted at:
Set these variables on the service: Set these variables on the service:
- `SETUP_PASSWORD` (required) - `SETUP_PASSWORD` (required)
- `PORT=8080` (required — must match the port in Public Networking)
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended) - `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended) - `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret) - `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
@@ -83,9 +82,8 @@ If Telegram DMs are set to pairing, the setup wizard can approve the pairing cod
1) Go to https://discord.com/developers/applications 1) Go to https://discord.com/developers/applications
2) **New Application** → choose a name 2) **New Application** → choose a name
3) **Bot** → **Add Bot** 3) **Bot** → **Add Bot**
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup) 4) Copy the **Bot Token** and paste into `/setup`
5) Copy the **Bot Token** and paste into `/setup` 5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
## Backups & migration ## Backups & migration
+1 -1
View File
@@ -45,7 +45,7 @@ run on host, set an explicit per-agent override:
See [Web tools](/tools/web). See [Web tools](/tools/web).
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough. macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested, more problematic, and has poorer tool compatibility. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows). Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
## 1) Install the CLI (recommended) ## 1) Install the CLI (recommended)
-10
View File
@@ -23,16 +23,6 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved. - **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved.
- **Resumable**: Halted workflows return a token; approve and resume without re-running everything. - **Resumable**: Halted workflows return a token; approve and resume without re-running everything.
## Why a DSL instead of plain programs?
Lobster is intentionally small. The goal is not "a new language," it's a predictable, AI-friendly pipeline spec with first-class approvals and resume tokens.
- **Approve/resume is built in**: A normal program can prompt a human, but it can’t *pause and resume* with a durable token without you inventing that runtime yourself.
- **Determinism + auditability**: Pipelines are data, so they’re easy to log, diff, replay, and review.
- **Constrained surface for AI**: A tiny grammar + JSON piping reduces “creative” code paths and makes validation realistic.
- **Safety policy baked in**: Timeouts, output caps, sandbox checks, and allowlists are enforced by the runtime, not each script.
- **Still programmable**: Each step can call any CLI or script. If you want JS/TS, generate `.lobster` files from code.
## How it works ## How it works
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout. Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
+1 -1
View File
@@ -68,7 +68,7 @@ Text + native (when enabled):
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) - `/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`) - `/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) - `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
- `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts)) - `/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. - Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
- `/stop` - `/stop`
- `/restart` - `/restart`
-4
View File
@@ -26,10 +26,6 @@ Primary goals:
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default. - Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
- Avoid nested fan-out: sub-agents cannot spawn sub-agents. - Avoid nested fan-out: sub-agents cannot spawn sub-agents.
Cost note: each sub-agent has its **own** context and token usage. For heavy or repetitive
tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model.
You can configure this via `agents.defaults.subagents.model` or per-agent overrides.
## Tool ## Tool
Use `sessions_spawn`: Use `sessions_spawn`:
-7
View File
@@ -174,7 +174,6 @@ Search the web using your configured provider.
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region. - `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr") - `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
- `ui_lang` (optional): ISO language code for UI elements - `ui_lang` (optional): ISO language code for UI elements
- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`)
**Examples:** **Examples:**
@@ -194,12 +193,6 @@ await web_search({
search_lang: "fr", search_lang: "fr",
ui_lang: "fr" ui_lang: "fr"
}); });
// Recent results (past week)
await web_search({
query: "TMBG interview",
freshness: "pw"
});
``` ```
## web_fetch ## web_fetch
+16 -109
View File
@@ -8,37 +8,21 @@ read_when:
# Text-to-speech (TTS) # Text-to-speech (TTS)
Clawdbot can convert outbound replies into audio using ElevenLabs, OpenAI, or Edge 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. It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubble.
## Supported services ## Supported services
- **ElevenLabs** (primary or fallback provider) - **ElevenLabs** (primary or fallback provider)
- **OpenAI** (primary or fallback provider; also used for summaries) - **OpenAI** (primary or fallback provider; also used for summaries)
- **Edge TTS** (primary or fallback provider; uses `node-edge-tts`, default when no API keys)
### Edge TTS notes ## Required keys
Edge TTS uses Microsoft Edge's online neural TTS service via the `node-edge-tts` At least one of:
library. It's a hosted service (not local), uses Microsoft’s endpoints, and does
not require an API key. `node-edge-tts` exposes speech configuration options and
output formats, but not all options are supported by the Edge service. citeturn2search0
Because Edge TTS is a public web service without a published SLA or quota, treat it
as best-effort. If you need guaranteed limits and support, use OpenAI or ElevenLabs.
Microsoft's Speech REST API documents a 10‑minute audio limit per request; Edge TTS
does not publish limits, so assume similar or lower limits. citeturn0search3
## Optional keys
If you want OpenAI or ElevenLabs:
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`) - `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
- `OPENAI_API_KEY` - `OPENAI_API_KEY`
Edge TTS does **not** require an API key. If no API keys are found, Clawdbot defaults If both are configured, the selected provider is used first and the other is a fallback.
to Edge TTS (unless disabled via `messages.tts.edge.enabled=false`).
If multiple providers are configured, the selected provider is used first and the others are fallback options.
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`), Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
so that provider must also be authenticated if you enable summaries. so that provider must also be authenticated if you enable summaries.
@@ -48,16 +32,11 @@ so that provider must also be authenticated if you enable summaries.
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio) - [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 Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication) - [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
## Is it enabled by default? ## Is it enabled by default?
No. Auto‑TTS is **off** by default. Enable it in config with No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`). which writes a local preference override.
Edge TTS **is** enabled by default once TTS is on, and is used automatically
when no OpenAI or ElevenLabs API keys are available.
## Config ## Config
@@ -70,7 +49,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{ {
messages: { messages: {
tts: { tts: {
auto: "always", enabled: true,
provider: "elevenlabs" provider: "elevenlabs"
} }
} }
@@ -83,7 +62,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
{ {
messages: { messages: {
tts: { tts: {
auto: "always", enabled: true,
provider: "openai", provider: "openai",
summaryModel: "openai/gpt-4.1-mini", summaryModel: "openai/gpt-4.1-mini",
modelOverrides: { modelOverrides: {
@@ -115,48 +94,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
} }
``` ```
### Edge TTS primary (no API key)
```json5
{
messages: {
tts: {
auto: "always",
provider: "edge",
edge: {
enabled: true,
voice: "en-US-MichelleNeural",
lang: "en-US",
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
rate: "+10%",
pitch: "-5%"
}
}
}
}
```
### Disable Edge TTS
```json5
{
messages: {
tts: {
edge: {
enabled: false
}
}
}
}
```
### Custom limits + prefs path ### Custom limits + prefs path
```json5 ```json5
{ {
messages: { messages: {
tts: { tts: {
auto: "always", enabled: true,
maxTextLength: 4000, maxTextLength: 4000,
timeoutMs: 30000, timeoutMs: 30000,
prefsPath: "~/.clawdbot/settings/tts.json" prefsPath: "~/.clawdbot/settings/tts.json"
@@ -165,25 +109,13 @@ Full schema is in [Gateway configuration](/gateway/configuration).
} }
``` ```
### Only reply with audio after an inbound voice note
```json5
{
messages: {
tts: {
auto: "inbound"
}
}
}
```
### Disable auto-summary for long replies ### Disable auto-summary for long replies
```json5 ```json5
{ {
messages: { messages: {
tts: { tts: {
auto: "always" enabled: true
} }
} }
} }
@@ -197,20 +129,15 @@ Then run:
### Notes on fields ### Notes on fields
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`). - `enabled`: master toggle (default `false`; local prefs can override).
- `inbound` only sends audio after an inbound voice note.
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
- `enabled`: legacy toggle (doctor migrates this to `auto`).
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies). - `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic). - `provider`: `"elevenlabs"` or `"openai"` (fallback is automatic).
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
otherwise `edge`.
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`. - `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
- Accepts `provider/model` or a configured model alias. - Accepts `provider/model` or a configured model alias.
- `modelOverrides`: allow the model to emit TTS directives (on by default). - `modelOverrides`: allow the model to emit TTS directives (on by default).
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded. - `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
- `timeoutMs`: request timeout (ms). - `timeoutMs`: request timeout (ms).
- `prefsPath`: override the local prefs JSON path (provider/limit/summary). - `prefsPath`: override the local prefs JSON path.
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`). - `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
- `elevenlabs.baseUrl`: override ElevenLabs API base URL. - `elevenlabs.baseUrl`: override ElevenLabs API base URL.
- `elevenlabs.voiceSettings`: - `elevenlabs.voiceSettings`:
@@ -220,20 +147,10 @@ Then run:
- `elevenlabs.applyTextNormalization`: `auto|on|off` - `elevenlabs.applyTextNormalization`: `auto|on|off`
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`) - `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism) - `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
- `edge.enabled`: allow Edge TTS usage (default `true`; no API key).
- `edge.voice`: Edge neural voice name (e.g. `en-US-MichelleNeural`).
- `edge.lang`: language code (e.g. `en-US`).
- `edge.outputFormat`: Edge output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
- See Microsoft Speech output formats for valid values; not all formats are supported by Edge.
- `edge.rate` / `edge.pitch` / `edge.volume`: percent strings (e.g. `+10%`, `-5%`).
- `edge.saveSubtitles`: write JSON subtitles alongside the audio file.
- `edge.proxy`: proxy URL for Edge TTS requests.
- `edge.timeoutMs`: request timeout override (ms).
## Model-driven overrides (default on) ## Model-driven overrides (default on)
By default, the model **can** emit TTS directives for a single reply. By default, the model **can** emit TTS directives for a single reply.
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
When enabled, the model can emit `[[tts:...]]` directives to override the voice 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 for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
@@ -250,7 +167,7 @@ Here you go.
``` ```
Available directive keys (when enabled): Available directive keys (when enabled):
- `provider` (`openai` | `elevenlabs` | `edge`) - `provider` (`openai` | `elevenlabs`)
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs) - `voice` (OpenAI voice) or `voiceId` (ElevenLabs)
- `model` (OpenAI TTS model or ElevenLabs model id) - `model` (OpenAI TTS model or ElevenLabs model id)
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost` - `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
@@ -308,15 +225,8 @@ These override `messages.tts.*` for that host.
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble. - 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). - **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
- 44.1kHz / 128kbps is the default balance for speech clarity. - 44.1kHz / 128kbps is the default balance for speech clarity.
- **Edge TTS**: uses `edge.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
- `node-edge-tts` accepts an `outputFormat`, but not all formats are available
from the Edge service. citeturn2search0
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus). citeturn1search0
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
guaranteed Opus voice notes. citeturn1search1
- If the configured Edge output format fails, Clawdbot retries with MP3.
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX. This is not configurable; Telegram expects Opus for voice-note UX.
## Auto-TTS behavior ## Auto-TTS behavior
@@ -354,10 +264,8 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
`/voice` as the native command there. Text `/tts ...` still works. `/voice` as the native command there. Text `/tts ...` still works.
``` ```
/tts on
/tts off /tts off
/tts always
/tts inbound
/tts tagged
/tts status /tts status
/tts provider openai /tts provider openai
/tts limit 2000 /tts limit 2000
@@ -368,7 +276,6 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
Notes: Notes:
- Commands require an authorized sender (allowlist/owner rules still apply). - Commands require an authorized sender (allowlist/owner rules still apply).
- `commands.text` or native command registration must be enabled. - `commands.text` or native command registration must be enabled.
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
- `limit` and `summary` are stored in local prefs, not the main config. - `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). - `/tts audio` generates a one-off audio reply (does not toggle TTS on).
-8
View File
@@ -118,14 +118,6 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate
- `--deliver`: Deliver assistant replies to the provider (default off) - `--deliver`: Deliver assistant replies to the provider (default off)
- `--thinking <level>`: Override thinking level for sends - `--thinking <level>`: Override thinking level for sends
- `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`) - `--timeout-ms <ms>`: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`)
## Troubleshooting
No output after sending a message:
- Run `/status` in the TUI to confirm the Gateway is connected and idle/busy.
- Check the Gateway logs: `clawdbot logs --follow`.
- Confirm the agent can run: `clawdbot status` and `clawdbot models status`.
- If you expect messages in a chat channel, enable delivery (`/deliver on` or `--deliver`).
- `--history-limit <n>`: History entries to load (default 200) - `--history-limit <n>`: History entries to load (default 200)
## Troubleshooting ## Troubleshooting
-1
View File
@@ -374,7 +374,6 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: (text, limit) => chunker: (text, limit) =>
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit), getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to, allowFrom, mode }) => { resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? ""; const trimmed = to?.trim() ?? "";
+2 -13
View File
@@ -684,7 +684,6 @@ async function processMessageWithPipeline(params: {
spaceId, spaceId,
runtime, runtime,
core, core,
config,
statusSink, statusSink,
typingMessageName, typingMessageName,
}); });
@@ -726,11 +725,10 @@ async function deliverGoogleChatReply(params: {
spaceId: string; spaceId: string;
runtime: GoogleChatRuntimeEnv; runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime; core: GoogleChatCoreRuntime;
config: ClawdbotConfig;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
typingMessageName?: string; typingMessageName?: string;
}): Promise<void> { }): Promise<void> {
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params; const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
const mediaList = payload.mediaUrls?.length const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
: payload.mediaUrl : payload.mediaUrl
@@ -801,16 +799,7 @@ async function deliverGoogleChatReply(params: {
if (payload.text) { if (payload.text) {
const chunkLimit = account.config.textChunkLimit ?? 4000; const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunkMode = core.channel.text.resolveChunkMode( const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
config,
"googlechat",
account.accountId,
);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
payload.text,
chunkLimit,
chunkMode,
);
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]; const chunk = chunks[i];
try { try {
-1
View File
@@ -186,7 +186,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
outbound: { outbound: {
deliveryMode: "direct", deliveryMode: "direct",
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
-1
View File
@@ -50,7 +50,6 @@ export const MatrixConfigSchema = z.object({
replyToMode: z.enum(["off", "first", "all"]).optional(), replyToMode: z.enum(["off", "first", "all"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(), threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(), textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().optional(), mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(), autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(), autoJoinAllowlist: z.array(allowFromEntry).optional(),
@@ -16,11 +16,10 @@ export async function deliverMatrixReplies(params: {
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const core = getMatrixRuntime(); const core = getMatrixRuntime();
const cfg = core.config.loadConfig();
const tableMode = const tableMode =
params.tableMode ?? params.tableMode ??
core.channel.text.resolveMarkdownTableMode({ core.channel.text.resolveMarkdownTableMode({
cfg, cfg: core.config.loadConfig(),
channel: "matrix", channel: "matrix",
accountId: params.accountId, accountId: params.accountId,
}); });
@@ -30,7 +29,6 @@ export async function deliverMatrixReplies(params: {
} }
}; };
const chunkLimit = Math.min(params.textLimit, 4000); const chunkLimit = Math.min(params.textLimit, 4000);
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
let hasReplied = false; let hasReplied = false;
for (const reply of params.replies) { for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
@@ -56,11 +54,7 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied); Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) { if (mediaList.length === 0) {
for (const chunk of core.channel.text.chunkMarkdownTextWithMode( for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
text,
chunkLimit,
chunkMode,
)) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed) continue; if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, { await sendMessageMatrix(params.roomId, trimmed, {
@@ -42,9 +42,7 @@ const runtimeStub = {
channel: { channel: {
text: { text: {
resolveTextChunkLimit: () => 4000, resolveTextChunkLimit: () => 4000,
resolveChunkMode: () => "length",
chunkMarkdownText: (text: string) => (text ? [text] : []), chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code", resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text, convertMarkdownTables: (text: string) => text,
}, },
+1 -6
View File
@@ -61,12 +61,7 @@ export async function sendMessageMatrix(
); );
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
convertedMessage,
chunkLimit,
chunkMode,
);
const threadId = normalizeThreadId(opts.threadId); const threadId = normalizeThreadId(opts.threadId);
const relation = threadId const relation = threadId
? buildThreadRelation(threadId, opts.replyToId) ? buildThreadRelation(threadId, opts.replyToId)
-1
View File
@@ -6,7 +6,6 @@ import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
export const matrixOutbound: ChannelOutboundAdapter = { export const matrixOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, deps, replyToId, threadId }) => { sendText: async ({ to, text, deps, replyToId, threadId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix; const send = deps?.sendMatrix ?? sendMessageMatrix;
-2
View File
@@ -69,8 +69,6 @@ export type MatrixConfig = {
threadReplies?: "off" | "inbound" | "always"; threadReplies?: "off" | "inbound" | "always";
/** Outbound text chunk size (chars). Default: 4000. */ /** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number; textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Max outbound media size in MB. */ /** Max outbound media size in MB. */
mediaMaxMb?: number; mediaMaxMb?: number;
/** Auto-join invites (always|allowlist|off). Default: always. */ /** Auto-join invites (always|allowlist|off). Default: always. */
-1
View File
@@ -158,7 +158,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
outbound: { outbound: {
deliveryMode: "direct", deliveryMode: "direct",
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => { resolveTarget: ({ to }) => {
const trimmed = to?.trim(); const trimmed = to?.trim();
@@ -25,7 +25,6 @@ const MattermostAccountSchemaBase = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"), groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(), blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
}) })
@@ -738,12 +738,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
if (mediaUrls.length === 0) { if (mediaUrls.length === 0) {
const chunkMode = core.channel.text.resolveChunkMode( const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
cfg,
"mattermost",
account.accountId,
);
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
for (const chunk of chunks.length > 0 ? chunks : [text]) { for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) continue; if (!chunk) continue;
await sendMessageMattermost(to, chunk, { await sendMessageMattermost(to, chunk, {
-2
View File
@@ -36,8 +36,6 @@ export type MattermostAccountConfig = {
groupPolicy?: GroupPolicy; groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */ /** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number; textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */ /** Disable block streaming for this account. */
blockStreaming?: boolean; blockStreaming?: boolean;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
+9 -12
View File
@@ -9,21 +9,18 @@ import {
} from "./messenger.js"; } from "./messenger.js";
import { setMSTeamsRuntime } from "./runtime.js"; import { setMSTeamsRuntime } from "./runtime.js";
const chunkMarkdownText = (text: string, limit: number) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
for (let index = 0; index < text.length; index += limit) {
chunks.push(text.slice(index, index + limit));
}
return chunks;
};
const runtimeStub = { const runtimeStub = {
channel: { channel: {
text: { text: {
chunkMarkdownText, chunkMarkdownText: (text: string, limit: number) => {
chunkMarkdownTextWithMode: chunkMarkdownText, if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
for (let index = 0; index < text.length; index += limit) {
chunks.push(text.slice(index, index + limit));
}
return chunks;
},
resolveMarkdownTableMode: () => "code", resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text, convertMarkdownTables: (text: string) => text,
}, },
+4 -12
View File
@@ -1,5 +1,4 @@
import { import {
type ChunkMode,
isSilentReplyText, isSilentReplyText,
loadWebMedia, loadWebMedia,
type MarkdownTableMode, type MarkdownTableMode,
@@ -64,7 +63,6 @@ export type MSTeamsReplyRenderOptions = {
chunkText?: boolean; chunkText?: boolean;
mediaMode?: "split" | "inline"; mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
}; };
/** /**
@@ -131,16 +129,11 @@ function pushTextMessages(
opts: { opts: {
chunkText: boolean; chunkText: boolean;
chunkLimit: number; chunkLimit: number;
chunkMode: ChunkMode;
}, },
) { ) {
if (!text) return; if (!text) return;
if (opts.chunkText) { if (opts.chunkText) {
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownTextWithMode( for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
text,
opts.chunkLimit,
opts.chunkMode,
)) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue; if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push({ text: trimmed }); out.push({ text: trimmed });
@@ -204,7 +197,6 @@ export function renderReplyPayloadsToMessages(
const out: MSTeamsRenderedMessage[] = []; const out: MSTeamsRenderedMessage[] = [];
const chunkLimit = Math.min(options.textChunkLimit, 4000); const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false; const chunkText = options.chunkText !== false;
const chunkMode = options.chunkMode ?? "length";
const mediaMode = options.mediaMode ?? "split"; const mediaMode = options.mediaMode ?? "split";
const tableMode = const tableMode =
options.tableMode ?? options.tableMode ??
@@ -223,7 +215,7 @@ export function renderReplyPayloadsToMessages(
if (!text && mediaList.length === 0) continue; if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) { if (mediaList.length === 0) {
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); pushTextMessages(out, text, { chunkText, chunkLimit });
continue; continue;
} }
@@ -237,13 +229,13 @@ export function renderReplyPayloadsToMessages(
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] }); if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
} }
} else { } else {
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); pushTextMessages(out, text, { chunkText, chunkLimit });
} }
continue; continue;
} }
// mediaMode === "split" // mediaMode === "split"
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); pushTextMessages(out, text, { chunkText, chunkLimit });
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
if (!mediaUrl) continue; if (!mediaUrl) continue;
out.push({ mediaUrl }); out.push({ mediaUrl });
-1
View File
@@ -7,7 +7,6 @@ import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
export const msteamsOutbound: ChannelOutboundAdapter = { export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
sendText: async ({ cfg, to, text, deps }) => { sendText: async ({ cfg, to, text, deps }) => {
@@ -59,7 +59,6 @@ export function createMSTeamsReplyDispatcher(params: {
cfg: params.cfg, cfg: params.cfg,
agentId: params.agentId, agentId: params.agentId,
}); });
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({ core.channel.reply.createReplyDispatcherWithTyping({
@@ -76,7 +75,6 @@ export function createMSTeamsReplyDispatcher(params: {
chunkText: true, chunkText: true,
mediaMode: "split", mediaMode: "split",
tableMode, tableMode,
chunkMode,
}); });
const mediaMaxBytes = resolveChannelMediaMaxBytes({ const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg, cfg: params.cfg,
-1
View File
@@ -247,7 +247,6 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
outbound: { outbound: {
deliveryMode: "direct", deliveryMode: "direct",
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, accountId, replyToId }) => { sendText: async ({ to, text, accountId, replyToId }) => {
const result = await sendMessageNextcloudTalk(to, text, { const result = await sendMessageNextcloudTalk(to, text, {
@@ -44,7 +44,6 @@ export const NextcloudTalkAccountSchemaBase = z
dmHistoryLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(), blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(), mediaMaxMb: z.number().positive().optional(),
-2
View File
@@ -62,8 +62,6 @@ export type NextcloudTalkAccountConfig = {
dms?: Record<string, DmConfig>; dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */ /** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number; textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */ /** Disable block streaming for this account. */
blockStreaming?: boolean; blockStreaming?: boolean;
/** Merge streamed block replies before sending. */ /** Merge streamed block replies before sending. */
+1 -12
View File
@@ -18,20 +18,12 @@ import {
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
signalOnboardingAdapter, signalOnboardingAdapter,
SignalConfigSchema, SignalConfigSchema,
type ChannelMessageActionAdapter,
type ChannelPlugin, type ChannelPlugin,
type ResolvedSignalAccount, type ResolvedSignalAccount,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { getSignalRuntime } from "./runtime.js"; import { getSignalRuntime } from "./runtime.js";
const signalMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx),
supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx),
handleAction: async (ctx) =>
await getSignalRuntime().channel.signal.messageActions.handleAction(ctx),
};
const meta = getChatChannelMeta("signal"); const meta = getChatChannelMeta("signal");
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = { export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
@@ -50,9 +42,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
capabilities: { capabilities: {
chatTypes: ["direct", "group"], chatTypes: ["direct", "group"],
media: true, media: true,
reactions: true,
}, },
actions: signalMessageActions,
streaming: { streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
}, },
@@ -125,7 +115,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
normalizeTarget: normalizeSignalMessagingTarget, normalizeTarget: normalizeSignalMessagingTarget,
targetResolver: { targetResolver: {
looksLikeId: looksLikeSignalTargetId, looksLikeId: looksLikeSignalTargetId,
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>", hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
}, },
}, },
setup: { setup: {
@@ -207,7 +197,6 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
outbound: { outbound: {
deliveryMode: "direct", deliveryMode: "direct",
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit), chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => { sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal; const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
-1
View File
@@ -251,7 +251,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
outbound: { outbound: {
deliveryMode: "direct", deliveryMode: "direct",
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = const send =
-7
View File
@@ -1,12 +1,5 @@
# Changelog # Changelog
## 2026.1.24
### Changes
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
## 2026.1.23 ## 2026.1.23
### Changes ### Changes
-21
View File
@@ -75,27 +75,6 @@ Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL. - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
same shape — overrides deep-merge with `messages.tts`.
```json5
{
tts: {
provider: "openai",
openai: {
voice: "alloy"
}
}
}
```
Notes:
- Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
## CLI ## CLI
```bash ```bash
+11 -207
View File
@@ -99,39 +99,16 @@
"label": "Media Stream Path", "label": "Media Stream Path",
"advanced": true "advanced": true
}, },
"tts.provider": { "tts.model": {
"label": "TTS Provider Override", "label": "TTS Model",
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
"advanced": true "advanced": true
}, },
"tts.openai.model": { "tts.voice": {
"label": "OpenAI TTS Model", "label": "TTS Voice",
"advanced": true "advanced": true
}, },
"tts.openai.voice": { "tts.instructions": {
"label": "OpenAI TTS Voice", "label": "TTS Instructions",
"advanced": true
},
"tts.openai.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"advanced": true
},
"tts.elevenlabs.modelId": {
"label": "ElevenLabs Model ID",
"advanced": true
},
"tts.elevenlabs.voiceId": {
"label": "ElevenLabs Voice ID",
"advanced": true
},
"tts.elevenlabs.apiKey": {
"label": "ElevenLabs API Key",
"sensitive": true,
"advanced": true
},
"tts.elevenlabs.baseUrl": {
"label": "ElevenLabs Base URL",
"advanced": true "advanced": true
}, },
"publicUrl": { "publicUrl": {
@@ -393,193 +370,20 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"auto": {
"type": "string",
"enum": [
"off",
"always",
"inbound",
"tagged"
]
},
"enabled": {
"type": "boolean"
},
"mode": {
"type": "string",
"enum": [
"final",
"all"
]
},
"provider": { "provider": {
"type": "string", "type": "string",
"enum": [ "enum": [
"openai", "openai"
"elevenlabs",
"edge"
] ]
}, },
"summaryModel": { "model": {
"type": "string" "type": "string"
}, },
"modelOverrides": { "voice": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowText": {
"type": "boolean"
},
"allowProvider": {
"type": "boolean"
},
"allowVoice": {
"type": "boolean"
},
"allowModelId": {
"type": "boolean"
},
"allowVoiceSettings": {
"type": "boolean"
},
"allowNormalization": {
"type": "boolean"
},
"allowSeed": {
"type": "boolean"
}
}
},
"elevenlabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"voiceId": {
"type": "string"
},
"modelId": {
"type": "string"
},
"seed": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295
},
"applyTextNormalization": {
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"languageCode": {
"type": "string"
},
"voiceSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"stability": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"similarityBoost": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"style": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"useSpeakerBoost": {
"type": "boolean"
},
"speed": {
"type": "number",
"minimum": 0.5,
"maximum": 2
}
}
}
}
},
"openai": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
}
}
},
"edge": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"voice": {
"type": "string"
},
"lang": {
"type": "string"
},
"outputFormat": {
"type": "string"
},
"pitch": {
"type": "string"
},
"rate": {
"type": "string"
},
"volume": {
"type": "string"
},
"saveSubtitles": {
"type": "boolean"
},
"proxy": {
"type": "string"
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
"prefsPath": {
"type": "string" "type": "string"
}, },
"maxTextLength": { "instructions": {
"type": "integer", "type": "string"
"minimum": 1
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
} }
} }
}, },
+3 -21
View File
@@ -74,26 +74,9 @@ const voiceCallConfigSchema = {
}, },
"streaming.sttModel": { label: "Realtime STT Model", advanced: true }, "streaming.sttModel": { label: "Realtime STT Model", advanced: true },
"streaming.streamPath": { label: "Media Stream Path", advanced: true }, "streaming.streamPath": { label: "Media Stream Path", advanced: true },
"tts.provider": { "tts.model": { label: "TTS Model", advanced: true },
label: "TTS Provider Override", "tts.voice": { label: "TTS Voice", advanced: true },
help: "Deep-merges with messages.tts (Edge is ignored for calls).", "tts.instructions": { label: "TTS Instructions", advanced: true },
advanced: true,
},
"tts.openai.model": { label: "OpenAI TTS Model", advanced: true },
"tts.openai.voice": { label: "OpenAI TTS Voice", advanced: true },
"tts.openai.apiKey": {
label: "OpenAI API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.modelId": { label: "ElevenLabs Model ID", advanced: true },
"tts.elevenlabs.voiceId": { label: "ElevenLabs Voice ID", advanced: true },
"tts.elevenlabs.apiKey": {
label: "ElevenLabs API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.baseUrl": { label: "ElevenLabs Base URL", advanced: true },
publicUrl: { label: "Public Webhook URL", advanced: true }, publicUrl: { label: "Public Webhook URL", advanced: true },
skipSignatureVerification: { skipSignatureVerification: {
label: "Skip Signature Verification", label: "Skip Signature Verification",
@@ -178,7 +161,6 @@ const voiceCallPlugin = {
runtimePromise = createVoiceCallRuntime({ runtimePromise = createVoiceCallRuntime({
config: cfg, config: cfg,
coreConfig: api.config as CoreConfig, coreConfig: api.config as CoreConfig,
ttsRuntime: api.runtime.tts,
logger: api.logger, logger: api.logger,
}); });
} }
+22 -73
View File
@@ -82,82 +82,31 @@ export const SttConfigSchema = z
.default({ provider: "openai", model: "whisper-1" }); .default({ provider: "openai", model: "whisper-1" });
export type SttConfig = z.infer<typeof SttConfigSchema>; export type SttConfig = z.infer<typeof SttConfigSchema>;
export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
export const TtsConfigSchema = z export const TtsConfigSchema = z
.object({ .object({
auto: TtsAutoSchema.optional(), /** TTS provider (currently only OpenAI supported) */
enabled: z.boolean().optional(), provider: z.literal("openai").default("openai"),
mode: TtsModeSchema.optional(), /**
provider: TtsProviderSchema.optional(), * TTS model to use:
summaryModel: z.string().optional(), * - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
modelOverrides: z * - tts-1: lower latency
.object({ * - tts-1-hd: higher quality
enabled: z.boolean().optional(), */
allowText: z.boolean().optional(), model: z.string().min(1).default("gpt-4o-mini-tts"),
allowProvider: z.boolean().optional(), /**
allowVoice: z.boolean().optional(), * Voice ID. For best quality, use marin or cedar.
allowModelId: z.boolean().optional(), * All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
allowVoiceSettings: z.boolean().optional(), */
allowNormalization: z.boolean().optional(), voice: z.string().min(1).default("coral"),
allowSeed: z.boolean().optional(), /**
}) * Instructions for speech style (only works with gpt-4o-mini-tts).
.strict() * Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
.optional(), */
elevenlabs: z instructions: z.string().optional(),
.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
seed: z.number().int().min(0).max(4294967295).optional(),
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
languageCode: z.string().optional(),
voiceSettings: z
.object({
stability: z.number().min(0).max(1).optional(),
similarityBoost: z.number().min(0).max(1).optional(),
style: z.number().min(0).max(1).optional(),
useSpeakerBoost: z.boolean().optional(),
speed: z.number().min(0.5).max(2).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
openai: z
.object({
apiKey: z.string().optional(),
model: z.string().optional(),
voice: z.string().optional(),
})
.strict()
.optional(),
edge: z
.object({
enabled: z.boolean().optional(),
voice: z.string().optional(),
lang: z.string().optional(),
outputFormat: z.string().optional(),
pitch: z.string().optional(),
rate: z.string().optional(),
volume: z.string().optional(),
saveSubtitles: z.boolean().optional(),
proxy: z.string().optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
})
.strict()
.optional(),
prefsPath: z.string().optional(),
maxTextLength: z.number().int().min(1).optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
}) })
.strict() .strict()
.optional(); .default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>; export type TtsConfig = z.infer<typeof TtsConfigSchema>;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Webhook Server Configuration // Webhook Server Configuration
@@ -358,7 +307,7 @@ export const VoiceCallConfigSchema = z
/** STT configuration */ /** STT configuration */
stt: SttConfigSchema, stt: SttConfigSchema,
/** TTS override (deep-merges with core messages.tts) */ /** TTS configuration */
tts: TtsConfigSchema, tts: TtsConfigSchema,
/** Store path for call logs */ /** Store path for call logs */
-6
View File
@@ -2,16 +2,10 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import type { VoiceCallTtsConfig } from "./config.js";
export type CoreConfig = { export type CoreConfig = {
session?: { session?: {
store?: string; store?: string;
}; };
messages?: {
tts?: VoiceCallTtsConfig;
};
[key: string]: unknown;
}; };
type CoreAgentDeps = { type CoreAgentDeps = {
+2 -4
View File
@@ -143,7 +143,7 @@ export class CallManager {
// For notify mode with a message, use inline TwiML with <Say> // For notify mode with a message, use inline TwiML with <Say>
let inlineTwiml: string | undefined; let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) { if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice); const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice); inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
console.log( console.log(
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`, `[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
@@ -210,13 +210,11 @@ export class CallManager {
this.addTranscriptEntry(call, "bot", text); this.addTranscriptEntry(call, "bot", text);
// Play TTS // Play TTS
const voice =
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
await this.provider.playTts({ await this.provider.playTts({
callId, callId,
providerCallId: call.providerCallId, providerCallId: call.providerCallId,
text, text,
voice, voice: this.config.tts.voice,
}); });
return { success: true }; return { success: true };
@@ -68,7 +68,7 @@ export async function initiateCall(
// For notify mode with a message, use inline TwiML with <Say>. // For notify mode with a message, use inline TwiML with <Say>.
let inlineTwiml: string | undefined; let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) { if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice); const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice); inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`); console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
} }
@@ -120,13 +120,11 @@ export async function speak(
addTranscriptEntry(call, "bot", text); addTranscriptEntry(call, "bot", text);
const voice =
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await ctx.provider.playTts({ await ctx.provider.playTts({
callId, callId,
providerCallId: call.providerCallId, providerCallId: call.providerCallId,
text, text,
voice, voice: ctx.config.tts.voice,
}); });
return { success: true }; return { success: true };
@@ -246,3 +244,4 @@ export async function endCall(
return { success: false, error: err instanceof Error ? err.message : String(err) }; return { success: false, error: err instanceof Error ? err.message : String(err) };
} }
} }
+14 -14
View File
@@ -15,9 +15,9 @@ import type {
WebhookVerificationResult, WebhookVerificationResult,
} from "../types.js"; } from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js"; import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import { chunkAudio } from "../telephony-audio.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type { VoiceCallProvider } from "./base.js"; import type { VoiceCallProvider } from "./base.js";
import type { OpenAITTSProvider } from "./tts-openai.js";
import { chunkAudio } from "./tts-openai.js";
import { twilioApiRequest } from "./twilio/api.js"; import { twilioApiRequest } from "./twilio/api.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
@@ -53,8 +53,8 @@ export class TwilioProvider implements VoiceCallProvider {
/** Current public webhook URL (set when tunnel starts or from config) */ /** Current public webhook URL (set when tunnel starts or from config) */
private currentPublicUrl: string | null = null; private currentPublicUrl: string | null = null;
/** Optional telephony TTS provider for streaming TTS */ /** Optional OpenAI TTS provider for streaming TTS */
private ttsProvider: TelephonyTtsProvider | null = null; private ttsProvider: OpenAITTSProvider | null = null;
/** Optional media stream handler for sending audio */ /** Optional media stream handler for sending audio */
private mediaStreamHandler: MediaStreamHandler | null = null; private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -119,7 +119,7 @@ export class TwilioProvider implements VoiceCallProvider {
return this.currentPublicUrl; return this.currentPublicUrl;
} }
setTTSProvider(provider: TelephonyTtsProvider): void { setTTSProvider(provider: OpenAITTSProvider): void {
this.ttsProvider = provider; this.ttsProvider = provider;
} }
@@ -454,13 +454,13 @@ export class TwilioProvider implements VoiceCallProvider {
* Play TTS audio via Twilio. * Play TTS audio via Twilio.
* *
* Two modes: * Two modes:
* 1. Core TTS + Media Streams: If TTS provider and media stream are available, * 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
* generates audio via core TTS and streams it through WebSocket (preferred). * generates audio via OpenAI and streams it through WebSocket (preferred).
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices. * 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
* Note: This may not work on all Twilio accounts. * Note: This may not work on all Twilio accounts.
*/ */
async playTts(input: PlayTtsInput): Promise<void> { async playTts(input: PlayTtsInput): Promise<void> {
// Try telephony TTS via media stream first (if configured) // Try OpenAI TTS via media stream first (if configured)
const streamSid = this.callStreamMap.get(input.providerCallId); const streamSid = this.callStreamMap.get(input.providerCallId);
if (this.ttsProvider && this.mediaStreamHandler && streamSid) { if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
try { try {
@@ -468,7 +468,7 @@ export class TwilioProvider implements VoiceCallProvider {
return; return;
} catch (err) { } catch (err) {
console.warn( console.warn(
`[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`, `[voice-call] OpenAI TTS failed, falling back to Twilio <Say>:`,
err instanceof Error ? err.message : err, err instanceof Error ? err.message : err,
); );
// Fall through to TwiML <Say> fallback // Fall through to TwiML <Say> fallback
@@ -484,7 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
} }
console.warn( console.warn(
"[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active", "[voice-call] Using TwiML <Say> fallback - OpenAI TTS not configured or media stream not active",
); );
const pollyVoice = mapVoiceToPolly(input.voice); const pollyVoice = mapVoiceToPolly(input.voice);
@@ -502,8 +502,8 @@ export class TwilioProvider implements VoiceCallProvider {
} }
/** /**
* Play TTS via core TTS and Twilio Media Streams. * Play TTS via OpenAI and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket. * Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
* Uses a jitter buffer to smooth out timing variations. * Uses a jitter buffer to smooth out timing variations.
*/ */
private async playTtsViaStream( private async playTtsViaStream(
@@ -514,8 +514,8 @@ export class TwilioProvider implements VoiceCallProvider {
throw new Error("TTS provider and media stream handler required"); throw new Error("TTS provider and media stream handler required");
} }
// Generate audio with core TTS (returns mu-law at 8kHz) // Generate audio with OpenAI TTS (returns mu-law at 8kHz)
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text); const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law) // Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
const CHUNK_SIZE = 160; const CHUNK_SIZE = 160;
+13 -12
View File
@@ -6,9 +6,8 @@ import type { VoiceCallProvider } from "./providers/base.js";
import { MockProvider } from "./providers/mock.js"; import { MockProvider } from "./providers/mock.js";
import { PlivoProvider } from "./providers/plivo.js"; import { PlivoProvider } from "./providers/plivo.js";
import { TelnyxProvider } from "./providers/telnyx.js"; import { TelnyxProvider } from "./providers/telnyx.js";
import { OpenAITTSProvider } from "./providers/tts-openai.js";
import { TwilioProvider } from "./providers/twilio.js"; import { TwilioProvider } from "./providers/twilio.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js"; import { startTunnel, type TunnelResult } from "./tunnel.js";
import { import {
cleanupTailscaleExposure, cleanupTailscaleExposure,
@@ -82,10 +81,9 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
export async function createVoiceCallRuntime(params: { export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig; config: VoiceCallConfig;
coreConfig: CoreConfig; coreConfig: CoreConfig;
ttsRuntime?: TelephonyTtsRuntime;
logger?: Logger; logger?: Logger;
}): Promise<VoiceCallRuntime> { }): Promise<VoiceCallRuntime> {
const { config, coreConfig, ttsRuntime, logger } = params; const { config, coreConfig, logger } = params;
const log = logger ?? { const log = logger ?? {
info: console.log, info: console.log,
warn: console.warn, warn: console.warn,
@@ -151,24 +149,27 @@ export async function createVoiceCallRuntime(params: {
if (provider.name === "twilio" && config.streaming?.enabled) { if (provider.name === "twilio" && config.streaming?.enabled) {
const twilioProvider = provider as TwilioProvider; const twilioProvider = provider as TwilioProvider;
if (ttsRuntime?.textToSpeechTelephony) { const openaiApiKey =
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
if (openaiApiKey) {
try { try {
const ttsProvider = createTelephonyTtsProvider({ const ttsProvider = new OpenAITTSProvider({
coreConfig, apiKey: openaiApiKey,
ttsOverride: config.tts, voice: config.tts.voice,
runtime: ttsRuntime, model: config.tts.model,
instructions: config.tts.instructions,
}); });
twilioProvider.setTTSProvider(ttsProvider); twilioProvider.setTTSProvider(ttsProvider);
log.info("[voice-call] Telephony TTS provider configured"); log.info("[voice-call] OpenAI TTS provider configured");
} catch (err) { } catch (err) {
log.warn( log.warn(
`[voice-call] Failed to initialize telephony TTS: ${ `[voice-call] Failed to initialize OpenAI TTS: ${
err instanceof Error ? err.message : String(err) err instanceof Error ? err.message : String(err)
}`, }`,
); );
} }
} else { } else {
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled"); log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
} }
const mediaHandler = webhookServer.getMediaStreamHandler(); const mediaHandler = webhookServer.getMediaStreamHandler();
@@ -1,88 +0,0 @@
const TELEPHONY_SAMPLE_RATE = 8000;
function clamp16(value: number): number {
return Math.max(-32768, Math.min(32767, value));
}
/**
* Resample 16-bit PCM (little-endian mono) to 8kHz using linear interpolation.
*/
export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer {
if (inputSampleRate === TELEPHONY_SAMPLE_RATE) return input;
const inputSamples = Math.floor(input.length / 2);
if (inputSamples === 0) return Buffer.alloc(0);
const ratio = inputSampleRate / TELEPHONY_SAMPLE_RATE;
const outputSamples = Math.floor(inputSamples / ratio);
const output = Buffer.alloc(outputSamples * 2);
for (let i = 0; i < outputSamples; i++) {
const srcPos = i * ratio;
const srcIndex = Math.floor(srcPos);
const frac = srcPos - srcIndex;
const s0 = input.readInt16LE(srcIndex * 2);
const s1Index = Math.min(srcIndex + 1, inputSamples - 1);
const s1 = input.readInt16LE(s1Index * 2);
const sample = Math.round(s0 + frac * (s1 - s0));
output.writeInt16LE(clamp16(sample), i * 2);
}
return output;
}
/**
* Convert 16-bit PCM to 8-bit mu-law (G.711).
*/
export function pcmToMulaw(pcm: Buffer): Buffer {
const samples = Math.floor(pcm.length / 2);
const mulaw = Buffer.alloc(samples);
for (let i = 0; i < samples; i++) {
const sample = pcm.readInt16LE(i * 2);
mulaw[i] = linearToMulaw(sample);
}
return mulaw;
}
export function convertPcmToMulaw8k(
pcm: Buffer,
inputSampleRate: number,
): Buffer {
const pcm8k = resamplePcmTo8k(pcm, inputSampleRate);
return pcmToMulaw(pcm8k);
}
/**
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
*/
export function chunkAudio(
audio: Buffer,
chunkSize = 160,
): Generator<Buffer, void, unknown> {
return (function* () {
for (let i = 0; i < audio.length; i += chunkSize) {
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
}
})();
}
function linearToMulaw(sample: number): number {
const BIAS = 132;
const CLIP = 32635;
const sign = sample < 0 ? 0x80 : 0;
if (sample < 0) sample = -sample;
if (sample > CLIP) sample = CLIP;
sample += BIAS;
let exponent = 7;
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--) {
expMask >>= 1;
}
const mantissa = (sample >> (exponent + 3)) & 0x0f;
return ~(sign | (exponent << 4) | mantissa) & 0xff;
}
@@ -1,95 +0,0 @@
import type { CoreConfig } from "./core-bridge.js";
import type { VoiceCallTtsConfig } from "./config.js";
import { convertPcmToMulaw8k } from "./telephony-audio.js";
export type TelephonyTtsRuntime = {
textToSpeechTelephony: (params: {
text: string;
cfg: CoreConfig;
prefsPath?: string;
}) => Promise<{
success: boolean;
audioBuffer?: Buffer;
sampleRate?: number;
provider?: string;
error?: string;
}>;
};
export type TelephonyTtsProvider = {
synthesizeForTelephony: (text: string) => Promise<Buffer>;
};
export function createTelephonyTtsProvider(params: {
coreConfig: CoreConfig;
ttsOverride?: VoiceCallTtsConfig;
runtime: TelephonyTtsRuntime;
}): TelephonyTtsProvider {
const { coreConfig, ttsOverride, runtime } = params;
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
return {
synthesizeForTelephony: async (text: string) => {
const result = await runtime.textToSpeechTelephony({
text,
cfg: mergedConfig,
});
if (!result.success || !result.audioBuffer || !result.sampleRate) {
throw new Error(result.error ?? "TTS conversion failed");
}
return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
},
};
}
function applyTtsOverride(
coreConfig: CoreConfig,
override?: VoiceCallTtsConfig,
): CoreConfig {
if (!override) return coreConfig;
const base = coreConfig.messages?.tts;
const merged = mergeTtsConfig(base, override);
if (!merged) return coreConfig;
return {
...coreConfig,
messages: {
...(coreConfig.messages ?? {}),
tts: merged,
},
};
}
function mergeTtsConfig(
base?: VoiceCallTtsConfig,
override?: VoiceCallTtsConfig,
): VoiceCallTtsConfig | undefined {
if (!base && !override) return undefined;
if (!override) return base;
if (!base) return override;
return deepMerge(base, override);
}
function deepMerge<T>(base: T, override: T): T {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override;
}
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
if (value === undefined) continue;
const existing = (base as Record<string, unknown>)[key];
if (isPlainObject(existing) && isPlainObject(value)) {
result[key] = deepMerge(existing, value);
} else {
result[key] = value;
}
}
return result as T;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
-1
View File
@@ -276,7 +276,6 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
outbound: { outbound: {
deliveryMode: "gateway", deliveryMode: "gateway",
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit), chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => { resolveTarget: ({ to, allowFrom, mode }) => {
-1
View File
@@ -288,7 +288,6 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
if (remaining.length) chunks.push(remaining); if (remaining.length) chunks.push(remaining);
return chunks; return chunks;
}, },
chunkerMode: "text",
textChunkLimit: 2000, textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => { sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, { const result = await sendMessageZalo(to, text, {
+2 -11
View File
@@ -596,8 +596,6 @@ async function processMessageWithPipeline(params: {
chatId, chatId,
runtime, runtime,
core, core,
config,
accountId: account.accountId,
statusSink, statusSink,
fetcher, fetcher,
tableMode, tableMode,
@@ -616,13 +614,11 @@ async function deliverZaloReply(params: {
chatId: string; chatId: string;
runtime: ZaloRuntimeEnv; runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime; core: ZaloCoreRuntime;
config: ClawdbotConfig;
accountId?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch; fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params; const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code"; const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
@@ -648,12 +644,7 @@ async function deliverZaloReply(params: {
} }
if (text) { if (text) {
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
ZALO_TEXT_LIMIT,
chunkMode,
);
for (const chunk of chunks) { for (const chunk of chunks) {
try { try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
-1
View File
@@ -506,7 +506,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
if (remaining.length) chunks.push(remaining); if (remaining.length) chunks.push(remaining);
return chunks; return chunks;
}, },
chunkerMode: "text",
textChunkLimit: 2000, textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => { sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId }); const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
+2 -12
View File
@@ -332,8 +332,6 @@ async function processMessage(
isGroup, isGroup,
runtime, runtime,
core, core,
config,
accountId: account.accountId,
statusSink, statusSink,
tableMode: core.channel.text.resolveMarkdownTableMode({ tableMode: core.channel.text.resolveMarkdownTableMode({
cfg: config, cfg: config,
@@ -358,13 +356,10 @@ async function deliverZalouserReply(params: {
isGroup: boolean; isGroup: boolean;
runtime: RuntimeEnv; runtime: RuntimeEnv;
core: ZalouserCoreRuntime; core: ZalouserCoreRuntime;
config: ClawdbotConfig;
accountId?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
tableMode?: MarkdownTableMode; tableMode?: MarkdownTableMode;
}): Promise<void> { }): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } = const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
params;
const tableMode = params.tableMode ?? "code"; const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
@@ -395,12 +390,7 @@ async function deliverZalouserReply(params: {
} }
if (text) { if (text) {
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId); const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
ZALOUSER_TEXT_LIMIT,
chunkMode,
);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`); logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) { for (const chunk of chunks) {
try { try {
-1
View File
@@ -185,7 +185,6 @@
"linkedom": "^0.18.12", "linkedom": "^0.18.12",
"long": "5.3.2", "long": "5.3.2",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"node-edge-tts": "^1.2.9",
"osc-progress": "^0.3.0", "osc-progress": "^0.3.0",
"pdfjs-dist": "^5.4.530", "pdfjs-dist": "^5.4.530",
"playwright-core": "1.58.0", "playwright-core": "1.58.0",
-17
View File
@@ -127,9 +127,6 @@ importers:
markdown-it: markdown-it:
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.1.0 version: 14.1.0
node-edge-tts:
specifier: ^1.2.9
version: 1.2.9
osc-progress: osc-progress:
specifier: ^0.3.0 specifier: ^0.3.0
version: 0.3.0 version: 0.3.0
@@ -4301,10 +4298,6 @@ packages:
engines: {node: '>=14.18'} engines: {node: '>=14.18'}
hasBin: true hasBin: true
node-edge-tts@1.2.9:
resolution: {integrity: sha512-fvfW1dUgJdZAdTniC6MzLTMwnNUFKGKaUdRJ1OsveOYlfnPUETBU973CG89565txvbBowCQ4Czdeu3qSX8bNOg==}
hasBin: true
node-fetch@2.7.0: node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0} engines: {node: 4.x || >=6.0.0}
@@ -10209,16 +10202,6 @@ snapshots:
node-downloader-helper@2.1.10: {} node-downloader-helper@2.1.10: {}
node-edge-tts@1.2.9:
dependencies:
https-proxy-agent: 7.0.6
ws: 8.19.0
yargs: 17.7.2
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
node-fetch@2.7.0: node-fetch@2.7.0:
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
+1 -4
View File
@@ -23,14 +23,11 @@ const serialRuns = runs.filter((entry) => entry.name === "gateway");
const children = new Set(); const children = new Set();
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS";
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10); const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const localWorkers = Math.max(4, Math.min(16, os.cpus().length)); const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length)); const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
// Keep worker counts predictable for local runs and for CI on macOS. const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
const WARNING_SUPPRESSION_FLAGS = [ const WARNING_SUPPRESSION_FLAGS = [
"--disable-warning=ExperimentalWarning", "--disable-warning=ExperimentalWarning",
+25 -73
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path"; import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
@@ -27,7 +27,6 @@ import {
} from "../infra/shell-env.js"; } from "../infra/shell-env.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo, logWarn } from "../logger.js"; import { logInfo, logWarn } from "../logger.js";
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
import { import {
type ProcessSession, type ProcessSession,
type SessionStdin, type SessionStdin,
@@ -363,38 +362,23 @@ async function runExecProcess(opts: {
let stdin: SessionStdin | undefined; let stdin: SessionStdin | undefined;
if (opts.sandbox) { if (opts.sandbox) {
const { child: spawned } = await spawnWithFallback({ child = spawn(
argv: [ "docker",
"docker", buildDockerExecArgs({
...buildDockerExecArgs({ containerName: opts.sandbox.containerName,
containerName: opts.sandbox.containerName, command: opts.command,
command: opts.command, workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, env: opts.env,
env: opts.env, tty: opts.usePty,
tty: opts.usePty, }),
}), {
],
options: {
cwd: opts.workdir, cwd: opts.workdir,
env: process.env, env: process.env,
detached: process.platform !== "win32", detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
windowsHide: true, windowsHide: true,
}, },
fallbacks: [ ) as ChildProcessWithoutNullStreams;
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (err, fallback) => {
const errText = formatSpawnError(err);
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
opts.warnings.push(warning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} else if (opts.usePty) { } else if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig(); const { shell, args: shellArgs } = getShellConfig();
@@ -438,56 +422,24 @@ async function runExecProcess(opts: {
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`; const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`); logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
opts.warnings.push(warning); opts.warnings.push(warning);
const { child: spawned } = await spawnWithFallback({ child = spawn(shell, [...shellArgs, opts.command], {
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (fallbackErr, fallback) => {
const fallbackText = formatSpawnError(fallbackErr);
const fallbackWarning = `Warning: spawn failed (${fallbackText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${fallbackText}); retrying with ${fallback.label}.`);
opts.warnings.push(fallbackWarning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
} else {
const { shell, args: shellArgs } = getShellConfig();
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir, cwd: opts.workdir,
env: opts.env, env: opts.env,
detached: process.platform !== "win32", detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
windowsHide: true, windowsHide: true,
}, }) as ChildProcessWithoutNullStreams;
fallbacks: [ stdin = child.stdin;
{ }
label: "no-detach", } else {
options: { detached: false }, const { shell, args: shellArgs } = getShellConfig();
}, child = spawn(shell, [...shellArgs, opts.command], {
], cwd: opts.workdir,
onFallback: (err, fallback) => { env: opts.env,
const errText = formatSpawnError(err); detached: process.platform !== "win32",
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`; stdio: ["pipe", "pipe", "pipe"],
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`); windowsHide: true,
opts.warnings.push(warning); }) as ChildProcessWithoutNullStreams;
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} }
@@ -48,7 +48,6 @@ describe("models-config", () => {
const previous = process.env.COPILOT_GITHUB_TOKEN; const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN; const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN; const previousGithub = process.env.GITHUB_TOKEN;
const previousKimiCode = process.env.KIMICODE_API_KEY;
const previousMinimax = process.env.MINIMAX_API_KEY; const previousMinimax = process.env.MINIMAX_API_KEY;
const previousMoonshot = process.env.MOONSHOT_API_KEY; const previousMoonshot = process.env.MOONSHOT_API_KEY;
const previousSynthetic = process.env.SYNTHETIC_API_KEY; const previousSynthetic = process.env.SYNTHETIC_API_KEY;
@@ -56,7 +55,6 @@ describe("models-config", () => {
delete process.env.COPILOT_GITHUB_TOKEN; delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN; delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN; delete process.env.GITHUB_TOKEN;
delete process.env.KIMICODE_API_KEY;
delete process.env.MINIMAX_API_KEY; delete process.env.MINIMAX_API_KEY;
delete process.env.MOONSHOT_API_KEY; delete process.env.MOONSHOT_API_KEY;
delete process.env.SYNTHETIC_API_KEY; delete process.env.SYNTHETIC_API_KEY;
@@ -83,8 +81,6 @@ describe("models-config", () => {
else process.env.GH_TOKEN = previousGh; else process.env.GH_TOKEN = previousGh;
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN; if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGithub; else process.env.GITHUB_TOKEN = previousGithub;
if (previousKimiCode === undefined) delete process.env.KIMICODE_API_KEY;
else process.env.KIMICODE_API_KEY = previousKimiCode;
if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY; if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY;
else process.env.MINIMAX_API_KEY = previousMinimax; else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY; if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
@@ -180,4 +176,42 @@ describe("models-config", () => {
} }
}); });
}); });
it("adds venice provider when VENICE_API_KEY is set", async () => {
await withTempHome(async () => {
vi.resetModules();
const prevKey = process.env.VENICE_API_KEY;
const prevVitest = process.env.VITEST;
process.env.VENICE_API_KEY = "vapi-venice-test";
process.env.VITEST = "1";
try {
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
await ensureClawdbotModelsJson({});
const modelPath = path.join(resolveClawdbotAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<
string,
{
baseUrl?: string;
apiKey?: string;
models?: Array<{ id: string }>;
}
>;
};
expect(parsed.providers.venice?.baseUrl).toBe("https://api.venice.ai/api/v1");
expect(parsed.providers.venice?.apiKey).toBe("VENICE_API_KEY");
const ids = parsed.providers.venice?.models?.map((model) => model.id);
expect(ids).toContain("llama-3.3-70b");
} finally {
if (prevKey === undefined) delete process.env.VENICE_API_KEY;
else process.env.VENICE_API_KEY = prevKey;
if (prevVitest === undefined) delete process.env.VITEST;
else process.env.VITEST = prevVitest;
}
});
});
}); });
@@ -121,26 +121,6 @@ describe("getDmHistoryLimitFromSessionKey", () => {
} as ClawdbotConfig; } as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10); expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123", config)).toBe(10);
}); });
it("strips thread suffix from dm session keys", () => {
const config = {
channels: { telegram: { dmHistoryLimit: 10, dms: { "123": { historyLimit: 7 } } } },
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:thread:999", config)).toBe(
7,
);
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:123:topic:555", config)).toBe(7);
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123:thread:999", config)).toBe(7);
});
it("keeps non-numeric thread markers in dm ids", () => {
const config = {
channels: {
telegram: { dms: { "user:thread:abc": { historyLimit: 9 } } },
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:main:telegram:dm:user:thread:abc", config)).toBe(
9,
);
});
it("returns undefined for non-dm session kinds", () => { it("returns undefined for non-dm session kinds", () => {
const config = { const config = {
channels: { channels: {
+3 -3
View File
@@ -70,7 +70,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
}, },
streamSimple: (model: { api: string; provider: string; id: string }) => { streamSimple: (model: { api: string; provider: string; id: string }) => {
const stream = new actual.AssistantMessageEventStream(); const stream = new actual.AssistantMessageEventStream();
queueMicrotask(() => { setTimeout(() => {
stream.push({ stream.push({
type: "done", type: "done",
reason: "stop", reason: "stop",
@@ -80,7 +80,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
: buildAssistantMessage(model), : buildAssistantMessage(model),
}); });
stream.end(); stream.end();
}); }, 0);
return stream; return stream;
}, },
}; };
@@ -213,7 +213,7 @@ describe("runEmbeddedPiAgent", () => {
itIfNotWin32( itIfNotWin32(
"persists the first user message before assistant output", "persists the first user message before assistant output",
{ timeout: 120_000 }, { timeout: 60_000 },
async () => { async () => {
const sessionFile = nextSessionFile(); const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]); const cfg = makeOpenAiConfig(["mock-1"]);
-25
View File
@@ -15,8 +15,6 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.js"; import { getMachineDisplayName } from "../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js"; import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { isSubagentSessionKey } from "../../routing/session-key.js"; import { isSubagentSessionKey } from "../../routing/session-key.js";
@@ -257,28 +255,6 @@ export async function compactEmbeddedPiSessionDirect(
} }
} }
} }
const reactionGuidance =
runtimeChannel && params.config
? (() => {
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
: undefined;
// Resolve channel-specific message actions for system prompt // Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel const channelActions = runtimeChannel
? listChannelSupportedActions({ ? listChannelSupportedActions({
@@ -337,7 +313,6 @@ export async function compactEmbeddedPiSessionDirect(
ttsHint, ttsHint,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
reactionGuidance,
messageToolHints, messageToolHints,
sandboxInfo, sandboxInfo,
tools, tools,
+1 -9
View File
@@ -2,13 +2,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
const THREAD_SUFFIX_REGEX = /^(.*)(?::(?:thread|topic):\d+)$/i;
function stripThreadSuffix(value: string): string {
const match = value.match(THREAD_SUFFIX_REGEX);
return match?.[1] ?? value;
}
/** /**
* Limits conversation history to the last N user turns (and their associated * Limits conversation history to the last N user turns (and their associated
* assistant responses). This reduces token usage for long-running DM sessions. * assistant responses). This reduces token usage for long-running DM sessions.
@@ -51,8 +44,7 @@ export function getDmHistoryLimitFromSessionKey(
if (!provider) return undefined; if (!provider) return undefined;
const kind = providerParts[1]?.toLowerCase(); const kind = providerParts[1]?.toLowerCase();
const userIdRaw = providerParts.slice(2).join(":"); const userId = providerParts.slice(2).join(":");
const userId = stripThreadSuffix(userIdRaw);
if (kind !== "dm") return undefined; if (kind !== "dm") return undefined;
const getLimit = ( const getLimit = (
+7 -19
View File
@@ -15,7 +15,6 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js"; import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js";
@@ -256,25 +255,14 @@ export async function runEmbeddedAttempt(
} }
} }
const reactionGuidance = const reactionGuidance =
runtimeChannel && params.config runtimeChannel === "telegram" && params.config
? (() => { ? (() => {
if (runtimeChannel === "telegram") { const resolved = resolveTelegramReactionLevel({
const resolved = resolveTelegramReactionLevel({ cfg: params.config,
cfg: params.config, accountId: params.agentAccountId ?? undefined,
accountId: params.agentAccountId ?? undefined, });
}); const level = resolved.agentReactionGuidance;
const level = resolved.agentReactionGuidance; return level ? { level, channel: "Telegram" } : undefined;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})() })()
: undefined; : undefined;
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
-3
View File
@@ -94,9 +94,6 @@ function buildReactionSchema() {
messageId: Type.Optional(Type.String()), messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()), emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()), remove: Type.Optional(Type.Boolean()),
targetAuthor: Type.Optional(Type.String()),
targetAuthorUuid: Type.Optional(Type.String()),
groupId: Type.Optional(Type.String()),
}; };
} }
+1 -19
View File
@@ -2,8 +2,7 @@ import { describe, expect, it } from "vitest";
import { __testing } from "./web-search.js"; import { __testing } from "./web-search.js";
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } = const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl } = __testing;
__testing;
describe("web_search perplexity baseUrl defaults", () => { describe("web_search perplexity baseUrl defaults", () => {
it("detects a Perplexity key prefix", () => { it("detects a Perplexity key prefix", () => {
@@ -52,20 +51,3 @@ describe("web_search perplexity baseUrl defaults", () => {
); );
}); });
}); });
describe("web_search freshness normalization", () => {
it("accepts Brave shortcut values", () => {
expect(normalizeFreshness("pd")).toBe("pd");
expect(normalizeFreshness("PW")).toBe("pw");
});
it("accepts valid date ranges", () => {
expect(normalizeFreshness("2024-01-01to2024-01-31")).toBe("2024-01-01to2024-01-31");
});
it("rejects invalid date ranges", () => {
expect(normalizeFreshness("2024-13-01to2024-01-31")).toBeUndefined();
expect(normalizeFreshness("2024-02-30to2024-03-01")).toBeUndefined();
expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined();
});
});
+1 -63
View File
@@ -29,8 +29,6 @@ const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>(); const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
const WebSearchSchema = Type.Object({ const WebSearchSchema = Type.Object({
query: Type.String({ description: "Search query string." }), query: Type.String({ description: "Search query string." }),
@@ -57,12 +55,6 @@ const WebSearchSchema = Type.Object({
description: "ISO language code for UI elements.", description: "ISO language code for UI elements.",
}), }),
), ),
freshness: Type.Optional(
Type.String({
description:
"Filter results by discovery time (Brave only). Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.",
}),
),
}); });
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
@@ -227,35 +219,6 @@ function resolveSearchCount(value: unknown, fallback: number): number {
return clamped; return clamped;
} }
function normalizeFreshness(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const lower = trimmed.toLowerCase();
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) return lower;
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
if (!match) return undefined;
const [, start, end] = match;
if (!isValidIsoDate(start) || !isValidIsoDate(end)) return undefined;
if (start > end) return undefined;
return `${start}to${end}`;
}
function isValidIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return false;
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
);
}
function resolveSiteName(url: string | undefined): string | undefined { function resolveSiteName(url: string | undefined): string | undefined {
if (!url) return undefined; if (!url) return undefined;
try { try {
@@ -316,14 +279,11 @@ async function runWebSearch(params: {
country?: string; country?: string;
search_lang?: string; search_lang?: string;
ui_lang?: string; ui_lang?: string;
freshness?: string;
perplexityBaseUrl?: string; perplexityBaseUrl?: string;
perplexityModel?: string; perplexityModel?: string;
}): Promise<Record<string, unknown>> { }): Promise<Record<string, unknown>> {
const cacheKey = normalizeCacheKey( const cacheKey = normalizeCacheKey(
params.provider === "brave" `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
: `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
); );
const cached = readCache(SEARCH_CACHE, cacheKey); const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true }; if (cached) return { ...cached.value, cached: true };
@@ -367,9 +327,6 @@ async function runWebSearch(params: {
if (params.ui_lang) { if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang); url.searchParams.set("ui_lang", params.ui_lang);
} }
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
method: "GET", method: "GET",
@@ -442,23 +399,6 @@ export function createWebSearchTool(options?: {
const country = readStringParam(params, "country"); const country = readStringParam(params, "country");
const search_lang = readStringParam(params, "search_lang"); const search_lang = readStringParam(params, "search_lang");
const ui_lang = readStringParam(params, "ui_lang"); const ui_lang = readStringParam(params, "ui_lang");
const rawFreshness = readStringParam(params, "freshness");
if (rawFreshness && provider !== "brave") {
return jsonResult({
error: "unsupported_freshness",
message: "freshness is only supported by the Brave web_search provider.",
docs: "https://docs.clawd.bot/tools/web",
});
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined;
if (rawFreshness && !freshness) {
return jsonResult({
error: "invalid_freshness",
message:
"freshness must be one of pd, pw, pm, py, or a range like YYYY-MM-DDtoYYYY-MM-DD.",
docs: "https://docs.clawd.bot/tools/web",
});
}
const result = await runWebSearch({ const result = await runWebSearch({
query, query,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
@@ -469,7 +409,6 @@ export function createWebSearchTool(options?: {
country, country,
search_lang, search_lang,
ui_lang, ui_lang,
freshness,
perplexityBaseUrl: resolvePerplexityBaseUrl( perplexityBaseUrl: resolvePerplexityBaseUrl(
perplexityConfig, perplexityConfig,
perplexityAuth?.source, perplexityAuth?.source,
@@ -485,5 +424,4 @@ export function createWebSearchTool(options?: {
export const __testing = { export const __testing = {
inferPerplexityBaseUrlFromApiKey, inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl, resolvePerplexityBaseUrl,
normalizeFreshness,
} as const; } as const;
@@ -88,40 +88,6 @@ describe("web_search country and language parameters", () => {
const url = new URL(mockFetch.mock.calls[0][0] as string); const url = new URL(mockFetch.mock.calls[0][0] as string);
expect(url.searchParams.get("ui_lang")).toBe("de"); expect(url.searchParams.get("ui_lang")).toBe("de");
}); });
it("should pass freshness parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.(1, { query: "test", freshness: "pw" });
const url = new URL(mockFetch.mock.calls[0][0] as string);
expect(url.searchParams.get("freshness")).toBe("pw");
});
it("rejects invalid freshness values", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.(1, { query: "test", freshness: "yesterday" });
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "invalid_freshness" });
});
}); });
describe("web_search perplexity baseUrl defaults", () => { describe("web_search perplexity baseUrl defaults", () => {
@@ -154,27 +120,6 @@ describe("web_search perplexity baseUrl defaults", () => {
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions"); expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions");
}); });
it("rejects freshness for Perplexity provider", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
});
const result = await tool?.execute?.(1, { query: "test", freshness: "pw" });
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "unsupported_freshness" });
});
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => { it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", ""); vi.stubEnv("PERPLEXITY_API_KEY", "");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test"); vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
+14 -54
View File
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { import {
chunkByNewline, chunkByNewline,
chunkMarkdownText, chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText, chunkText,
chunkTextWithMode, chunkTextWithMode,
resolveChunkMode, resolveChunkMode,
@@ -247,10 +246,10 @@ describe("chunkByNewline", () => {
expect(chunks).toEqual(["Line one", "Line two", "Line three"]); expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
}); });
it("preserves blank lines by folding into the next chunk", () => { it("filters empty lines", () => {
const text = "Line one\n\n\nLine two\n\nLine three"; const text = "Line one\n\n\nLine two\n\nLine three";
const chunks = chunkByNewline(text, 1000); const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["Line one", "\n\nLine two", "\nLine three"]); expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
}); });
it("trims whitespace from lines", () => { it("trims whitespace from lines", () => {
@@ -259,12 +258,6 @@ describe("chunkByNewline", () => {
expect(chunks).toEqual(["Line one", "Line two"]); expect(chunks).toEqual(["Line one", "Line two"]);
}); });
it("preserves leading blank lines on the first chunk", () => {
const text = "\n\nLine one\nLine two";
const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["\n\nLine one", "Line two"]);
});
it("falls back to length-based for long lines", () => { it("falls back to length-based for long lines", () => {
const text = "Short line\n" + "a".repeat(50) + "\nAnother short"; const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
const chunks = chunkByNewline(text, 20); const chunks = chunkByNewline(text, 20);
@@ -276,12 +269,6 @@ describe("chunkByNewline", () => {
expect(chunks[4]).toBe("Another short"); expect(chunks[4]).toBe("Another short");
}); });
it("does not split long lines when splitLongLines is false", () => {
const text = "a".repeat(50);
const chunks = chunkByNewline(text, 20, { splitLongLines: false });
expect(chunks).toEqual([text]);
});
it("returns empty array for empty input", () => { it("returns empty array for empty input", () => {
expect(chunkByNewline("", 100)).toEqual([]); expect(chunkByNewline("", 100)).toEqual([]);
}); });
@@ -289,18 +276,6 @@ describe("chunkByNewline", () => {
it("returns empty array for whitespace-only input", () => { it("returns empty array for whitespace-only input", () => {
expect(chunkByNewline(" \n\n ", 100)).toEqual([]); expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
}); });
it("preserves trailing blank lines on the last chunk", () => {
const text = "Line one\n\n";
const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["Line one\n\n"]);
});
it("keeps whitespace when trimLines is false", () => {
const text = " indented line \nNext";
const chunks = chunkByNewline(text, 1000, { trimLines: false });
expect(chunks).toEqual([" indented line ", "Next"]);
});
}); });
describe("chunkTextWithMode", () => { describe("chunkTextWithMode", () => {
@@ -317,26 +292,6 @@ describe("chunkTextWithMode", () => {
}); });
}); });
describe("chunkMarkdownTextWithMode", () => {
it("uses markdown-aware chunking for length mode", () => {
const text = "Line one\nLine two";
expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000));
});
it("uses newline-based chunking for newline mode", () => {
const text = "Line one\nLine two";
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one", "Line two"]);
});
it("does not split inside code fences for newline mode", () => {
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([
"```js\nconst a = 1;\nconst b = 2;\n```",
"After",
]);
});
});
describe("resolveChunkMode", () => { describe("resolveChunkMode", () => {
it("returns length as default", () => { it("returns length as default", () => {
expect(resolveChunkMode(undefined, "telegram")).toBe("length"); expect(resolveChunkMode(undefined, "telegram")).toBe("length");
@@ -349,16 +304,16 @@ describe("resolveChunkMode", () => {
expect(resolveChunkMode(cfg, "__internal__")).toBe("length"); expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
}); });
it("supports provider-level overrides for slack", () => { it("supports provider-level overrides for bluebubbles", () => {
const cfg = { channels: { slack: { chunkMode: "newline" as const } } }; const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "slack")).toBe("newline"); expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
expect(resolveChunkMode(cfg, "discord")).toBe("length"); expect(resolveChunkMode(cfg, "discord")).toBe("length");
}); });
it("supports account-level overrides for slack", () => { it("supports account-level overrides for bluebubbles", () => {
const cfg = { const cfg = {
channels: { channels: {
slack: { bluebubbles: {
chunkMode: "length" as const, chunkMode: "length" as const,
accounts: { accounts: {
primary: { chunkMode: "newline" as const }, primary: { chunkMode: "newline" as const },
@@ -366,7 +321,12 @@ describe("resolveChunkMode", () => {
}, },
}, },
}; };
expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline"); expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
expect(resolveChunkMode(cfg, "slack", "other")).toBe("length"); expect(resolveChunkMode(cfg, "bluebubbles", "other")).toBe("length");
});
it("ignores chunkMode for non-bluebubbles providers", () => {
const cfg = { channels: { ["telegram" as string]: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "telegram")).toBe("length");
}); });
}); });
+15 -92
View File
@@ -101,6 +101,8 @@ export function resolveChunkMode(
accountId?: string | null, accountId?: string | null,
): ChunkMode { ): ChunkMode {
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE; if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
// Chunk mode is only supported for BlueBubbles.
if (provider !== "bluebubbles") return DEFAULT_CHUNK_MODE;
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined; const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
const providerConfig = (channelsConfig?.[provider] ?? const providerConfig = (channelsConfig?.[provider] ??
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined; (cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
@@ -109,56 +111,25 @@ export function resolveChunkMode(
} }
/** /**
* Split text on newlines, trimming line whitespace. * Split text on newlines, filtering empty lines.
* Blank lines are folded into the next non-empty line as leading "\n" prefixes. * Lines exceeding maxLineLength are further split using length-based chunking.
* Long lines can be split by length (default) or kept intact via splitLongLines:false.
*/ */
export function chunkByNewline( export function chunkByNewline(text: string, maxLineLength: number): string[] {
text: string,
maxLineLength: number,
opts?: {
splitLongLines?: boolean;
trimLines?: boolean;
isSafeBreak?: (index: number) => boolean;
},
): string[] {
if (!text) return []; if (!text) return [];
if (maxLineLength <= 0) return text.trim() ? [text] : []; const lines = text.split("\n");
const splitLongLines = opts?.splitLongLines !== false;
const trimLines = opts?.trimLines !== false;
const lines = splitByNewline(text, opts?.isSafeBreak);
const chunks: string[] = []; const chunks: string[] = [];
let pendingBlankLines = 0;
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) { if (!trimmed) continue; // skip empty lines
pendingBlankLines += 1;
continue; if (trimmed.length <= maxLineLength) {
chunks.push(trimmed);
} else {
// Long line: fall back to length-based chunking
const subChunks = chunkText(trimmed, maxLineLength);
chunks.push(...subChunks);
} }
const maxPrefix = Math.max(0, maxLineLength - 1);
const cappedBlankLines = pendingBlankLines > 0 ? Math.min(pendingBlankLines, maxPrefix) : 0;
const prefix = cappedBlankLines > 0 ? "\n".repeat(cappedBlankLines) : "";
pendingBlankLines = 0;
const lineValue = trimLines ? trimmed : line;
if (!splitLongLines || lineValue.length + prefix.length <= maxLineLength) {
chunks.push(prefix + lineValue);
continue;
}
const firstLimit = Math.max(1, maxLineLength - prefix.length);
const first = lineValue.slice(0, firstLimit);
chunks.push(prefix + first);
const remaining = lineValue.slice(firstLimit);
if (remaining) {
chunks.push(...chunkText(remaining, maxLineLength));
}
}
if (pendingBlankLines > 0 && chunks.length > 0) {
chunks[chunks.length - 1] += "\n".repeat(pendingBlankLines);
} }
return chunks; return chunks;
@@ -169,59 +140,11 @@ export function chunkByNewline(
*/ */
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] { export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
if (mode === "newline") { if (mode === "newline") {
const chunks: string[] = []; return chunkByNewline(text, limit);
const lineChunks = chunkByNewline(text, limit, { splitLongLines: false });
for (const line of lineChunks) {
const nested = chunkText(line, limit);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
} }
return chunkText(text, limit); return chunkText(text, limit);
} }
export function chunkMarkdownTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
if (mode === "newline") {
const spans = parseFenceSpans(text);
const chunks: string[] = [];
const lineChunks = chunkByNewline(text, limit, {
splitLongLines: false,
trimLines: false,
isSafeBreak: (index) => isSafeFenceBreak(spans, index),
});
for (const line of lineChunks) {
const nested = chunkMarkdownText(line, limit);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
}
return chunkMarkdownText(text, limit);
}
function splitByNewline(
text: string,
isSafeBreak: (index: number) => boolean = () => true,
): string[] {
const lines: string[] = [];
let start = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === "\n" && isSafeBreak(i)) {
lines.push(text.slice(start, i));
start = i + 1;
}
}
lines.push(text.slice(start));
return lines;
}
export function chunkText(text: string, limit: number): string[] { export function chunkText(text: string, limit: number): string[] {
if (!text) return []; if (!text) return [];
if (limit <= 0) return [text]; if (limit <= 0) return [text];
+4 -10
View File
@@ -10,17 +10,11 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe(""); expect(result.cleaned).toBe("");
}); });
it("does not treat /models as a /model directive", () => { it("extracts /models with argument", () => {
const result = extractModelDirective("/models gpt-5"); const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(false); expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBeUndefined(); expect(result.rawModel).toBe("gpt-5");
expect(result.cleaned).toBe("/models gpt-5"); expect(result.cleaned).toBe("");
});
it("does not parse /models as a /model directive (no args)", () => {
const result = extractModelDirective("/models");
expect(result.hasDirective).toBe(false);
expect(result.cleaned).toBe("/models");
}); });
it("extracts /model with provider/model format", () => { it("extracts /model with provider/model format", () => {
+1 -1
View File
@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false }; if (!body) return { cleaned: "", hasDirective: false };
const modelMatch = body.match( const modelMatch = body.match(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i, /(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
); );
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean); const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
+2 -2
View File
@@ -69,7 +69,7 @@ export function resolveBlockStreamingChunking(
}); });
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
// When chunkMode is "newline", use newline-based streaming // BlueBubbles-only: if chunkMode is "newline", use newline-based streaming
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
if (channelChunkMode === "newline") { if (channelChunkMode === "newline") {
// For newline mode: use very low minChars to flush quickly on newlines // For newline mode: use very low minChars to flush quickly on newlines
@@ -103,7 +103,7 @@ export function resolveBlockStreamingCoalescing(
): BlockStreamingCoalescing | undefined { ): BlockStreamingCoalescing | undefined {
const providerKey = normalizeChunkProvider(provider); const providerKey = normalizeChunkProvider(provider);
// When chunkMode is "newline", disable coalescing to send each line immediately // BlueBubbles-only: when chunkMode is "newline", disable coalescing to send each line immediately
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
if (channelChunkMode === "newline") { if (channelChunkMode === "newline") {
return undefined; return undefined;
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff. Show more