Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca26e17273 | ||
|
|
34ab1d245c | ||
|
|
a11b98f801 | ||
|
|
67db63ba05 | ||
|
|
bbefb2e5a5 | ||
|
|
50f233d16d | ||
|
|
48aea87028 | ||
|
|
612a27f3dd | ||
|
|
737037129e | ||
|
|
f29f51569a | ||
|
|
bfa57aae44 | ||
|
|
98cecc9c56 | ||
|
|
6cc1f5abb8 | ||
|
|
9fbee08590 | ||
|
|
0f662c2935 | ||
|
|
32bcd291d5 | ||
|
|
5f9863098b | ||
|
|
fdecf5c59a | ||
|
|
83f92e34af | ||
|
|
9366cbc7db | ||
|
|
d4f895d8f2 | ||
|
|
f08c34a73f | ||
|
|
6a9301c27d | ||
|
|
653401774d | ||
|
|
c6cdbb630c | ||
|
|
da2439f2cc | ||
|
|
92ab3f22dc | ||
|
|
43a6c5b77f | ||
|
|
495616d13e | ||
|
|
bac80f0886 | ||
|
|
ef078fec70 | ||
|
|
8e3ac01db6 | ||
|
|
c3f90dd4e2 | ||
|
|
00c4556d7b | ||
|
|
a4bc69dbec | ||
|
|
3f1457de2a | ||
|
|
8507ea08bd | ||
|
|
7ae2548fc6 | ||
|
|
f06f83ddd0 | ||
|
|
c78297d80f | ||
|
|
69f6e1a20b | ||
|
|
5f6409a73d | ||
|
|
06a7e1e8ce | ||
|
|
9eaaadf8ee |
No files matched your search
@@ -7,6 +7,10 @@
|
|||||||
[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.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 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'
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 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]
|
||||||
+1798
-125
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
# 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
@@ -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
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
## 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`.
|
||||||
|
|||||||
@@ -12,30 +12,43 @@ 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: update Fly.io guide notes.
|
- Docs: update Fly.io guide notes.
|
||||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||||
|
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
- Telegram: 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
|
||||||
|
- macOS: rearm gateway receive loop before push handling to avoid node invoke stalls. (#1752) Thanks @ngutman.
|
||||||
|
- 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: 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)
|
||||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||||
|
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||||
|
- 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: 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -12,4 +12,3 @@ 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
@@ -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,4 +12,3 @@ data class CameraHudState(
|
|||||||
val kind: CameraHudKind,
|
val kind: CameraHudKind,
|
||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,4 +12,3 @@ 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,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0A0A0A</color>
|
<color name="ic_launcher_background">#0A0A0A</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clawdbot Node</string>
|
<string name="app_name">Clawdbot Node</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
-1
@@ -23,4 +23,3 @@ class VoiceWakeCommandExtractorTest {
|
|||||||
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,4 +16,3 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "ClawdbotNodeAndroid"
|
rootProject.name = "ClawdbotNodeAndroid"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
||||||
@@ -3,4 +3,3 @@ parent_config: ../../.swiftlint.yml
|
|||||||
included:
|
included:
|
||||||
- Sources
|
- Sources
|
||||||
- ../shared/ClawdisNodeKit/Sources
|
- ../shared/ClawdisNodeKit/Sources
|
||||||
|
|
||||||
@@ -33,4 +33,4 @@
|
|||||||
],
|
],
|
||||||
"squares" : "shared"
|
"squares" : "shared"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -427,8 +427,8 @@ public actor GatewayChannelActor {
|
|||||||
Task { await self.handleReceiveFailure(err) }
|
Task { await self.handleReceiveFailure(err) }
|
||||||
case let .success(msg):
|
case let .success(msg):
|
||||||
Task {
|
Task {
|
||||||
await self.handle(msg)
|
|
||||||
await self.listen()
|
await self.listen()
|
||||||
|
await self.handle(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,46 +574,22 @@ public actor GatewayChannelActor {
|
|||||||
params: [String: AnyCodable]?,
|
params: [String: AnyCodable]?,
|
||||||
timeoutMs: Double? = nil) async throws -> Data
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
{
|
{
|
||||||
do {
|
try await self.connectOrThrow(context: "gateway connect")
|
||||||
try await self.connect()
|
|
||||||
} catch {
|
|
||||||
throw self.wrap(error, context: "gateway connect")
|
|
||||||
}
|
|
||||||
let id = UUID().uuidString
|
|
||||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
|
||||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
|
||||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
|
||||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
|
||||||
}
|
|
||||||
return ProtoAnyCodable(dict)
|
|
||||||
}
|
|
||||||
let frame = RequestFrame(
|
|
||||||
type: "req",
|
|
||||||
id: id,
|
|
||||||
method: method,
|
|
||||||
params: paramsObject)
|
|
||||||
let data: Data
|
|
||||||
do {
|
|
||||||
data = try self.encoder.encode(frame)
|
|
||||||
} catch {
|
|
||||||
self.logger.error(
|
|
||||||
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||||
self.pending[id] = cont
|
self.pending[payload.id] = cont
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||||
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await self.task?.send(.data(data))
|
try await self.task?.send(.data(payload.data))
|
||||||
} catch {
|
} catch {
|
||||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||||
let waiter = self.pending.removeValue(forKey: id)
|
let waiter = self.pending.removeValue(forKey: payload.id)
|
||||||
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
||||||
self.connected = false
|
self.connected = false
|
||||||
self.task?.cancel(with: .goingAway, reason: nil)
|
self.task?.cancel(with: .goingAway, reason: nil)
|
||||||
@@ -657,6 +633,42 @@ public actor GatewayChannelActor {
|
|||||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func connectOrThrow(context: String) async throws {
|
||||||
|
do {
|
||||||
|
try await self.connect()
|
||||||
|
} catch {
|
||||||
|
throw self.wrap(error, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeRequest(
|
||||||
|
method: String,
|
||||||
|
params: [String: AnyCodable]?,
|
||||||
|
kind: String) throws -> (id: String, data: Data)
|
||||||
|
{
|
||||||
|
let id = UUID().uuidString
|
||||||
|
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||||
|
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||||
|
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||||
|
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||||
|
}
|
||||||
|
return ProtoAnyCodable(dict)
|
||||||
|
}
|
||||||
|
let frame = RequestFrame(
|
||||||
|
type: "req",
|
||||||
|
id: id,
|
||||||
|
method: method,
|
||||||
|
params: paramsObject)
|
||||||
|
do {
|
||||||
|
let data = try self.encoder.encode(frame)
|
||||||
|
return (id: id, data: data)
|
||||||
|
} catch {
|
||||||
|
self.logger.error(
|
||||||
|
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func failPending(_ error: Error) async {
|
private func failPending(_ error: Error) async {
|
||||||
let waiters = self.pending
|
let waiters = self.pending
|
||||||
self.pending.removeAll()
|
self.pending.removeAll()
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
|
|
||||||
|
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var queue: [URLSessionWebSocketTask.Message] = []
|
||||||
|
private var pendingHandler: (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||||
|
private var pendingContinuation: CheckedContinuation<URLSessionWebSocketTask.Message, Error>?
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
var state: URLSessionTask.State = .running
|
||||||
|
|
||||||
|
func resume() {}
|
||||||
|
|
||||||
|
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
||||||
|
state = .canceling
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||||
|
guard case let .data(data) = message else { return }
|
||||||
|
guard let frame = try? decoder.decode(RequestFrame.self, from: data) else { return }
|
||||||
|
switch frame.method {
|
||||||
|
case "connect":
|
||||||
|
enqueueResponse(id: frame.id, payload: helloOkPayload())
|
||||||
|
default:
|
||||||
|
enqueueResponse(id: frame.id, payload: ["ok": true])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||||
|
try await withCheckedThrowingContinuation { cont in
|
||||||
|
lock.lock()
|
||||||
|
if !queue.isEmpty {
|
||||||
|
let msg = queue.removeFirst()
|
||||||
|
lock.unlock()
|
||||||
|
cont.resume(returning: msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingContinuation = cont
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receive(
|
||||||
|
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
|
||||||
|
{
|
||||||
|
lock.lock()
|
||||||
|
if !queue.isEmpty {
|
||||||
|
let msg = queue.removeFirst()
|
||||||
|
lock.unlock()
|
||||||
|
completionHandler(.success(msg))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingHandler = completionHandler
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(_ message: URLSessionWebSocketTask.Message) {
|
||||||
|
lock.lock()
|
||||||
|
if let handler = pendingHandler {
|
||||||
|
pendingHandler = nil
|
||||||
|
lock.unlock()
|
||||||
|
handler(.success(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let continuation = pendingContinuation {
|
||||||
|
pendingContinuation = nil
|
||||||
|
lock.unlock()
|
||||||
|
continuation.resume(returning: message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queue.append(message)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueueResponse(id: String, payload: [String: Any]) {
|
||||||
|
let response = ResponseFrame(
|
||||||
|
type: "res",
|
||||||
|
id: id,
|
||||||
|
ok: true,
|
||||||
|
payload: ClawdbotProtocol.AnyCodable(payload),
|
||||||
|
error: nil)
|
||||||
|
guard let data = try? encoder.encode(response) else { return }
|
||||||
|
enqueue(.data(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func helloOkPayload() -> [String: Any] {
|
||||||
|
[
|
||||||
|
"type": "hello.ok",
|
||||||
|
"protocol": 1,
|
||||||
|
"server": [:],
|
||||||
|
"features": [:],
|
||||||
|
"snapshot": [
|
||||||
|
"presence": [],
|
||||||
|
"health": [:],
|
||||||
|
"stateVersion": [
|
||||||
|
"presence": 0,
|
||||||
|
"health": 0,
|
||||||
|
],
|
||||||
|
"uptimeMs": 0,
|
||||||
|
],
|
||||||
|
"policy": [
|
||||||
|
"tickIntervalMs": 1000,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class FakeWebSocketSession: WebSocketSessioning {
|
||||||
|
let task: FakeWebSocketTask
|
||||||
|
|
||||||
|
init(task: FakeWebSocketTask) {
|
||||||
|
self.task = task
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||||
|
WebSocketTaskBox(task: task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor AsyncSignal {
|
||||||
|
private var continuation: CheckedContinuation<Result<Void, Error>, Never>?
|
||||||
|
private var stored: Result<Void, Error>?
|
||||||
|
|
||||||
|
func finish(_ result: Result<Void, Error>) {
|
||||||
|
if let continuation {
|
||||||
|
self.continuation = nil
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stored = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func wait() async throws {
|
||||||
|
let result = await withCheckedContinuation { cont in
|
||||||
|
if let stored {
|
||||||
|
self.stored = nil
|
||||||
|
cont.resume(returning: stored)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation = cont
|
||||||
|
}
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
return
|
||||||
|
case let .failure(error):
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TestError: Error {
|
||||||
|
case timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GatewayChannelTests {
|
||||||
|
@Test
|
||||||
|
func listenRearmsBeforePushHandler() async throws {
|
||||||
|
let task = FakeWebSocketTask()
|
||||||
|
let session = FakeWebSocketSession(task: task)
|
||||||
|
let signal = AsyncSignal()
|
||||||
|
let url = URL(string: "ws://example.invalid")!
|
||||||
|
final class ChannelBox { var channel: GatewayChannelActor? }
|
||||||
|
let box = ChannelBox()
|
||||||
|
|
||||||
|
let channel = GatewayChannelActor(
|
||||||
|
url: url,
|
||||||
|
token: nil,
|
||||||
|
session: WebSocketSessionBox(session: session),
|
||||||
|
pushHandler: { push in
|
||||||
|
guard case let .event(evt) = push, evt.event == "test.event" else { return }
|
||||||
|
guard let channel = box.channel else { return }
|
||||||
|
let params: [String: ClawdbotKit.AnyCodable] = [
|
||||||
|
"event": ClawdbotKit.AnyCodable("test"),
|
||||||
|
"payloadJSON": ClawdbotKit.AnyCodable(NSNull()),
|
||||||
|
]
|
||||||
|
do {
|
||||||
|
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 50)
|
||||||
|
await signal.finish(.success(()))
|
||||||
|
} catch {
|
||||||
|
await signal.finish(.failure(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
box.channel = channel
|
||||||
|
|
||||||
|
let challenge = EventFrame(
|
||||||
|
type: "event",
|
||||||
|
event: "connect.challenge",
|
||||||
|
payload: ClawdbotProtocol.AnyCodable(["nonce": "test-nonce"]),
|
||||||
|
seq: nil,
|
||||||
|
stateversion: nil)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
task.enqueue(.data(try encoder.encode(challenge)))
|
||||||
|
|
||||||
|
try await channel.connect()
|
||||||
|
|
||||||
|
let event = EventFrame(
|
||||||
|
type: "event",
|
||||||
|
event: "test.event",
|
||||||
|
payload: ClawdbotProtocol.AnyCodable([:]),
|
||||||
|
seq: nil,
|
||||||
|
stateversion: nil)
|
||||||
|
task.enqueue(.data(try encoder.encode(event)))
|
||||||
|
|
||||||
|
try await AsyncTimeout.withTimeout(seconds: 1, onTimeout: { TestError.timeout }) {
|
||||||
|
try await signal.wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
@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
@@ -1 +0,0 @@
|
|||||||
@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
|
|
||||||
+3119
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
{"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
|
||||||
-3059
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
|||||||
{"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
|
|
||||||
-3047
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
|||||||
{"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
|
|
||||||
Vendored
+2
-2
@@ -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-DsXRcnEw.js"></script>
|
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<clawdbot-app></clawdbot-app>
|
<clawdbot-app></clawdbot-app>
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ 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.
|
||||||
|
|||||||
@@ -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.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.linkPreview`, `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.
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ Example:
|
|||||||
|
|
||||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
|
## External daemon mode (httpUrl)
|
||||||
|
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
signal: {
|
||||||
|
httpUrl: "http://127.0.0.1:8080",
|
||||||
|
autoStart: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
|
||||||
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `channels.signal.dmPolicy = "pairing"`.
|
- Default: `channels.signal.dmPolicy = "pairing"`.
|
||||||
@@ -142,6 +158,7 @@ Provider options:
|
|||||||
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
||||||
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
||||||
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
||||||
|
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
|
||||||
- `channels.signal.receiveMode`: `on-start | manual`.
|
- `channels.signal.receiveMode`: `on-start | manual`.
|
||||||
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
||||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||||
|
|||||||
@@ -525,6 +525,7 @@ 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).
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -1048,6 +1048,7 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"platforms",
|
"platforms",
|
||||||
"platforms/macos",
|
"platforms/macos",
|
||||||
|
"platforms/macos-vm",
|
||||||
"platforms/ios",
|
"platforms/ios",
|
||||||
"platforms/android",
|
"platforms/android",
|
||||||
"platforms/windows",
|
"platforms/windows",
|
||||||
|
|||||||
@@ -1021,6 +1021,7 @@ 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,
|
||||||
|
|||||||
+382
-176
File diff suppressed because it is too large.
Load diff
@@ -114,3 +114,9 @@ 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.
|
||||||
@@ -192,6 +192,30 @@ 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
|
||||||
|
|||||||
+19
-5
@@ -80,6 +80,7 @@ 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 |
|
||||||
|
|
||||||
@@ -235,6 +236,12 @@ 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.
|
||||||
@@ -268,11 +275,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/clawdbot.json` should be read on restart.
|
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/clawdbot.json` should be read on restart.
|
||||||
|
|
||||||
Verify the config exists:
|
Verify the config exists:
|
||||||
```bash
|
```bash
|
||||||
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
|
fly ssh console --command "cat /data/clawdbot.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Writing Config via SSH
|
### Writing Config via SSH
|
||||||
@@ -281,18 +288,24 @@ 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/clawdbot.json"
|
echo '{"your":"config"}' | fly ssh console -C "tee /data/clawdbot.json"
|
||||||
|
|
||||||
# Or use sftp
|
# Or use sftp
|
||||||
fly sftp shell
|
fly sftp shell
|
||||||
> put /local/path/config.json /data/.clawdbot/clawdbot.json
|
> put /local/path/config.json /data/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/clawdbot.json"
|
fly ssh console --command "rm /data/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
|
||||||
@@ -330,6 +343,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
---
|
||||||
|
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
|
||||||
|
read_when:
|
||||||
|
- You want Clawdbot isolated from your main macOS environment
|
||||||
|
- You want iMessage integration (BlueBubbles) in a sandbox
|
||||||
|
- You want a resettable macOS environment you can clone
|
||||||
|
- You want to compare local vs hosted macOS VM options
|
||||||
|
---
|
||||||
|
|
||||||
|
# Clawdbot on macOS VMs (Sandboxing)
|
||||||
|
|
||||||
|
## Recommended default (most users)
|
||||||
|
|
||||||
|
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
|
||||||
|
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
|
||||||
|
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
|
||||||
|
|
||||||
|
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
|
||||||
|
|
||||||
|
## macOS VM options
|
||||||
|
|
||||||
|
### Local VM on your Apple Silicon Mac (Lume)
|
||||||
|
|
||||||
|
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
|
||||||
|
|
||||||
|
This gives you:
|
||||||
|
- Full macOS environment in isolation (your host stays clean)
|
||||||
|
- iMessage support via BlueBubbles (impossible on Linux/Windows)
|
||||||
|
- Instant reset by cloning VMs
|
||||||
|
- No extra hardware or cloud costs
|
||||||
|
|
||||||
|
### Hosted Mac providers (cloud)
|
||||||
|
|
||||||
|
If you want macOS in the cloud, hosted Mac providers work too:
|
||||||
|
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
|
||||||
|
- Other hosted Mac vendors also work; follow their VM + SSH docs
|
||||||
|
|
||||||
|
Once you have SSH access to a macOS VM, continue at step 6 below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick path (Lume, experienced users)
|
||||||
|
|
||||||
|
1. Install Lume
|
||||||
|
2. `lume create clawdbot --os macos --ipsw latest`
|
||||||
|
3. Complete Setup Assistant, enable Remote Login (SSH)
|
||||||
|
4. `lume run clawdbot --no-display`
|
||||||
|
5. SSH in, install Clawdbot, configure channels
|
||||||
|
6. Done
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you need (Lume)
|
||||||
|
|
||||||
|
- Apple Silicon Mac (M1/M2/M3/M4)
|
||||||
|
- macOS Sequoia or later on the host
|
||||||
|
- ~60 GB free disk space per VM
|
||||||
|
- ~20 minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Install Lume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `~/.local/bin` isn't in your PATH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Create the macOS VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume create clawdbot --os macos --ipsw latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This downloads macOS and creates the VM. A VNC window opens automatically.
|
||||||
|
|
||||||
|
Note: The download can take a while depending on your connection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Complete Setup Assistant
|
||||||
|
|
||||||
|
In the VNC window:
|
||||||
|
1. Select language and region
|
||||||
|
2. Skip Apple ID (or sign in if you want iMessage later)
|
||||||
|
3. Create a user account (remember the username and password)
|
||||||
|
4. Skip all optional features
|
||||||
|
|
||||||
|
After setup completes, enable SSH:
|
||||||
|
1. Open System Settings → General → Sharing
|
||||||
|
2. Enable "Remote Login"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Get the VM's IP address
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume get clawdbot
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for the IP address (usually `192.168.64.x`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) SSH into the VM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh youruser@192.168.64.X
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `youruser` with the account you created, and the IP with your VM's IP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Install Clawdbot
|
||||||
|
|
||||||
|
Inside the VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g clawdbot@latest
|
||||||
|
clawdbot onboard --install-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Configure channels
|
||||||
|
|
||||||
|
Edit the config file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano ~/.clawdbot/clawdbot.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Add your channels:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"dmPolicy": "allowlist",
|
||||||
|
"allowFrom": ["+15551234567"]
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"botToken": "YOUR_BOT_TOKEN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then login to WhatsApp (scan QR):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels login
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Run the VM headlessly
|
||||||
|
|
||||||
|
Stop the VM and restart without display:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot
|
||||||
|
lume run clawdbot --no-display
|
||||||
|
```
|
||||||
|
|
||||||
|
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
|
||||||
|
|
||||||
|
To check status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh youruser@192.168.64.X "clawdbot status"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bonus: iMessage integration
|
||||||
|
|
||||||
|
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
|
||||||
|
|
||||||
|
Inside the VM:
|
||||||
|
|
||||||
|
1. Download BlueBubbles from bluebubbles.app
|
||||||
|
2. Sign in with your Apple ID
|
||||||
|
3. Enable the Web API and set a password
|
||||||
|
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
|
||||||
|
|
||||||
|
Add to your Clawdbot config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"bluebubbles": {
|
||||||
|
"serverUrl": "http://localhost:1234",
|
||||||
|
"password": "your-api-password",
|
||||||
|
"webhookPath": "/bluebubbles-webhook"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the gateway. Now your agent can send and receive iMessages.
|
||||||
|
|
||||||
|
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Save a golden image
|
||||||
|
|
||||||
|
Before customizing further, snapshot your clean state:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot
|
||||||
|
lume clone clawdbot clawdbot-golden
|
||||||
|
```
|
||||||
|
|
||||||
|
Reset anytime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lume stop clawdbot && lume delete clawdbot
|
||||||
|
lume clone clawdbot-golden clawdbot
|
||||||
|
lume run clawdbot --no-display
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running 24/7
|
||||||
|
|
||||||
|
Keep the VM running by:
|
||||||
|
- Keeping your Mac plugged in
|
||||||
|
- Disabling sleep in System Settings → Energy Saver
|
||||||
|
- Using `caffeinate` if needed
|
||||||
|
|
||||||
|
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
|
||||||
|
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
|
||||||
|
| Lume command not found | Add `~/.local/bin` to your PATH |
|
||||||
|
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [VPS hosting](/vps)
|
||||||
|
- [Nodes](/nodes)
|
||||||
|
- [Gateway remote](/gateway/remote)
|
||||||
|
- [BlueBubbles channel](/channels/bluebubbles)
|
||||||
|
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
|
||||||
|
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
|
||||||
|
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
|
||||||
|
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
|
||||||
@@ -67,6 +67,22 @@ 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:
|
||||||
|
|||||||
@@ -104,6 +104,87 @@ 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:
|
||||||
|
|||||||
+5
-3
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
|
|||||||
|
|
||||||
## One-click deploy
|
## One-click deploy
|
||||||
|
|
||||||
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/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,6 +55,7 @@ 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)
|
||||||
@@ -82,8 +83,9 @@ 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) Copy the **Bot Token** and paste into `/setup`
|
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup)
|
||||||
5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
5) Copy the **Bot Token** and paste into `/setup`
|
||||||
|
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
||||||
|
|
||||||
## Backups & migration
|
## Backups & migration
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ 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";
|
||||||
@@ -148,6 +150,58 @@ 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),
|
||||||
|
|||||||
@@ -187,6 +187,47 @@ 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,
|
||||||
@@ -344,14 +385,14 @@ describe("send", () => {
|
|||||||
).rejects.toThrow("password is required");
|
).rejects.toThrow("password is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when chatGuid cannot be resolved", async () => {
|
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ data: [] }),
|
json: () => Promise.resolve({ data: [] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageBlueBubbles("+15559999999", "Hello", {
|
sendMessageBlueBubbles("chat_id:999", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
password: "test",
|
password: "test",
|
||||||
}),
|
}),
|
||||||
@@ -398,6 +439,57 @@ 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({
|
||||||
|
|||||||
@@ -257,11 +257,17 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
return guid;
|
return guid;
|
||||||
}
|
}
|
||||||
if (!participantMatch && guid) {
|
if (!participantMatch && guid) {
|
||||||
const participants = extractParticipantAddresses(chat).map((entry) =>
|
// Only consider DM chats (`;-;` separator) as participant matches.
|
||||||
normalizeBlueBubblesHandle(entry),
|
// Group chats (`;+;` separator) should never match when searching by handle/phone.
|
||||||
);
|
// This prevents routing "send to +1234567890" to a group chat that contains that number.
|
||||||
if (participants.includes(normalizedHandle)) {
|
const isDmChat = guid.includes(";-;");
|
||||||
participantMatch = guid;
|
if (isDmChat) {
|
||||||
|
const participants = extractParticipantAddresses(chat).map((entry) =>
|
||||||
|
normalizeBlueBubblesHandle(entry),
|
||||||
|
);
|
||||||
|
if (participants.includes(normalizedHandle)) {
|
||||||
|
participantMatch = guid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +276,55 @@ 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,
|
||||||
@@ -297,6 +352,17 @@ 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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -75,6 +75,27 @@ 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
|
||||||
|
|||||||
@@ -99,16 +99,39 @@
|
|||||||
"label": "Media Stream Path",
|
"label": "Media Stream Path",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.model": {
|
"tts.provider": {
|
||||||
"label": "TTS Model",
|
"label": "TTS Provider Override",
|
||||||
|
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.voice": {
|
"tts.openai.model": {
|
||||||
"label": "TTS Voice",
|
"label": "OpenAI TTS Model",
|
||||||
"advanced": true
|
"advanced": true
|
||||||
},
|
},
|
||||||
"tts.instructions": {
|
"tts.openai.voice": {
|
||||||
"label": "TTS Instructions",
|
"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
|
"advanced": true
|
||||||
},
|
},
|
||||||
"publicUrl": {
|
"publicUrl": {
|
||||||
@@ -370,20 +393,193 @@
|
|||||||
"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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"model": {
|
"summaryModel": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"voice": {
|
"modelOverrides": {
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"instructions": {
|
"maxTextLength": {
|
||||||
"type": "string"
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"timeoutMs": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1000,
|
||||||
|
"maximum": 120000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,9 +74,26 @@ 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.model": { label: "TTS Model", advanced: true },
|
"tts.provider": {
|
||||||
"tts.voice": { label: "TTS Voice", advanced: true },
|
label: "TTS Provider Override",
|
||||||
"tts.instructions": { label: "TTS Instructions", advanced: true },
|
help: "Deep-merges with messages.tts (Edge is ignored for calls).",
|
||||||
|
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",
|
||||||
@@ -161,6 +178,7 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,31 +82,82 @@ 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({
|
||||||
/** TTS provider (currently only OpenAI supported) */
|
auto: TtsAutoSchema.optional(),
|
||||||
provider: z.literal("openai").default("openai"),
|
enabled: z.boolean().optional(),
|
||||||
/**
|
mode: TtsModeSchema.optional(),
|
||||||
* TTS model to use:
|
provider: TtsProviderSchema.optional(),
|
||||||
* - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
|
summaryModel: z.string().optional(),
|
||||||
* - tts-1: lower latency
|
modelOverrides: z
|
||||||
* - tts-1-hd: higher quality
|
.object({
|
||||||
*/
|
enabled: z.boolean().optional(),
|
||||||
model: z.string().min(1).default("gpt-4o-mini-tts"),
|
allowText: z.boolean().optional(),
|
||||||
/**
|
allowProvider: z.boolean().optional(),
|
||||||
* Voice ID. For best quality, use marin or cedar.
|
allowVoice: z.boolean().optional(),
|
||||||
* All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
|
allowModelId: z.boolean().optional(),
|
||||||
*/
|
allowVoiceSettings: z.boolean().optional(),
|
||||||
voice: z.string().min(1).default("coral"),
|
allowNormalization: z.boolean().optional(),
|
||||||
/**
|
allowSeed: z.boolean().optional(),
|
||||||
* Instructions for speech style (only works with gpt-4o-mini-tts).
|
})
|
||||||
* Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
|
.strict()
|
||||||
*/
|
.optional(),
|
||||||
instructions: z.string().optional(),
|
elevenlabs: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
voiceId: z.string().optional(),
|
||||||
|
modelId: z.string().optional(),
|
||||||
|
seed: z.number().int().min(0).max(4294967295).optional(),
|
||||||
|
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
|
||||||
|
languageCode: z.string().optional(),
|
||||||
|
voiceSettings: z
|
||||||
|
.object({
|
||||||
|
stability: z.number().min(0).max(1).optional(),
|
||||||
|
similarityBoost: z.number().min(0).max(1).optional(),
|
||||||
|
style: z.number().min(0).max(1).optional(),
|
||||||
|
useSpeakerBoost: z.boolean().optional(),
|
||||||
|
speed: z.number().min(0.5).max(2).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
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()
|
||||||
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
|
.optional();
|
||||||
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
|
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Webhook Server Configuration
|
// Webhook Server Configuration
|
||||||
@@ -307,7 +358,7 @@ export const VoiceCallConfigSchema = z
|
|||||||
/** STT configuration */
|
/** STT configuration */
|
||||||
stt: SttConfigSchema,
|
stt: SttConfigSchema,
|
||||||
|
|
||||||
/** TTS configuration */
|
/** TTS override (deep-merges with core messages.tts) */
|
||||||
tts: TtsConfigSchema,
|
tts: TtsConfigSchema,
|
||||||
|
|
||||||
/** Store path for call logs */
|
/** Store path for call logs */
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ 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 = {
|
||||||
|
|||||||
@@ -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.voice);
|
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.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,11 +210,13 @@ 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: this.config.tts.voice,
|
voice,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -19,4 +19,3 @@ export type CallManagerContext = {
|
|||||||
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
||||||
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,4 +175,3 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|||||||
|
|
||||||
persistCallRecord(ctx.storePath, call);
|
persistCallRecord(ctx.storePath, call);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,4 +31,3 @@ 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.voice);
|
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.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,11 +120,13 @@ 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: ctx.config.tts.voice,
|
voice,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -244,4 +246,3 @@ 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,4 +48,3 @@ export function addTranscriptEntry(
|
|||||||
};
|
};
|
||||||
call.transcript.push(entry);
|
call.transcript.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,4 +86,3 @@ export async function getCallHistoryFromStore(
|
|||||||
|
|
||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,4 +84,3 @@ export function waitForFinalTranscript(
|
|||||||
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,4 +7,3 @@ export function generateNotifyTwiml(message: string, voice: string): string {
|
|||||||
<Hangup/>
|
<Hangup/>
|
||||||
</Response>`;
|
</Response>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,4 +26,3 @@ describe("PlivoProvider", () => {
|
|||||||
expect(result.providerResponseBody).toContain('length="300"');
|
expect(result.providerResponseBody).toContain('length="300"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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 OpenAI TTS provider for streaming TTS */
|
/** Optional telephony TTS provider for streaming TTS */
|
||||||
private ttsProvider: OpenAITTSProvider | null = null;
|
private ttsProvider: TelephonyTtsProvider | 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: OpenAITTSProvider): void {
|
setTTSProvider(provider: TelephonyTtsProvider): 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. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
|
* 1. Core TTS + Media Streams: If TTS provider and media stream are available,
|
||||||
* generates audio via OpenAI and streams it through WebSocket (preferred).
|
* generates audio via core TTS 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 OpenAI TTS via media stream first (if configured)
|
// Try telephony 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] OpenAI TTS failed, falling back to Twilio <Say>:`,
|
`[voice-call] Telephony 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 - OpenAI TTS not configured or media stream not active",
|
"[voice-call] Using TwiML <Say> fallback - telephony 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 OpenAI and Twilio Media Streams.
|
* Play TTS via core TTS and Twilio Media Streams.
|
||||||
* Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
|
* Generates audio with core 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 OpenAI TTS (returns mu-law at 8kHz)
|
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||||
const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
|
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(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,4 +27,3 @@ export function verifyTwilioProviderWebhook(params: {
|
|||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,8 +6,9 @@ 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,
|
||||||
@@ -81,9 +82,10 @@ 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, logger } = params;
|
const { config, coreConfig, ttsRuntime, logger } = params;
|
||||||
const log = logger ?? {
|
const log = logger ?? {
|
||||||
info: console.log,
|
info: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
@@ -149,27 +151,24 @@ 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;
|
||||||
const openaiApiKey =
|
if (ttsRuntime?.textToSpeechTelephony) {
|
||||||
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
|
|
||||||
if (openaiApiKey) {
|
|
||||||
try {
|
try {
|
||||||
const ttsProvider = new OpenAITTSProvider({
|
const ttsProvider = createTelephonyTtsProvider({
|
||||||
apiKey: openaiApiKey,
|
coreConfig,
|
||||||
voice: config.tts.voice,
|
ttsOverride: config.tts,
|
||||||
model: config.tts.model,
|
runtime: ttsRuntime,
|
||||||
instructions: config.tts.instructions,
|
|
||||||
});
|
});
|
||||||
twilioProvider.setTTSProvider(ttsProvider);
|
twilioProvider.setTTSProvider(ttsProvider);
|
||||||
log.info("[voice-call] OpenAI TTS provider configured");
|
log.info("[voice-call] Telephony TTS provider configured");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`[voice-call] Failed to initialize OpenAI TTS: ${
|
`[voice-call] Failed to initialize telephony TTS: ${
|
||||||
err instanceof Error ? err.message : String(err)
|
err instanceof Error ? err.message : String(err)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
|
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaHandler = webhookServer.getMediaStreamHandler();
|
const mediaHandler = webhookServer.getMediaStreamHandler();
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -15,4 +15,3 @@ describe("zalouser outbound chunker", () => {
|
|||||||
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
+10
-10
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/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
|
||||||
@@ -30,4 +30,3 @@ export type Entry = {
|
|||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
lines: number;
|
lines: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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": "..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import 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,6 +27,7 @@ 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,
|
||||||
@@ -362,23 +363,38 @@ async function runExecProcess(opts: {
|
|||||||
let stdin: SessionStdin | undefined;
|
let stdin: SessionStdin | undefined;
|
||||||
|
|
||||||
if (opts.sandbox) {
|
if (opts.sandbox) {
|
||||||
child = spawn(
|
const { child: spawned } = await spawnWithFallback({
|
||||||
"docker",
|
argv: [
|
||||||
buildDockerExecArgs({
|
"docker",
|
||||||
containerName: opts.sandbox.containerName,
|
...buildDockerExecArgs({
|
||||||
command: opts.command,
|
containerName: opts.sandbox.containerName,
|
||||||
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
command: opts.command,
|
||||||
env: opts.env,
|
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
|
||||||
tty: opts.usePty,
|
env: opts.env,
|
||||||
}),
|
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,
|
||||||
},
|
},
|
||||||
) as ChildProcessWithoutNullStreams;
|
fallbacks: [
|
||||||
|
{
|
||||||
|
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();
|
||||||
@@ -422,24 +438,56 @@ 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);
|
||||||
child = spawn(shell, [...shellArgs, opts.command], {
|
const { child: spawned } = await spawnWithFallback({
|
||||||
|
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;
|
},
|
||||||
stdin = child.stdin;
|
fallbacks: [
|
||||||
}
|
{
|
||||||
} else {
|
label: "no-detach",
|
||||||
const { shell, args: shellArgs } = getShellConfig();
|
options: { detached: false },
|
||||||
child = spawn(shell, [...shellArgs, opts.command], {
|
},
|
||||||
cwd: opts.workdir,
|
],
|
||||||
env: opts.env,
|
onFallback: (err, fallback) => {
|
||||||
detached: process.platform !== "win32",
|
const errText = formatSpawnError(err);
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
|
||||||
windowsHide: true,
|
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
|
||||||
}) as ChildProcessWithoutNullStreams;
|
opts.warnings.push(warning);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
child = spawned as ChildProcessWithoutNullStreams;
|
||||||
stdin = child.stdin;
|
stdin = child.stdin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ModelCatalogEntry = {
|
|||||||
provider: string;
|
provider: string;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
|
input?: Array<"text" | "image">;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DiscoveredModel = {
|
type DiscoveredModel = {
|
||||||
@@ -16,6 +17,7 @@ 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");
|
||||||
@@ -80,7 +82,10 @@ 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;
|
||||||
models.push({ id, name, provider, contextWindow, reasoning });
|
const input = Array.isArray(entry?.input)
|
||||||
|
? (entry.input as Array<"text" | "image">)
|
||||||
|
: undefined;
|
||||||
|
models.push({ id, name, provider, contextWindow, reasoning, input });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
@@ -105,3 +110,27 @@ 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,4 +27,14 @@ 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -77,6 +77,29 @@ 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;
|
||||||
@@ -321,7 +344,7 @@ export function sanitizeUserFacingText(text: string): string {
|
|||||||
return formatRawAssistantErrorForUi(trimmed);
|
return formatRawAssistantErrorForUi(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return stripped;
|
return collapseConsecutiveDuplicateBlocks(stripped);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
|
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
|
||||||
|
|||||||
@@ -333,7 +333,13 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
name: "message",
|
name: "message",
|
||||||
description,
|
description,
|
||||||
parameters: schema,
|
parameters: schema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args, signal) => {
|
||||||
|
// 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", {
|
||||||
@@ -366,6 +372,9 @@ 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;
|
||||||
|
|
||||||
@@ -379,6 +388,7 @@ 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);
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ describe("extractModelDirective", () => {
|
|||||||
expect(result.cleaned).toBe("");
|
expect(result.cleaned).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts /models with argument", () => {
|
it("does not treat /models as a /model directive", () => {
|
||||||
const result = extractModelDirective("/models gpt-5");
|
const result = extractModelDirective("/models gpt-5");
|
||||||
expect(result.hasDirective).toBe(true);
|
expect(result.hasDirective).toBe(false);
|
||||||
expect(result.rawModel).toBe("gpt-5");
|
expect(result.rawModel).toBeUndefined();
|
||||||
expect(result.cleaned).toBe("");
|
expect(result.cleaned).toBe("/models gpt-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
|||||||
@@ -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)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
/(?:^|\s)\/model(?=$|\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);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ReasoningLevel } from "../thinking.js";
|
import type { NoticeLevel, ReasoningLevel } from "../thinking.js";
|
||||||
import {
|
import {
|
||||||
type ElevatedLevel,
|
type ElevatedLevel,
|
||||||
normalizeElevatedLevel,
|
normalizeElevatedLevel,
|
||||||
|
normalizeNoticeLevel,
|
||||||
normalizeReasoningLevel,
|
normalizeReasoningLevel,
|
||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
normalizeVerboseLevel,
|
normalizeVerboseLevel,
|
||||||
@@ -112,6 +113,22 @@ 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;
|
||||||
@@ -152,5 +169,5 @@ export function extractStatusDirective(body?: string): {
|
|||||||
return extractSimpleDirective(body, ["status"]);
|
return extractSimpleDirective(body, ["status"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
|
||||||
export { extractExecDirective } from "./exec/directive.js";
|
export { extractExecDirective } from "./exec/directive.js";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
@@ -93,6 +94,16 @@ 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;
|
||||||
|
|||||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user