Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d370e92e | ||
|
|
2c1be8af4b | ||
|
|
50519189d2 | ||
|
|
d25fc7aa2b | ||
|
|
cdceff2284 | ||
|
|
cb52ffb842 | ||
|
|
3a35d313d9 | ||
|
|
116fbb747f | ||
|
|
b1a555da13 | ||
|
|
c92aaca8b0 | ||
|
|
c3e777e3e1 | ||
|
|
2e3b14187b | ||
|
|
f8a22521bd | ||
|
|
8477394414 | ||
|
|
e6e71457e0 | ||
|
|
2684a364c6 | ||
|
|
b9dc117309 | ||
|
|
9205ee55de | ||
|
|
6e23e81678 | ||
|
|
0163f53f5d | ||
|
|
25f2d2adb3 | ||
|
|
7540d1e8c1 | ||
|
|
fc0e303e05 | ||
|
|
6a7a1d7085 | ||
|
|
92e794dc18 | ||
|
|
6375ee836f | ||
|
|
a6c97b5a48 | ||
|
|
5ea15ff7fe | ||
|
|
dd57483e5e | ||
|
|
3696aade09 | ||
|
|
426168a338 | ||
|
|
2f58d59f22 | ||
|
|
cbe19ad2f2 | ||
|
|
d57b88c7af | ||
|
|
ce89bc2b40 | ||
|
|
85b27fe5fe | ||
|
|
c147962434 | ||
|
|
72858a5311 | ||
|
|
21445cfc0a | ||
|
|
5ad203e47b | ||
|
|
3b53213b41 | ||
|
|
81c6ab0ec0 | ||
|
|
3ea887be5a | ||
|
|
c565de0f71 |
No files matched your search
+9
-1
@@ -6,12 +6,14 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Highlights
|
||||
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
|
||||
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice
|
||||
|
||||
### Changes
|
||||
- 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 auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Tools: add link understanding with CLI preprocessing, SSRF guard, and structured link summaries. (#1637) Thanks @dasilva333.
|
||||
- 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
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
@@ -19,15 +21,21 @@ Docs: https://docs.clawd.bot
|
||||
### Fixes
|
||||
- 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: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- 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)
|
||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||
- 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: use the active auth profile for auto-compaction recovery.
|
||||
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||
- 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)
|
||||
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||
|
||||
## 2026.1.23-1
|
||||
|
||||
|
||||
@@ -1167,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case basehash = "baseHash"
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1167,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
|
||||
public struct ConfigPatchParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
public let basehash: String?
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
public let restartdelayms: Int?
|
||||
|
||||
public init(
|
||||
raw: String,
|
||||
basehash: String?
|
||||
basehash: String?,
|
||||
sessionkey: String?,
|
||||
note: String?,
|
||||
restartdelayms: Int?
|
||||
) {
|
||||
self.raw = raw
|
||||
self.basehash = basehash
|
||||
self.sessionkey = sessionkey
|
||||
self.note = note
|
||||
self.restartdelayms = restartdelayms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case raw
|
||||
case basehash = "baseHash"
|
||||
case sessionkey = "sessionKey"
|
||||
case note
|
||||
case restartdelayms = "restartDelayMs"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ Provider options:
|
||||
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
||||
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
||||
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
||||
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming.
|
||||
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
||||
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
||||
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
||||
|
||||
@@ -32,7 +32,7 @@ Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
|
||||
- Under **Connection settings**, select **HTTP endpoint URL**.
|
||||
- Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`.
|
||||
- *Tip: Run `clawdbot status` to find your gateway's public URL.*
|
||||
- Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**.
|
||||
- Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**.
|
||||
- Enter your email address (e.g. `user@example.com`) in the text box.
|
||||
- Click **Save** at the bottom.
|
||||
6) **Enable the app status**:
|
||||
|
||||
@@ -105,8 +105,29 @@ Groups:
|
||||
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
||||
- Signal-cli does not expose read receipts for groups.
|
||||
|
||||
## Reactions (message tool)
|
||||
- Use `message action=react` with `channel=signal`.
|
||||
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
|
||||
- `messageId` is the Signal timestamp for the message you’re reacting to.
|
||||
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
|
||||
|
||||
Examples:
|
||||
```
|
||||
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
|
||||
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
|
||||
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
|
||||
```
|
||||
|
||||
Config:
|
||||
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
|
||||
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
|
||||
- `off`/`ack` disables agent reactions (message tool `react` will error).
|
||||
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
|
||||
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
- DMs: `signal:+15551234567` (or plain E.164).
|
||||
- UUID DMs: `uuid:<id>` (or bare UUID).
|
||||
- Groups: `signal:group:<groupId>`.
|
||||
- Usernames: `username:<name>` (if supported by your Signal account).
|
||||
|
||||
|
||||
+10
-2
@@ -66,11 +66,12 @@ Name lookup:
|
||||
- Discord only: `--poll-duration-hours`, `--message`
|
||||
|
||||
- `react`
|
||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
|
||||
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
||||
- Required: `--message-id`, `--target`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
|
||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid`
|
||||
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||
- WhatsApp only: `--participant`, `--from-me`
|
||||
- Signal group reactions: `--target-author` or `--target-author-uuid` required
|
||||
|
||||
- `reactions`
|
||||
- Channels: Discord/Google Chat/Slack
|
||||
@@ -213,6 +214,13 @@ clawdbot message react --channel slack \
|
||||
--target C123 --message-id 456 --emoji "✅"
|
||||
```
|
||||
|
||||
React in a Signal group:
|
||||
```
|
||||
clawdbot message react --channel signal \
|
||||
--target signal:group:abc123 --message-id 1737630212345 \
|
||||
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
|
||||
```
|
||||
|
||||
Send Telegram inline buttons:
|
||||
```
|
||||
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
||||
|
||||
@@ -295,6 +295,16 @@ Example (OpenAI‑compatible):
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- For custom providers, `reasoning`, `input`, `cost`, `contextWindow`, and `maxTokens` are optional.
|
||||
When omitted, Clawdbot defaults to:
|
||||
- `reasoning: false`
|
||||
- `input: ["text"]`
|
||||
- `cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }`
|
||||
- `contextWindow: 200000`
|
||||
- `maxTokens: 8192`
|
||||
- Recommended: set explicit values that match your proxy/model limits.
|
||||
|
||||
## CLI examples
|
||||
|
||||
```bash
|
||||
|
||||
+9
-1
@@ -780,6 +780,14 @@
|
||||
{
|
||||
"source": "/plugins",
|
||||
"destination": "/plugin"
|
||||
},
|
||||
{
|
||||
"source": "/install/railway",
|
||||
"destination": "/railway"
|
||||
},
|
||||
{
|
||||
"source": "/install/railway/",
|
||||
"destination": "/railway"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
@@ -818,6 +826,7 @@
|
||||
"install/ansible",
|
||||
"install/nix",
|
||||
"install/docker",
|
||||
"railway",
|
||||
"install/bun"
|
||||
]
|
||||
},
|
||||
@@ -1005,7 +1014,6 @@
|
||||
"plugins/zalouser",
|
||||
"tools/exec",
|
||||
"tools/web",
|
||||
"tools/link-understanding",
|
||||
"tools/apply-patch",
|
||||
"tools/elevated",
|
||||
"tools/browser",
|
||||
|
||||
@@ -1502,7 +1502,7 @@ voice notes; other channels send MP3 audio.
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always", // off | always | inbound | tagged
|
||||
mode: "final", // final | all (include tool/block replies)
|
||||
provider: "elevenlabs",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
@@ -1539,8 +1539,10 @@ voice notes; other channels send MP3 audio.
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
|
||||
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
||||
- `messages.tts.auto` controls auto‑TTS (`off`, `always`, `inbound`, `tagged`).
|
||||
- `/tts off|always|inbound|tagged` sets the per‑session auto mode (overrides config).
|
||||
- `messages.tts.enabled` is legacy; doctor migrates it to `messages.tts.auto`.
|
||||
- `prefsPath` stores local overrides (provider/limit/summarize).
|
||||
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
||||
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
||||
@@ -2010,35 +2012,6 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
`tools.links` configures link understanding (URL preprocessing via CLI):
|
||||
- `tools.links.enabled`: enable/disable link understanding (default: enabled when models are configured).
|
||||
- `tools.links.maxLinks`: max URLs per message (default 3).
|
||||
- `tools.links.timeoutSeconds`: default CLI timeout (default 30).
|
||||
- `tools.links.scope`: optional gating rules (same shape as media understanding scope).
|
||||
- `tools.links.models`: ordered CLI entries (fallbacks in order).
|
||||
- `command`: executable to run (required).
|
||||
- `args`: templated args (supports `{{LinkUrl}}`).
|
||||
- `timeoutSeconds`: optional per-entry override.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
links: {
|
||||
enabled: true,
|
||||
maxLinks: 2,
|
||||
scope: {
|
||||
default: "deny",
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
||||
},
|
||||
models: [
|
||||
{ command: "link-understand", args: ["--url", "{{LinkUrl}}"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`agents.defaults.subagents` configures sub-agent defaults:
|
||||
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||
@@ -2875,6 +2848,11 @@ Related docs:
|
||||
- [Tailscale](/gateway/tailscale)
|
||||
- [Remote access](/gateway/remote)
|
||||
|
||||
Trusted proxies:
|
||||
- `gateway.trustedProxies`: list of reverse proxy IPs that terminate TLS in front of the Gateway.
|
||||
- When a connection comes from one of these IPs, Clawdbot uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks.
|
||||
- Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`.
|
||||
|
||||
Notes:
|
||||
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||
@@ -3209,7 +3187,7 @@ clawdbot dns setup --apply
|
||||
|
||||
## Template variables
|
||||
|
||||
Template placeholders are expanded in `tools.media.*.models[].args`, `tools.media.models[].args`, and `tools.links.models[].args` (and any future templated argument fields).
|
||||
Template placeholders are expanded in `tools.media.*.models[].args` and `tools.media.models[].args` (and any future templated argument fields).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
@@ -3224,7 +3202,6 @@ Template placeholders are expanded in `tools.media.*.models[].args`, `tools.medi
|
||||
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
||||
| `{{MediaPath}}` | Local media path (if downloaded) |
|
||||
| `{{MediaType}}` | Media type (image/audio/document/…) |
|
||||
| `{{LinkUrl}}` | URL currently being processed (link understanding only) |
|
||||
| `{{Transcript}}` | Audio transcript (when enabled) |
|
||||
| `{{Prompt}}` | Resolved media prompt for CLI entries |
|
||||
| `{{MaxChars}}` | Resolved max output chars for CLI entries |
|
||||
|
||||
@@ -322,6 +322,11 @@ Tailscale.
|
||||
you terminate TLS or proxy in front of the gateway, disable
|
||||
`gateway.auth.allowTailscale` and use token/password auth instead.
|
||||
|
||||
Trusted proxies:
|
||||
- If you terminate TLS in front of the Gateway, set `gateway.trustedProxies` to your proxy IPs.
|
||||
- Clawdbot will trust `x-forwarded-for` (or `x-real-ip`) from those IPs to determine the client IP for local pairing checks and HTTP auth/local checks.
|
||||
- Ensure your proxy **overwrites** `x-forwarded-for` and blocks direct access to the Gateway port.
|
||||
|
||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||
|
||||
### 0.6.1) Browser control server over Tailscale (recommended)
|
||||
|
||||
@@ -31,6 +31,24 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
||||
|
||||
## Common Issues
|
||||
|
||||
### No API key found for provider "anthropic"
|
||||
|
||||
This means the **agent’s auth store is empty** or missing Anthropic credentials.
|
||||
Auth is **per agent**, so a new agent won’t inherit the main agent’s keys.
|
||||
|
||||
Fix options:
|
||||
- Re-run onboarding and choose **Anthropic** for that agent.
|
||||
- Or paste a setup-token on the **gateway host**:
|
||||
```bash
|
||||
clawdbot models auth setup-token --provider anthropic
|
||||
```
|
||||
- Or copy `auth-profiles.json` from the main agent dir to the new agent dir.
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
clawdbot models status
|
||||
```
|
||||
|
||||
### OAuth token refresh failed (Anthropic Claude subscription)
|
||||
|
||||
This means the stored Anthropic OAuth token expired and the refresh failed.
|
||||
|
||||
+113
-31
@@ -7,10 +7,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [What is Clawdbot?](#what-is-clawdbot)
|
||||
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
|
||||
- [What’s the value proposition?](#whats-the-value-proposition)
|
||||
- [Quick start and first-run setup](#quick-start-and-first-run-setup)
|
||||
- [I'm stuck — what's the fastest way to get unstuck?](#im-stuck--whats-the-fastest-way-to-get-unstuck)
|
||||
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
|
||||
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
|
||||
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
|
||||
@@ -26,6 +24,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
|
||||
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
|
||||
- [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps)
|
||||
- [Where are the cloud/VPS install guides?](#where-are-the-cloudvps-install-guides)
|
||||
- [Can I ask Clawd to update itself?](#can-i-ask-clawd-to-update-itself)
|
||||
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
|
||||
- [Do I need a Claude or OpenAI subscription to run this?](#do-i-need-a-claude-or-openai-subscription-to-run-this)
|
||||
@@ -49,6 +48,9 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
|
||||
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
|
||||
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
|
||||
- [What is Clawdbot?](#what-is-clawdbot)
|
||||
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
|
||||
- [What’s the value proposition?](#whats-the-value-proposition)
|
||||
- [Skills and automation](#skills-and-automation)
|
||||
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
|
||||
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
|
||||
@@ -64,6 +66,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How does memory work?](#how-does-memory-work)
|
||||
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
|
||||
- [Where things live on disk](#where-things-live-on-disk)
|
||||
- [Is all data used with Clawdbot saved locally?](#is-all-data-used-with-clawdbot-saved-locally)
|
||||
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
|
||||
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
|
||||
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
|
||||
@@ -204,36 +207,52 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
```
|
||||
Asks the running gateway for a full snapshot (WS-only). See [Health](/gateway/health).
|
||||
|
||||
## What is Clawdbot?
|
||||
|
||||
### What is Clawdbot, in one paragraph?
|
||||
|
||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Google Chat, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
|
||||
|
||||
### What’s the value proposition?
|
||||
|
||||
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
|
||||
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
|
||||
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
|
||||
SaaS.
|
||||
|
||||
Highlights:
|
||||
- **Your devices, your data:** run the Gateway wherever you want (Mac, Linux, VPS) and keep the
|
||||
workspace + session history local.
|
||||
- **Real channels, not a web sandbox:** WhatsApp/Telegram/Slack/Discord/Signal/iMessage/etc,
|
||||
plus mobile voice and Canvas on supported platforms.
|
||||
- **Model-agnostic:** use Anthropic, OpenAI, MiniMax, OpenRouter, etc., with per‑agent routing
|
||||
and failover.
|
||||
- **Local-only option:** run local models so **all data can stay on your device** if you want.
|
||||
- **Multi-agent routing:** separate agents per channel, account, or task, each with its own
|
||||
workspace and defaults.
|
||||
- **Open source and hackable:** inspect, extend, and self-host without vendor lock‑in.
|
||||
|
||||
Docs: [Gateway](/gateway), [Channels](/channels), [Multi‑agent](/concepts/multi-agent),
|
||||
[Memory](/concepts/memory).
|
||||
|
||||
## Quick start and first-run setup
|
||||
|
||||
### I'm stuck — what's the fastest way to get unstuck?
|
||||
|
||||
Use a local AI agent that can **see your machine**. That is far more effective than asking
|
||||
in Discord, because most "I'm stuck" cases are **local config or environment issues** that
|
||||
remote helpers cannot inspect.
|
||||
|
||||
- **Claude Code**: https://www.anthropic.com/claude-code/
|
||||
- **OpenAI Codex**: https://openai.com/codex/
|
||||
|
||||
These tools can read the repo, run commands, inspect logs, and help fix your machine-level
|
||||
setup (PATH, services, permissions, auth files). Give them the **full source checkout** via
|
||||
the hackable (git) install:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git
|
||||
```
|
||||
|
||||
This installs Clawdbot **from a git checkout**, so the agent can read the code + docs and
|
||||
reason about the exact version you are running. You can always switch back to stable later
|
||||
by re-running the installer without `--install-method git`.
|
||||
|
||||
If you discover a real bug or fix, please file a GitHub issue or send a PR:
|
||||
https://github.com/clawdbot/clawdbot/issues
|
||||
https://github.com/clawdbot/clawdbot/pulls
|
||||
|
||||
Start with these commands (share outputs when asking for help):
|
||||
|
||||
```bash
|
||||
clawdbot status
|
||||
clawdbot models status
|
||||
clawdbot doctor
|
||||
```
|
||||
|
||||
What they do:
|
||||
- `clawdbot status`: quick snapshot of gateway/agent health + basic config.
|
||||
- `clawdbot models status`: checks provider auth + model availability.
|
||||
- `clawdbot doctor`: validates and repairs common config/state issues.
|
||||
|
||||
Other useful CLI checks: `clawdbot status --all`, `clawdbot logs --follow`,
|
||||
`clawdbot gateway status`, `clawdbot health --verbose`.
|
||||
|
||||
Quick debug loop: [First 60 seconds if something's broken](#first-60-seconds-if-somethings-broken).
|
||||
Install docs: [Install](/install), [Installer flags](/install/installer), [Updating](/install/updating).
|
||||
|
||||
### What’s the recommended way to install and set up Clawdbot?
|
||||
|
||||
The repo recommends running from source and using the onboarding wizard:
|
||||
@@ -437,6 +456,27 @@ Any Linux VPS works. Install on the server, then use SSH/Tailscale to reach the
|
||||
Guides: [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [Fly.io](/platforms/fly).
|
||||
Remote access: [Gateway remote](/gateway/remote).
|
||||
|
||||
### Where are the cloud/VPS install guides?
|
||||
|
||||
We keep a **hosting hub** with the common providers. Pick one and follow the guide:
|
||||
|
||||
- [VPS hosting](/vps) (all providers in one place)
|
||||
- [Railway](/railway) (one‑click, browser‑based setup)
|
||||
- [Fly.io](/platforms/fly)
|
||||
- [Hetzner](/platforms/hetzner)
|
||||
- [exe.dev](/platforms/exe-dev)
|
||||
|
||||
How it works in the cloud: the **Gateway runs on the server**, and you access it
|
||||
from your laptop/phone via the Control UI (or Tailscale/SSH). Your state + workspace
|
||||
live on the server, so treat the host as the source of truth and back it up.
|
||||
|
||||
You can pair **nodes** (Mac/iOS/Android/headless) to that cloud Gateway to access
|
||||
local screen/camera/canvas or run commands on your laptop while keeping the
|
||||
Gateway in the cloud.
|
||||
|
||||
Hub: [Platforms](/platforms). Remote access: [Gateway remote](/gateway/remote).
|
||||
Nodes: [Nodes](/nodes), [Nodes CLI](/cli/nodes).
|
||||
|
||||
### Can I ask Clawd to update itself?
|
||||
|
||||
Short answer: **possible, not recommended**. The update flow can restart the
|
||||
@@ -680,6 +720,34 @@ lowest friction and you’re okay with sleep/restarts, run it locally.
|
||||
|
||||
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
|
||||
|
||||
## What is Clawdbot?
|
||||
|
||||
### What is Clawdbot, in one paragraph?
|
||||
|
||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Google Chat, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
|
||||
|
||||
### What’s the value proposition?
|
||||
|
||||
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
|
||||
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
|
||||
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
|
||||
SaaS.
|
||||
|
||||
Highlights:
|
||||
- **Your devices, your data:** run the Gateway wherever you want (Mac, Linux, VPS) and keep the
|
||||
workspace + session history local.
|
||||
- **Real channels, not a web sandbox:** WhatsApp/Telegram/Slack/Discord/Signal/iMessage/etc,
|
||||
plus mobile voice and Canvas on supported platforms.
|
||||
- **Model-agnostic:** use Anthropic, OpenAI, MiniMax, OpenRouter, etc., with per‑agent routing
|
||||
and failover.
|
||||
- **Local-only option:** run local models so **all data can stay on your device** if you want.
|
||||
- **Multi-agent routing:** separate agents per channel, account, or task, each with its own
|
||||
workspace and defaults.
|
||||
- **Open source and hackable:** inspect, extend, and self-host without vendor lock‑in.
|
||||
|
||||
Docs: [Gateway](/gateway), [Channels](/channels), [Multi‑agent](/concepts/multi-agent),
|
||||
[Memory](/concepts/memory).
|
||||
|
||||
## Skills and automation
|
||||
|
||||
### How do I customize skills without keeping the repo dirty?
|
||||
@@ -851,6 +919,20 @@ models — see [Memory](/concepts/memory) for the setup details.
|
||||
|
||||
## Where things live on disk
|
||||
|
||||
### Is all data used with Clawdbot saved locally?
|
||||
|
||||
No — **Clawdbot’s state is local**, but **external services still see what you send them**.
|
||||
|
||||
- **Local by default:** sessions, memory files, config, and workspace live on the Gateway host
|
||||
(`~/.clawdbot` + your workspace directory).
|
||||
- **Remote by necessity:** messages you send to model providers (Anthropic/OpenAI/etc.) go to
|
||||
their APIs, and chat platforms (WhatsApp/Telegram/Slack/etc.) store message data on their
|
||||
servers.
|
||||
- **You control the footprint:** using local models keeps prompts on your machine, but channel
|
||||
traffic still goes through the channel’s servers.
|
||||
|
||||
Related: [Agent workspace](/concepts/agent-workspace), [Memory](/concepts/memory).
|
||||
|
||||
### Where does Clawdbot store its data?
|
||||
|
||||
Everything lives under `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`):
|
||||
|
||||
@@ -23,6 +23,8 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
|
||||
## VPS & hosting
|
||||
|
||||
- VPS hub: [VPS hosting](/vps)
|
||||
- Railway (one-click): [Railway](/railway)
|
||||
- Fly.io: [Fly.io](/platforms/fly)
|
||||
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
|
||||
@@ -10,7 +10,13 @@ This flow lets the macOS app act as a full remote control for a Clawdbot gateway
|
||||
|
||||
## Modes
|
||||
- **Local (this Mac)**: Everything runs on the laptop. No SSH involved.
|
||||
- **Remote over SSH**: Clawdbot commands are executed on the remote host. The mac app opens an SSH connection with `-o BatchMode` plus your chosen identity/key.
|
||||
- **Remote over SSH (default)**: Clawdbot commands are executed on the remote host. The mac app opens an SSH connection with `-o BatchMode` plus your chosen identity/key and a local port-forward.
|
||||
- **Remote direct (ws/wss)**: No SSH tunnel. The mac app connects to the gateway URL directly (for example, via Tailscale Serve or a public HTTPS reverse proxy).
|
||||
|
||||
## Remote transports
|
||||
Remote mode supports two transports:
|
||||
- **SSH tunnel** (default): Uses `ssh -N -L ...` to forward the gateway port to localhost. The gateway will see the node’s IP as `127.0.0.1` because the tunnel is loopback.
|
||||
- **Direct (ws/wss)**: Connects straight to the gateway URL. The gateway sees the real client IP.
|
||||
|
||||
## Prereqs on the remote host
|
||||
1) Install Node + pnpm and build/install the Clawdbot CLI (`pnpm install && pnpm build && pnpm link --global`).
|
||||
@@ -20,16 +26,19 @@ This flow lets the macOS app act as a full remote control for a Clawdbot gateway
|
||||
## macOS app setup
|
||||
1) Open *Settings → General*.
|
||||
2) Under **Clawdbot runs**, pick **Remote over SSH** and set:
|
||||
- **Transport**: **SSH tunnel** or **Direct (ws/wss)**.
|
||||
- **SSH target**: `user@host` (optional `:port`).
|
||||
- If the gateway is on the same LAN and advertises Bonjour, pick it from the discovered list to auto-fill this field.
|
||||
- **Gateway URL** (Direct only): `wss://gateway.example.ts.net` (or `ws://...` for local/LAN).
|
||||
- **Identity file** (advanced): path to your key.
|
||||
- **Project root** (advanced): remote checkout path used for commands.
|
||||
- **CLI path** (advanced): optional path to a runnable `clawdbot` entrypoint/binary (auto-filled when advertised).
|
||||
3) Hit **Test remote**. Success indicates the remote `clawdbot status --json` runs correctly. Failures usually mean PATH/CLI issues; exit 127 means the CLI isn’t found remotely.
|
||||
4) Health checks and Web Chat will now run through this SSH tunnel automatically.
|
||||
|
||||
## Web Chat over SSH
|
||||
- Web Chat connects to the gateway over the forwarded WebSocket control port (default 18789).
|
||||
## Web Chat
|
||||
- **SSH tunnel**: Web Chat connects to the gateway over the forwarded WebSocket control port (default 18789).
|
||||
- **Direct (ws/wss)**: Web Chat connects straight to the configured gateway URL.
|
||||
- There is no separate WebChat HTTP server anymore.
|
||||
|
||||
## Permissions
|
||||
@@ -49,6 +58,7 @@ This flow lets the macOS app act as a full remote control for a Clawdbot gateway
|
||||
- **exit 127 / not found**: `clawdbot` isn’t on PATH for non-login shells. Add it to `/etc/paths`, your shell rc, or symlink into `/usr/local/bin`/`/opt/homebrew/bin`.
|
||||
- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`clawdbot status --json`).
|
||||
- **Web Chat stuck**: confirm the gateway is running on the remote host and the forwarded port matches the gateway WS port; the UI requires a healthy WS connection.
|
||||
- **Node IP shows 127.0.0.1**: expected with the SSH tunnel. Switch **Transport** to **Direct (ws/wss)** if you want the gateway to see the real client IP.
|
||||
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
|
||||
|
||||
## Notification sounds
|
||||
|
||||
@@ -180,6 +180,9 @@ components can talk to a remote Gateway as if it were on localhost.
|
||||
or restarts it if needed.
|
||||
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
|
||||
ExitOnForwardFailure + keepalive options.
|
||||
- **IP reporting:** the SSH tunnel uses loopback, so the gateway will see the node
|
||||
IP as `127.0.0.1`. Use **Direct (ws/wss)** transport if you want the real client
|
||||
IP to appear (see [macOS remote access](/platforms/mac/remote)).
|
||||
|
||||
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
|
||||
details, see [Gateway protocol](/gateway/protocol).
|
||||
|
||||
@@ -7,7 +7,8 @@ read_when:
|
||||
# Windows (WSL2)
|
||||
|
||||
Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The
|
||||
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
|
||||
CLI + Gateway run inside Linux, which keeps the runtime consistent and makes
|
||||
tooling far more compatible (Node/Bun/pnpm, Linux binaries, skills). Native
|
||||
Windows installs are untested and more problematic.
|
||||
|
||||
Native Windows companion apps are planned.
|
||||
|
||||
@@ -105,3 +105,26 @@ clawdbot onboard --auth-choice claude-cli
|
||||
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
||||
auto-migrated on load.
|
||||
- Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**401 errors / token suddenly invalid**
|
||||
- Claude subscription auth can expire or be revoked. Re-run `claude setup-token`
|
||||
and paste it into the **gateway host**.
|
||||
- If the Claude CLI login lives on a different machine, use
|
||||
`clawdbot models auth paste-token --provider anthropic` on the gateway host.
|
||||
|
||||
**No API key found for provider "anthropic"**
|
||||
- Auth is **per agent**. New agents don’t inherit the main agent’s keys.
|
||||
- Re-run onboarding for that agent, or paste a setup-token / API key on the
|
||||
gateway host, then verify with `clawdbot models status`.
|
||||
|
||||
**No credentials found for profile `anthropic:default` or `anthropic:claude-cli`**
|
||||
- Run `clawdbot models status` to see which auth profile is active.
|
||||
- Re-run onboarding, or paste a setup-token / API key for that profile.
|
||||
|
||||
**No available auth profile (all in cooldown/unavailable)**
|
||||
- Check `clawdbot models status --json` for `auth.unusableProfiles`.
|
||||
- Add another Anthropic profile or wait for cooldown.
|
||||
|
||||
More: [/gateway/troubleshooting](/gateway/troubleshooting) and [/help/faq](/help/faq).
|
||||
@@ -11,6 +11,15 @@ default model as `provider/model`.
|
||||
|
||||
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
|
||||
|
||||
## Highlight: Venius (Venice AI)
|
||||
|
||||
Venius is our recommended Venice AI setup for privacy-first inference with an option to use Opus for hard tasks.
|
||||
|
||||
- Default: `venice/llama-3.3-70b`
|
||||
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
|
||||
|
||||
See [Venice AI](/providers/venice).
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Authenticate with the provider (usually via `clawdbot onboard`).
|
||||
@@ -35,6 +44,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [Z.AI](/providers/zai)
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Venius (Venice AI, privacy-focused)](/providers/venice)
|
||||
- [Ollama (local models)](/providers/ollama)
|
||||
|
||||
## Transcription providers
|
||||
|
||||
@@ -9,6 +9,15 @@ read_when:
|
||||
Clawdbot can use many LLM providers. Pick one, authenticate, then set the default
|
||||
model as `provider/model`.
|
||||
|
||||
## Highlight: Venius (Venice AI)
|
||||
|
||||
Venius is our recommended Venice AI setup for privacy-first inference with an option to use Opus for the hardest tasks.
|
||||
|
||||
- Default: `venice/llama-3.3-70b`
|
||||
- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
|
||||
|
||||
See [Venice AI](/providers/venice).
|
||||
|
||||
## Quick start (two steps)
|
||||
|
||||
1) Authenticate with the provider (usually via `clawdbot onboard`).
|
||||
@@ -32,6 +41,7 @@ model as `provider/model`.
|
||||
- [Z.AI](/providers/zai)
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Venius (Venice AI)](/providers/venice)
|
||||
- [Amazon Bedrock](/bedrock)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
|
||||
@@ -215,5 +215,5 @@ ollama serve
|
||||
## See Also
|
||||
|
||||
- [Model Providers](/concepts/model-providers) - Overview of all providers
|
||||
- [Model Selection](/agents/model-selection) - How to choose models
|
||||
- [Configuration](/configuration) - Full config reference
|
||||
- [Model Selection](/concepts/models) - How to choose models
|
||||
- [Configuration](/gateway/configuration) - Full config reference
|
||||
@@ -0,0 +1,264 @@
|
||||
---
|
||||
summary: "Use Venice AI privacy-focused models in Clawdbot"
|
||||
read_when:
|
||||
- You want privacy-focused inference in Clawdbot
|
||||
- You want Venice AI setup guidance
|
||||
---
|
||||
# Venice AI (Venius highlight)
|
||||
|
||||
**Venius** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
|
||||
|
||||
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.
|
||||
|
||||
## Why Venice in Clawdbot
|
||||
|
||||
- **Private inference** for open-source models (no logging).
|
||||
- **Uncensored models** when you need them.
|
||||
- **Anonymized access** to proprietary models (Opus/GPT/Gemini) when quality matters.
|
||||
- OpenAI-compatible `/v1` endpoints.
|
||||
|
||||
## Privacy Modes
|
||||
|
||||
Venice offers two privacy levels — understanding this is key to choosing your model:
|
||||
|
||||
| Mode | Description | Models |
|
||||
|------|-------------|--------|
|
||||
| **Private** | Fully private. Prompts/responses are **never stored or logged**. Ephemeral. | Llama, Qwen, DeepSeek, Venice Uncensored, etc. |
|
||||
| **Anonymized** | Proxied through Venice with metadata stripped. The underlying provider (OpenAI, Anthropic) sees anonymized requests. | Claude, GPT, Gemini, Grok, Kimi, MiniMax |
|
||||
|
||||
## Features
|
||||
|
||||
- **Privacy-focused**: Choose between "private" (fully private) and "anonymized" (proxied) modes
|
||||
- **Uncensored models**: Access to models without content restrictions
|
||||
- **Major model access**: Use Claude, GPT-5.2, Gemini, Grok via Venice's anonymized proxy
|
||||
- **OpenAI-compatible API**: Standard `/v1` endpoints for easy integration
|
||||
- **Streaming**: ✅ Supported on all models
|
||||
- **Function calling**: ✅ Supported on select models (check model capabilities)
|
||||
- **Vision**: ✅ Supported on models with vision capability
|
||||
- **No hard rate limits**: Fair-use throttling may apply for extreme usage
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Get API Key
|
||||
|
||||
1. Sign up at [venice.ai](https://venice.ai)
|
||||
2. Go to **Settings → API Keys → Create new key**
|
||||
3. Copy your API key (format: `vapi_xxxxxxxxxxxx`)
|
||||
|
||||
### 2. Configure Clawdbot
|
||||
|
||||
**Option A: Environment Variable**
|
||||
|
||||
```bash
|
||||
export VENICE_API_KEY="vapi_xxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
**Option B: Interactive Setup (Recommended)**
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice venice-api-key
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Prompt for your API key (or use existing `VENICE_API_KEY`)
|
||||
2. Show all available Venice models
|
||||
3. Let you pick your default model
|
||||
4. Configure the provider automatically
|
||||
|
||||
**Option C: Non-interactive**
|
||||
|
||||
```bash
|
||||
clawdbot onboard --non-interactive \
|
||||
--auth-choice venice-api-key \
|
||||
--venice-api-key "vapi_xxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
### 3. Verify Setup
|
||||
|
||||
```bash
|
||||
clawdbot chat --model venice/llama-3.3-70b "Hello, are you working?"
|
||||
```
|
||||
|
||||
## Model Selection
|
||||
|
||||
After setup, Clawdbot shows all available Venice models. Pick based on your needs:
|
||||
|
||||
- **Default (our pick)**: `venice/llama-3.3-70b` for private, balanced performance.
|
||||
- **Best overall quality**: `venice/claude-opus-45` for hard jobs (Opus remains the strongest).
|
||||
- **Privacy**: Choose "private" models for fully private inference.
|
||||
- **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy.
|
||||
|
||||
Change your default model anytime:
|
||||
|
||||
```bash
|
||||
clawdbot models set venice/claude-opus-45
|
||||
clawdbot models set venice/llama-3.3-70b
|
||||
```
|
||||
|
||||
List all available models:
|
||||
|
||||
```bash
|
||||
clawdbot models list | grep venice
|
||||
```
|
||||
|
||||
## Configure via `clawdbot configure`
|
||||
|
||||
1. Run `clawdbot configure`
|
||||
2. Select **Model/auth**
|
||||
3. Choose **Venice AI**
|
||||
|
||||
## Which Model Should I Use?
|
||||
|
||||
| Use Case | Recommended Model | Why |
|
||||
|----------|-------------------|-----|
|
||||
| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
|
||||
| **Best overall quality** | `claude-opus-45` | Opus remains the strongest for hard tasks |
|
||||
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
|
||||
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
|
||||
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
|
||||
| **Uncensored** | `venice-uncensored` | No content restrictions |
|
||||
| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
|
||||
| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
|
||||
|
||||
## Available Models (25 Total)
|
||||
|
||||
### Private Models (15) — Fully Private, No Logging
|
||||
|
||||
| Model ID | Name | Context (tokens) | Features |
|
||||
|----------|------|------------------|----------|
|
||||
| `llama-3.3-70b` | Llama 3.3 70B | 131k | General |
|
||||
| `llama-3.2-3b` | Llama 3.2 3B | 131k | Fast, lightweight |
|
||||
| `hermes-3-llama-3.1-405b` | Hermes 3 Llama 3.1 405B | 131k | Complex tasks |
|
||||
| `qwen3-235b-a22b-thinking-2507` | Qwen3 235B Thinking | 131k | Reasoning |
|
||||
| `qwen3-235b-a22b-instruct-2507` | Qwen3 235B Instruct | 131k | General |
|
||||
| `qwen3-coder-480b-a35b-instruct` | Qwen3 Coder 480B | 262k | Code |
|
||||
| `qwen3-next-80b` | Qwen3 Next 80B | 262k | General |
|
||||
| `qwen3-vl-235b-a22b` | Qwen3 VL 235B | 262k | Vision |
|
||||
| `qwen3-4b` | Venice Small (Qwen3 4B) | 32k | Fast, reasoning |
|
||||
| `deepseek-v3.2` | DeepSeek V3.2 | 163k | Reasoning |
|
||||
| `venice-uncensored` | Venice Uncensored | 32k | Uncensored |
|
||||
| `mistral-31-24b` | Venice Medium (Mistral) | 131k | Vision |
|
||||
| `google-gemma-3-27b-it` | Gemma 3 27B Instruct | 202k | Vision |
|
||||
| `openai-gpt-oss-120b` | OpenAI GPT OSS 120B | 131k | General |
|
||||
| `zai-org-glm-4.7` | GLM 4.7 | 202k | Reasoning, multilingual |
|
||||
|
||||
### Anonymized Models (10) — Via Venice Proxy
|
||||
|
||||
| Model ID | Original | Context (tokens) | Features |
|
||||
|----------|----------|------------------|----------|
|
||||
| `claude-opus-45` | Claude Opus 4.5 | 202k | Reasoning, vision |
|
||||
| `claude-sonnet-45` | Claude Sonnet 4.5 | 202k | Reasoning, vision |
|
||||
| `openai-gpt-52` | GPT-5.2 | 262k | Reasoning |
|
||||
| `openai-gpt-52-codex` | GPT-5.2 Codex | 262k | Reasoning, vision |
|
||||
| `gemini-3-pro-preview` | Gemini 3 Pro | 202k | Reasoning, vision |
|
||||
| `gemini-3-flash-preview` | Gemini 3 Flash | 262k | Reasoning, vision |
|
||||
| `grok-41-fast` | Grok 4.1 Fast | 262k | Reasoning, vision |
|
||||
| `grok-code-fast-1` | Grok Code Fast 1 | 262k | Reasoning, code |
|
||||
| `kimi-k2-thinking` | Kimi K2 Thinking | 262k | Reasoning |
|
||||
| `minimax-m21` | MiniMax M2.1 | 202k | Reasoning |
|
||||
|
||||
## Model Discovery
|
||||
|
||||
Clawdbot automatically discovers models from the Venice API when `VENICE_API_KEY` is set. If the API is unreachable, it falls back to a static catalog.
|
||||
|
||||
The `/models` endpoint is public (no auth needed for listing), but inference requires a valid API key.
|
||||
|
||||
## Streaming & Tool Support
|
||||
|
||||
| Feature | Support |
|
||||
|---------|---------|
|
||||
| **Streaming** | ✅ All models |
|
||||
| **Function calling** | ✅ Most models (check `supportsFunctionCalling` in API) |
|
||||
| **Vision/Images** | ✅ Models marked with "Vision" feature |
|
||||
| **JSON mode** | ✅ Supported via `response_format` |
|
||||
|
||||
## Pricing
|
||||
|
||||
Venice uses a credit-based system. Check [venice.ai/pricing](https://venice.ai/pricing) for current rates:
|
||||
|
||||
- **Private models**: Generally lower cost
|
||||
- **Anonymized models**: Similar to direct API pricing + small Venice fee
|
||||
|
||||
## Comparison: Venice vs Direct API
|
||||
|
||||
| Aspect | Venice (Anonymized) | Direct API |
|
||||
|--------|---------------------|------------|
|
||||
| **Privacy** | Metadata stripped, anonymized | Your account linked |
|
||||
| **Latency** | +10-50ms (proxy) | Direct |
|
||||
| **Features** | Most features supported | Full features |
|
||||
| **Billing** | Venice credits | Provider billing |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# Use default private model
|
||||
clawdbot chat --model venice/llama-3.3-70b
|
||||
|
||||
# Use Claude via Venice (anonymized)
|
||||
clawdbot chat --model venice/claude-opus-45
|
||||
|
||||
# Use uncensored model
|
||||
clawdbot chat --model venice/venice-uncensored
|
||||
|
||||
# Use vision model with image
|
||||
clawdbot chat --model venice/qwen3-vl-235b-a22b
|
||||
|
||||
# Use coding model
|
||||
clawdbot chat --model venice/qwen3-coder-480b-a35b-instruct
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API key not recognized
|
||||
|
||||
```bash
|
||||
echo $VENICE_API_KEY
|
||||
clawdbot models list | grep venice
|
||||
```
|
||||
|
||||
Ensure the key starts with `vapi_`.
|
||||
|
||||
### Model not available
|
||||
|
||||
The Venice model catalog updates dynamically. Run `clawdbot models list` to see currently available models. Some models may be temporarily offline.
|
||||
|
||||
### Connection issues
|
||||
|
||||
Venice API is at `https://api.venice.ai/api/v1`. Ensure your network allows HTTPS connections.
|
||||
|
||||
## Config file example
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { VENICE_API_KEY: "vapi_..." },
|
||||
agents: { defaults: { model: { primary: "venice/llama-3.3-70b" } } },
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
venice: {
|
||||
baseUrl: "https://api.venice.ai/api/v1",
|
||||
apiKey: "${VENICE_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "llama-3.3-70b",
|
||||
name: "Llama 3.3 70B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- [Venice AI](https://venice.ai)
|
||||
- [API Documentation](https://docs.venice.ai)
|
||||
- [Pricing](https://venice.ai/pricing)
|
||||
- [Status](https://status.venice.ai)
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: Deploy on Railway
|
||||
---
|
||||
|
||||
Deploy Clawdbot on Railway with a one-click template and finish setup in your browser.
|
||||
This is the easiest “no terminal on the server” path: Railway runs the Gateway for you,
|
||||
and you configure everything via the `/setup` web wizard.
|
||||
|
||||
## Quick checklist (new users)
|
||||
|
||||
1) Click **Deploy on Railway** (below).
|
||||
2) Add a **Volume** mounted at `/data`.
|
||||
3) Set the required **Variables** (at least `SETUP_PASSWORD`).
|
||||
4) Enable **HTTP Proxy** on port `8080`.
|
||||
5) Open `https://<your-railway-domain>/setup` and finish the wizard.
|
||||
|
||||
## One-click deploy
|
||||
|
||||
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
||||
|
||||
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
||||
|
||||
Railway will either:
|
||||
- give you a generated domain (often `https://<something>.up.railway.app`), or
|
||||
- use your custom domain if you attached one.
|
||||
|
||||
Then open:
|
||||
|
||||
- `https://<your-railway-domain>/setup` — setup wizard (password protected)
|
||||
- `https://<your-railway-domain>/clawdbot` — Control UI
|
||||
|
||||
## What you get
|
||||
|
||||
- Hosted Clawdbot Gateway + Control UI
|
||||
- Web setup wizard at `/setup` (no terminal commands)
|
||||
- Persistent storage via Railway Volume (`/data`) so config/credentials/workspace survive redeploys
|
||||
- Backup export at `/setup/export` to migrate off Railway later
|
||||
|
||||
## Required Railway settings
|
||||
|
||||
### Public Networking
|
||||
|
||||
Enable **HTTP Proxy** for the service.
|
||||
|
||||
- Port: `8080`
|
||||
|
||||
### Volume (required)
|
||||
|
||||
Attach a volume mounted at:
|
||||
|
||||
- `/data`
|
||||
|
||||
### Variables
|
||||
|
||||
Set these variables on the service:
|
||||
|
||||
- `SETUP_PASSWORD` (required)
|
||||
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
|
||||
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
|
||||
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
|
||||
|
||||
## Setup flow
|
||||
|
||||
1) Visit `https://<your-railway-domain>/setup` and enter your `SETUP_PASSWORD`.
|
||||
2) Choose a model/auth provider and paste your key.
|
||||
3) (Optional) Add Telegram/Discord/Slack tokens.
|
||||
4) Click **Run setup**.
|
||||
|
||||
If Telegram DMs are set to pairing, the setup wizard can approve the pairing code.
|
||||
|
||||
## Getting chat tokens
|
||||
|
||||
### Telegram bot token
|
||||
|
||||
1) Message `@BotFather` in Telegram
|
||||
2) Run `/newbot`
|
||||
3) Copy the token (looks like `123456789:AA...`)
|
||||
4) Paste it into `/setup`
|
||||
|
||||
### Discord bot token
|
||||
|
||||
1) Go to https://discord.com/developers/applications
|
||||
2) **New Application** → choose a name
|
||||
3) **Bot** → **Add Bot**
|
||||
4) Copy the **Bot Token** and paste into `/setup`
|
||||
5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
|
||||
|
||||
## Backups & migration
|
||||
|
||||
Download a backup at:
|
||||
|
||||
- `https://<your-railway-domain>/setup/export`
|
||||
|
||||
This exports your Clawdbot state + workspace so you can migrate to another host without losing config or memory.
|
||||
@@ -45,7 +45,7 @@ run on host, set an explicit per-agent override:
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
|
||||
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
|
||||
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested, more problematic, and has poorer tool compatibility. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
|
||||
|
||||
## 1) Install the CLI (recommended)
|
||||
|
||||
|
||||
@@ -232,13 +232,6 @@ Notes:
|
||||
- See [Web tools](/tools/web) for setup.
|
||||
- See [Firecrawl](/tools/firecrawl) for the optional anti-bot fallback.
|
||||
|
||||
### Link understanding (inbound)
|
||||
Preprocess inbound messages by running a CLI on detected URLs and appending the
|
||||
results to the message body before the agent sees it. This is **not** an agent
|
||||
tool; it runs in the inbound auto-reply pipeline.
|
||||
|
||||
See [Link understanding](/tools/link-understanding) for setup and configuration.
|
||||
|
||||
### `browser`
|
||||
Control the dedicated clawd browser.
|
||||
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
---
|
||||
summary: "Run a CLI on URLs in inbound messages and inject results before the agent replies"
|
||||
read_when:
|
||||
- You want to summarize or enrich URLs in inbound messages
|
||||
- You need to enable or scope link understanding
|
||||
- You want to wire a custom CLI to preprocess links
|
||||
---
|
||||
|
||||
# Link understanding
|
||||
|
||||
Link understanding is the **inbound URL enrichment pipeline**: Clawdbot detects
|
||||
links in a message, runs your **local CLI** to interpret them, and injects the
|
||||
results into the message body **before the agent sees it**. The goal is a
|
||||
predictable, structured prompt envelope without adding a browsing tool.
|
||||
|
||||
This is **not** an agent tool. It is a preprocessing step in the auto-reply
|
||||
pipeline, shared across all channels (including webchat).
|
||||
|
||||
## What it enables
|
||||
|
||||
- Summarize or extract data from links (docs, tickets, dashboards, runbooks).
|
||||
- Inject structured context (titles, owners, status, timestamps, key facts).
|
||||
- Normalize or transform link payloads (HTML → text, JSON → bullets).
|
||||
- Gate link processing by channel, chat type, or session key.
|
||||
- Apply SSRF protections before your CLI runs.
|
||||
|
||||
## When it runs (pipeline order)
|
||||
|
||||
Link understanding runs after inbound normalization and media understanding,
|
||||
but before the agent executes the reply run:
|
||||
|
||||
1) Inbound message is normalized and routed.
|
||||
2) Media understanding may rewrite the body (images/audio/video).
|
||||
3) Link understanding detects URLs in the current command/body and appends results.
|
||||
4) The agent receives the final body and command parsing uses the updated text.
|
||||
|
||||
## How it works
|
||||
|
||||
1) Clawdbot scans the inbound message for **bare** `http/https` URLs.
|
||||
2) It dedupes links in order and caps them at `tools.links.maxLinks` (default 3).
|
||||
3) For each link, it runs your configured CLI entries **in order** until one returns non-empty stdout.
|
||||
4) It appends the resulting outputs to the message body (structured envelope).
|
||||
5) The agent sees the original text plus the appended link outputs.
|
||||
|
||||
No requests are made by Clawdbot itself; **your CLI does the fetching/parsing**.
|
||||
|
||||
## URL detection rules
|
||||
|
||||
- Only **bare URLs** are extracted (e.g. `https://example.com`).
|
||||
- Markdown links are ignored (`[label](https://example.com)` is stripped).
|
||||
- Links are deduped (first occurrence wins).
|
||||
- Only `http` and `https` are allowed.
|
||||
- Local and private hosts are blocked (see **Security** below).
|
||||
|
||||
## Configuration
|
||||
|
||||
Link understanding is configured under `tools.links`:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
links: {
|
||||
enabled: true,
|
||||
maxLinks: 3,
|
||||
timeoutSeconds: 30,
|
||||
scope: {
|
||||
default: "allow",
|
||||
rules: [{ action: "deny", match: { chatType: "group" } }]
|
||||
},
|
||||
models: [
|
||||
{
|
||||
command: "link-understand",
|
||||
args: ["--url", "{{LinkUrl}}", "--format", "markdown"],
|
||||
timeoutSeconds: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `tools.links` fields
|
||||
|
||||
- `enabled`: enable/disable link understanding (default: enabled when models are configured).
|
||||
- `maxLinks`: max URLs processed per message (default 3).
|
||||
- `timeoutSeconds`: default timeout for CLI runs (default 30).
|
||||
- `scope`: optional gating rules (same structure as media understanding scope).
|
||||
- `models`: ordered list of CLI entries (fallbacks in order).
|
||||
|
||||
### Model entries (`tools.links.models[]`)
|
||||
|
||||
Each entry is a CLI command:
|
||||
|
||||
- `type`: optional, only `"cli"` is supported (default).
|
||||
- `command`: executable to run (required).
|
||||
- `args`: CLI args (templated; see **Template variables**).
|
||||
- `timeoutSeconds`: optional override for this entry.
|
||||
|
||||
The first entry that returns **non-empty stdout** wins. If a command fails or
|
||||
returns empty output, Clawdbot tries the next entry.
|
||||
|
||||
## Performance + ordering
|
||||
|
||||
- Links are processed **sequentially** (per message, in order).
|
||||
- Each CLI entry has a timeout (per entry or default).
|
||||
- Failure to run a CLI does **not** abort the agent run; it just tries the next entry.
|
||||
- `maxLinks` is the primary cap for latency. Keep it low for chatty channels.
|
||||
|
||||
## Template variables
|
||||
|
||||
`tools.links.models[].args` supports the standard template variables plus `{{LinkUrl}}`.
|
||||
See [Template variables](/gateway/configuration#template-variables) for the full list.
|
||||
|
||||
Link-specific variable:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `{{LinkUrl}}` | URL currently being processed |
|
||||
|
||||
## Output format
|
||||
|
||||
Each link is wrapped in a predictable envelope and appended to the message body:
|
||||
|
||||
```
|
||||
<original message>
|
||||
|
||||
[Link]
|
||||
URL: https://example.com
|
||||
Source: link-understand
|
||||
Summary:
|
||||
<cli output>
|
||||
```
|
||||
|
||||
When multiple links are present, the header is numbered (`[Link 1/2]`, `[Link 2/2]`).
|
||||
`Source` is the CLI `command` that produced the output.
|
||||
|
||||
### Output best practices
|
||||
|
||||
- Keep summaries short and stable (avoid prompt bloat).
|
||||
- Prefer a compact, consistent structure (title → bullets → status).
|
||||
- Avoid surrounding fences unless you want the agent to treat it as literal text.
|
||||
|
||||
|
||||
## Scope gating
|
||||
|
||||
`tools.links.scope` uses the same rules as media understanding:
|
||||
|
||||
- `default`: `"allow"` or `"deny"`.
|
||||
- `rules[]`: first match wins.
|
||||
- `match.channel` (surface/channel)
|
||||
- `match.chatType` (`direct`, `group`, `channel`)
|
||||
- `match.keyPrefix` (session key prefix)
|
||||
|
||||
Example (only allow direct messages):
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
links: {
|
||||
scope: {
|
||||
default: "deny",
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
||||
},
|
||||
models: [{ command: "link-understand", args: ["--url", "{{LinkUrl}}"] }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security (SSRF guard)
|
||||
|
||||
Clawdbot blocks local/private hosts before invoking your CLI. The guard rejects:
|
||||
|
||||
- `localhost`, `*.localhost`, `*.local`, `*.internal`
|
||||
- Loopback addresses (IPv4 + IPv6)
|
||||
- Private and link-local IP ranges (IPv4 + IPv6)
|
||||
- `metadata.google.internal`
|
||||
- Addresses that resolve to private/internal IPs
|
||||
|
||||
If a URL is blocked, it is **skipped** and your CLI is not invoked for it.
|
||||
URLs that fail DNS resolution are also skipped.
|
||||
|
||||
## Decision tracking
|
||||
|
||||
Each run records a decision payload (useful in logs/debugging) in `ctx.LinkUnderstandingDecisions`:
|
||||
|
||||
```json5
|
||||
{
|
||||
outcome: "success" | "skipped" | "disabled" | "scope-deny" | "no-links",
|
||||
urls: [
|
||||
{
|
||||
url: "https://example.com",
|
||||
attempts: [
|
||||
{ type: "cli", command: "link-understand", outcome: "success" }
|
||||
],
|
||||
chosen: { type: "cli", command: "link-understand", outcome: "success" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Custom usage patterns
|
||||
|
||||
### Ticket summarizer (single link)
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
links: {
|
||||
maxLinks: 1,
|
||||
models: [
|
||||
{
|
||||
command: "ticket-summary",
|
||||
args: ["--url", "{{LinkUrl}}", "--format", "brief"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-channel allowlist (only Discord + Slack)
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
links: {
|
||||
scope: {
|
||||
default: "deny",
|
||||
rules: [
|
||||
{ action: "allow", match: { channel: "discord" } },
|
||||
{ action: "allow", match: { channel: "slack" } }
|
||||
]
|
||||
},
|
||||
models: [{ command: "link-understand", args: ["--url", "{{LinkUrl}}"] }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Per-agent override (support agent only)
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "support",
|
||||
tools: {
|
||||
links: {
|
||||
enabled: true,
|
||||
maxLinks: 2,
|
||||
models: [{ command: "link-understand", args: ["--url", "{{LinkUrl}}"] }]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Markdown link behavior (ignored)
|
||||
|
||||
Message:
|
||||
```
|
||||
Please check [our docs](https://docs.example.com) and https://status.example.com
|
||||
```
|
||||
|
||||
Only `https://status.example.com` is processed (markdown links are stripped).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Nothing happens:** ensure `tools.links.models` is set and `enabled` is not `false`.
|
||||
- **No links detected:** only bare URLs are extracted; markdown links are ignored.
|
||||
- **Output missing:** your CLI returned empty stdout; try logging or return a default line.
|
||||
- **Wrong channel scope:** check `tools.links.scope` rules and `match.chatType`.
|
||||
- **SSRF blocked:** local/private URLs are skipped by design.
|
||||
|
||||
Enable verbose logs to see CLI execution and scope decisions:
|
||||
|
||||
```bash
|
||||
clawdbot gateway run --verbose
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal CLI runner
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
links: {
|
||||
models: [{ command: "link-understand", args: ["--url", "{{LinkUrl}}"] }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Limit to 1 link, custom timeout
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
links: {
|
||||
maxLinks: 1,
|
||||
timeoutSeconds: 10,
|
||||
models: [{ command: "link-understand", args: ["--url", "{{LinkUrl}}"] }]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fallback chain (first non-empty output wins)
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
links: {
|
||||
models: [
|
||||
{ command: "link-understand", args: ["--url", "{{LinkUrl}}"] },
|
||||
{ command: "link-backup", args: ["{{LinkUrl}}"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -23,6 +23,16 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
|
||||
- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved.
|
||||
- **Resumable**: Halted workflows return a token; approve and resume without re-running everything.
|
||||
|
||||
## Why a DSL instead of plain programs?
|
||||
|
||||
Lobster is intentionally small. The goal is not "a new language," it's a predictable, AI-friendly pipeline spec with first-class approvals and resume tokens.
|
||||
|
||||
- **Approve/resume is built in**: A normal program can prompt a human, but it can’t *pause and resume* with a durable token without you inventing that runtime yourself.
|
||||
- **Determinism + auditability**: Pipelines are data, so they’re easy to log, diff, replay, and review.
|
||||
- **Constrained surface for AI**: A tiny grammar + JSON piping reduces “creative” code paths and makes validation realistic.
|
||||
- **Safety policy baked in**: Timeouts, output caps, sandbox checks, and allowlists are enforced by the runtime, not each script.
|
||||
- **Still programmable**: Each step can call any CLI or script. If you want JS/TS, generate `.lobster` files from code.
|
||||
|
||||
## How it works
|
||||
|
||||
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
|
||||
|
||||
@@ -68,7 +68,7 @@ Text + native (when enabled):
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||
- `/tts off|always|inbound|tagged|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
|
||||
+109
-16
@@ -8,21 +8,37 @@ read_when:
|
||||
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
Clawdbot can convert outbound replies into audio using ElevenLabs or OpenAI.
|
||||
Clawdbot can convert outbound replies into audio using ElevenLabs, OpenAI, or Edge TTS.
|
||||
It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubble.
|
||||
|
||||
## Supported services
|
||||
|
||||
- **ElevenLabs** (primary or fallback provider)
|
||||
- **OpenAI** (primary or fallback provider; also used for summaries)
|
||||
- **Edge TTS** (primary or fallback provider; uses `node-edge-tts`, default when no API keys)
|
||||
|
||||
## Required keys
|
||||
### Edge TTS notes
|
||||
|
||||
At least one of:
|
||||
Edge TTS uses Microsoft Edge's online neural TTS service via the `node-edge-tts`
|
||||
library. It's a hosted service (not local), uses Microsoft’s endpoints, and does
|
||||
not require an API key. `node-edge-tts` exposes speech configuration options and
|
||||
output formats, but not all options are supported by the Edge service. citeturn2search0
|
||||
|
||||
Because Edge TTS is a public web service without a published SLA or quota, treat it
|
||||
as best-effort. If you need guaranteed limits and support, use OpenAI or ElevenLabs.
|
||||
Microsoft's Speech REST API documents a 10‑minute audio limit per request; Edge TTS
|
||||
does not publish limits, so assume similar or lower limits. citeturn0search3
|
||||
|
||||
## Optional keys
|
||||
|
||||
If you want OpenAI or ElevenLabs:
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
If both are configured, the selected provider is used first and the other is a fallback.
|
||||
Edge TTS does **not** require an API key. If no API keys are found, Clawdbot defaults
|
||||
to Edge TTS (unless disabled via `messages.tts.edge.enabled=false`).
|
||||
|
||||
If multiple providers are configured, the selected provider is used first and the others are fallback options.
|
||||
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
|
||||
so that provider must also be authenticated if you enable summaries.
|
||||
|
||||
@@ -32,11 +48,16 @@ so that provider must also be authenticated if you enable summaries.
|
||||
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
|
||||
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
|
||||
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
|
||||
- [node-edge-tts](https://github.com/SchneeHertz/node-edge-tts)
|
||||
- [Microsoft Speech output formats](https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech#audio-outputs)
|
||||
|
||||
## Is it enabled by default?
|
||||
|
||||
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
|
||||
which writes a local preference override.
|
||||
No. Auto‑TTS is **off** by default. Enable it in config with
|
||||
`messages.tts.auto` or per session with `/tts always` (alias: `/tts on`).
|
||||
|
||||
Edge TTS **is** enabled by default once TTS is on, and is used automatically
|
||||
when no OpenAI or ElevenLabs API keys are available.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -49,7 +70,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
provider: "elevenlabs"
|
||||
}
|
||||
}
|
||||
@@ -62,7 +83,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
provider: "openai",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
@@ -94,13 +115,48 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Edge TTS primary (no API key)
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "edge",
|
||||
edge: {
|
||||
enabled: true,
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
rate: "+10%",
|
||||
pitch: "-5%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable Edge TTS
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
edge: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom limits + prefs path
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
auto: "always",
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.clawdbot/settings/tts.json"
|
||||
@@ -109,13 +165,25 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Only reply with audio after an inbound voice note
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "inbound"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable auto-summary for long replies
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true
|
||||
auto: "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,15 +197,20 @@ Then run:
|
||||
|
||||
### Notes on fields
|
||||
|
||||
- `enabled`: master toggle (default `false`; local prefs can override).
|
||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||
- `inbound` only sends audio after an inbound voice note.
|
||||
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
- `provider`: `"elevenlabs"` or `"openai"` (fallback is automatic).
|
||||
- `provider`: `"elevenlabs"`, `"openai"`, or `"edge"` (fallback is automatic).
|
||||
- If `provider` is **unset**, Clawdbot prefers `openai` (if key), then `elevenlabs` (if key),
|
||||
otherwise `edge`.
|
||||
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
|
||||
- Accepts `provider/model` or a configured model alias.
|
||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||
- `timeoutMs`: request timeout (ms).
|
||||
- `prefsPath`: override the local prefs JSON path.
|
||||
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
|
||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||
- `elevenlabs.voiceSettings`:
|
||||
@@ -147,10 +220,20 @@ Then run:
|
||||
- `elevenlabs.applyTextNormalization`: `auto|on|off`
|
||||
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
|
||||
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
|
||||
- `edge.enabled`: allow Edge TTS usage (default `true`; no API key).
|
||||
- `edge.voice`: Edge neural voice name (e.g. `en-US-MichelleNeural`).
|
||||
- `edge.lang`: language code (e.g. `en-US`).
|
||||
- `edge.outputFormat`: Edge output format (e.g. `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- See Microsoft Speech output formats for valid values; not all formats are supported by Edge.
|
||||
- `edge.rate` / `edge.pitch` / `edge.volume`: percent strings (e.g. `+10%`, `-5%`).
|
||||
- `edge.saveSubtitles`: write JSON subtitles alongside the audio file.
|
||||
- `edge.proxy`: proxy URL for Edge TTS requests.
|
||||
- `edge.timeoutMs`: request timeout override (ms).
|
||||
|
||||
## Model-driven overrides (default on)
|
||||
|
||||
By default, the model **can** emit TTS directives for a single reply.
|
||||
When `messages.tts.auto` is `tagged`, these directives are required to trigger audio.
|
||||
|
||||
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
||||
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||
@@ -167,7 +250,7 @@ Here you go.
|
||||
```
|
||||
|
||||
Available directive keys (when enabled):
|
||||
- `provider` (`openai` | `elevenlabs`)
|
||||
- `provider` (`openai` | `elevenlabs` | `edge`)
|
||||
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs)
|
||||
- `model` (OpenAI TTS model or ElevenLabs model id)
|
||||
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
|
||||
@@ -225,8 +308,15 @@ These override `messages.tts.*` for that host.
|
||||
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
- **Edge TTS**: uses `edge.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- `node-edge-tts` accepts an `outputFormat`, but not all formats are available
|
||||
from the Edge service. citeturn2search0
|
||||
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus). citeturn1search0
|
||||
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
|
||||
guaranteed Opus voice notes. citeturn1search1
|
||||
- If the configured Edge output format fails, Clawdbot retries with MP3.
|
||||
|
||||
This is not configurable; Telegram expects Opus for voice-note UX.
|
||||
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
@@ -264,8 +354,10 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
||||
`/voice` as the native command there. Text `/tts ...` still works.
|
||||
|
||||
```
|
||||
/tts on
|
||||
/tts off
|
||||
/tts always
|
||||
/tts inbound
|
||||
/tts tagged
|
||||
/tts status
|
||||
/tts provider openai
|
||||
/tts limit 2000
|
||||
@@ -276,6 +368,7 @@ Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
||||
Notes:
|
||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||
- `commands.text` or native command registration must be enabled.
|
||||
- `off|always|inbound|tagged` are per‑session toggles (`/tts on` is an alias for `/tts always`).
|
||||
- `limit` and `summary` are stored in local prefs, not the main config.
|
||||
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
---
|
||||
summary: "VPS hosting hub for Clawdbot (Railway/Fly/Hetzner/exe.dev)"
|
||||
read_when:
|
||||
- You want to run the Gateway in the cloud
|
||||
- You need a quick map of VPS/hosting guides
|
||||
---
|
||||
# VPS hosting
|
||||
|
||||
This hub links to the supported VPS/hosting guides and explains how cloud
|
||||
deployments work at a high level.
|
||||
|
||||
## Pick a provider
|
||||
|
||||
- **Railway** (one‑click + browser setup): [Railway](/railway)
|
||||
- **Fly.io**: [Fly.io](/platforms/fly)
|
||||
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
|
||||
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
|
||||
https://x.com/techfrenAJ/status/2014934471095812547
|
||||
|
||||
## How cloud setups work
|
||||
|
||||
- The **Gateway runs on the VPS** and owns state + workspace.
|
||||
- You connect from your laptop/phone via the **Control UI** or **Tailscale/SSH**.
|
||||
- Treat the VPS as the source of truth and **back up** the state + workspace.
|
||||
|
||||
Remote access: [Gateway remote](/gateway/remote)
|
||||
Platforms hub: [Platforms](/platforms)
|
||||
|
||||
## Using nodes with a VPS
|
||||
|
||||
You can keep the Gateway in the cloud and pair **nodes** on your local devices
|
||||
(Mac/iOS/Android/headless). Nodes provide local screen/camera/canvas and `system.run`
|
||||
capabilities while the Gateway stays in the cloud.
|
||||
|
||||
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes)
|
||||
@@ -47,7 +47,8 @@ function mergeBlueBubblesAccountConfig(
|
||||
};
|
||||
const { accounts: _ignored, ...rest } = base;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...rest, ...account };
|
||||
const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
|
||||
return { ...rest, ...account, chunkMode };
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesAccount(params: {
|
||||
|
||||
@@ -38,6 +38,7 @@ const bluebubblesAccountSchema = z.object({
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
|
||||
@@ -1851,16 +1851,21 @@ async function processMessage(
|
||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||
? account.config.textChunkLimit
|
||||
: DEFAULT_TEXT_LIMIT;
|
||||
const chunkMode = account.config.chunkMode ?? "length";
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
const chunks =
|
||||
chunkMode === "newline"
|
||||
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
|
||||
: core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
if (!chunks.length && text) chunks.push(text);
|
||||
if (!chunks.length) return;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
@@ -1869,6 +1874,17 @@ async function processMessage(
|
||||
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
// In newline mode, restart typing after each chunk if more chunks remain
|
||||
// Small delay allows the Apple API to finish clearing the typing state from message send
|
||||
if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
}).catch(() => {
|
||||
// Ignore typing errors
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
|
||||
@@ -38,6 +38,8 @@ export type BlueBubblesAccountConfig = {
|
||||
dms?: Record<string, unknown>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: Record<string, unknown>;
|
||||
|
||||
@@ -6,8 +6,8 @@ export function normalizeGoogleChatTarget(raw?: string | null): string | undefin
|
||||
if (!trimmed) return undefined;
|
||||
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
const normalized = withoutPrefix
|
||||
.replace(/^user:/i, "users/")
|
||||
.replace(/^space:/i, "spaces/");
|
||||
.replace(/^user:(users\/)?/i, "users/")
|
||||
.replace(/^space:(spaces\/)?/i, "spaces/");
|
||||
if (isGoogleChatUserTarget(normalized)) {
|
||||
const suffix = normalized.slice("users/".length);
|
||||
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
|
||||
|
||||
@@ -18,12 +18,20 @@ import {
|
||||
setAccountEnabledInConfigSection,
|
||||
signalOnboardingAdapter,
|
||||
SignalConfigSchema,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ResolvedSignalAccount,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getSignalRuntime } from "./runtime.js";
|
||||
|
||||
const signalMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx),
|
||||
supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx),
|
||||
handleAction: async (ctx) =>
|
||||
await getSignalRuntime().channel.signal.messageActions.handleAction(ctx),
|
||||
};
|
||||
|
||||
const meta = getChatChannelMeta("signal");
|
||||
|
||||
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
@@ -42,7 +50,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
},
|
||||
actions: signalMessageActions,
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
@@ -115,7 +125,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
||||
normalizeTarget: normalizeSignalMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeSignalTargetId,
|
||||
hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
|
||||
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"linkedom": "^0.18.12",
|
||||
"long": "5.3.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
"node-edge-tts": "^1.2.9",
|
||||
"osc-progress": "^0.3.0",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"playwright-core": "1.58.0",
|
||||
|
||||
Generated
+642
-180
File diff suppressed because it is too large.
Load diff
@@ -282,6 +282,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
"kimi-code": "KIMICODE_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
synthetic: "SYNTHETIC_API_KEY",
|
||||
venice: "VENICE_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
opencode: "OPENCODE_API_KEY",
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SYNTHETIC_BASE_URL,
|
||||
SYNTHETIC_MODEL_CATALOG,
|
||||
} from "./synthetic-models.js";
|
||||
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
|
||||
|
||||
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
|
||||
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
@@ -340,6 +341,15 @@ function buildSyntheticProvider(): ProviderConfig {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildVeniceProvider(): Promise<ProviderConfig> {
|
||||
const models = await discoverVeniceModels();
|
||||
return {
|
||||
baseUrl: VENICE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildOllamaProvider(): Promise<ProviderConfig> {
|
||||
const models = await discoverOllamaModels();
|
||||
return {
|
||||
@@ -385,6 +395,13 @@ export async function resolveImplicitProviders(params: {
|
||||
providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey };
|
||||
}
|
||||
|
||||
const veniceKey =
|
||||
resolveEnvApiKeyVarName("venice") ??
|
||||
resolveApiKeyFromProfiles({ provider: "venice", store: authStore });
|
||||
if (veniceKey) {
|
||||
providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey };
|
||||
}
|
||||
|
||||
const qwenProfiles = listProfilesForProvider(authStore, "qwen-portal");
|
||||
if (qwenProfiles.length > 0) {
|
||||
providers["qwen-portal"] = {
|
||||
|
||||
@@ -48,15 +48,19 @@ describe("models-config", () => {
|
||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||
const previousGh = process.env.GH_TOKEN;
|
||||
const previousGithub = process.env.GITHUB_TOKEN;
|
||||
const previousKimiCode = process.env.KIMICODE_API_KEY;
|
||||
const previousMinimax = process.env.MINIMAX_API_KEY;
|
||||
const previousMoonshot = process.env.MOONSHOT_API_KEY;
|
||||
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
|
||||
const previousVenice = process.env.VENICE_API_KEY;
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
delete process.env.KIMICODE_API_KEY;
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
delete process.env.MOONSHOT_API_KEY;
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
delete process.env.VENICE_API_KEY;
|
||||
|
||||
try {
|
||||
vi.resetModules();
|
||||
@@ -79,12 +83,16 @@ describe("models-config", () => {
|
||||
else process.env.GH_TOKEN = previousGh;
|
||||
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
|
||||
else process.env.GITHUB_TOKEN = previousGithub;
|
||||
if (previousKimiCode === undefined) delete process.env.KIMICODE_API_KEY;
|
||||
else process.env.KIMICODE_API_KEY = previousKimiCode;
|
||||
if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY;
|
||||
else process.env.MINIMAX_API_KEY = previousMinimax;
|
||||
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
|
||||
else process.env.MOONSHOT_API_KEY = previousMoonshot;
|
||||
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
|
||||
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
|
||||
if (previousVenice === undefined) delete process.env.VENICE_API_KEY;
|
||||
else process.env.VENICE_API_KEY = previousVenice;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
||||
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
|
||||
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
||||
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
|
||||
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
@@ -255,6 +257,28 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
}
|
||||
}
|
||||
}
|
||||
const reactionGuidance =
|
||||
runtimeChannel && params.config
|
||||
? (() => {
|
||||
if (runtimeChannel === "telegram") {
|
||||
const resolved = resolveTelegramReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Telegram" } : undefined;
|
||||
}
|
||||
if (runtimeChannel === "signal") {
|
||||
const resolved = resolveSignalReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Signal" } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
})()
|
||||
: undefined;
|
||||
// Resolve channel-specific message actions for system prompt
|
||||
const channelActions = runtimeChannel
|
||||
? listChannelSupportedActions({
|
||||
@@ -313,6 +337,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
ttsHint,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
reactionGuidance,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
|
||||
import { getMachineDisplayName } from "../../../infra/machine-name.js";
|
||||
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
|
||||
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
|
||||
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
|
||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
@@ -255,14 +256,25 @@ export async function runEmbeddedAttempt(
|
||||
}
|
||||
}
|
||||
const reactionGuidance =
|
||||
runtimeChannel === "telegram" && params.config
|
||||
runtimeChannel && params.config
|
||||
? (() => {
|
||||
const resolved = resolveTelegramReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Telegram" } : undefined;
|
||||
if (runtimeChannel === "telegram") {
|
||||
const resolved = resolveTelegramReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Telegram" } : undefined;
|
||||
}
|
||||
if (runtimeChannel === "signal") {
|
||||
const resolved = resolveSignalReactionLevel({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
const level = resolved.agentReactionGuidance;
|
||||
return level ? { level, channel: "Signal" } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
})()
|
||||
: undefined;
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
|
||||
@@ -94,6 +94,9 @@ function buildReactionSchema() {
|
||||
messageId: Type.Optional(Type.String()),
|
||||
emoji: Type.Optional(Type.String()),
|
||||
remove: Type.Optional(Type.Boolean()),
|
||||
targetAuthor: Type.Optional(Type.String()),
|
||||
targetAuthorUuid: Type.Optional(Type.String()),
|
||||
groupId: Type.Optional(Type.String()),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import type { ModelDefinitionConfig } from "../config/types.js";
|
||||
|
||||
export const VENICE_BASE_URL = "https://api.venice.ai/api/v1";
|
||||
export const VENICE_DEFAULT_MODEL_ID = "llama-3.3-70b";
|
||||
export const VENICE_DEFAULT_MODEL_REF = `venice/${VENICE_DEFAULT_MODEL_ID}`;
|
||||
|
||||
// Venice uses credit-based pricing, not per-token costs.
|
||||
// Set to 0 as costs vary by model and account type.
|
||||
export const VENICE_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete catalog of Venice AI models.
|
||||
*
|
||||
* Venice provides two privacy modes:
|
||||
* - "private": Fully private inference, no logging, ephemeral
|
||||
* - "anonymized": Proxied through Venice with metadata stripped (for proprietary models)
|
||||
*
|
||||
* Note: The `privacy` field is included for documentation purposes but is not
|
||||
* propagated to ModelDefinitionConfig as it's not part of the core model schema.
|
||||
* Privacy mode is determined by the model itself, not configurable at runtime.
|
||||
*
|
||||
* This catalog serves as a fallback when the Venice API is unreachable.
|
||||
*/
|
||||
export const VENICE_MODEL_CATALOG = [
|
||||
// ============================================
|
||||
// PRIVATE MODELS (Fully private, no logging)
|
||||
// ============================================
|
||||
|
||||
// Llama models
|
||||
{
|
||||
id: "llama-3.3-70b",
|
||||
name: "Llama 3.3 70B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "llama-3.2-3b",
|
||||
name: "Llama 3.2 3B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "hermes-3-llama-3.1-405b",
|
||||
name: "Hermes 3 Llama 3.1 405B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
|
||||
// Qwen models
|
||||
{
|
||||
id: "qwen3-235b-a22b-thinking-2507",
|
||||
name: "Qwen3 235B Thinking",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "qwen3-235b-a22b-instruct-2507",
|
||||
name: "Qwen3 235B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "qwen3-coder-480b-a35b-instruct",
|
||||
name: "Qwen3 Coder 480B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "qwen3-next-80b",
|
||||
name: "Qwen3 Next 80B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "qwen3-vl-235b-a22b",
|
||||
name: "Qwen3 VL 235B (Vision)",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "qwen3-4b",
|
||||
name: "Venice Small (Qwen3 4B)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 32768,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
|
||||
// DeepSeek
|
||||
{
|
||||
id: "deepseek-v3.2",
|
||||
name: "DeepSeek V3.2",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 163840,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
|
||||
// Venice-specific models
|
||||
{
|
||||
id: "venice-uncensored",
|
||||
name: "Venice Uncensored (Dolphin-Mistral)",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 32768,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "mistral-31-24b",
|
||||
name: "Venice Medium (Mistral)",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
|
||||
// Other private models
|
||||
{
|
||||
id: "google-gemma-3-27b-it",
|
||||
name: "Google Gemma 3 27B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 202752,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "openai-gpt-oss-120b",
|
||||
name: "OpenAI GPT OSS 120B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
{
|
||||
id: "zai-org-glm-4.7",
|
||||
name: "GLM 4.7",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 202752,
|
||||
maxTokens: 8192,
|
||||
privacy: "private",
|
||||
},
|
||||
|
||||
// ============================================
|
||||
// ANONYMIZED MODELS (Proxied through Venice)
|
||||
// These are proprietary models accessed via Venice's proxy
|
||||
// ============================================
|
||||
|
||||
// Anthropic (via Venice)
|
||||
{
|
||||
id: "claude-opus-45",
|
||||
name: "Claude Opus 4.5 (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 202752,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-45",
|
||||
name: "Claude Sonnet 4.5 (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 202752,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
|
||||
// OpenAI (via Venice)
|
||||
{
|
||||
id: "openai-gpt-52",
|
||||
name: "GPT-5.2 (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
{
|
||||
id: "openai-gpt-52-codex",
|
||||
name: "GPT-5.2 Codex (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
|
||||
// Google (via Venice)
|
||||
{
|
||||
id: "gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 202752,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash-preview",
|
||||
name: "Gemini 3 Flash (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
|
||||
// xAI (via Venice)
|
||||
{
|
||||
id: "grok-41-fast",
|
||||
name: "Grok 4.1 Fast (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
{
|
||||
id: "grok-code-fast-1",
|
||||
name: "Grok Code Fast 1 (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
|
||||
// Other anonymized models
|
||||
{
|
||||
id: "kimi-k2-thinking",
|
||||
name: "Kimi K2 Thinking (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
{
|
||||
id: "minimax-m21",
|
||||
name: "MiniMax M2.1 (via Venice)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 202752,
|
||||
maxTokens: 8192,
|
||||
privacy: "anonymized",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type VeniceCatalogEntry = (typeof VENICE_MODEL_CATALOG)[number];
|
||||
|
||||
/**
|
||||
* Build a ModelDefinitionConfig from a Venice catalog entry.
|
||||
*
|
||||
* Note: The `privacy` field from the catalog is not included in the output
|
||||
* as ModelDefinitionConfig doesn't support custom metadata fields. Privacy
|
||||
* mode is inherent to each model and documented in the catalog/docs.
|
||||
*/
|
||||
export function buildVeniceModelDefinition(entry: VeniceCatalogEntry): ModelDefinitionConfig {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
reasoning: entry.reasoning,
|
||||
input: [...entry.input],
|
||||
cost: VENICE_DEFAULT_COST,
|
||||
contextWindow: entry.contextWindow,
|
||||
maxTokens: entry.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Venice API response types
|
||||
interface VeniceModelSpec {
|
||||
name: string;
|
||||
privacy: "private" | "anonymized";
|
||||
availableContextTokens: number;
|
||||
capabilities: {
|
||||
supportsReasoning: boolean;
|
||||
supportsVision: boolean;
|
||||
supportsFunctionCalling: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface VeniceModel {
|
||||
id: string;
|
||||
model_spec: VeniceModelSpec;
|
||||
}
|
||||
|
||||
interface VeniceModelsResponse {
|
||||
data: VeniceModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover models from Venice API with fallback to static catalog.
|
||||
* The /models endpoint is public and doesn't require authentication.
|
||||
*/
|
||||
export async function discoverVeniceModels(): Promise<ModelDefinitionConfig[]> {
|
||||
// Skip API discovery in test environment
|
||||
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
|
||||
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${VENICE_BASE_URL}/models`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`[venice-models] Failed to discover models: HTTP ${response.status}, using static catalog`,
|
||||
);
|
||||
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as VeniceModelsResponse;
|
||||
if (!Array.isArray(data.data) || data.data.length === 0) {
|
||||
console.warn("[venice-models] No models found from API, using static catalog");
|
||||
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
|
||||
}
|
||||
|
||||
// Merge discovered models with catalog metadata
|
||||
const catalogById = new Map<string, VeniceCatalogEntry>(
|
||||
VENICE_MODEL_CATALOG.map((m) => [m.id, m]),
|
||||
);
|
||||
const models: ModelDefinitionConfig[] = [];
|
||||
|
||||
for (const apiModel of data.data) {
|
||||
const catalogEntry = catalogById.get(apiModel.id);
|
||||
if (catalogEntry) {
|
||||
// Use catalog metadata for known models
|
||||
models.push(buildVeniceModelDefinition(catalogEntry));
|
||||
} else {
|
||||
// Create definition for newly discovered models not in catalog
|
||||
const isReasoning =
|
||||
apiModel.model_spec.capabilities.supportsReasoning ||
|
||||
apiModel.id.toLowerCase().includes("thinking") ||
|
||||
apiModel.id.toLowerCase().includes("reason") ||
|
||||
apiModel.id.toLowerCase().includes("r1");
|
||||
|
||||
const hasVision = apiModel.model_spec.capabilities.supportsVision;
|
||||
|
||||
models.push({
|
||||
id: apiModel.id,
|
||||
name: apiModel.model_spec.name || apiModel.id,
|
||||
reasoning: isReasoning,
|
||||
input: hasVision ? ["text", "image"] : ["text"],
|
||||
cost: VENICE_DEFAULT_COST,
|
||||
contextWindow: apiModel.model_spec.availableContextTokens || 128000,
|
||||
maxTokens: 8192,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return models.length > 0 ? models : VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
|
||||
} catch (error) {
|
||||
console.warn(`[venice-models] Discovery failed: ${String(error)}, using static catalog`);
|
||||
return VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { chunkMarkdownText, chunkText, resolveTextChunkLimit } from "./chunk.js";
|
||||
import {
|
||||
chunkByNewline,
|
||||
chunkMarkdownText,
|
||||
chunkText,
|
||||
chunkTextWithMode,
|
||||
resolveChunkMode,
|
||||
resolveTextChunkLimit,
|
||||
} from "./chunk.js";
|
||||
|
||||
function expectFencesBalanced(chunks: string[]) {
|
||||
for (const chunk of chunks) {
|
||||
@@ -231,3 +238,95 @@ describe("chunkMarkdownText", () => {
|
||||
expect(chunks.join("")).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkByNewline", () => {
|
||||
it("splits text on newlines", () => {
|
||||
const text = "Line one\nLine two\nLine three";
|
||||
const chunks = chunkByNewline(text, 1000);
|
||||
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
|
||||
});
|
||||
|
||||
it("filters empty lines", () => {
|
||||
const text = "Line one\n\n\nLine two\n\nLine three";
|
||||
const chunks = chunkByNewline(text, 1000);
|
||||
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
|
||||
});
|
||||
|
||||
it("trims whitespace from lines", () => {
|
||||
const text = " Line one \n Line two ";
|
||||
const chunks = chunkByNewline(text, 1000);
|
||||
expect(chunks).toEqual(["Line one", "Line two"]);
|
||||
});
|
||||
|
||||
it("falls back to length-based for long lines", () => {
|
||||
const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
|
||||
const chunks = chunkByNewline(text, 20);
|
||||
expect(chunks[0]).toBe("Short line");
|
||||
// Long line gets split into multiple chunks
|
||||
expect(chunks[1].length).toBe(20);
|
||||
expect(chunks[2].length).toBe(20);
|
||||
expect(chunks[3].length).toBe(10);
|
||||
expect(chunks[4]).toBe("Another short");
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(chunkByNewline("", 100)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array for whitespace-only input", () => {
|
||||
expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkTextWithMode", () => {
|
||||
it("uses length-based chunking for length mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
const chunks = chunkTextWithMode(text, 1000, "length");
|
||||
expect(chunks).toEqual(["Line one\nLine two"]);
|
||||
});
|
||||
|
||||
it("uses newline-based chunking for newline mode", () => {
|
||||
const text = "Line one\nLine two";
|
||||
const chunks = chunkTextWithMode(text, 1000, "newline");
|
||||
expect(chunks).toEqual(["Line one", "Line two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChunkMode", () => {
|
||||
it("returns length as default", () => {
|
||||
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
|
||||
expect(resolveChunkMode({}, "discord")).toBe("length");
|
||||
expect(resolveChunkMode(undefined, "bluebubbles")).toBe("length");
|
||||
});
|
||||
|
||||
it("returns length for internal channel", () => {
|
||||
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
|
||||
});
|
||||
|
||||
it("supports provider-level overrides for bluebubbles", () => {
|
||||
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "discord")).toBe("length");
|
||||
});
|
||||
|
||||
it("supports account-level overrides for bluebubbles", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
chunkMode: "length" as const,
|
||||
accounts: {
|
||||
primary: { chunkMode: "newline" as const },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
|
||||
expect(resolveChunkMode(cfg, "bluebubbles", "other")).toBe("length");
|
||||
});
|
||||
|
||||
it("ignores chunkMode for non-bluebubbles providers", () => {
|
||||
const cfg = { channels: { ["telegram" as string]: { chunkMode: "newline" as const } } };
|
||||
expect(resolveChunkMode(cfg, "telegram")).toBe("length");
|
||||
});
|
||||
});
|
||||
+83
-1
@@ -10,11 +10,20 @@ import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
|
||||
|
||||
export type TextChunkProvider = ChannelId | typeof INTERNAL_MESSAGE_CHANNEL;
|
||||
|
||||
/**
|
||||
* Chunking mode for outbound messages:
|
||||
* - "length": Split only when exceeding textChunkLimit (default)
|
||||
* - "newline": Split on every newline, with fallback to length-based for long lines
|
||||
*/
|
||||
export type ChunkMode = "length" | "newline";
|
||||
|
||||
const DEFAULT_CHUNK_LIMIT = 4000;
|
||||
const DEFAULT_CHUNK_MODE: ChunkMode = "length";
|
||||
|
||||
type ProviderChunkConfig = {
|
||||
textChunkLimit?: number;
|
||||
accounts?: Record<string, { textChunkLimit?: number }>;
|
||||
chunkMode?: ChunkMode;
|
||||
accounts?: Record<string, { textChunkLimit?: number; chunkMode?: ChunkMode }>;
|
||||
};
|
||||
|
||||
function resolveChunkLimitForProvider(
|
||||
@@ -63,6 +72,79 @@ export function resolveTextChunkLimit(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveChunkModeForProvider(
|
||||
cfgSection: ProviderChunkConfig | undefined,
|
||||
accountId?: string | null,
|
||||
): ChunkMode | undefined {
|
||||
if (!cfgSection) return undefined;
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accounts = cfgSection.accounts;
|
||||
if (accounts && typeof accounts === "object") {
|
||||
const direct = accounts[normalizedAccountId];
|
||||
if (direct?.chunkMode) {
|
||||
return direct.chunkMode;
|
||||
}
|
||||
const matchKey = Object.keys(accounts).find(
|
||||
(key) => key.toLowerCase() === normalizedAccountId.toLowerCase(),
|
||||
);
|
||||
const match = matchKey ? accounts[matchKey] : undefined;
|
||||
if (match?.chunkMode) {
|
||||
return match.chunkMode;
|
||||
}
|
||||
}
|
||||
return cfgSection.chunkMode;
|
||||
}
|
||||
|
||||
export function resolveChunkMode(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
provider?: TextChunkProvider,
|
||||
accountId?: string | null,
|
||||
): ChunkMode {
|
||||
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
|
||||
// Chunk mode is only supported for BlueBubbles.
|
||||
if (provider !== "bluebubbles") return DEFAULT_CHUNK_MODE;
|
||||
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
|
||||
const providerConfig = (channelsConfig?.[provider] ??
|
||||
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
|
||||
const mode = resolveChunkModeForProvider(providerConfig, accountId);
|
||||
return mode ?? DEFAULT_CHUNK_MODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text on newlines, filtering empty lines.
|
||||
* Lines exceeding maxLineLength are further split using length-based chunking.
|
||||
*/
|
||||
export function chunkByNewline(text: string, maxLineLength: number): string[] {
|
||||
if (!text) return [];
|
||||
const lines = text.split("\n");
|
||||
const chunks: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue; // skip empty lines
|
||||
|
||||
if (trimmed.length <= maxLineLength) {
|
||||
chunks.push(trimmed);
|
||||
} else {
|
||||
// Long line: fall back to length-based chunking
|
||||
const subChunks = chunkText(trimmed, maxLineLength);
|
||||
chunks.push(...subChunks);
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified chunking function that dispatches based on mode.
|
||||
*/
|
||||
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||
if (mode === "newline") {
|
||||
return chunkByNewline(text, limit);
|
||||
}
|
||||
return chunkText(text, limit);
|
||||
}
|
||||
|
||||
export function chunkText(text: string, limit: number): string[] {
|
||||
if (!text) return [];
|
||||
if (limit <= 0) return [text];
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
listDeliverableMessageChannels,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
|
||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||
@@ -68,6 +68,17 @@ export function resolveBlockStreamingChunking(
|
||||
fallbackLimit: providerChunkLimit,
|
||||
});
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
|
||||
// BlueBubbles-only: if chunkMode is "newline", use newline-based streaming
|
||||
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
if (channelChunkMode === "newline") {
|
||||
// For newline mode: use very low minChars to flush quickly on newlines
|
||||
const minChars = Math.max(1, Math.floor(chunkCfg?.minChars ?? 1));
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? textLimit));
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
return { minChars, maxChars, breakPreference: "newline" };
|
||||
}
|
||||
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||
const minFallback = DEFAULT_BLOCK_STREAM_MIN;
|
||||
@@ -91,6 +102,13 @@ export function resolveBlockStreamingCoalescing(
|
||||
},
|
||||
): BlockStreamingCoalescing | undefined {
|
||||
const providerKey = normalizeChunkProvider(provider);
|
||||
|
||||
// BlueBubbles-only: when chunkMode is "newline", disable coalescing to send each line immediately
|
||||
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
if (channelChunkMode === "newline") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
|
||||
const providerChunkLimit = providerId
|
||||
? getChannelDock(providerId)?.outbound?.textChunkLimit
|
||||
|
||||
@@ -6,17 +6,20 @@ import {
|
||||
getTtsMaxLength,
|
||||
getTtsProvider,
|
||||
isSummarizationEnabled,
|
||||
isTtsEnabled,
|
||||
isTtsProviderConfigured,
|
||||
normalizeTtsAutoMode,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsApiKey,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
resolveTtsProviderOrder,
|
||||
setLastTtsAttempt,
|
||||
setSummarizationEnabled,
|
||||
setTtsEnabled,
|
||||
setTtsMaxLength,
|
||||
setTtsProvider,
|
||||
textToSpeech,
|
||||
} from "../../tts/tts.js";
|
||||
import { updateSessionStore } from "../../config/sessions.js";
|
||||
|
||||
type ParsedTtsCommand = {
|
||||
action: string;
|
||||
@@ -37,10 +40,11 @@ function ttsUsage(): ReplyPayload {
|
||||
// Keep usage in one place so help/validation stays consistent.
|
||||
return {
|
||||
text:
|
||||
"⚙️ Usage: /tts <on|off|status|provider|limit|summary|audio> [value]" +
|
||||
"⚙️ Usage: /tts <off|always|inbound|tagged|status|provider|limit|summary|audio> [value]" +
|
||||
"\nExamples:\n" +
|
||||
"/tts on\n" +
|
||||
"/tts always\n" +
|
||||
"/tts provider openai\n" +
|
||||
"/tts provider edge\n" +
|
||||
"/tts limit 2000\n" +
|
||||
"/tts summary off\n" +
|
||||
"/tts audio Hello from Clawdbot",
|
||||
@@ -68,14 +72,30 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
return { shouldContinue: false, reply: ttsUsage() };
|
||||
}
|
||||
|
||||
if (action === "on") {
|
||||
setTtsEnabled(prefsPath, true);
|
||||
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
|
||||
}
|
||||
|
||||
if (action === "off") {
|
||||
setTtsEnabled(prefsPath, false);
|
||||
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
|
||||
const requestedAuto = normalizeTtsAutoMode(
|
||||
action === "on" ? "always" : action === "off" ? "off" : action,
|
||||
);
|
||||
if (requestedAuto) {
|
||||
const entry = params.sessionEntry;
|
||||
const sessionKey = params.sessionKey;
|
||||
const store = params.sessionStore;
|
||||
if (entry && store && sessionKey) {
|
||||
entry.ttsAuto = requestedAuto;
|
||||
entry.updatedAt = Date.now();
|
||||
store[sessionKey] = entry;
|
||||
if (params.storePath) {
|
||||
await updateSessionStore(params.storePath, (store) => {
|
||||
store[sessionKey] = entry;
|
||||
});
|
||||
}
|
||||
}
|
||||
const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "audio") {
|
||||
@@ -126,33 +146,45 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
if (action === "provider") {
|
||||
const currentProvider = getTtsProvider(config, prefsPath);
|
||||
if (!args.trim()) {
|
||||
const fallback = currentProvider === "openai" ? "elevenlabs" : "openai";
|
||||
const fallback = resolveTtsProviderOrder(currentProvider)
|
||||
.slice(1)
|
||||
.filter((provider) => isTtsProviderConfigured(config, provider));
|
||||
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
|
||||
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
|
||||
const hasEdge = isTtsProviderConfigured(config, "edge");
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
`🎙️ TTS provider\n` +
|
||||
`Primary: ${currentProvider}\n` +
|
||||
`Fallback: ${fallback}\n` +
|
||||
`Fallbacks: ${fallback.join(", ") || "none"}\n` +
|
||||
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
|
||||
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
|
||||
`Usage: /tts provider openai | elevenlabs`,
|
||||
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
|
||||
`Usage: /tts provider openai | elevenlabs | edge`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const requested = args.trim().toLowerCase();
|
||||
if (requested !== "openai" && requested !== "elevenlabs") {
|
||||
if (requested !== "openai" && requested !== "elevenlabs" && requested !== "edge") {
|
||||
return { shouldContinue: false, reply: ttsUsage() };
|
||||
}
|
||||
|
||||
setTtsProvider(prefsPath, requested);
|
||||
const fallback = requested === "openai" ? "elevenlabs" : "openai";
|
||||
const fallback = resolveTtsProviderOrder(requested)
|
||||
.slice(1)
|
||||
.filter((provider) => isTtsProviderConfigured(config, provider));
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ TTS provider set to ${requested} (fallback: ${fallback}).` },
|
||||
reply: {
|
||||
text:
|
||||
`✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
|
||||
(requested === "edge"
|
||||
? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
|
||||
: ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,16 +229,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
}
|
||||
|
||||
if (action === "status") {
|
||||
const enabled = isTtsEnabled(config, prefsPath);
|
||||
const sessionAuto = params.sessionEntry?.ttsAuto;
|
||||
const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
|
||||
const enabled = autoMode !== "off";
|
||||
const provider = getTtsProvider(config, prefsPath);
|
||||
const hasKey = Boolean(resolveTtsApiKey(config, provider));
|
||||
const hasKey = isTtsProviderConfigured(config, provider);
|
||||
const providerStatus =
|
||||
provider === "edge"
|
||||
? hasKey
|
||||
? "✅ enabled"
|
||||
: "❌ disabled"
|
||||
: hasKey
|
||||
? "✅ key"
|
||||
: "❌ no key";
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath);
|
||||
const last = getLastTtsAttempt();
|
||||
const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
|
||||
const lines = [
|
||||
"📊 TTS status",
|
||||
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
|
||||
`Provider: ${provider} (${hasKey ? "✅ key" : "❌ no key"})`,
|
||||
`Auto: ${enabled ? autoLabel : "off"}`,
|
||||
`Provider: ${provider} (${providerStatus})`,
|
||||
`Text limit: ${maxLength} chars`,
|
||||
`Auto-summary: ${summarize ? "on" : "off"}`,
|
||||
];
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||
import {
|
||||
@@ -14,7 +16,55 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
||||
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||
import { maybeApplyTtsToPayload } from "../../tts/tts.js";
|
||||
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
|
||||
|
||||
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
|
||||
const AUDIO_HEADER_RE = /^\[Audio\b/i;
|
||||
|
||||
const normalizeMediaType = (value: string): string => value.split(";")[0]?.trim().toLowerCase();
|
||||
|
||||
const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => {
|
||||
const rawTypes = [
|
||||
typeof ctx.MediaType === "string" ? ctx.MediaType : undefined,
|
||||
...(Array.isArray(ctx.MediaTypes) ? ctx.MediaTypes : []),
|
||||
].filter(Boolean) as string[];
|
||||
const types = rawTypes.map((type) => normalizeMediaType(type));
|
||||
if (types.some((type) => type === "audio" || type.startsWith("audio/"))) return true;
|
||||
|
||||
const body =
|
||||
typeof ctx.BodyForCommands === "string"
|
||||
? ctx.BodyForCommands
|
||||
: typeof ctx.CommandBody === "string"
|
||||
? ctx.CommandBody
|
||||
: typeof ctx.RawBody === "string"
|
||||
? ctx.RawBody
|
||||
: typeof ctx.Body === "string"
|
||||
? ctx.Body
|
||||
: "";
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return false;
|
||||
if (AUDIO_PLACEHOLDER_RE.test(trimmed)) return true;
|
||||
return AUDIO_HEADER_RE.test(trimmed);
|
||||
};
|
||||
|
||||
const resolveSessionTtsAuto = (
|
||||
ctx: FinalizedMsgContext,
|
||||
cfg: ClawdbotConfig,
|
||||
): string | undefined => {
|
||||
const targetSessionKey =
|
||||
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
||||
const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim();
|
||||
if (!sessionKey) return undefined;
|
||||
const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
try {
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey.toLowerCase()] ?? store[sessionKey];
|
||||
return normalizeTtsAutoMode(entry?.ttsAuto);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export type DispatchFromConfigResult = {
|
||||
queuedFinal: boolean;
|
||||
@@ -81,6 +131,8 @@ export async function dispatchReplyFromConfig(params: {
|
||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||
}
|
||||
|
||||
const inboundAudio = isInboundAudioContext(ctx);
|
||||
const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (hookRunner?.hasHooks("message_received")) {
|
||||
const timestamp =
|
||||
@@ -223,6 +275,8 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "tool",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload);
|
||||
@@ -239,6 +293,8 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "block",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
||||
@@ -262,6 +318,8 @@ export async function dispatchReplyFromConfig(params: {
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "final",
|
||||
inboundAudio,
|
||||
ttsAuto: sessionTtsAuto,
|
||||
});
|
||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||
// Route final reply to originating channel.
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { TtsAutoMode } from "../../config/types.tts.js";
|
||||
import {
|
||||
DEFAULT_RESET_TRIGGERS,
|
||||
deriveSessionMetaPatch,
|
||||
@@ -128,6 +129,7 @@ export async function initSessionState(params: {
|
||||
let persistedThinking: string | undefined;
|
||||
let persistedVerbose: string | undefined;
|
||||
let persistedReasoning: string | undefined;
|
||||
let persistedTtsAuto: TtsAutoMode | undefined;
|
||||
let persistedModelOverride: string | undefined;
|
||||
let persistedProviderOverride: string | undefined;
|
||||
|
||||
@@ -220,6 +222,7 @@ export async function initSessionState(params: {
|
||||
persistedThinking = entry.thinkingLevel;
|
||||
persistedVerbose = entry.verboseLevel;
|
||||
persistedReasoning = entry.reasoningLevel;
|
||||
persistedTtsAuto = entry.ttsAuto;
|
||||
persistedModelOverride = entry.modelOverride;
|
||||
persistedProviderOverride = entry.providerOverride;
|
||||
} else {
|
||||
@@ -258,6 +261,7 @@ export async function initSessionState(params: {
|
||||
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
|
||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||
reasoningLevel: persistedReasoning ?? baseEntry?.reasoningLevel,
|
||||
ttsAuto: persistedTtsAuto ?? baseEntry?.ttsAuto,
|
||||
responseUsage: baseEntry?.responseUsage,
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
getTtsMaxLength,
|
||||
getTtsProvider,
|
||||
isSummarizationEnabled,
|
||||
isTtsEnabled,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
} from "../tts/tts.js";
|
||||
@@ -252,15 +252,23 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
|
||||
return `📎 Media: ${parts.join(" · ")}`;
|
||||
};
|
||||
|
||||
const formatVoiceModeLine = (config?: ClawdbotConfig): string | null => {
|
||||
const formatVoiceModeLine = (
|
||||
config?: ClawdbotConfig,
|
||||
sessionEntry?: SessionEntry,
|
||||
): string | null => {
|
||||
if (!config) return null;
|
||||
const ttsConfig = resolveTtsConfig(config);
|
||||
const prefsPath = resolveTtsPrefsPath(ttsConfig);
|
||||
if (!isTtsEnabled(ttsConfig, prefsPath)) return null;
|
||||
const autoMode = resolveTtsAutoMode({
|
||||
config: ttsConfig,
|
||||
prefsPath,
|
||||
sessionAuto: sessionEntry?.ttsAuto,
|
||||
});
|
||||
if (autoMode === "off") return null;
|
||||
const provider = getTtsProvider(ttsConfig, prefsPath);
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
|
||||
return `🔊 Voice: on · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
||||
return `🔊 Voice: ${autoMode} · provider=${provider} · limit=${maxLength} · summary=${summarize}`;
|
||||
};
|
||||
|
||||
export function buildStatusMessage(args: StatusArgs): string {
|
||||
@@ -398,7 +406,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
const usageCostLine =
|
||||
usagePair && costLine ? `${usagePair} · ${costLine}` : (usagePair ?? costLine);
|
||||
const mediaLine = formatMediaUnderstandingLine(args.mediaDecisions);
|
||||
const voiceLine = formatVoiceModeLine(args.config);
|
||||
const voiceLine = formatVoiceModeLine(args.config, args.sessionEntry);
|
||||
|
||||
return [
|
||||
versionLine,
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
MediaUnderstandingDecision,
|
||||
MediaUnderstandingOutput,
|
||||
} from "../media-understanding/types.js";
|
||||
import type { LinkUnderstandingDecision } from "../link-understanding/types.js";
|
||||
|
||||
/** Valid message channels for routing. */
|
||||
export type OriginatingChannelType = ChannelId | InternalMessageChannel;
|
||||
@@ -73,7 +72,6 @@ export type MsgContext = {
|
||||
MediaUnderstanding?: MediaUnderstandingOutput[];
|
||||
MediaUnderstandingDecisions?: MediaUnderstandingDecision[];
|
||||
LinkUnderstanding?: string[];
|
||||
LinkUnderstandingDecisions?: LinkUnderstandingDecision[];
|
||||
Prompt?: string;
|
||||
MaxChars?: number;
|
||||
ChatType?: string;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { signalMessageActions } from "./signal.js";
|
||||
|
||||
const sendReactionSignal = vi.fn(async () => ({ ok: true }));
|
||||
const removeReactionSignal = vi.fn(async () => ({ ok: true }));
|
||||
|
||||
vi.mock("../../../signal/send-reactions.js", () => ({
|
||||
sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args),
|
||||
removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args),
|
||||
}));
|
||||
|
||||
describe("signalMessageActions", () => {
|
||||
it("returns no actions when no configured accounts exist", () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
expect(signalMessageActions.listActions({ cfg })).toEqual([]);
|
||||
});
|
||||
|
||||
it("hides react when reactions are disabled", () => {
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
|
||||
} as ClawdbotConfig;
|
||||
expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]);
|
||||
});
|
||||
|
||||
it("enables react when at least one account allows reactions", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
actions: { reactions: false },
|
||||
accounts: {
|
||||
work: { account: "+15550001111", actions: { reactions: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]);
|
||||
});
|
||||
|
||||
it("skips send for plugin dispatch", () => {
|
||||
expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false);
|
||||
expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks reactions when action gate is disabled", async () => {
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await expect(
|
||||
signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { to: "+15550001111", messageId: "123", emoji: "✅" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
}),
|
||||
).rejects.toThrow(/actions\.reactions/);
|
||||
});
|
||||
|
||||
it("uses account-level actions when enabled", async () => {
|
||||
sendReactionSignal.mockClear();
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
actions: { reactions: false },
|
||||
accounts: {
|
||||
work: { account: "+15550001111", actions: { reactions: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", {
|
||||
accountId: "work",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes uuid recipients", async () => {
|
||||
sendReactionSignal.mockClear();
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111" } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
recipient: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
messageId: "123",
|
||||
emoji: "🔥",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
expect(sendReactionSignal).toHaveBeenCalledWith(
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
123,
|
||||
"🔥",
|
||||
{ accountId: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("requires targetAuthor for group reactions", async () => {
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111" } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await expect(
|
||||
signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
}),
|
||||
).rejects.toThrow(/targetAuthor/);
|
||||
});
|
||||
|
||||
it("passes groupId and targetAuthor for group reactions", async () => {
|
||||
sendReactionSignal.mockClear();
|
||||
const cfg = {
|
||||
channels: { signal: { account: "+15550001111" } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await signalMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
to: "signal:group:group-id",
|
||||
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
messageId: "123",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", {
|
||||
accountId: undefined,
|
||||
groupId: "group-id",
|
||||
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
|
||||
targetAuthorUuid: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
|
||||
import { listEnabledSignalAccounts, resolveSignalAccount } from "../../../signal/accounts.js";
|
||||
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
|
||||
import { sendReactionSignal, removeReactionSignal } from "../../../signal/send-reactions.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
|
||||
|
||||
const providerId = "signal";
|
||||
const GROUP_PREFIX = "group:";
|
||||
|
||||
function normalizeSignalReactionRecipient(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
|
||||
if (!withoutSignal) return withoutSignal;
|
||||
if (withoutSignal.toLowerCase().startsWith("uuid:")) {
|
||||
return withoutSignal.slice("uuid:".length).trim();
|
||||
}
|
||||
return withoutSignal;
|
||||
}
|
||||
|
||||
function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId?: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
|
||||
if (!withoutSignal) return {};
|
||||
if (withoutSignal.toLowerCase().startsWith(GROUP_PREFIX)) {
|
||||
const groupId = withoutSignal.slice(GROUP_PREFIX.length).trim();
|
||||
return groupId ? { groupId } : {};
|
||||
}
|
||||
return { recipient: normalizeSignalReactionRecipient(withoutSignal) };
|
||||
}
|
||||
|
||||
export const signalMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listEnabledSignalAccounts(cfg);
|
||||
if (accounts.length === 0) return [];
|
||||
const configuredAccounts = accounts.filter((account) => account.configured);
|
||||
if (configuredAccounts.length === 0) return [];
|
||||
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
|
||||
const reactionsEnabled = configuredAccounts.some((account) =>
|
||||
createActionGate(account.config.actions)("reactions"),
|
||||
);
|
||||
if (reactionsEnabled) {
|
||||
actions.add("react");
|
||||
}
|
||||
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsAction: ({ action }) => action !== "send",
|
||||
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action === "send") {
|
||||
throw new Error("Send should be handled by outbound, not actions handler.");
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
// Check reaction level first
|
||||
const reactionLevelInfo = resolveSignalReactionLevel({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (!reactionLevelInfo.agentReactionsEnabled) {
|
||||
throw new Error(
|
||||
`Signal agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
|
||||
`Set channels.signal.reactionLevel to "minimal" or "extensive" to enable.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Also check the action gate for backward compatibility
|
||||
const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions;
|
||||
const isActionEnabled = createActionGate(actionConfig);
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Signal reactions are disabled via actions.reactions.");
|
||||
}
|
||||
|
||||
const recipientRaw =
|
||||
readStringParam(params, "recipient") ??
|
||||
readStringParam(params, "to", {
|
||||
required: true,
|
||||
label: "recipient (UUID, phone number, or group)",
|
||||
});
|
||||
const target = resolveSignalReactionTarget(recipientRaw);
|
||||
if (!target.recipient && !target.groupId) {
|
||||
throw new Error("recipient or group required");
|
||||
}
|
||||
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
label: "messageId (timestamp)",
|
||||
});
|
||||
const targetAuthor = readStringParam(params, "targetAuthor");
|
||||
const targetAuthorUuid = readStringParam(params, "targetAuthorUuid");
|
||||
if (target.groupId && !targetAuthor && !targetAuthorUuid) {
|
||||
throw new Error("targetAuthor or targetAuthorUuid required for group reactions.");
|
||||
}
|
||||
|
||||
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
|
||||
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
|
||||
|
||||
const timestamp = parseInt(messageId, 10);
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
throw new Error(`Invalid messageId: ${messageId}. Expected numeric timestamp.`);
|
||||
}
|
||||
|
||||
if (remove) {
|
||||
if (!emoji) throw new Error("Emoji required to remove reaction.");
|
||||
await removeReactionSignal(target.recipient ?? "", timestamp, emoji, {
|
||||
accountId: accountId ?? undefined,
|
||||
groupId: target.groupId,
|
||||
targetAuthor,
|
||||
targetAuthorUuid,
|
||||
});
|
||||
return jsonResult({ ok: true, removed: emoji });
|
||||
}
|
||||
|
||||
if (!emoji) throw new Error("Emoji required to add reaction.");
|
||||
await sendReactionSignal(target.recipient ?? "", timestamp, emoji, {
|
||||
accountId: accountId ?? undefined,
|
||||
groupId: target.groupId,
|
||||
targetAuthor,
|
||||
targetAuthorUuid,
|
||||
});
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} not supported for ${providerId}.`);
|
||||
},
|
||||
};
|
||||
@@ -164,6 +164,17 @@ export function resolveGoogleChatGroupRequireMention(params: GroupMentionParams)
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGoogleChatGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
return resolveChannelGroupToolsPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "googlechat",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSlackGroupRequireMention(params: GroupMentionParams): boolean {
|
||||
const account = resolveSlackAccount({
|
||||
cfg: params.cfg,
|
||||
@@ -240,17 +251,6 @@ export function resolveIMessageGroupToolPolicy(
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGoogleChatGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
return resolveChannelGroupToolsPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "googlechat",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDiscordGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js";
|
||||
|
||||
describe("signal target normalization", () => {
|
||||
it("normalizes uuid targets by stripping uuid:", () => {
|
||||
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes signal:uuid targets", () => {
|
||||
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts uuid prefixes for target detection", () => {
|
||||
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
|
||||
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts compact UUIDs for target detection", () => {
|
||||
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
|
||||
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid uuid prefixes", () => {
|
||||
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
|
||||
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,12 +19,30 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined
|
||||
const id = normalized.slice("u:".length).trim();
|
||||
return id ? `username:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (lower.startsWith("uuid:")) {
|
||||
const id = normalized.slice("uuid:".length).trim();
|
||||
return id ? id.toLowerCase() : undefined;
|
||||
}
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
// UUID pattern for signal-cli recipient IDs
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i;
|
||||
|
||||
export function looksLikeSignalTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
|
||||
if (/^(signal:)?uuid:/i.test(trimmed)) {
|
||||
const stripped = trimmed
|
||||
.replace(/^signal:/i, "")
|
||||
.replace(/^uuid:/i, "")
|
||||
.trim();
|
||||
if (!stripped) return false;
|
||||
return UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped);
|
||||
}
|
||||
// Accept UUIDs (used by signal-cli for reactions)
|
||||
if (UUID_PATTERN.test(trimmed) || UUID_COMPACT_PATTERN.test(trimmed)) return true;
|
||||
return /^\+?\d{3,}$/.test(trimmed);
|
||||
}
|
||||
@@ -67,6 +67,28 @@ describe("cli program (smoke)", () => {
|
||||
expect(messageCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs message react with signal author fields", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"message",
|
||||
"react",
|
||||
"--channel",
|
||||
"signal",
|
||||
"--target",
|
||||
"signal:group:abc123",
|
||||
"--message-id",
|
||||
"1737630212345",
|
||||
"--emoji",
|
||||
"✅",
|
||||
"--target-author-uuid",
|
||||
"123e4567-e89b-12d3-a456-426614174000",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(messageCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs status command", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["status"], { from: "user" });
|
||||
|
||||
@@ -13,6 +13,8 @@ export function registerMessageReactionsCommands(message: Command, helpers: Mess
|
||||
.option("--remove", "Remove reaction", false)
|
||||
.option("--participant <id>", "WhatsApp reaction participant")
|
||||
.option("--from-me", "WhatsApp reaction fromMe", false)
|
||||
.option("--target-author <id>", "Signal reaction target author (uuid or phone)")
|
||||
.option("--target-author-uuid <uuid>", "Signal reaction target author uuid")
|
||||
.action(async (opts) => {
|
||||
await helpers.runMessageAction("react", opts);
|
||||
});
|
||||
|
||||
+6
-7
@@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||
import { formatUncaughtError } from "../infra/errors.js";
|
||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||
import { enableConsoleCapture } from "../logging.js";
|
||||
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
|
||||
import { getPrimaryCommand } from "./argv.js";
|
||||
import { tryRouteCli } from "./route.js";
|
||||
|
||||
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
|
||||
@@ -50,12 +50,11 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
});
|
||||
|
||||
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
|
||||
if (hasHelpOrVersion(parseArgv)) {
|
||||
const primary = getPrimaryCommand(parseArgv);
|
||||
if (primary) {
|
||||
const { registerSubCliByName } = await import("./program/register.subclis.js");
|
||||
await registerSubCliByName(program, primary);
|
||||
}
|
||||
// Register the primary subcommand if one exists (for lazy-loading)
|
||||
const primary = getPrimaryCommand(parseArgv);
|
||||
if (primary) {
|
||||
const { registerSubCliByName } = await import("./program/register.subclis.js");
|
||||
await registerSubCliByName(program, primary);
|
||||
}
|
||||
await program.parseAsync(parseArgv);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AuthChoiceGroupId =
|
||||
| "opencode-zen"
|
||||
| "minimax"
|
||||
| "synthetic"
|
||||
| "venice"
|
||||
| "qwen";
|
||||
|
||||
export type AuthChoiceGroup = {
|
||||
@@ -66,6 +67,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
||||
hint: "Anthropic-compatible (multi-model)",
|
||||
choices: ["synthetic-api-key"],
|
||||
},
|
||||
{
|
||||
value: "venice",
|
||||
label: "Venice AI",
|
||||
hint: "Privacy-focused (uncensored models)",
|
||||
choices: ["venice-api-key"],
|
||||
},
|
||||
{
|
||||
value: "google",
|
||||
label: "Google",
|
||||
@@ -190,6 +197,11 @@ export function buildAuthChoiceOptions(params: {
|
||||
options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" });
|
||||
options.push({ value: "kimi-code-api-key", label: "Kimi Code API key" });
|
||||
options.push({ value: "synthetic-api-key", label: "Synthetic API key" });
|
||||
options.push({
|
||||
value: "venice-api-key",
|
||||
label: "Venice AI API key",
|
||||
hint: "Privacy-focused inference (uncensored models)",
|
||||
});
|
||||
options.push({
|
||||
value: "github-copilot",
|
||||
label: "GitHub Copilot (GitHub device login)",
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
applyOpenrouterProviderConfig,
|
||||
applySyntheticConfig,
|
||||
applySyntheticProviderConfig,
|
||||
applyVeniceConfig,
|
||||
applyVeniceProviderConfig,
|
||||
applyVercelAiGatewayConfig,
|
||||
applyVercelAiGatewayProviderConfig,
|
||||
applyZaiConfig,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
MOONSHOT_DEFAULT_MODEL_REF,
|
||||
OPENROUTER_DEFAULT_MODEL_REF,
|
||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
VENICE_DEFAULT_MODEL_REF,
|
||||
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
|
||||
setGeminiApiKey,
|
||||
setKimiCodeApiKey,
|
||||
@@ -37,6 +40,7 @@ import {
|
||||
setOpencodeZenApiKey,
|
||||
setOpenrouterApiKey,
|
||||
setSyntheticApiKey,
|
||||
setVeniceApiKey,
|
||||
setVercelAiGatewayApiKey,
|
||||
setZaiApiKey,
|
||||
ZAI_DEFAULT_MODEL_REF,
|
||||
@@ -77,6 +81,8 @@ export async function applyAuthChoiceApiProviders(
|
||||
authChoice = "zai-api-key";
|
||||
} else if (params.opts.tokenProvider === "synthetic") {
|
||||
authChoice = "synthetic-api-key";
|
||||
} else if (params.opts.tokenProvider === "venice") {
|
||||
authChoice = "venice-api-key";
|
||||
} else if (params.opts.tokenProvider === "opencode") {
|
||||
authChoice = "opencode-zen";
|
||||
}
|
||||
@@ -457,6 +463,65 @@ export async function applyAuthChoiceApiProviders(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (authChoice === "venice-api-key") {
|
||||
let hasCredential = false;
|
||||
|
||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "venice") {
|
||||
await setVeniceApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Venice AI provides privacy-focused inference with uncensored models.",
|
||||
"Get your API key at: https://venice.ai/settings/api",
|
||||
"Supports 'private' (fully private) and 'anonymized' (proxy) modes.",
|
||||
].join("\n"),
|
||||
"Venice AI",
|
||||
);
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey("venice");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing VENICE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await setVeniceApiKey(envKey.apiKey, params.agentDir);
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Venice AI API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setVeniceApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "venice:default",
|
||||
provider: "venice",
|
||||
mode: "api_key",
|
||||
});
|
||||
{
|
||||
const applied = await applyDefaultModelChoice({
|
||||
config: nextConfig,
|
||||
setDefaultModel: params.setDefaultModel,
|
||||
defaultModel: VENICE_DEFAULT_MODEL_REF,
|
||||
applyDefaultConfig: applyVeniceConfig,
|
||||
applyProviderConfig: applyVeniceProviderConfig,
|
||||
noteDefault: VENICE_DEFAULT_MODEL_REF,
|
||||
noteAgentModel,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
nextConfig = applied.config;
|
||||
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||
}
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
}
|
||||
|
||||
if (authChoice === "opencode-zen") {
|
||||
let hasCredential = false;
|
||||
if (!hasCredential && params.opts?.token && params.opts?.tokenProvider === "opencode") {
|
||||
|
||||
@@ -19,6 +19,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
|
||||
"google-gemini-cli": "google-gemini-cli",
|
||||
"zai-api-key": "zai",
|
||||
"synthetic-api-key": "synthetic",
|
||||
"venice-api-key": "venice",
|
||||
"github-copilot": "github-copilot",
|
||||
"copilot-proxy": "copilot-proxy",
|
||||
"minimax-cloud": "minimax",
|
||||
|
||||
@@ -4,6 +4,12 @@ import {
|
||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
SYNTHETIC_MODEL_CATALOG,
|
||||
} from "../agents/synthetic-models.js";
|
||||
import {
|
||||
buildVeniceModelDefinition,
|
||||
VENICE_BASE_URL,
|
||||
VENICE_DEFAULT_MODEL_REF,
|
||||
VENICE_MODEL_CATALOG,
|
||||
} from "../agents/venice-models.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
OPENROUTER_DEFAULT_MODEL_REF,
|
||||
@@ -330,6 +336,81 @@ export function applySyntheticConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Venice provider configuration without changing the default model.
|
||||
* Registers Venice models and sets up the provider, but preserves existing model selection.
|
||||
*/
|
||||
export function applyVeniceProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[VENICE_DEFAULT_MODEL_REF] = {
|
||||
...models[VENICE_DEFAULT_MODEL_REF],
|
||||
alias: models[VENICE_DEFAULT_MODEL_REF]?.alias ?? "Llama 3.3 70B",
|
||||
};
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
const existingProvider = providers.venice;
|
||||
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
|
||||
const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
|
||||
const mergedModels = [
|
||||
...existingModels,
|
||||
...veniceModels.filter((model) => !existingModels.some((existing) => existing.id === model.id)),
|
||||
];
|
||||
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
> as { apiKey?: string };
|
||||
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
|
||||
const normalizedApiKey = resolvedApiKey?.trim();
|
||||
providers.venice = {
|
||||
...existingProviderRest,
|
||||
baseUrl: VENICE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
|
||||
models: mergedModels.length > 0 ? mergedModels : veniceModels,
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Venice provider configuration AND set Venice as the default model.
|
||||
* Use this when Venice is the primary provider choice during onboarding.
|
||||
*/
|
||||
export function applyVeniceConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyVeniceProviderConfig(cfg);
|
||||
const existingModel = next.agents?.defaults?.model;
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: VENICE_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAuthProfileConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params: {
|
||||
|
||||
@@ -99,6 +99,19 @@ export async function setSyntheticApiKey(key: string, agentDir?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function setVeniceApiKey(key: string, agentDir?: string) {
|
||||
// Write to resolved agent dir so gateway finds credentials on startup.
|
||||
upsertAuthProfile({
|
||||
profileId: "venice:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "venice",
|
||||
key,
|
||||
},
|
||||
agentDir: resolveAuthAgentDir(agentDir),
|
||||
});
|
||||
}
|
||||
|
||||
export const ZAI_DEFAULT_MODEL_REF = "zai/glm-4.7";
|
||||
export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto";
|
||||
export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.5";
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
SYNTHETIC_DEFAULT_MODEL_ID,
|
||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||
} from "../agents/synthetic-models.js";
|
||||
export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js";
|
||||
export {
|
||||
applyAuthProfileConfig,
|
||||
applyKimiCodeConfig,
|
||||
@@ -12,6 +13,8 @@ export {
|
||||
applyOpenrouterProviderConfig,
|
||||
applySyntheticConfig,
|
||||
applySyntheticProviderConfig,
|
||||
applyVeniceConfig,
|
||||
applyVeniceProviderConfig,
|
||||
applyVercelAiGatewayConfig,
|
||||
applyVercelAiGatewayProviderConfig,
|
||||
applyZaiConfig,
|
||||
@@ -39,6 +42,7 @@ export {
|
||||
setOpencodeZenApiKey,
|
||||
setOpenrouterApiKey,
|
||||
setSyntheticApiKey,
|
||||
setVeniceApiKey,
|
||||
setVercelAiGatewayApiKey,
|
||||
setZaiApiKey,
|
||||
writeOAuthCredentials,
|
||||
|
||||
@@ -16,6 +16,7 @@ export type AuthChoice =
|
||||
| "moonshot-api-key"
|
||||
| "kimi-code-api-key"
|
||||
| "synthetic-api-key"
|
||||
| "venice-api-key"
|
||||
| "codex-cli"
|
||||
| "apiKey"
|
||||
| "gemini-api-key"
|
||||
@@ -68,6 +69,7 @@ export type OnboardOptions = {
|
||||
zaiApiKey?: string;
|
||||
minimaxApiKey?: string;
|
||||
syntheticApiKey?: string;
|
||||
veniceApiKey?: string;
|
||||
opencodeZenApiKey?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayBind?: GatewayBind;
|
||||
|
||||
@@ -138,6 +138,16 @@ describe("legacy config detection", () => {
|
||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.channels?.telegram?.requireMention).toBeUndefined();
|
||||
});
|
||||
it("migrates messages.tts.enabled to messages.tts.auto", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
messages: { tts: { enabled: true } },
|
||||
});
|
||||
expect(res.changes).toContain("Moved messages.tts.enabled → messages.tts.auto (always).");
|
||||
expect(res.config?.messages?.tts?.auto).toBe("always");
|
||||
expect(res.config?.messages?.tts?.enabled).toBeUndefined();
|
||||
});
|
||||
it("migrates legacy model config to agent.models + model lists", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
|
||||
+102
-7
@@ -1,7 +1,9 @@
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
|
||||
import { parseModelRef } from "../agents/model-selection.js";
|
||||
import { resolveTalkApiKey } from "./talk.js";
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
|
||||
import type { ModelDefinitionConfig } from "./types.models.js";
|
||||
|
||||
type WarnState = { warned: boolean };
|
||||
|
||||
@@ -23,6 +25,34 @@ const DEFAULT_MODEL_ALIASES: Readonly<Record<string, string>> = {
|
||||
"gemini-flash": "google/gemini-3-flash-preview",
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL_COST: ModelDefinitionConfig["cost"] = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
const DEFAULT_MODEL_INPUT: ModelDefinitionConfig["input"] = ["text"];
|
||||
const DEFAULT_MODEL_MAX_TOKENS = 8192;
|
||||
|
||||
type ModelDefinitionLike = Partial<ModelDefinitionConfig> &
|
||||
Pick<ModelDefinitionConfig, "id" | "name">;
|
||||
|
||||
function isPositiveNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function resolveModelCost(
|
||||
raw?: Partial<ModelDefinitionConfig["cost"]>,
|
||||
): ModelDefinitionConfig["cost"] {
|
||||
return {
|
||||
input: typeof raw?.input === "number" ? raw.input : DEFAULT_MODEL_COST.input,
|
||||
output: typeof raw?.output === "number" ? raw.output : DEFAULT_MODEL_COST.output,
|
||||
cacheRead: typeof raw?.cacheRead === "number" ? raw.cacheRead : DEFAULT_MODEL_COST.cacheRead,
|
||||
cacheWrite:
|
||||
typeof raw?.cacheWrite === "number" ? raw.cacheWrite : DEFAULT_MODEL_COST.cacheWrite,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnthropicDefaultAuthMode(cfg: ClawdbotConfig): AnthropicAuthDefaultsMode | null {
|
||||
const profiles = cfg.auth?.profiles ?? {};
|
||||
const anthropicProfiles = Object.entries(profiles).filter(
|
||||
@@ -114,12 +144,77 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig {
|
||||
}
|
||||
|
||||
export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const existingAgent = cfg.agents?.defaults;
|
||||
if (!existingAgent) return cfg;
|
||||
const existingModels = existingAgent.models ?? {};
|
||||
if (Object.keys(existingModels).length === 0) return cfg;
|
||||
|
||||
let mutated = false;
|
||||
let nextCfg = cfg;
|
||||
|
||||
const providerConfig = nextCfg.models?.providers;
|
||||
if (providerConfig) {
|
||||
const nextProviders = { ...providerConfig };
|
||||
for (const [providerId, provider] of Object.entries(providerConfig)) {
|
||||
const models = provider.models;
|
||||
if (!Array.isArray(models) || models.length === 0) continue;
|
||||
let providerMutated = false;
|
||||
const nextModels = models.map((model) => {
|
||||
const raw = model as ModelDefinitionLike;
|
||||
let modelMutated = false;
|
||||
|
||||
const reasoning = typeof raw.reasoning === "boolean" ? raw.reasoning : false;
|
||||
if (raw.reasoning !== reasoning) modelMutated = true;
|
||||
|
||||
const input = raw.input ?? [...DEFAULT_MODEL_INPUT];
|
||||
if (raw.input === undefined) modelMutated = true;
|
||||
|
||||
const cost = resolveModelCost(raw.cost);
|
||||
const costMutated =
|
||||
!raw.cost ||
|
||||
raw.cost.input !== cost.input ||
|
||||
raw.cost.output !== cost.output ||
|
||||
raw.cost.cacheRead !== cost.cacheRead ||
|
||||
raw.cost.cacheWrite !== cost.cacheWrite;
|
||||
if (costMutated) modelMutated = true;
|
||||
|
||||
const contextWindow = isPositiveNumber(raw.contextWindow)
|
||||
? raw.contextWindow
|
||||
: DEFAULT_CONTEXT_TOKENS;
|
||||
if (raw.contextWindow !== contextWindow) modelMutated = true;
|
||||
|
||||
const defaultMaxTokens = Math.min(DEFAULT_MODEL_MAX_TOKENS, contextWindow);
|
||||
const maxTokens = isPositiveNumber(raw.maxTokens) ? raw.maxTokens : defaultMaxTokens;
|
||||
if (raw.maxTokens !== maxTokens) modelMutated = true;
|
||||
|
||||
if (!modelMutated) return model;
|
||||
providerMutated = true;
|
||||
return {
|
||||
...raw,
|
||||
reasoning,
|
||||
input,
|
||||
cost,
|
||||
contextWindow,
|
||||
maxTokens,
|
||||
} as ModelDefinitionConfig;
|
||||
});
|
||||
|
||||
if (!providerMutated) continue;
|
||||
nextProviders[providerId] = { ...provider, models: nextModels };
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (mutated) {
|
||||
nextCfg = {
|
||||
...nextCfg,
|
||||
models: {
|
||||
...nextCfg.models,
|
||||
providers: nextProviders,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const existingAgent = nextCfg.agents?.defaults;
|
||||
if (!existingAgent) return mutated ? nextCfg : cfg;
|
||||
const existingModels = existingAgent.models ?? {};
|
||||
if (Object.keys(existingModels).length === 0) return mutated ? nextCfg : cfg;
|
||||
|
||||
const nextModels: Record<string, { alias?: string }> = {
|
||||
...existingModels,
|
||||
};
|
||||
@@ -135,9 +230,9 @@ export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
if (!mutated) return cfg;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
...nextCfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
...nextCfg.agents,
|
||||
defaults: { ...existingAgent, models: nextModels },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,6 +40,26 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
delete tools.bash;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "messages.tts.enabled->auto",
|
||||
describe: "Move messages.tts.enabled to messages.tts.auto",
|
||||
apply: (raw, changes) => {
|
||||
const messages = getRecord(raw.messages);
|
||||
const tts = getRecord(messages?.tts);
|
||||
if (!tts) return;
|
||||
if (tts.auto !== undefined) {
|
||||
if ("enabled" in tts) {
|
||||
delete tts.enabled;
|
||||
changes.push("Removed messages.tts.enabled (messages.tts.auto already set).");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof tts.enabled !== "boolean") return;
|
||||
tts.auto = tts.enabled ? "always" : "off";
|
||||
delete tts.enabled;
|
||||
changes.push(`Moved messages.tts.enabled → messages.tts.auto (${String(tts.auto)}).`);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent.defaults-v2",
|
||||
describe: "Move agent config to agents.defaults and tools",
|
||||
|
||||
@@ -120,6 +120,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
message:
|
||||
"agent.imageModelFallbacks was replaced by agents.defaults.imageModel.fallbacks (auto-migrated on load).",
|
||||
},
|
||||
{
|
||||
path: ["messages", "tts", "enabled"],
|
||||
message: "messages.tts.enabled was replaced by messages.tts.auto (auto-migrated on load).",
|
||||
},
|
||||
{
|
||||
path: ["gateway", "token"],
|
||||
message: "gateway.token is ignored; use gateway.auth.token instead (auto-migrated on load).",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
|
||||
import { applyModelDefaults } from "./defaults.js";
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
|
||||
@@ -55,4 +56,28 @@ describe("applyModelDefaults", () => {
|
||||
"gemini-flash",
|
||||
);
|
||||
});
|
||||
|
||||
it("fills missing model provider defaults", () => {
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
myproxy: {
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
apiKey: "sk-test",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "gpt-5.2", name: "GPT-5.2" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const next = applyModelDefaults(cfg);
|
||||
const model = next.models?.providers?.myproxy?.models?.[0];
|
||||
|
||||
expect(model?.reasoning).toBe(false);
|
||||
expect(model?.input).toEqual(["text"]);
|
||||
expect(model?.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
expect(model?.contextWindow).toBe(DEFAULT_CONTEXT_TOKENS);
|
||||
expect(model?.maxTokens).toBe(8192);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
import type { NormalizedChatType } from "../../channels/chat-type.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { DeliveryContext } from "../../utils/delivery-context.js";
|
||||
import type { TtsAutoMode } from "../types.tts.js";
|
||||
|
||||
export type SessionScope = "per-sender" | "global";
|
||||
|
||||
@@ -42,6 +43,7 @@ export type SessionEntry = {
|
||||
verboseLevel?: string;
|
||||
reasoningLevel?: string;
|
||||
elevatedLevel?: string;
|
||||
ttsAuto?: TtsAutoMode;
|
||||
execHost?: string;
|
||||
execSecurity?: string;
|
||||
execAsk?: string;
|
||||
|
||||
@@ -218,4 +218,10 @@ export type GatewayConfig = {
|
||||
tls?: GatewayTlsConfig;
|
||||
http?: GatewayHttpConfig;
|
||||
nodes?: GatewayNodesConfig;
|
||||
/**
|
||||
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
||||
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
|
||||
* `x-real-ip`) to determine the client IP for local pairing and HTTP checks.
|
||||
*/
|
||||
trustedProxies?: string[];
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
|
||||
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
|
||||
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
|
||||
|
||||
export type SignalAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
@@ -64,6 +65,19 @@ export type SignalAccountConfig = {
|
||||
reactionNotifications?: SignalReactionNotificationMode;
|
||||
/** Allowlist for reaction notifications when mode is allowlist. */
|
||||
reactionAllowlist?: Array<string | number>;
|
||||
/** Action toggles for message tool capabilities. */
|
||||
actions?: {
|
||||
/** Enable/disable sending reactions via message tool (default: true). */
|
||||
reactions?: boolean;
|
||||
};
|
||||
/**
|
||||
* Controls agent reaction behavior:
|
||||
* - "off": No reactions
|
||||
* - "ack": Only automatic ack reactions (👀 when processing)
|
||||
* - "minimal": Agent can react sparingly (default)
|
||||
* - "extensive": Agent can react liberally
|
||||
*/
|
||||
reactionLevel?: SignalReactionLevel;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
};
|
||||
|
||||
+20
-2
@@ -1,7 +1,9 @@
|
||||
export type TtsProvider = "elevenlabs" | "openai";
|
||||
export type TtsProvider = "elevenlabs" | "openai" | "edge";
|
||||
|
||||
export type TtsMode = "final" | "all";
|
||||
|
||||
export type TtsAutoMode = "off" | "always" | "inbound" | "tagged";
|
||||
|
||||
export type TtsModelOverrideConfig = {
|
||||
/** Enable model-provided overrides for TTS. */
|
||||
enabled?: boolean;
|
||||
@@ -22,7 +24,9 @@ export type TtsModelOverrideConfig = {
|
||||
};
|
||||
|
||||
export type TtsConfig = {
|
||||
/** Enable auto-TTS (can be overridden by local prefs). */
|
||||
/** Auto-TTS mode (preferred). */
|
||||
auto?: TtsAutoMode;
|
||||
/** Legacy: enable auto-TTS when `auto` is not set. */
|
||||
enabled?: boolean;
|
||||
/** Apply TTS to final replies only or to all replies (tool/block/final). */
|
||||
mode?: TtsMode;
|
||||
@@ -55,6 +59,20 @@ export type TtsConfig = {
|
||||
model?: string;
|
||||
voice?: string;
|
||||
};
|
||||
/** Microsoft Edge (node-edge-tts) configuration. */
|
||||
edge?: {
|
||||
/** Explicitly allow Edge TTS usage (no API key required). */
|
||||
enabled?: boolean;
|
||||
voice?: string;
|
||||
lang?: string;
|
||||
outputFormat?: string;
|
||||
pitch?: string;
|
||||
rate?: string;
|
||||
volume?: string;
|
||||
saveSubtitles?: boolean;
|
||||
proxy?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
/** Optional path for local TTS user preferences JSON. */
|
||||
prefsPath?: string;
|
||||
/** Hard cap for text sent to TTS (chars). */
|
||||
|
||||
@@ -28,18 +28,19 @@ export const ModelDefinitionSchema = z
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
api: ModelApiSchema.optional(),
|
||||
reasoning: z.boolean(),
|
||||
input: z.array(z.union([z.literal("text"), z.literal("image")])),
|
||||
reasoning: z.boolean().optional(),
|
||||
input: z.array(z.union([z.literal("text"), z.literal("image")])).optional(),
|
||||
cost: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cacheRead: z.number(),
|
||||
cacheWrite: z.number(),
|
||||
input: z.number().optional(),
|
||||
output: z.number().optional(),
|
||||
cacheRead: z.number().optional(),
|
||||
cacheWrite: z.number().optional(),
|
||||
})
|
||||
.strict(),
|
||||
contextWindow: z.number().positive(),
|
||||
maxTokens: z.number().positive(),
|
||||
.strict()
|
||||
.optional(),
|
||||
contextWindow: z.number().positive().optional(),
|
||||
maxTokens: z.number().positive().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
compat: ModelCompatSchema,
|
||||
})
|
||||
@@ -155,10 +156,12 @@ export const MarkdownConfigSchema = z
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const TtsProviderSchema = z.enum(["elevenlabs", "openai"]);
|
||||
export const TtsProviderSchema = z.enum(["elevenlabs", "openai", "edge"]);
|
||||
export const TtsModeSchema = z.enum(["final", "all"]);
|
||||
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
|
||||
export const TtsConfigSchema = z
|
||||
.object({
|
||||
auto: TtsAutoSchema.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
mode: TtsModeSchema.optional(),
|
||||
provider: TtsProviderSchema.optional(),
|
||||
@@ -206,6 +209,21 @@ export const TtsConfigSchema = z
|
||||
})
|
||||
.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(),
|
||||
|
||||
@@ -499,6 +499,13 @@ export const SignalAccountSchemaBase = z
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
})
|
||||
.strict();
|
||||
@@ -633,6 +640,7 @@ export const BlueBubblesAccountSchemaBase = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
|
||||
@@ -324,6 +324,7 @@ export const ClawdbotSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
trustedProxies: z.array(z.string()).optional(),
|
||||
tailscale: z
|
||||
.object({
|
||||
mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChannelType } from "@buape/carbon";
|
||||
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
|
||||
import {
|
||||
removeAckReactionAfterReply,
|
||||
@@ -129,6 +130,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
senderDisplay && senderTag && senderDisplay !== senderTag
|
||||
? `${senderDisplay} (${senderTag})`
|
||||
: (senderDisplay ?? senderTag ?? author.id);
|
||||
const isForumParent =
|
||||
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
|
||||
const forumParentSlug =
|
||||
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||
const threadChannelId = threadChannel?.id;
|
||||
const isForumStarter =
|
||||
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
|
||||
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
|
||||
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
|
||||
const groupSubject = isDirectMessage ? undefined : groupChannel;
|
||||
const channelDescription = channelInfo?.topic?.trim();
|
||||
@@ -182,6 +191,9 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
if (replyContext) {
|
||||
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
||||
}
|
||||
if (forumContextLine) {
|
||||
combinedBody = `${combinedBody}\n${forumContextLine}`;
|
||||
}
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
|
||||
@@ -142,4 +142,19 @@ describe("gateway auth", () => {
|
||||
expect(res.method).toBe("tailscale");
|
||||
expect(res.user).toBe("peter");
|
||||
});
|
||||
|
||||
it("treats trusted proxy loopback clients as direct", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.2" },
|
||||
headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" },
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("none");
|
||||
});
|
||||
});
|
||||
+25
-5
@@ -1,6 +1,7 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||
import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js";
|
||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
||||
|
||||
export type ResolvedGatewayAuth = {
|
||||
@@ -53,9 +54,26 @@ function getHostName(hostHeader?: string): string {
|
||||
return name ?? "";
|
||||
}
|
||||
|
||||
function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
||||
function headerValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function resolveRequestClientIp(
|
||||
req?: IncomingMessage,
|
||||
trustedProxies?: string[],
|
||||
): string | undefined {
|
||||
if (!req) return undefined;
|
||||
return resolveGatewayClientIp({
|
||||
remoteAddr: req.socket?.remoteAddress ?? "",
|
||||
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
|
||||
realIp: headerValue(req.headers?.["x-real-ip"]),
|
||||
trustedProxies,
|
||||
});
|
||||
}
|
||||
|
||||
function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
|
||||
if (!req) return false;
|
||||
const clientIp = req.socket?.remoteAddress ?? "";
|
||||
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
|
||||
if (!isLoopbackAddress(clientIp)) return false;
|
||||
|
||||
const host = getHostName(req.headers?.host);
|
||||
@@ -68,7 +86,8 @@ function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
||||
req.headers?.["x-forwarded-host"],
|
||||
);
|
||||
|
||||
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
|
||||
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
|
||||
return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy);
|
||||
}
|
||||
|
||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
@@ -135,9 +154,10 @@ export async function authorizeGatewayConnect(params: {
|
||||
auth: ResolvedGatewayAuth;
|
||||
connectAuth?: ConnectAuth | null;
|
||||
req?: IncomingMessage;
|
||||
trustedProxies?: string[];
|
||||
}): Promise<GatewayAuthResult> {
|
||||
const { auth, connectAuth, req } = params;
|
||||
const localDirect = isLocalDirectRequest(req);
|
||||
const { auth, connectAuth, req, trustedProxies } = params;
|
||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||
|
||||
if (auth.allowTailscale && !localDirect) {
|
||||
const tailscaleUser = getTailscaleUser(req);
|
||||
|
||||
+37
-1
@@ -8,7 +8,7 @@ vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
|
||||
}));
|
||||
|
||||
import { isLocalGatewayAddress } from "./net.js";
|
||||
import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
|
||||
|
||||
describe("gateway net", () => {
|
||||
beforeEach(() => {
|
||||
@@ -38,4 +38,40 @@ describe("gateway net", () => {
|
||||
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
|
||||
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
|
||||
});
|
||||
|
||||
test("uses forwarded-for when remote is a trusted proxy", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.2",
|
||||
forwardedFor: "203.0.113.9, 10.0.0.2",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(clientIp).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
test("ignores forwarded-for from untrusted proxies", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.3",
|
||||
forwardedFor: "203.0.113.9",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(clientIp).toBe("10.0.0.3");
|
||||
});
|
||||
|
||||
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "::ffff:10.0.0.2",
|
||||
forwardedFor: "203.0.113.9:1234",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(clientIp).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
test("falls back to x-real-ip when forwarded-for is missing", () => {
|
||||
const clientIp = resolveGatewayClientIp({
|
||||
remoteAddr: "10.0.0.2",
|
||||
realIp: "203.0.113.10",
|
||||
trustedProxies: ["10.0.0.2"],
|
||||
});
|
||||
expect(clientIp).toBe("203.0.113.10");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,56 @@ function normalizeIPv4MappedAddress(ip: string): string {
|
||||
return ip;
|
||||
}
|
||||
|
||||
function normalizeIp(ip: string | undefined): string | undefined {
|
||||
const trimmed = ip?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return normalizeIPv4MappedAddress(trimmed.toLowerCase());
|
||||
}
|
||||
|
||||
function stripOptionalPort(ip: string): string {
|
||||
if (ip.startsWith("[")) {
|
||||
const end = ip.indexOf("]");
|
||||
if (end !== -1) return ip.slice(1, end);
|
||||
}
|
||||
if (net.isIP(ip)) return ip;
|
||||
const lastColon = ip.lastIndexOf(":");
|
||||
if (lastColon > -1 && ip.includes(".") && ip.indexOf(":") === lastColon) {
|
||||
const candidate = ip.slice(0, lastColon);
|
||||
if (net.isIP(candidate) === 4) return candidate;
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
|
||||
const raw = forwardedFor?.split(",")[0]?.trim();
|
||||
if (!raw) return undefined;
|
||||
return normalizeIp(stripOptionalPort(raw));
|
||||
}
|
||||
|
||||
function parseRealIp(realIp?: string): string | undefined {
|
||||
const raw = realIp?.trim();
|
||||
if (!raw) return undefined;
|
||||
return normalizeIp(stripOptionalPort(raw));
|
||||
}
|
||||
|
||||
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized || !trustedProxies || trustedProxies.length === 0) return false;
|
||||
return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized);
|
||||
}
|
||||
|
||||
export function resolveGatewayClientIp(params: {
|
||||
remoteAddr?: string;
|
||||
forwardedFor?: string;
|
||||
realIp?: string;
|
||||
trustedProxies?: string[];
|
||||
}): string | undefined {
|
||||
const remote = normalizeIp(params.remoteAddr);
|
||||
if (!remote) return undefined;
|
||||
if (!isTrustedProxyAddress(remote, params.trustedProxies)) return remote;
|
||||
return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote;
|
||||
}
|
||||
|
||||
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
||||
if (isLoopbackAddress(ip)) return true;
|
||||
if (!ip) return false;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getBearerToken, resolveAgentIdForRequest, resolveSessionKey } from "./h
|
||||
type OpenAiHttpOptions = {
|
||||
auth: ResolvedGatewayAuth;
|
||||
maxBodyBytes?: number;
|
||||
trustedProxies?: string[];
|
||||
};
|
||||
|
||||
type OpenAiChatMessage = {
|
||||
@@ -168,6 +169,7 @@ export async function handleOpenAiHttpRequest(
|
||||
auth: opts.auth,
|
||||
connectAuth: { token, password: token },
|
||||
req,
|
||||
trustedProxies: opts.trustedProxies,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
sendUnauthorized(res);
|
||||
|
||||
@@ -60,6 +60,7 @@ type OpenResponsesHttpOptions = {
|
||||
auth: ResolvedGatewayAuth;
|
||||
maxBodyBytes?: number;
|
||||
config?: GatewayHttpResponsesConfig;
|
||||
trustedProxies?: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_BODY_BYTES = 20 * 1024 * 1024;
|
||||
@@ -331,6 +332,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||
auth: opts.auth,
|
||||
connectAuth: { token, password: token },
|
||||
req,
|
||||
trustedProxies: opts.trustedProxies,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
sendUnauthorized(res);
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function startGatewayDiscovery(params: {
|
||||
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
|
||||
canvasPort?: number;
|
||||
wideAreaDiscoveryEnabled: boolean;
|
||||
tailscaleMode: "off" | "serve" | "funnel";
|
||||
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
}) {
|
||||
let bonjourStop: (() => Promise<void>) | null = null;
|
||||
@@ -20,8 +21,11 @@ export async function startGatewayDiscovery(params: {
|
||||
process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" &&
|
||||
process.env.NODE_ENV !== "test" &&
|
||||
!process.env.VITEST;
|
||||
const tailscaleEnabled = params.tailscaleMode !== "off";
|
||||
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
|
||||
const tailnetDns = needsTailnetDns ? await resolveTailnetDnsHint() : undefined;
|
||||
const tailnetDns = needsTailnetDns
|
||||
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
|
||||
: undefined;
|
||||
const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim();
|
||||
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
|
||||
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const getTailnetHostname = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
|
||||
|
||||
import { resolveTailnetDnsHint } from "./server-discovery.js";
|
||||
|
||||
describe("resolveTailnetDnsHint", () => {
|
||||
const prevTailnetDns = { value: undefined as string | undefined };
|
||||
|
||||
beforeEach(() => {
|
||||
prevTailnetDns.value = process.env.CLAWDBOT_TAILNET_DNS;
|
||||
delete process.env.CLAWDBOT_TAILNET_DNS;
|
||||
getTailnetHostname.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevTailnetDns.value === undefined) {
|
||||
delete process.env.CLAWDBOT_TAILNET_DNS;
|
||||
} else {
|
||||
process.env.CLAWDBOT_TAILNET_DNS = prevTailnetDns.value;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns env hint when disabled", async () => {
|
||||
process.env.CLAWDBOT_TAILNET_DNS = "studio.tailnet.ts.net.";
|
||||
const value = await resolveTailnetDnsHint({ enabled: false });
|
||||
expect(value).toBe("studio.tailnet.ts.net");
|
||||
expect(getTailnetHostname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips tailscale lookup when disabled", async () => {
|
||||
const value = await resolveTailnetDnsHint({ enabled: false });
|
||||
expect(value).toBeUndefined();
|
||||
expect(getTailnetHostname).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses tailscale lookup when enabled", async () => {
|
||||
getTailnetHostname.mockResolvedValue("host.tailnet.ts.net");
|
||||
const value = await resolveTailnetDnsHint({ enabled: true });
|
||||
expect(value).toBe("host.tailnet.ts.net");
|
||||
expect(getTailnetHostname).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -55,11 +55,13 @@ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}):
|
||||
export async function resolveTailnetDnsHint(opts?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
exec?: typeof runExec;
|
||||
enabled?: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
const env = opts?.env ?? process.env;
|
||||
const envRaw = env.CLAWDBOT_TAILNET_DNS?.trim();
|
||||
const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : "";
|
||||
if (envValue) return envValue;
|
||||
if (opts?.enabled === false) return undefined;
|
||||
|
||||
const exec =
|
||||
opts?.exec ??
|
||||
|
||||
@@ -227,21 +227,36 @@ export function createGatewayHttpServer(opts: {
|
||||
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
|
||||
|
||||
try {
|
||||
const configSnapshot = loadConfig();
|
||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||
if (await handleHooksRequest(req, res)) return;
|
||||
if (await handleSlackHttpRequest(req, res)) return;
|
||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
||||
if (await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth })) return;
|
||||
if (
|
||||
await handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
})
|
||||
)
|
||||
return;
|
||||
if (openResponsesEnabled) {
|
||||
if (
|
||||
await handleOpenResponsesHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
config: openResponsesConfig,
|
||||
trustedProxies,
|
||||
})
|
||||
)
|
||||
return;
|
||||
}
|
||||
if (openAiChatCompletionsEnabled) {
|
||||
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
|
||||
if (
|
||||
await handleOpenAiHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
})
|
||||
)
|
||||
return;
|
||||
}
|
||||
if (canvasHost) {
|
||||
if (await handleA2uiHttpRequest(req, res)) return;
|
||||
@@ -251,14 +266,14 @@ export function createGatewayHttpServer(opts: {
|
||||
if (
|
||||
handleControlUiAvatarRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId),
|
||||
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
|
||||
})
|
||||
)
|
||||
return;
|
||||
if (
|
||||
handleControlUiHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
config: loadConfig(),
|
||||
config: configSnapshot,
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
OPENAI_TTS_VOICES,
|
||||
getTtsProvider,
|
||||
isTtsEnabled,
|
||||
isTtsProviderConfigured,
|
||||
resolveTtsAutoMode,
|
||||
resolveTtsApiKey,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
resolveTtsProviderOrder,
|
||||
setTtsEnabled,
|
||||
setTtsProvider,
|
||||
textToSpeech,
|
||||
@@ -22,13 +25,20 @@ export const ttsHandlers: GatewayRequestHandlers = {
|
||||
const config = resolveTtsConfig(cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
const provider = getTtsProvider(config, prefsPath);
|
||||
const autoMode = resolveTtsAutoMode({ config, prefsPath });
|
||||
const fallbackProviders = resolveTtsProviderOrder(provider)
|
||||
.slice(1)
|
||||
.filter((candidate) => isTtsProviderConfigured(config, candidate));
|
||||
respond(true, {
|
||||
enabled: isTtsEnabled(config, prefsPath),
|
||||
auto: autoMode,
|
||||
provider,
|
||||
fallbackProvider: provider === "openai" ? "elevenlabs" : "openai",
|
||||
fallbackProvider: fallbackProviders[0] ?? null,
|
||||
fallbackProviders,
|
||||
prefsPath,
|
||||
hasOpenAIKey: Boolean(resolveTtsApiKey(config, "openai")),
|
||||
hasElevenLabsKey: Boolean(resolveTtsApiKey(config, "elevenlabs")),
|
||||
edgeEnabled: isTtsProviderConfigured(config, "edge"),
|
||||
});
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
@@ -90,11 +100,14 @@ export const ttsHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
"tts.setProvider": async ({ params, respond }) => {
|
||||
const provider = typeof params.provider === "string" ? params.provider.trim() : "";
|
||||
if (provider !== "openai" && provider !== "elevenlabs") {
|
||||
if (provider !== "openai" && provider !== "elevenlabs" && provider !== "edge") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "Invalid provider. Use openai or elevenlabs."),
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"Invalid provider. Use openai, elevenlabs, or edge.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -128,6 +141,12 @@ export const ttsHandlers: GatewayRequestHandlers = {
|
||||
configured: Boolean(resolveTtsApiKey(config, "elevenlabs")),
|
||||
models: ["eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_monolingual_v1"],
|
||||
},
|
||||
{
|
||||
id: "edge",
|
||||
name: "Edge TTS",
|
||||
configured: isTtsProviderConfigured(config, "edge"),
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
active: getTtsProvider(config, prefsPath),
|
||||
});
|
||||
|
||||
@@ -339,6 +339,7 @@ export async function startGatewayServer(
|
||||
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
|
||||
: undefined,
|
||||
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
|
||||
tailscaleMode,
|
||||
logDiscovery,
|
||||
});
|
||||
bonjourStop = discovery.bonjourStop;
|
||||
|
||||
@@ -73,6 +73,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
const requestOrigin = headerValue(upgradeReq.headers.origin);
|
||||
const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
|
||||
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
|
||||
const realIp = headerValue(upgradeReq.headers["x-real-ip"]);
|
||||
|
||||
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
|
||||
const canvasHostOverride =
|
||||
@@ -228,6 +229,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
connId,
|
||||
remoteAddr,
|
||||
forwardedFor,
|
||||
realIp,
|
||||
requestHost,
|
||||
requestOrigin,
|
||||
requestUserAgent,
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { authorizeGatewayConnect } from "../../auth.js";
|
||||
import { loadConfig } from "../../../config/config.js";
|
||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||
import { isLocalGatewayAddress } from "../../net.js";
|
||||
import { isLocalGatewayAddress, resolveGatewayClientIp } from "../../net.js";
|
||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
@@ -104,6 +104,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connId: string;
|
||||
remoteAddr?: string;
|
||||
forwardedFor?: string;
|
||||
realIp?: string;
|
||||
requestHost?: string;
|
||||
requestOrigin?: string;
|
||||
requestUserAgent?: string;
|
||||
@@ -133,6 +134,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connId,
|
||||
remoteAddr,
|
||||
forwardedFor,
|
||||
realIp,
|
||||
requestHost,
|
||||
requestOrigin,
|
||||
requestUserAgent,
|
||||
@@ -157,6 +159,11 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
logWsControl,
|
||||
} = params;
|
||||
|
||||
const configSnapshot = loadConfig();
|
||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||
const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies });
|
||||
const isLocalClient = isLocalGatewayAddress(clientIp);
|
||||
|
||||
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
||||
|
||||
socket.on("message", async (data) => {
|
||||
@@ -300,7 +307,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
if (!device) {
|
||||
const allowInsecureControlUi =
|
||||
isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
|
||||
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
||||
const canSkipDevice =
|
||||
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
|
||||
|
||||
@@ -380,7 +387,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
close(1008, "device signature expired");
|
||||
return;
|
||||
}
|
||||
const nonceRequired = !isLocalGatewayAddress(remoteAddr);
|
||||
const nonceRequired = !isLocalClient;
|
||||
const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
|
||||
if (nonceRequired && !providedNonce) {
|
||||
setHandshakeState("failed");
|
||||
@@ -495,6 +502,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
auth: resolvedAuth,
|
||||
connectAuth: connectParams.auth,
|
||||
req: upgradeReq,
|
||||
trustedProxies,
|
||||
});
|
||||
let authOk = authResult.ok;
|
||||
let authMethod = authResult.method ?? "none";
|
||||
@@ -556,8 +564,8 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
clientMode: connectParams.client.mode,
|
||||
role,
|
||||
scopes,
|
||||
remoteIp: remoteAddr,
|
||||
silent: isLocalGatewayAddress(remoteAddr),
|
||||
remoteIp: clientIp,
|
||||
silent: isLocalClient,
|
||||
});
|
||||
const context = buildRequestContext();
|
||||
if (pairing.request.silent === true) {
|
||||
@@ -640,7 +648,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
clientMode: connectParams.client.mode,
|
||||
role,
|
||||
scopes,
|
||||
remoteIp: remoteAddr,
|
||||
remoteIp: clientIp,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -689,7 +697,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
if (presenceKey) {
|
||||
upsertPresence(presenceKey, {
|
||||
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
|
||||
ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr,
|
||||
ip: isLocalClient ? undefined : clientIp,
|
||||
version: connectParams.client.version,
|
||||
platform: connectParams.client.platform,
|
||||
deviceFamily: connectParams.client.deviceFamily,
|
||||
@@ -748,7 +756,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
setHandshakeState("connected");
|
||||
if (role === "node") {
|
||||
const context = buildRequestContext();
|
||||
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
|
||||
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: clientIp });
|
||||
const instanceIdRaw = connectParams.client.instanceId;
|
||||
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
|
||||
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
|
||||
|
||||
@@ -70,7 +70,7 @@ function mergeActionIntoArgsIfSupported(params: {
|
||||
export async function handleToolsInvokeHttpRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number },
|
||||
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[] },
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
if (url.pathname !== "/tools/invoke") return false;
|
||||
@@ -80,11 +80,13 @@ export async function handleToolsInvokeHttpRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const token = getBearerToken(req);
|
||||
const authResult = await authorizeGatewayConnect({
|
||||
auth: opts.auth,
|
||||
connectAuth: token ? { token, password: token } : null,
|
||||
req,
|
||||
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
sendUnauthorized(res);
|
||||
@@ -110,7 +112,6 @@ export async function handleToolsInvokeHttpRequest(
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const cfg = loadConfig();
|
||||
const rawSessionKey = resolveSessionKeyFromBody(body);
|
||||
const sessionKey =
|
||||
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;
|
||||
|
||||
@@ -212,7 +212,8 @@ export async function deliverOutboundPayloads(params: {
|
||||
results.push(await handler.sendText(text));
|
||||
return;
|
||||
}
|
||||
for (const chunk of handler.chunker(text, textLimit)) {
|
||||
const chunks = handler.chunker(text, textLimit);
|
||||
for (const chunk of chunks) {
|
||||
throwIfAborted(abortSignal);
|
||||
results.push(await handler.sendText(chunk));
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import { runLinkUnderstanding } from "./runner.js";
|
||||
|
||||
vi.mock("./runner.js", () => ({
|
||||
runLinkUnderstanding: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("applyLinkUnderstanding", () => {
|
||||
it("keeps command parsing bodies unchanged when link output is applied", async () => {
|
||||
const mockedRunLinkUnderstanding = vi.mocked(runLinkUnderstanding);
|
||||
mockedRunLinkUnderstanding.mockResolvedValue({
|
||||
urls: ["https://example.com"],
|
||||
outputs: [{ url: "https://example.com", text: "Summary", source: "link-cli" }],
|
||||
decisions: [],
|
||||
});
|
||||
|
||||
const { applyLinkUnderstanding } = await import("./apply.js");
|
||||
const ctx: MsgContext = {
|
||||
Body: "check https://example.com",
|
||||
RawBody: "raw override",
|
||||
CommandBody: "/think low check https://example.com",
|
||||
};
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: { links: { models: [{ command: "link-cli" }] } },
|
||||
};
|
||||
|
||||
await applyLinkUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Body).toContain("[Link]");
|
||||
expect(ctx.CommandBody).toBe("/think low check https://example.com");
|
||||
expect(ctx.RawBody).toBe("raw override");
|
||||
expect(ctx.BodyForCommands).toBe("/think low check https://example.com");
|
||||
expect(ctx.BodyForCommands).not.toContain("[Link]");
|
||||
});
|
||||
|
||||
it("preserves original body for command parsing when no overrides exist", async () => {
|
||||
const mockedRunLinkUnderstanding = vi.mocked(runLinkUnderstanding);
|
||||
mockedRunLinkUnderstanding.mockResolvedValue({
|
||||
urls: ["https://example.com"],
|
||||
outputs: [{ url: "https://example.com", text: "Summary", source: "link-cli" }],
|
||||
decisions: [],
|
||||
});
|
||||
|
||||
const { applyLinkUnderstanding } = await import("./apply.js");
|
||||
const ctx: MsgContext = {
|
||||
Body: "check https://example.com",
|
||||
};
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: { links: { models: [{ command: "link-cli" }] } },
|
||||
};
|
||||
|
||||
await applyLinkUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Body).toContain("[Link]");
|
||||
expect(ctx.RawBody).toBe("check https://example.com");
|
||||
expect(ctx.BodyForCommands).toBe("check https://example.com");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||
import { formatLinkUnderstandingBody, formatLinkUnderstandingSections } from "./format.js";
|
||||
import { formatLinkUnderstandingBody } from "./format.js";
|
||||
import { runLinkUnderstanding } from "./runner.js";
|
||||
import type { LinkUnderstandingDecision, LinkUnderstandingOutput } from "./types.js";
|
||||
|
||||
export type ApplyLinkUnderstandingResult = {
|
||||
outputs: LinkUnderstandingOutput[];
|
||||
outputs: string[];
|
||||
urls: string[];
|
||||
decisions: LinkUnderstandingDecision[];
|
||||
};
|
||||
|
||||
export async function applyLinkUnderstanding(params: {
|
||||
@@ -20,29 +18,16 @@ export async function applyLinkUnderstanding(params: {
|
||||
ctx: params.ctx,
|
||||
});
|
||||
|
||||
if (result.decisions.length > 0) {
|
||||
params.ctx.LinkUnderstandingDecisions = [
|
||||
...(params.ctx.LinkUnderstandingDecisions ?? []),
|
||||
...result.decisions,
|
||||
];
|
||||
}
|
||||
|
||||
if (result.outputs.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const originalBody = params.ctx.Body;
|
||||
const sections = formatLinkUnderstandingSections(result.outputs);
|
||||
if (sections.length > 0) {
|
||||
params.ctx.LinkUnderstanding = [...(params.ctx.LinkUnderstanding ?? []), ...sections];
|
||||
}
|
||||
params.ctx.LinkUnderstanding = [...(params.ctx.LinkUnderstanding ?? []), ...result.outputs];
|
||||
params.ctx.Body = formatLinkUnderstandingBody({
|
||||
body: params.ctx.Body,
|
||||
outputs: result.outputs,
|
||||
});
|
||||
if (!params.ctx.CommandBody && !params.ctx.RawBody && typeof originalBody === "string") {
|
||||
params.ctx.RawBody = originalBody;
|
||||
}
|
||||
|
||||
finalizeInboundContext(params.ctx, {
|
||||
forceBodyForAgent: true,
|
||||
forceBodyForCommands: true,
|
||||
|
||||
@@ -20,10 +20,8 @@ describe("extractLinksFromMessage", () => {
|
||||
expect(links).toEqual(["https://bare.example"]);
|
||||
});
|
||||
|
||||
it("blocks loopback and private hosts", () => {
|
||||
const links = extractLinksFromMessage(
|
||||
"http://localhost/test http://127.0.0.1/test http://[::1]/test http://10.0.0.1/test https://ok.test",
|
||||
);
|
||||
it("blocks 127.0.0.1", () => {
|
||||
const links = extractLinksFromMessage("http://127.0.0.1/test https://ok.test");
|
||||
expect(links).toEqual(["https://ok.test"]);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user