Compare commits

..
Author SHA1 Message Date
Peter Steinberger fbe59ae8e0 fix: soften form save guardrails in config UI (#1678) (thanks @Glucksberg) 2026-01-25 05:46:27 +00:00
GlucksbergandClaude Opus 4.5 ffb25253b4 fix(ui): improve config save UX
Follow-up to #1609 fix:
- Remove formUnsafe check from canSave (was blocking save even with valid changes)
- Suppress disconnect message for code 1012 (service restart is expected during config save)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 05:37:49 +00:00
183 changed files with 7728 additions and 9587 deletions

No files matched your search

-4
View File
@@ -7,10 +7,6 @@
[exclude-files] [exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs. # pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$ pattern = (^|/)pnpm-lock\.yaml$
# Generated output and vendored assets.
pattern = (^|/)(dist|vendor)/
# Local config file with allowlist patterns.
pattern = (^|/)\.detect-secrets\.cfg$
[exclude-lines] [exclude-lines]
# Fastlane checks for private key marker; not a real key. # Fastlane checks for private key marker; not a real key.
-17
View File
@@ -1,17 +0,0 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-windows-2025
# Ignore patterns for known issues
paths:
.github/workflows/**/*.yml:
ignore:
# Ignore shellcheck warnings (we run shellcheck separately)
- 'shellcheck reported issue.+'
# Ignore intentional if: false for disabled jobs
- 'constant expression "false" in condition'
-113
View File
@@ -1,113 +0,0 @@
# Dependabot configuration
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
registries:
npm-npmjs:
type: npm-registry
url: https://registry.npmjs.org
replaces-base: true
updates:
# npm dependencies (root)
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
production:
dependency-type: production
update-types:
- minor
- patch
development:
dependency-type: development
update-types:
- minor
- patch
open-pull-requests-limit: 10
registries:
- npm-npmjs
# GitHub Actions
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
actions:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - macOS app
- package-ecosystem: swift
directory: /apps/macos
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - shared ClawdbotKit
- package-ecosystem: swift
directory: /apps/shared/ClawdbotKit
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - Swabble
- package-ecosystem: swift
directory: /Swabble
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Gradle - Android app
- package-ecosystem: gradle
directory: /apps/android
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
android-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
-105
View File
@@ -1,105 +0,0 @@
# Pre-commit hooks for clawdbot
# Install: prek install
# Run manually: prek run --all-files
#
# See https://pre-commit.com for more information
repos:
# Basic file hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: end-of-file-fixer
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: check-yaml
args: [--allow-multiple-documents]
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
# Secret detection (same as CI)
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args:
- --baseline
- .secrets.baseline
- --exclude-files
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
- --exclude-lines
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
- --exclude-lines
- 'case \.apiKeyEnv: "API key \(env var\)"'
- --exclude-lines
- 'case apikey = "apiKey"'
- --exclude-lines
- '"gateway\.remote\.password"'
- --exclude-lines
- '"gateway\.auth\.password"'
- --exclude-lines
- '"talk\.apiKey"'
- --exclude-lines
- '=== "string"'
- --exclude-lines
- 'typeof remote\?\.password === "string"'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0
hooks:
- id: shellcheck
args: [--severity=error] # Only fail on errors, not warnings/info
# Exclude vendor and scripts with embedded code or known issues
exclude: '^(vendor/|scripts/e2e/)'
# GitHub Actions linting
- repo: https://github.com/rhysd/actionlint
rev: v1.7.10
hooks:
- id: actionlint
# GitHub Actions security audit
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
hooks:
- id: zizmor
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
exclude: '^(vendor/|Swabble/)'
# Project checks (same commands as CI)
- repo: local
hooks:
# oxlint --type-aware src test
- id: oxlint
name: oxlint
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# oxfmt --check src test
- id: oxfmt
name: oxfmt
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# swiftlint (same as CI)
- id: swiftlint
name: swiftlint
entry: swiftlint --config .swiftlint.yml
language: system
pass_filenames: false
types: [swift]
# swiftformat --lint (same as CI)
- id: swiftformat
name: swiftformat
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
language: system
pass_filenames: false
types: [swift]
+123 -1821
View File
File diff suppressed because it is too large. Load diff
-25
View File
@@ -1,25 +0,0 @@
# ShellCheck configuration
# https://www.shellcheck.net/wiki/
# Disable common false positives and style suggestions
# SC2034: Variable appears unused (often exported or used indirectly)
disable=SC2034
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
disable=SC2155
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
disable=SC2295
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
disable=SC1012
# SC2026: Word outside quotes (info-level, often intentional)
disable=SC2026
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
disable=SC2016
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
disable=SC2129
+1 -1
View File
@@ -23,7 +23,7 @@
# Whitespace # Whitespace
--trimwhitespace always --trimwhitespace always
--emptybraces no-space --emptybraces no-space
--nospaceoperators ...,..< --nospaceoperators ...,..<
--ranges no-space --ranges no-space
--someAny true --someAny true
--voidtype void --voidtype void
-1
View File
@@ -37,7 +37,6 @@
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working). - Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install` - Install deps: `pnpm install`
- Pre-commit hooks: `prek install` (runs same checks as CI)
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches). - Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`. - Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`. - Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
+1 -9
View File
@@ -12,7 +12,6 @@ Docs: https://docs.clawd.bot
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts - TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web - 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 - TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
- 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: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
@@ -20,18 +19,14 @@ Docs: https://docs.clawd.bot
- 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: 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. - Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
### Fixes ### Fixes
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
- 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: allow form saves with unsupported schema paths while blocking missing schema; clear stale disconnect banners on reconnect. (#1678) Thanks @Glucksberg.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. - 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.
- 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)
@@ -42,12 +37,9 @@ Docs: https://docs.clawd.bot
- 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.
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671) - 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: 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.
+2 -2
View File
@@ -459,7 +459,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
## Clawd ## Clawd
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞 Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
by Peter Steinberger and the community. by Peter Steinberger and the community.
- [clawd.me](https://clawd.me) - [clawd.me](https://clawd.me)
@@ -468,7 +468,7 @@ by Peter Steinberger and the community.
## Community ## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖 AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
+1
View File
@@ -12,3 +12,4 @@ If you believe you’ve found a security issue in Clawdbot, please report it pri
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see: For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
- `https://docs.clawd.bot/gateway/security` - `https://docs.clawd.bot/gateway/security`
+1 -1
View File
@@ -212,4 +212,4 @@
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
</item> </item>
</channel> </channel>
</rss> </rss>
@@ -12,3 +12,4 @@ data class CameraHudState(
val kind: CameraHudKind, val kind: CameraHudKind,
val message: String, val message: String,
) )
@@ -12,3 +12,4 @@ enum class VoiceWakeMode(val rawValue: String) {
} }
} }
} }
@@ -135,7 +135,7 @@ class SmsManager(private val context: Context) {
/** /**
* Send an SMS message. * Send an SMS message.
* *
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields * @param paramsJson JSON with "to" (phone number) and "message" (text) fields
* @return SendResult indicating success or failure * @return SendResult indicating success or failure
*/ */
@@ -1,3 +1,4 @@
<resources> <resources>
<color name="ic_launcher_background">#0A0A0A</color> <color name="ic_launcher_background">#0A0A0A</color>
</resources> </resources>
@@ -1,3 +1,4 @@
<resources> <resources>
<string name="app_name">Clawdbot Node</string> <string name="app_name">Clawdbot Node</string>
</resources> </resources>
@@ -23,3 +23,4 @@ class VoiceWakeCommandExtractorTest {
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude"))) assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
} }
} }
+1
View File
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "ClawdbotNodeAndroid" rootProject.name = "ClawdbotNodeAndroid"
include(":app") include(":app")
+1
View File
@@ -3,3 +3,4 @@ parent_config: ../../.swiftlint.yml
included: included:
- Sources - Sources
- ../shared/ClawdisNodeKit/Sources - ../shared/ClawdisNodeKit/Sources
+1 -1
View File
@@ -33,4 +33,4 @@
], ],
"squares" : "shared" "squares" : "shared"
} }
} }
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
@@ -173,4 +173,4 @@
"iPod5,1": "iPod touch (5th generation)", "iPod5,1": "iPod touch (5th generation)",
"iPod7,1": "iPod touch (6th generation)", "iPod7,1": "iPod touch (6th generation)",
"iPod9,1": "iPod touch (7th generation)" "iPod9,1": "iPod touch (7th generation)"
} }
@@ -211,4 +211,4 @@
"Mac Pro (2019)", "Mac Pro (2019)",
"Mac Pro (Rack, 2019)" "Mac Pro (Rack, 2019)"
] ]
} }
-1
View File
@@ -1 +0,0 @@
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
+1
View File
@@ -0,0 +1 @@
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);overflow:hidden}@supports (height: 100dvh){.shell{height:100dvh}}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-heightLine truncated
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index-DQcOTEYz.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-DsXRcnEw.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
+2 -2
View File
@@ -6,8 +6,8 @@
<title>Clawdbot Control</title> <title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" /> <link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script> <script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css"> <link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
</head> </head>
<body> <body>
<clawdbot-app></clawdbot-app> <clawdbot-app></clawdbot-app>
-1
View File
@@ -213,7 +213,6 @@ Prefer `chat_guid` for stable routing:
- `chat_id:123` - `chat_id:123`
- `chat_identifier:...` - `chat_identifier:...`
- Direct handles: `+15555550123`, `user@example.com` - Direct handles: `+15555550123`, `user@example.com`
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
## Security ## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted. - Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
+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.
-1
View File
@@ -525,7 +525,6 @@ Provider options:
- `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.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).
-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)
-89
View File
@@ -1,89 +0,0 @@
---
summary: "Diagnostics flags for targeted debug logs"
read_when:
- You need targeted debug logs without raising global logging levels
- You need to capture subsystem-specific logs for support
---
# Diagnostics Flags
Diagnostics flags let you enable targeted debug logs without turning on verbose logging everywhere. Flags are opt-in and have no effect unless a subsystem checks them.
## How it works
- Flags are strings (case-insensitive).
- You can enable flags in config or via an env override.
- Wildcards are supported:
- `telegram.*` matches `telegram.http`
- `*` enables all flags
## Enable via config
```json
{
"diagnostics": {
"flags": ["telegram.http"]
}
}
```
Multiple flags:
```json
{
"diagnostics": {
"flags": ["telegram.http", "gateway.*"]
}
}
```
Restart the gateway after changing flags.
## Env override (one-off)
```bash
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
```
Disable all flags:
```bash
CLAWDBOT_DIAGNOSTICS=0
```
## Where logs go
Flags emit logs into the standard diagnostics log file. By default:
```
/tmp/clawdbot/clawdbot-YYYY-MM-DD.log
```
If you set `logging.file`, use that path instead. Logs are JSONL (one JSON object per line). Redaction still applies based on `logging.redactSensitive`.
## Extract logs
Pick the latest log file:
```bash
ls -t /tmp/clawdbot/clawdbot-*.log | head -n 1
```
Filter for Telegram HTTP diagnostics:
```bash
rg "telegram http error" /tmp/clawdbot/clawdbot-*.log
```
Or tail while reproducing:
```bash
tail -f /tmp/clawdbot/clawdbot-$(date +%F).log | rg "telegram http error"
```
For remote gateways, you can also use `clawdbot logs --follow` (see [/cli/logs](/cli/logs)).
## Notes
- If `logging.level` is set higher than `warn`, these logs may be suppressed. Default `info` is fine.
- Flags are safe to leave enabled; they only affect log volume for the specific subsystem.
- Use [/logging](/logging) to change log destinations, levels, and redaction.
-1
View File
@@ -1021,7 +1021,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,
+3 -36
View File
@@ -24,7 +24,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How do I try the latest bits?](#how-do-i-try-the-latest-bits) - [How do I try the latest bits?](#how-do-i-try-the-latest-bits)
- [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take) - [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take)
- [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback) - [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback)
- [Windows install says git not found or clawdbot not recognized](#windows-install-says-git-not-found-or-clawdbot-not-recognized)
- [The docs didn’t answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer) - [The docs didn’t answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer)
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux) - [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
- [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps) - [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps)
@@ -40,7 +39,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported) - [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [How does Codex auth work?](#how-does-codex-auth-work) - [How does Codex auth work?](#how-does-codex-auth-work)
- [Do you support OpenAI subscription auth (Codex OAuth)?](#do-you-support-openai-subscription-auth-codex-oauth) - [Do you support OpenAI subscription auth (Codex OAuth)?](#do-you-support-openai-subscription-auth-codex-oauth)
- [How do I set up Gemini CLI OAuth](#how-do-i-set-up-gemini-cli-oauth)
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats) - [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region) - [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this) - [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this)
@@ -513,26 +511,6 @@ curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --verb
More options: [Installer flags](/install/installer). More options: [Installer flags](/install/installer).
### Windows install says git not found or clawdbot not recognized
Two common Windows issues:
**1) npm error spawn git / git not found**
- Install **Git for Windows** and make sure `git` is on your PATH.
- Close and reopen PowerShell, then re-run the installer.
**2) clawdbot is not recognized after install**
- Your npm global bin folder is not on PATH.
- Check the path:
```powershell
npm config get prefix
```
- Ensure `<prefix>\\bin` is on PATH (on most systems it is `%AppData%\\npm`).
- Close and reopen PowerShell after updating PATH.
If you want the smoothest Windows setup, use **WSL2** instead of native Windows.
Docs: [Windows](/platforms/windows).
### The docs didnt answer my question how do I get a better answer ### The docs didnt answer my question how do I get a better answer
Use the **hackable (git) install** so you have the full source and docs locally, then ask Use the **hackable (git) install** so you have the full source and docs locally, then ask
@@ -632,10 +610,9 @@ Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
Yes. You can authenticate with **Claude Code CLI OAuth** or a **setup-token** Yes. You can authenticate with **Claude Code CLI OAuth** or a **setup-token**
instead of an API key. This is the subscription path. instead of an API key. This is the subscription path.
Claude Pro/Max subscriptions **do not include an API key**, so this is the Important: you must verify with Anthropic that this usage is allowed under
correct approach for subscription accounts. Important: you must verify with their subscription policy and terms. If you want the most explicit, supported
Anthropic that this usage is allowed under their subscription policy and terms. path, use an Anthropic API key.
If you want the most explicit, supported path, use an Anthropic API key.
### How does Anthropic setuptoken auth work ### How does Anthropic setuptoken auth work
@@ -687,16 +664,6 @@ can import the CLI login or run the OAuth flow for you.
See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard). See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard).
### How do I set up Gemini CLI OAuth
Gemini CLI uses a **plugin auth flow**, not a client id or secret in `clawdbot.json`.
Steps:
1) Enable the plugin: `clawdbot plugins enable google-gemini-cli-auth`
2) Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers).
### Is a local model OK for casual chats ### Is a local model OK for casual chats
Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security). Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
-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.
-24
View File
@@ -192,30 +192,6 @@ Use this if you want diagnostics events available to plugins or custom sinks:
} }
``` ```
### Diagnostics flags (targeted logs)
Use flags to turn on extra, targeted debug logs without raising `logging.level`.
Flags are case-insensitive and support wildcards (e.g. `telegram.*` or `*`).
```json
{
"diagnostics": {
"flags": ["telegram.http"]
}
}
```
Env override (one-off):
```
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
```
Notes:
- Flag logs go to the standard log file (same as `logging.file`).
- Output is still redacted according to `logging.redactSensitive`.
- Full guide: [/diagnostics/flags](/diagnostics/flags).
### Export to OpenTelemetry ### Export to OpenTelemetry
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
+5 -19
View File
@@ -80,7 +80,6 @@ primary_region = "iad"
|---------|-----| |---------|-----|
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway | | `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
| `--allow-unconfigured` | Starts without a config file (you'll create one after) | | `--allow-unconfigured` | Starts without a config file (you'll create one after) |
| `internal_port = 3000` | Must match `--port 3000` (or `CLAWDBOT_GATEWAY_PORT`) for Fly health checks |
| `memory = "2048mb"` | 512MB is too small; 2GB recommended | | `memory = "2048mb"` | 512MB is too small; 2GB recommended |
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume | | `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
@@ -236,12 +235,6 @@ The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
**Fix:** Add `--bind lan` to your process command in `fly.toml`. **Fix:** Add `--bind lan` to your process command in `fly.toml`.
### Health checks failing / connection refused
Fly can't reach the gateway on the configured port.
**Fix:** Ensure `internal_port` matches the gateway port (set `--port 3000` or `CLAWDBOT_GATEWAY_PORT=3000`).
### OOM / Memory Issues ### OOM / Memory Issues
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts. Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
@@ -275,11 +268,11 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
### Config Not Being Read ### Config Not Being Read
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/clawdbot.json` should be read on restart. If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
Verify the config exists: Verify the config exists:
```bash ```bash
fly ssh console --command "cat /data/clawdbot.json" fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
``` ```
### Writing Config via SSH ### Writing Config via SSH
@@ -288,24 +281,18 @@ The `fly ssh console -C` command doesn't support shell redirection. To write a c
```bash ```bash
# Use echo + tee (pipe from local to remote) # Use echo + tee (pipe from local to remote)
echo '{"your":"config"}' | fly ssh console -C "tee /data/clawdbot.json" echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
# Or use sftp # Or use sftp
fly sftp shell fly sftp shell
> put /local/path/config.json /data/clawdbot.json > put /local/path/config.json /data/.clawdbot/clawdbot.json
``` ```
**Note:** `fly sftp` may fail if the file already exists. Delete first: **Note:** `fly sftp` may fail if the file already exists. Delete first:
```bash ```bash
fly ssh console --command "rm /data/clawdbot.json" fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
``` ```
### State Not Persisting
If you lose credentials or sessions after a restart, the state dir is writing to the container filesystem.
**Fix:** Ensure `CLAWDBOT_STATE_DIR=/data` is set in `fly.toml` and redeploy.
## Updates ## Updates
```bash ```bash
@@ -343,7 +330,6 @@ fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js g
- The Dockerfile is compatible with both architectures - The Dockerfile is compatible with both architectures
- For WhatsApp/Telegram onboarding, use `fly ssh console` - For WhatsApp/Telegram onboarding, use `fly ssh console`
- Persistent data lives on the volume at `/data` - Persistent data lives on the volume at `/data`
- Signal requires Java + signal-cli; use a custom image and keep memory at 2GB+.
## Cost ## Cost
-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:
+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
-54
View File
@@ -25,11 +25,9 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js"; import { sendMessageBlueBubbles } from "./send.js";
import { import {
extractHandleFromChatGuid,
looksLikeBlueBubblesTargetId, looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle, normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget, normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js"; } from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js"; import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
@@ -150,58 +148,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
looksLikeId: looksLikeBlueBubblesTargetId, looksLikeId: looksLikeBlueBubblesTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>", hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
}, },
formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => {
if (looksLikeBlueBubblesTargetId(value)) return true;
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
};
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_guid") {
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle;
}
if (parsed.kind === "handle") {
return normalizeBlueBubblesHandle(parsed.to);
}
} catch {
// Fall through
}
// Strip common prefixes and try raw extraction
const stripped = trimmed
.replace(/^bluebubbles:/i, "")
.replace(/^chat_guid:/i, "")
.replace(/^chat_id:/i, "")
.replace(/^chat_identifier:/i, "");
const handle = extractHandleFromChatGuid(stripped);
if (handle) return handle;
// Don't return raw chat_guid formats - they contain internal routing info
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
return stripped;
};
// Try to get a clean display from the display parameter first
const trimmedDisplay = display?.trim();
if (trimmedDisplay) {
if (!shouldParseDisplay(trimmedDisplay)) {
return trimmedDisplay;
}
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
if (cleanDisplay) return cleanDisplay;
}
// Fall back to extracting from target
const cleanTarget = extractCleanDisplay(target);
if (cleanTarget) return cleanTarget;
// Last resort: return display or target as-is
return display?.trim() || target?.trim() || "";
},
}, },
setup: { setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
+2 -94
View File
@@ -187,47 +187,6 @@ describe("send", () => {
expect(result).toBe("iMessage;-;+15551234567"); expect(result).toBe("iMessage;-;+15551234567");
}); });
it("returns null when handle only exists in group chat (not DM)", async () => {
// This is the critical fix: if a phone number only exists as a participant in a group chat
// (no direct DM chat), we should NOT send to that group. Return null instead.
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-the-council",
participants: [
{ address: "+12622102921" },
{ address: "+15550001111" },
{ address: "+15550002222" },
],
},
],
}),
})
// Empty second page to stop pagination
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+12622102921",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
// Should return null, NOT the group chat GUID
expect(result).toBeNull();
});
it("returns null when chat not found", async () => { it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
@@ -385,14 +344,14 @@ describe("send", () => {
).rejects.toThrow("password is required"); ).rejects.toThrow("password is required");
}); });
it("throws when chatGuid cannot be resolved for non-handle targets", async () => { it("throws when chatGuid cannot be resolved", async () => {
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ data: [] }), json: () => Promise.resolve({ data: [] }),
}); });
await expect( await expect(
sendMessageBlueBubbles("chat_id:999", "Hello", { sendMessageBlueBubbles("+15559999999", "Hello", {
serverUrl: "http://localhost:1234", serverUrl: "http://localhost:1234",
password: "test", password: "test",
}), }),
@@ -439,57 +398,6 @@ describe("send", () => {
expect(body.method).toBeUndefined(); expect(body.method).toBeUndefined();
}); });
it("creates a new chat when handle target is missing", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-guid" },
}),
),
});
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("new-msg-guid");
expect(mockFetch).toHaveBeenCalledTimes(2);
const createCall = mockFetch.mock.calls[1];
expect(createCall[0]).toContain("/api/v1/chat/new");
const body = JSON.parse(createCall[1].body);
expect(body.addresses).toEqual(["+15550009999"]);
expect(body.message).toBe("Hello new chat");
});
it("throws when creating a new chat requires Private API", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve("Private API not enabled"),
});
await expect(
sendMessageBlueBubbles("+15550008888", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("Private API must be enabled");
});
it("uses private-api when reply metadata is present", async () => { it("uses private-api when reply metadata is present", async () => {
mockFetch mockFetch
.mockResolvedValueOnce({ .mockResolvedValueOnce({
+5 -71
View File
@@ -257,17 +257,11 @@ export async function resolveChatGuidForTarget(params: {
return guid; return guid;
} }
if (!participantMatch && guid) { if (!participantMatch && guid) {
// Only consider DM chats (`;-;` separator) as participant matches. const participants = extractParticipantAddresses(chat).map((entry) =>
// Group chats (`;+;` separator) should never match when searching by handle/phone. normalizeBlueBubblesHandle(entry),
// This prevents routing "send to +1234567890" to a group chat that contains that number. );
const isDmChat = guid.includes(";-;"); if (participants.includes(normalizedHandle)) {
if (isDmChat) { participantMatch = guid;
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
} }
} }
} }
@@ -276,55 +270,6 @@ export async function resolveChatGuidForTarget(params: {
return participantMatch; return participantMatch;
} }
/**
* Creates a new chat (DM) and optionally sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
}): Promise<BlueBubblesSendResult> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/new",
password: params.password,
});
const payload = {
addresses: [params.address],
message: params.message,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
params.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
// Check for Private API not enabled error
if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) {
throw new Error(
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
);
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) return { messageId: "ok" };
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
export async function sendMessageBlueBubbles( export async function sendMessageBlueBubbles(
to: string, to: string,
text: string, text: string,
@@ -352,17 +297,6 @@ export async function sendMessageBlueBubbles(
target, target,
}); });
if (!chatGuid) { if (!chatGuid) {
// If target is a phone number/handle and no existing chat found,
// auto-create a new DM chat using the /api/v1/chat/new endpoint
if (target.kind === "handle") {
return createNewChatWithMessage({
baseUrl,
password,
address: target.address,
message: trimmedText,
timeoutMs: opts.timeoutMs,
});
}
throw new Error( throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
); );
@@ -33,7 +33,7 @@ export function registerMatrixAutoJoin(params: {
// For "allowlist" mode, handle invites manually // For "allowlist" mode, handle invites manually
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => { client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") return; if (autoJoin !== "allowlist") return;
// Get room alias if available // Get room alias if available
let alias: string | undefined; let alias: string | undefined;
let altAliases: string[] = []; let altAliases: string[] = [];
@@ -25,7 +25,7 @@ async function fetchMatrixMediaBuffer(params: {
// matrix-bot-sdk provides mxcToHttp helper // matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl); const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null; if (!url) return null;
// Use the client's download method which handles auth // Use the client's download method which handles auth
try { try {
const buffer = await params.client.downloadContent(params.mxcUrl); const buffer = await params.client.downloadContent(params.mxcUrl);
@@ -61,7 +61,7 @@ async function fetchEncryptedMediaBuffer(params: {
Buffer.from(encryptedBuffer), Buffer.from(encryptedBuffer),
params.file, params.file,
); );
return { buffer: decrypted }; return { buffer: decrypted };
} }
@@ -77,7 +77,7 @@ export async function downloadMatrixMedia(params: {
placeholder: string; placeholder: string;
} | null> { } | null> {
let fetched: { buffer: Buffer; headerType?: string } | null; let fetched: { buffer: Buffer; headerType?: string } | null;
if (params.file) { if (params.file) {
// Encrypted media // Encrypted media
fetched = await fetchEncryptedMediaBuffer({ fetched = await fetchEncryptedMediaBuffer({
@@ -93,7 +93,7 @@ export async function downloadMatrixMedia(params: {
maxBytes: params.maxBytes, maxBytes: params.maxBytes,
}); });
} }
if (!fetched) return null; if (!fetched) return null;
const headerType = fetched.headerType ?? params.contentType ?? undefined; const headerType = fetched.headerType ?? params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer( const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
+1 -1
View File
@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
}); });
if (typeof account.requireMention === "boolean") return account.requireMention; if (typeof account.requireMention === "boolean") return account.requireMention;
return true; return true;
} }
@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
return listMattermostAccountIds(cfg) return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId })) .map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled); .filter((account) => account.enabled);
} }
@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
throw new Error("Mattermost file upload failed"); throw new Error("Mattermost file upload failed");
} }
return info; return info;
} }
@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
? `${params.baseSessionKey}:thread:${threadId}` ? `${params.baseSessionKey}:thread:${threadId}`
: params.baseSessionKey; : params.baseSessionKey;
return { sessionKey, parentSessionKey: params.parentSessionKey }; return { sessionKey, parentSessionKey: params.parentSessionKey };
} }
@@ -67,4 +67,4 @@ export async function probeMattermost(
} finally { } finally {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer);
} }
} }
@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
); );
} }
return normalized; return normalized;
} }
+1 -1
View File
@@ -184,4 +184,4 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
mattermost: { ...cfg.channels?.mattermost, enabled: false }, mattermost: { ...cfg.channels?.mattermost, enabled: false },
}, },
}), }),
}; };
@@ -22,11 +22,11 @@ parallel:
security = session: security_expert security = session: security_expert
prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues." prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues."
context: overview context: overview
perf = session: performance_expert perf = session: performance_expert
prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions." prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions."
context: overview context: overview
style = session: reviewer style = session: reviewer
prompt: "Review for code style, maintainability, and adherence to best practices." prompt: "Review for code style, maintainability, and adherence to best practices."
context: overview context: overview
-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 };
@@ -19,3 +19,4 @@ export type CallManagerContext = {
transcriptWaiters: Map<CallId, TranscriptWaiter>; transcriptWaiters: Map<CallId, TranscriptWaiter>;
maxDurationTimers: Map<CallId, NodeJS.Timeout>; maxDurationTimers: Map<CallId, NodeJS.Timeout>;
}; };
@@ -175,3 +175,4 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
persistCallRecord(ctx.storePath, call); persistCallRecord(ctx.storePath, call);
} }
@@ -31,3 +31,4 @@ export function findCall(params: {
providerCallId: params.callIdOrProviderCallId, providerCallId: params.callIdOrProviderCallId,
}); });
} }
@@ -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) };
} }
} }
@@ -48,3 +48,4 @@ export function addTranscriptEntry(
}; };
call.transcript.push(entry); call.transcript.push(entry);
} }
@@ -86,3 +86,4 @@ export async function getCallHistoryFromStore(
return calls; return calls;
} }
@@ -84,3 +84,4 @@ export function waitForFinalTranscript(
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout }); ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
}); });
} }
@@ -7,3 +7,4 @@ export function generateNotifyTwiml(message: string, voice: string): string {
<Hangup/> <Hangup/>
</Response>`; </Response>`;
} }
@@ -26,3 +26,4 @@ describe("PlivoProvider", () => {
expect(result.providerResponseBody).toContain('length="300"'); expect(result.providerResponseBody).toContain('length="300"');
}); });
}); });
+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;
@@ -27,3 +27,4 @@ export function verifyTwilioProviderWebhook(params: {
reason: result.reason, reason: result.reason,
}; };
} }
+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
@@ -15,3 +15,4 @@ describe("zalouser outbound chunker", () => {
expect(chunks.every((c) => c.length <= limit)).toBe(true); expect(chunks.every((c) => c.length <= limit)).toBe(true);
}); });
}); });
+10 -10
View File
@@ -124,7 +124,7 @@ EOF
# Function to list categories # Function to list categories
list_categories() { list_categories() {
echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n" echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n"
# Get unique categories from recent logs # Get unique categories from recent logs
log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \ log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \
grep -E "category: \"[^\"]+\"" | \ grep -E "category: \"[^\"]+\"" | \
@@ -133,7 +133,7 @@ list_categories() {
while read -r cat; do while read -r cat; do
echo " • $cat" echo " • $cat"
done done
echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}" echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}"
} }
@@ -230,29 +230,29 @@ fi
if [[ "$STREAM_MODE" == true ]]; then if [[ "$STREAM_MODE" == true ]]; then
# Streaming mode # Streaming mode
CMD="sudo log stream --predicate '$PREDICATE' --level $LOG_LEVEL --info" CMD="sudo log stream --predicate '$PREDICATE' --level $LOG_LEVEL --info"
echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}" echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n" echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n"
else else
# Show mode # Show mode
CMD="sudo log show --predicate '$PREDICATE'" CMD="sudo log show --predicate '$PREDICATE'"
# Add log level for show command # Add log level for show command
if [[ "$LOG_LEVEL" == "debug" ]]; then if [[ "$LOG_LEVEL" == "debug" ]]; then
CMD="$CMD --debug" CMD="$CMD --debug"
else else
CMD="$CMD --info" CMD="$CMD --info"
fi fi
# Add time range # Add time range
CMD="$CMD --last $TIME_RANGE" CMD="$CMD --last $TIME_RANGE"
if [[ "$SHOW_TAIL" == true ]]; then if [[ "$SHOW_TAIL" == true ]]; then
echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}" echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}"
else else
echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}" echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}"
fi fi
# Show applied filters # Show applied filters
if [[ "$ERRORS_ONLY" == true ]]; then if [[ "$ERRORS_ONLY" == true ]]; then
echo -e "${RED}Filter: Errors only${NC}" echo -e "${RED}Filter: Errors only${NC}"
@@ -277,14 +277,14 @@ if [[ -n "$OUTPUT_FILE" ]]; then
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
handle_sudo_error handle_sudo_error
fi fi
echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n" echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n"
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE" eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE"
else else
eval "$CMD" > "$OUTPUT_FILE" 2>&1 eval "$CMD" > "$OUTPUT_FILE" 2>&1
fi fi
# Check if file was created and has content # Check if file was created and has content
if [[ -s "$OUTPUT_FILE" ]]; then if [[ -s "$OUTPUT_FILE" ]]; then
LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ') LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
@@ -298,7 +298,7 @@ else
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
handle_sudo_error handle_sudo_error
fi fi
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
# Apply tail for non-streaming mode # Apply tail for non-streaming mode
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" eval "$CMD" 2>&1 | tail -n "$TAIL_LINES"
+2 -2
View File
@@ -102,12 +102,12 @@ ws.send(
); );
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\"); const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\")); if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" })); ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" }));
const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000); const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000);
if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\")); if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\"));
if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\"); if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\");
ws.close(); ws.close();
console.log(\"ok\"); console.log(\"ok\");
NODE" NODE"
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if [[ $# -lt 1 ]]; then
echo "usage: run-node-tool.sh <tool> [args...]" >&2
exit 2
fi
tool="$1"
shift
if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then
exec pnpm exec "$tool" "$@"
fi
if { [[ -f "$ROOT_DIR/bun.lockb" ]] || [[ -f "$ROOT_DIR/bun.lock" ]]; } && command -v bun >/dev/null 2>&1; then
exec bunx --bun "$tool" "$@"
fi
if command -v npm >/dev/null 2>&1; then
exec npm exec -- "$tool" "$@"
fi
if command -v npx >/dev/null 2>&1; then
exec npx "$tool" "$@"
fi
echo "Missing package manager: pnpm, bun, or npm required." >&2
exit 1
+1
View File
@@ -30,3 +30,4 @@ export type Entry = {
avatar_url: string; avatar_url: string;
lines: number; lines: number;
}; };
+1 -1
View File
@@ -84,7 +84,7 @@ curl http://127.0.0.1:8000/places/{place_id}
"open_now": true "open_now": true
} }
], ],
"next_page_token": "..." "next_page_token": "..."
} }
``` ```
+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;
} }
+1 -30
View File
@@ -8,7 +8,6 @@ export type ModelCatalogEntry = {
provider: string; provider: string;
contextWindow?: number; contextWindow?: number;
reasoning?: boolean; reasoning?: boolean;
input?: Array<"text" | "image">;
}; };
type DiscoveredModel = { type DiscoveredModel = {
@@ -17,7 +16,6 @@ type DiscoveredModel = {
provider: string; provider: string;
contextWindow?: number; contextWindow?: number;
reasoning?: boolean; reasoning?: boolean;
input?: Array<"text" | "image">;
}; };
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent"); type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
@@ -82,10 +80,7 @@ export async function loadModelCatalog(params?: {
? entry.contextWindow ? entry.contextWindow
: undefined; : undefined;
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined; const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
const input = Array.isArray(entry?.input) models.push({ id, name, provider, contextWindow, reasoning });
? (entry.input as Array<"text" | "image">)
: undefined;
models.push({ id, name, provider, contextWindow, reasoning, input });
} }
if (models.length === 0) { if (models.length === 0) {
@@ -110,27 +105,3 @@ export async function loadModelCatalog(params?: {
return modelCatalogPromise; return modelCatalogPromise;
} }
/**
* Check if a model supports image input based on its catalog entry.
*/
export function modelSupportsVision(entry: ModelCatalogEntry | undefined): boolean {
return entry?.input?.includes("image") ?? false;
}
/**
* Find a model in the catalog by provider and model ID.
*/
export function findModelInCatalog(
catalog: ModelCatalogEntry[],
provider: string,
modelId: string,
): ModelCatalogEntry | undefined {
const normalizedProvider = provider.toLowerCase().trim();
const normalizedModelId = modelId.toLowerCase().trim();
return catalog.find(
(entry) =>
entry.provider.toLowerCase() === normalizedProvider &&
entry.id.toLowerCase() === normalizedModelId,
);
}
@@ -27,14 +27,4 @@ describe("sanitizeUserFacingText", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}'; const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded"); expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
}); });
it("collapses consecutive duplicate paragraphs", () => {
const text = "Hello there!\n\nHello there!";
expect(sanitizeUserFacingText(text)).toBe("Hello there!");
});
it("does not collapse distinct paragraphs", () => {
const text = "Hello there!\n\nDifferent line.";
expect(sanitizeUserFacingText(text)).toBe(text);
});
}); });
+1 -24
View File
@@ -77,29 +77,6 @@ function stripFinalTagsFromText(text: string): string {
return text.replace(FINAL_TAG_RE, ""); return text.replace(FINAL_TAG_RE, "");
} }
function collapseConsecutiveDuplicateBlocks(text: string): string {
const trimmed = text.trim();
if (!trimmed) return text;
const blocks = trimmed.split(/\n{2,}/);
if (blocks.length < 2) return text;
const normalizeBlock = (value: string) => value.trim().replace(/\s+/g, " ");
const result: string[] = [];
let lastNormalized: string | null = null;
for (const block of blocks) {
const normalized = normalizeBlock(block);
if (lastNormalized && normalized === lastNormalized) {
continue;
}
result.push(block.trim());
lastNormalized = normalized;
}
if (result.length === blocks.length) return text;
return result.join("\n\n");
}
function isLikelyHttpErrorText(raw: string): boolean { function isLikelyHttpErrorText(raw: string): boolean {
const match = raw.match(HTTP_STATUS_PREFIX_RE); const match = raw.match(HTTP_STATUS_PREFIX_RE);
if (!match) return false; if (!match) return false;
@@ -344,7 +321,7 @@ export function sanitizeUserFacingText(text: string): string {
return formatRawAssistantErrorForUi(trimmed); return formatRawAssistantErrorForUi(trimmed);
} }
return collapseConsecutiveDuplicateBlocks(stripped); return stripped;
} }
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean { export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
+1 -11
View File
@@ -333,13 +333,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
name: "message", name: "message",
description, description,
parameters: schema, parameters: schema,
execute: async (_toolCallId, args, signal) => { execute: async (_toolCallId, args) => {
// Check if already aborted before doing any work
if (signal?.aborted) {
const err = new Error("Message send aborted");
err.name = "AbortError";
throw err;
}
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig(); const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { const action = readStringParam(params, "action", {
@@ -372,9 +366,6 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
currentThreadTs: options?.currentThreadTs, currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode, replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef, hasRepliedRef: options?.hasRepliedRef,
// Direct tool invocations should not add cross-context decoration.
// The agent is composing a message, not forwarding from another chat.
skipCrossContextDecoration: true,
} }
: undefined; : undefined;
@@ -388,7 +379,6 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
agentId: options?.agentSessionKey agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined, : undefined,
abortSignal: signal,
}); });
const toolResult = getToolResult(result); const toolResult = getToolResult(result);
+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 -19
View File
@@ -1,8 +1,7 @@
import type { NoticeLevel, ReasoningLevel } from "../thinking.js"; import type { ReasoningLevel } from "../thinking.js";
import { import {
type ElevatedLevel, type ElevatedLevel,
normalizeElevatedLevel, normalizeElevatedLevel,
normalizeNoticeLevel,
normalizeReasoningLevel, normalizeReasoningLevel,
normalizeThinkLevel, normalizeThinkLevel,
normalizeVerboseLevel, normalizeVerboseLevel,
@@ -113,22 +112,6 @@ export function extractVerboseDirective(body?: string): {
}; };
} }
export function extractNoticeDirective(body?: string): {
cleaned: string;
noticeLevel?: NoticeLevel;
rawLevel?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const extracted = extractLevelDirective(body, ["notice", "notices"], normalizeNoticeLevel);
return {
cleaned: extracted.cleaned,
noticeLevel: extracted.level,
rawLevel: extracted.rawLevel,
hasDirective: extracted.hasDirective,
};
}
export function extractElevatedDirective(body?: string): { export function extractElevatedDirective(body?: string): {
cleaned: string; cleaned: string;
elevatedLevel?: ElevatedLevel; elevatedLevel?: ElevatedLevel;
@@ -169,5 +152,5 @@ export function extractStatusDirective(body?: string): {
return extractSimpleDirective(body, ["status"]); return extractSimpleDirective(body, ["status"]);
} }
export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
export { extractExecDirective } from "./exec/directive.js"; export { extractExecDirective } from "./exec/directive.js";
-11
View File
@@ -1,6 +1,5 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type VerboseLevel = "off" | "on" | "full"; export type VerboseLevel = "off" | "on" | "full";
export type NoticeLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on" | "ask" | "full"; export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full"; export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream"; export type ReasoningLevel = "off" | "on" | "stream";
@@ -94,16 +93,6 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
return undefined; return undefined;
} }
// Normalize system notice flags used to toggle system notifications.
export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined {
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off";
if (["full", "all", "everything"].includes(key)) return "full";
if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
// Normalize response-usage display modes used to toggle per-response usage footers. // Normalize response-usage display modes used to toggle per-response usage footers.
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined { export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
if (!raw) return undefined; if (!raw) return undefined;
-6
View File
@@ -240,12 +240,6 @@ export type ChannelThreadingToolContext = {
currentThreadTs?: string; currentThreadTs?: string;
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean }; hasRepliedRef?: { value: boolean };
/**
* When true, skip cross-context decoration (e.g., "[from X]" prefix).
* Use this for direct tool invocations where the agent is composing a new message,
* not forwarding/relaying a message from another conversation.
*/
skipCrossContextDecoration?: boolean;
}; };
export type ChannelMessagingAdapter = { export type ChannelMessagingAdapter = {
-1
View File
@@ -100,7 +100,6 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (json) warnings.push(message); if (json) warnings.push(message);
else defaultRuntime.log(message); else defaultRuntime.log(message);
}, },
config: cfg,
}); });
try { try {
-3
View File
@@ -11,7 +11,6 @@ import {
} from "./daemon-runtime.js"; } from "./daemon-runtime.js";
import { guardCancel } from "./onboard-helpers.js"; import { guardCancel } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
import { loadConfig } from "../config/config.js";
export async function maybeInstallDaemon(params: { export async function maybeInstallDaemon(params: {
runtime: RuntimeEnv; runtime: RuntimeEnv;
@@ -82,14 +81,12 @@ export async function maybeInstallDaemon(params: {
progress.setLabel("Preparing Gateway service…"); progress.setLabel("Preparing Gateway service…");
const cfg = loadConfig();
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env, env: process.env,
port: params.port, port: params.port,
token: params.gatewayToken, token: params.gatewayToken,
runtime: daemonRuntime, runtime: daemonRuntime,
warn: (message, title) => note(message, title), warn: (message, title) => note(message, title),
config: cfg,
}); });
progress.setLabel("Installing Gateway service…"); progress.setLabel("Installing Gateway service…");
-134
View File
@@ -95,140 +95,6 @@ describe("buildGatewayInstallPlan", () => {
expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime"); expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime");
expect(mocks.resolvePreferredNodePath).toHaveBeenCalled(); expect(mocks.resolvePreferredNodePath).toHaveBeenCalled();
}); });
it("merges config env vars into the environment", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({
CLAWDBOT_PORT: "3000",
HOME: "/Users/me",
});
const plan = await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
config: {
env: {
vars: {
GOOGLE_API_KEY: "test-key",
},
CUSTOM_VAR: "custom-value",
},
},
});
// Config env vars should be present
expect(plan.environment.GOOGLE_API_KEY).toBe("test-key");
expect(plan.environment.CUSTOM_VAR).toBe("custom-value");
// Service environment vars should take precedence
expect(plan.environment.CLAWDBOT_PORT).toBe("3000");
expect(plan.environment.HOME).toBe("/Users/me");
});
it("does not include empty config env values", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({ CLAWDBOT_PORT: "3000" });
const plan = await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
config: {
env: {
vars: {
VALID_KEY: "valid",
EMPTY_KEY: "",
},
},
},
});
expect(plan.environment.VALID_KEY).toBe("valid");
expect(plan.environment.EMPTY_KEY).toBeUndefined();
});
it("drops whitespace-only config env values", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({});
const plan = await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
config: {
env: {
vars: {
VALID_KEY: "valid",
},
TRIMMED_KEY: " ",
},
},
});
expect(plan.environment.VALID_KEY).toBe("valid");
expect(plan.environment.TRIMMED_KEY).toBeUndefined();
});
it("keeps service env values over config env vars", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({
HOME: "/Users/service",
CLAWDBOT_PORT: "3000",
});
const plan = await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
config: {
env: {
HOME: "/Users/config",
vars: {
CLAWDBOT_PORT: "9999",
},
},
},
});
expect(plan.environment.HOME).toBe("/Users/service");
expect(plan.environment.CLAWDBOT_PORT).toBe("3000");
});
}); });
describe("gatewayInstallErrorHint", () => { describe("gatewayInstallErrorHint", () => {
+1 -12
View File
@@ -7,8 +7,6 @@ import {
} from "../daemon/runtime-paths.js"; } from "../daemon/runtime-paths.js";
import { buildServiceEnvironment } from "../daemon/service-env.js"; import { buildServiceEnvironment } from "../daemon/service-env.js";
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import { collectConfigEnvVars } from "../config/env-vars.js";
import type { ClawdbotConfig } from "../config/types.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
type WarnFn = (message: string, title?: string) => void; type WarnFn = (message: string, title?: string) => void;
@@ -33,8 +31,6 @@ export async function buildGatewayInstallPlan(params: {
devMode?: boolean; devMode?: boolean;
nodePath?: string; nodePath?: string;
warn?: WarnFn; warn?: WarnFn;
/** Full config to extract env vars from (env vars + inline env keys). */
config?: ClawdbotConfig;
}): Promise<GatewayInstallPlan> { }): Promise<GatewayInstallPlan> {
const devMode = params.devMode ?? resolveGatewayDevMode(); const devMode = params.devMode ?? resolveGatewayDevMode();
const nodePath = const nodePath =
@@ -54,7 +50,7 @@ export async function buildGatewayInstallPlan(params: {
const warning = renderSystemNodeWarning(systemNode, programArguments[0]); const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
if (warning) params.warn?.(warning, "Gateway runtime"); if (warning) params.warn?.(warning, "Gateway runtime");
} }
const serviceEnvironment = buildServiceEnvironment({ const environment = buildServiceEnvironment({
env: params.env, env: params.env,
port: params.port, port: params.port,
token: params.token, token: params.token,
@@ -64,13 +60,6 @@ export async function buildGatewayInstallPlan(params: {
: undefined, : undefined,
}); });
// Merge config env vars into the service environment (vars + inline env keys).
// Config env vars are added first so service-specific vars take precedence.
const environment: Record<string, string | undefined> = {
...collectConfigEnvVars(params.config),
};
Object.assign(environment, serviceEnvironment);
return { programArguments, workingDirectory, environment }; return { programArguments, workingDirectory, environment };
} }
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more