Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19affcac90 | ||
|
|
92f95abdcf | ||
|
|
477fa49a30 | ||
|
|
36b96c2b28 | ||
|
|
2eb78b8da7 | ||
|
|
3110e37db4 | ||
|
|
93bb0257f0 | ||
|
|
197b8f7c3b | ||
|
|
deba1b6739 | ||
|
|
aab98a6d18 | ||
|
|
849a008f34 | ||
|
|
8791e46cf3 | ||
|
|
b779029517 | ||
|
|
9c039e8356 | ||
|
|
4aeba76741 | ||
|
|
d92a9e351e | ||
|
|
a1acd7dae8 | ||
|
|
67420e9a81 | ||
|
|
e4335ea094 | ||
|
|
0c632f4855 | ||
|
|
a322075764 | ||
|
|
359cb66e68 | ||
|
|
00370139a5 | ||
|
|
17422608b2 | ||
|
|
f871563f37 | ||
|
|
85549ac3b6 | ||
|
|
1bad96aa2b | ||
|
|
b0dcdc4982 | ||
|
|
13d39b8fb1 | ||
|
|
50d26d827e | ||
|
|
3fc9acc105 | ||
|
|
d58828ebd7 | ||
|
|
f90eea5195 | ||
|
|
3f40f4ab54 | ||
|
|
65a55b97e0 | ||
|
|
852f947b44 | ||
|
|
54ad1ead80 | ||
|
|
c6de1b1f7d | ||
|
|
f601dac30d | ||
|
|
2bbf2698cb | ||
|
|
f6097bc6e3 | ||
|
|
39e482414a | ||
|
|
d6933b074a | ||
|
|
bcdfe461d4 | ||
|
|
16ce76307e | ||
|
|
c37b4c18e0 | ||
|
|
2899a986a8 | ||
|
|
7a63b4995b | ||
|
|
7a36e6fcd9 | ||
|
|
77b19643e2 | ||
|
|
aa45f512f4 | ||
|
|
59dfe0337d | ||
|
|
4963432777 | ||
|
|
a737bfaab4 | ||
|
|
67c89e00c5 | ||
|
|
8f572ab361 | ||
|
|
435edaf997 | ||
|
|
c3c9dee65e | ||
|
|
946b32c842 | ||
|
|
4dd515b65f | ||
|
|
d9a9f6db7d | ||
|
|
a6f7ab499f | ||
|
|
3c7c819e9b | ||
|
|
58dd2e9514 | ||
|
|
424b7fe493 | ||
|
|
1657c5e3d2 | ||
|
|
2ceceb8c25 | ||
|
|
39be40cd23 | ||
|
|
0faa200924 | ||
|
|
ff605194ef | ||
|
|
78998dba9e | ||
|
|
0716a624a8 | ||
|
|
e005dcb8e7 | ||
|
|
3300fba57c | ||
|
|
fa3a768a3a | ||
|
|
da4f3211b8 | ||
|
|
d85f91d247 | ||
|
|
5dcf43d6ad | ||
|
|
50cecd8210 | ||
|
|
7dc8ea815e | ||
|
|
2110cac5d6 | ||
|
|
9eee832282 | ||
|
|
5d17b84e8a | ||
|
|
3fed0ac2e8 | ||
|
|
2694e59ba6 | ||
|
|
266fd748d0 | ||
|
|
564cc9359d | ||
|
|
ff46f8ce58 | ||
|
|
8e5153ba10 | ||
|
|
e2c6a96cd3 | ||
|
|
7200dabfb2 | ||
|
|
d923dc56ec | ||
|
|
5eb6b779f5 | ||
|
|
0928e3c866 | ||
|
|
734bb6b4fd | ||
|
|
a2ba7ddf90 | ||
|
|
64e656af82 | ||
|
|
a2d7632cf3 | ||
|
|
17665d1732 |
No files matched your search
+1
-1
@@ -48,4 +48,4 @@
|
||||
--allman false
|
||||
|
||||
# Exclusions
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
|
||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
||||
@@ -2,6 +2,60 @@
|
||||
|
||||
**Why this looks different:** the project was renamed from **Clawdis → Clawdbot**. To make the transition clear, releases now use **date-based versions** (`YYYY.M.D`) and the changelog is **compressed** into milestone summaries. Full detail still lives in git history and the docs.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||
|
||||
## 2026.1.5-3
|
||||
|
||||
### Fixes
|
||||
- NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid `ERR_MODULE_NOT_FOUND` in Node 25 npx installs.
|
||||
|
||||
## 2026.1.5-2
|
||||
|
||||
### Fixes
|
||||
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
|
||||
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/*.js` modules.
|
||||
|
||||
## 2026.1.5-1
|
||||
|
||||
### Fixes
|
||||
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
|
||||
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/index.js`.
|
||||
|
||||
## 2026.1.5
|
||||
|
||||
### Highlights
|
||||
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
|
||||
- Agent tools: new `image` tool routed to the image model (when configured).
|
||||
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
|
||||
- Docs: document built-in model shorthands + precedence (user config wins).
|
||||
|
||||
### Fixes
|
||||
- Control UI: render Markdown in tool result cards.
|
||||
- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts.
|
||||
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
|
||||
- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall
|
||||
- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame).
|
||||
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
|
||||
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
|
||||
- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files.
|
||||
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
|
||||
- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj
|
||||
- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT).
|
||||
- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176).
|
||||
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
|
||||
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
|
||||
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
|
||||
- Onboarding: when running from source, auto-build missing Control UI assets (`pnpm ui:build`).
|
||||
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
|
||||
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
|
||||
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
|
||||
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
|
||||
- Control UI: render Markdown in chat messages (sanitized).
|
||||
|
||||
|
||||
## 2026.1.4
|
||||
|
||||
### Highlights
|
||||
@@ -10,6 +64,7 @@
|
||||
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
|
||||
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
|
||||
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
|
||||
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.
|
||||
|
||||
### Breaking
|
||||
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: 5c8eedd642...c1243a7978.
@@ -20,7 +20,7 @@ It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, iMe
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
Website: https://clawd.me · Docs: [`docs/index.md`](docs/index.md) · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Docker (optional): [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
||||
Website: https://clawd.me · Docs: https://docs.clawdbot.com/ · FAQ: [`docs/faq.md`](docs/faq.md) · Wizard: [`docs/wizard.md`](docs/wizard.md) · Nix: [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [`docs/docker.md`](docs/docker.md) · Discord: https://discord.gg/clawd
|
||||
|
||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||
|
||||
@@ -68,7 +68,7 @@ Using Claude Pro/Max subscription? See `docs/onboarding.md` for the Anthropic OA
|
||||
- Nix mode for declarative config; Docker-based installs.
|
||||
- Health, doctor migrations, structured logging, release tooling.
|
||||
|
||||
## Changes since 2.0.0-beta5 (2026-01-03)
|
||||
## Changes since 2026.1.4 (2026-01-04)
|
||||
|
||||
### Highlights
|
||||
- Project rename completed: CLIs, paths, bundle IDs, env vars, and docs unified on Clawdbot.
|
||||
@@ -111,6 +111,12 @@ Your surfaces
|
||||
└─ iOS node (Canvas + voice)
|
||||
```
|
||||
|
||||
## Skills registry (ClawdHub)
|
||||
|
||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
https://clawdhub.com
|
||||
|
||||
## Quick start (from source)
|
||||
|
||||
Runtime: **Node ≥22** + **pnpm**.
|
||||
@@ -214,6 +220,25 @@ Minimal `~/.clawdbot/clawdbot.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Env vars: loaded from `.env` in the current working directory, plus a global fallback at `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`) without overriding existing values.
|
||||
|
||||
Optional: import missing keys from your login shell env (sources your shell profile) via config or env var:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: {
|
||||
shellEnv: {
|
||||
enabled: true,
|
||||
timeoutMs: 15000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Env var: `CLAWDBOT_LOAD_SHELL_ENV=1`
|
||||
- Timeout override: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
|
||||
- Behavior: only imports known/expected keys, never overrides existing `process.env`.
|
||||
|
||||
### WhatsApp
|
||||
|
||||
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||
|
||||
+42
-198
@@ -1,212 +1,56 @@
|
||||
<?xml version="1.0" standalone="yes"?>
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<title>Clawdis</title>
|
||||
<item>
|
||||
<title>2.0.0-beta5</title>
|
||||
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
|
||||
<title>2026.1.5-3</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>2765</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
||||
<sparkle:version>3091</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2.0.0-beta5</h2>
|
||||
<h3>Fixed</h3>
|
||||
<ul>
|
||||
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
|
||||
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
|
||||
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
|
||||
<li>Tests: cover read tool image metadata + text output.</li>
|
||||
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Skills config schema moved under <code>skills.*</code>:</li>
|
||||
</ul>
|
||||
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
|
||||
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
|
||||
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
|
||||
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
|
||||
<ul>
|
||||
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
|
||||
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
|
||||
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
|
||||
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdbot doctor</code> to migrate).</li>
|
||||
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdbot doctor</code> to migrate).</li>
|
||||
</ul>
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</li>
|
||||
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
|
||||
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
|
||||
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
|
||||
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDBOT_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
|
||||
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
|
||||
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
|
||||
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
|
||||
<li>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
|
||||
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</li>
|
||||
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
|
||||
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
|
||||
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: allow agent-triggered reactions via <code>clawdbot_discord</code> when enabled, and surface message ids in context.</li>
|
||||
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
|
||||
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
|
||||
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
|
||||
<li>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
|
||||
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
|
||||
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
|
||||
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
|
||||
<li>CLI: add ASCII banner header to wizard entry points.</li>
|
||||
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
|
||||
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
|
||||
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
|
||||
<li>CLI: enhance <code>clawdbot tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
|
||||
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
|
||||
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
|
||||
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
||||
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
|
||||
<li>Tests: add a Docker-based onboarding E2E harness.</li>
|
||||
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
|
||||
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
|
||||
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
|
||||
</ul>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
|
||||
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
|
||||
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
|
||||
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
|
||||
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
|
||||
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
|
||||
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
|
||||
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
|
||||
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
|
||||
<li>Agent prompt: remove hardcoded user name in system prompt example.</li>
|
||||
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
|
||||
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
|
||||
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
|
||||
<li>Gateway CLI: read <code>CLAWDBOT_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
|
||||
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
|
||||
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
|
||||
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
|
||||
<li>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
|
||||
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
|
||||
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
|
||||
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
|
||||
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
|
||||
<li>Skills: switch imsg installer to brew tap formula.</li>
|
||||
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
|
||||
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
|
||||
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
|
||||
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
|
||||
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
|
||||
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
|
||||
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
|
||||
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
|
||||
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
|
||||
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
|
||||
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
|
||||
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
|
||||
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
|
||||
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
|
||||
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
|
||||
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
|
||||
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
|
||||
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
|
||||
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
|
||||
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
|
||||
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
|
||||
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
|
||||
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
|
||||
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
|
||||
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
|
||||
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
|
||||
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
|
||||
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
|
||||
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
|
||||
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
|
||||
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
|
||||
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
|
||||
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
|
||||
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
|
||||
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</li>
|
||||
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
|
||||
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
|
||||
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
|
||||
<li>Browser CLI: add <code>clawdbot browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
|
||||
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
|
||||
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
|
||||
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
|
||||
<li>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
|
||||
<li>macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.</li>
|
||||
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
|
||||
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
|
||||
<li>macOS Debug: add an icon for the App Logging submenu.</li>
|
||||
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
|
||||
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
|
||||
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
|
||||
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
|
||||
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
|
||||
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
|
||||
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
|
||||
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
|
||||
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
|
||||
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
|
||||
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
|
||||
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
|
||||
<li>Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).</li>
|
||||
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
|
||||
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
|
||||
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
|
||||
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
|
||||
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
|
||||
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
|
||||
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
|
||||
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
|
||||
<li>macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.</li>
|
||||
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
|
||||
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
|
||||
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
|
||||
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
|
||||
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
|
||||
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
|
||||
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
|
||||
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
|
||||
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
|
||||
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
|
||||
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
|
||||
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
|
||||
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
|
||||
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
|
||||
<li>macOS menu: device list now shows connected nodes only.</li>
|
||||
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
|
||||
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
|
||||
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
|
||||
<li>Canvas A2UI: polish the debug status HUD styling.</li>
|
||||
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
|
||||
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
|
||||
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
|
||||
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
|
||||
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
|
||||
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
|
||||
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
|
||||
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
|
||||
<li>iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.</li>
|
||||
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
|
||||
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
|
||||
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
|
||||
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdbot sessions dir.</li>
|
||||
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
|
||||
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2.0.0-beta5/Clawdbot-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-2</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3089</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
|
||||
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.5-1</title>
|
||||
<pubDate>Mon, 05 Jan 2026 03:32:13 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>3087</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.5-1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.5-1</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
|
||||
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/index.js</code>.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-1/Clawdbot-2026.1.5-1.zip" length="150253493" type="application/octet-stream" sparkle:edSignature="gmb6ubX9AobT8OjraFY2zrS7qch050lSWA0Wuwzr68/kcCySG+GXzxSqvFp2DHTAMH3OsvkiCqQtET8qFuv9DQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -48,6 +48,10 @@ android {
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -64,7 +68,7 @@ dependencies {
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.12.2")
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.webkit:webkit:1.15.0")
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
@@ -96,7 +100,8 @@ dependencies {
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.13.3")
|
||||
testImplementation("org.robolectric:robolectric:4.16")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
|
||||
@@ -98,14 +98,31 @@ class NodeForegroundService : Service() {
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val launchPending =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
1,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||
val stopPending =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
2,
|
||||
stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(launchPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
|
||||
@@ -515,7 +515,6 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
private fun hasBackgroundLocationPermission(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return true
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.clawdbot.android.node
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.CancellationSignal
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -64,6 +66,15 @@ class LocationCaptureManager(private val context: Context) {
|
||||
providers: List<String>,
|
||||
maxAgeMs: Long?,
|
||||
): Location? {
|
||||
val fineOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarseOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!fineOk && !coarseOk) {
|
||||
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val candidates =
|
||||
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
|
||||
@@ -72,12 +83,20 @@ class LocationCaptureManager(private val context: Context) {
|
||||
return freshest
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private suspend fun requestCurrent(
|
||||
manager: LocationManager,
|
||||
providers: List<String>,
|
||||
timeoutMs: Long,
|
||||
): Location {
|
||||
val fineOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val coarseOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!fineOk && !coarseOk) {
|
||||
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
|
||||
}
|
||||
val resolved =
|
||||
providers.firstOrNull { manager.isProviderEnabled(it) }
|
||||
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
|
||||
|
||||
@@ -133,7 +133,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
if (requestedMode != null) {
|
||||
viewModel.setLocationMode(requestedMode)
|
||||
if (requestedMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (requestedMode == LocationMode.Always) {
|
||||
val backgroundOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
@@ -191,7 +191,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (fineOk || coarseOk) {
|
||||
viewModel.setLocationMode(targetMode)
|
||||
if (targetMode == LocationMode.Always && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (targetMode == LocationMode.Always) {
|
||||
val backgroundOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@@ -28,9 +28,9 @@ import androidx.compose.ui.unit.dp
|
||||
fun StatusPill(
|
||||
bridge: BridgeState,
|
||||
voiceEnabled: Boolean,
|
||||
activity: StatusActivity? = null,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
activity: StatusActivity? = null,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.clawdbot.android
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Intent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class NodeForegroundServiceTest {
|
||||
@Test
|
||||
fun buildNotificationSetsLaunchIntent() {
|
||||
val service = Robolectric.buildService(NodeForegroundService::class.java).get()
|
||||
val notification = buildNotification(service)
|
||||
|
||||
val pendingIntent = notification.contentIntent
|
||||
assertNotNull(pendingIntent)
|
||||
|
||||
val savedIntent = Shadows.shadowOf(pendingIntent).savedIntent
|
||||
assertNotNull(savedIntent)
|
||||
assertEquals(MainActivity::class.java.name, savedIntent.component?.className)
|
||||
|
||||
val expectedFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
assertEquals(expectedFlags, savedIntent.flags and expectedFlags)
|
||||
}
|
||||
|
||||
private fun buildNotification(service: NodeForegroundService): Notification {
|
||||
val method =
|
||||
NodeForegroundService::class.java.getDeclaredMethod(
|
||||
"buildNotification",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
)
|
||||
method.isAccessible = true
|
||||
return method.invoke(service, "Title", "Text") as Notification
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
outPath: outPath)
|
||||
|
||||
let state = CaptureState()
|
||||
let recordQueue = DispatchQueue(label: "com.clawdis.screenrecord")
|
||||
let recordQueue = DispatchQueue(label: "com.clawdbot.screenrecord")
|
||||
|
||||
try await self.startCapture(state: state, config: config, recordQueue: recordQueue)
|
||||
try await Task.sleep(nanoseconds: UInt64(config.durationMs) * 1_000_000)
|
||||
|
||||
@@ -9,7 +9,7 @@ import SwiftUI
|
||||
final class AppState {
|
||||
private let isPreview: Bool
|
||||
private var isInitializing = true
|
||||
private nonisolated var configWatcher: ConfigFileWatcher?
|
||||
private var configWatcher: ConfigFileWatcher?
|
||||
private var suppressVoiceWakeGlobalSync = false
|
||||
private var voiceWakeGlobalSyncTask: Task<Void, Never>?
|
||||
|
||||
@@ -321,6 +321,7 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
deinit {
|
||||
self.configWatcher?.stop()
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import ClawdbotProtocol
|
||||
|
||||
// The generated gateway protocol models are value types, but they don't currently declare Sendable.
|
||||
// We use them across actors via GatewayConnection's event stream, so mark them as unchecked.
|
||||
extension HelloOk: @unchecked Sendable {}
|
||||
extension EventFrame: @unchecked Sendable {}
|
||||
@@ -151,6 +151,7 @@ struct GeneralSettings: View {
|
||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
||||
if status == .authorizedAlways {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
private struct UncheckedSendable<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -63,7 +60,7 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func withTimeout<T>(
|
||||
private func withTimeout<T: Sendable>(
|
||||
timeoutMs: Int,
|
||||
operation: @escaping () async throws -> T) async throws -> T
|
||||
{
|
||||
@@ -71,15 +68,38 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
return try await withThrowingTaskGroup(of: UncheckedSendable<T>.self) { group in
|
||||
group.addTask { try await UncheckedSendable(value: operation()) }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||
throw Error.timeout
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
var didFinish = false
|
||||
|
||||
func finish(returning value: T) {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
continuation.resume(returning: value)
|
||||
}
|
||||
|
||||
func finish(throwing error: Swift.Error) {
|
||||
guard !didFinish else { return }
|
||||
didFinish = true
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
|
||||
let timeoutItem = DispatchWorkItem {
|
||||
finish(throwing: Error.timeout)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + .milliseconds(timeoutMs),
|
||||
execute: timeoutItem)
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let value = try await operation()
|
||||
timeoutItem.cancel()
|
||||
finish(returning: value)
|
||||
} catch {
|
||||
timeoutItem.cancel()
|
||||
finish(throwing: error)
|
||||
}
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ extension OnboardingView {
|
||||
|
||||
var navigationBar: some View {
|
||||
let wizardLockIndex = self.wizardPageOrderIndex
|
||||
HStack(spacing: 20) {
|
||||
return HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: {}, label: {
|
||||
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -232,44 +232,52 @@ struct OnboardingWizardStepView: View {
|
||||
|
||||
private var selectOptions: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.optionItems) { item in
|
||||
Button {
|
||||
self.selectedIndex = item.index
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
ForEach(self.optionItems, id: \.index) { item in
|
||||
self.selectOptionRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var multiselectOptions: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.optionItems) { item in
|
||||
Toggle(isOn: self.bindingForOption(item)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(self.optionItems, id: \.index) { item in
|
||||
self.multiselectOptionRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectOptionRow(_ item: WizardOptionItem) -> some View {
|
||||
Button {
|
||||
self.selectedIndex = item.index
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func multiselectOptionRow(_ item: WizardOptionItem) -> some View {
|
||||
Toggle(isOn: self.bindingForOption(item)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
||||
|
||||
@@ -140,6 +140,7 @@ enum PermissionManager {
|
||||
private static func ensureLocation(interactive: Bool) async -> Bool {
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
switch status {
|
||||
// Note: macOS only supports authorizedAlways, not authorizedWhenInUse (iOS only)
|
||||
case .authorizedAlways:
|
||||
return true
|
||||
case .notDetermined:
|
||||
@@ -201,6 +202,7 @@ enum PermissionManager {
|
||||
|
||||
case .location:
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
// Note: macOS only supports authorizedAlways
|
||||
results[cap] = status == .authorizedAlways
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,7 +588,9 @@ actor TalkModeRuntime {
|
||||
let stream = client.streamSynthesize(voiceId: voiceId, request: request)
|
||||
guard self.isCurrent(input.generation) else { return }
|
||||
|
||||
if self.interruptOnSpeech, ! await self.prepareForPlayback(generation: input.generation) { return }
|
||||
if self.interruptOnSpeech {
|
||||
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||
}
|
||||
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
@@ -643,7 +645,9 @@ actor TalkModeRuntime {
|
||||
|
||||
private func playSystemVoice(input: TalkPlaybackInput) async throws {
|
||||
self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)")
|
||||
if self.interruptOnSpeech, ! await self.prepareForPlayback(generation: input.generation) { return }
|
||||
if self.interruptOnSpeech {
|
||||
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||
}
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
await TalkSystemSpeechSynthesizer.shared.stop()
|
||||
@@ -727,7 +731,6 @@ actor TalkModeRuntime {
|
||||
}
|
||||
|
||||
extension TalkModeRuntime {
|
||||
|
||||
// MARK: - Audio playback (MainActor helpers)
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -3,14 +3,14 @@ import Foundation
|
||||
|
||||
public let GATEWAY_PROTOCOL_VERSION = 2
|
||||
|
||||
public enum ErrorCode: String, Codable {
|
||||
public enum ErrorCode: String, Codable, Sendable {
|
||||
case notLinked = "NOT_LINKED"
|
||||
case agentTimeout = "AGENT_TIMEOUT"
|
||||
case invalidRequest = "INVALID_REQUEST"
|
||||
case unavailable = "UNAVAILABLE"
|
||||
}
|
||||
|
||||
public struct ConnectParams: Codable {
|
||||
public struct ConnectParams: Codable, Sendable {
|
||||
public let minprotocol: Int
|
||||
public let maxprotocol: Int
|
||||
public let client: [String: AnyCodable]
|
||||
@@ -47,7 +47,7 @@ public struct ConnectParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct HelloOk: Codable {
|
||||
public struct HelloOk: Codable, Sendable {
|
||||
public let type: String
|
||||
public let _protocol: Int
|
||||
public let server: [String: AnyCodable]
|
||||
@@ -84,7 +84,7 @@ public struct HelloOk: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct RequestFrame: Codable {
|
||||
public struct RequestFrame: Codable, Sendable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let method: String
|
||||
@@ -109,7 +109,7 @@ public struct RequestFrame: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ResponseFrame: Codable {
|
||||
public struct ResponseFrame: Codable, Sendable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let ok: Bool
|
||||
@@ -138,7 +138,7 @@ public struct ResponseFrame: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct EventFrame: Codable {
|
||||
public struct EventFrame: Codable, Sendable {
|
||||
public let type: String
|
||||
public let event: String
|
||||
public let payload: AnyCodable?
|
||||
@@ -167,7 +167,7 @@ public struct EventFrame: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PresenceEntry: Codable {
|
||||
public struct PresenceEntry: Codable, Sendable {
|
||||
public let host: String?
|
||||
public let ip: String?
|
||||
public let version: String?
|
||||
@@ -228,7 +228,7 @@ public struct PresenceEntry: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct StateVersion: Codable {
|
||||
public struct StateVersion: Codable, Sendable {
|
||||
public let presence: Int
|
||||
public let health: Int
|
||||
|
||||
@@ -245,7 +245,7 @@ public struct StateVersion: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct Snapshot: Codable {
|
||||
public struct Snapshot: Codable, Sendable {
|
||||
public let presence: [PresenceEntry]
|
||||
public let health: AnyCodable
|
||||
public let stateversion: StateVersion
|
||||
@@ -278,7 +278,7 @@ public struct Snapshot: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ErrorShape: Codable {
|
||||
public struct ErrorShape: Codable, Sendable {
|
||||
public let code: String
|
||||
public let message: String
|
||||
public let details: AnyCodable?
|
||||
@@ -307,7 +307,7 @@ public struct ErrorShape: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentEvent: Codable {
|
||||
public struct AgentEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let seq: Int
|
||||
public let stream: String
|
||||
@@ -336,7 +336,7 @@ public struct AgentEvent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SendParams: Codable {
|
||||
public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
@@ -369,7 +369,7 @@ public struct SendParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentParams: Codable {
|
||||
public struct AgentParams: Codable, Sendable {
|
||||
public let message: String
|
||||
public let to: String?
|
||||
public let sessionid: String?
|
||||
@@ -422,7 +422,7 @@ public struct AgentParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentWaitParams: Codable {
|
||||
public struct AgentWaitParams: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let afterms: Int?
|
||||
public let timeoutms: Int?
|
||||
@@ -443,7 +443,7 @@ public struct AgentWaitParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeParams: Codable {
|
||||
public struct WakeParams: Codable, Sendable {
|
||||
public let mode: AnyCodable
|
||||
public let text: String
|
||||
|
||||
@@ -460,7 +460,7 @@ public struct WakeParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRequestParams: Codable {
|
||||
public struct NodePairRequestParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
@@ -509,10 +509,10 @@ public struct NodePairRequestParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairListParams: Codable {
|
||||
public struct NodePairListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct NodePairApproveParams: Codable {
|
||||
public struct NodePairApproveParams: Codable, Sendable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
@@ -525,7 +525,7 @@ public struct NodePairApproveParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRejectParams: Codable {
|
||||
public struct NodePairRejectParams: Codable, Sendable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
@@ -538,7 +538,7 @@ public struct NodePairRejectParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairVerifyParams: Codable {
|
||||
public struct NodePairVerifyParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let token: String
|
||||
|
||||
@@ -555,7 +555,7 @@ public struct NodePairVerifyParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeRenameParams: Codable {
|
||||
public struct NodeRenameParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let displayname: String
|
||||
|
||||
@@ -572,10 +572,10 @@ public struct NodeRenameParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeListParams: Codable {
|
||||
public struct NodeListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct NodeDescribeParams: Codable {
|
||||
public struct NodeDescribeParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
|
||||
public init(
|
||||
@@ -588,7 +588,7 @@ public struct NodeDescribeParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeParams: Codable {
|
||||
public struct NodeInvokeParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let command: String
|
||||
public let params: AnyCodable?
|
||||
@@ -617,7 +617,7 @@ public struct NodeInvokeParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsListParams: Codable {
|
||||
public struct SessionsListParams: Codable, Sendable {
|
||||
public let limit: Int?
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
@@ -642,7 +642,7 @@ public struct SessionsListParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable {
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
@@ -679,7 +679,7 @@ public struct SessionsPatchParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsResetParams: Codable {
|
||||
public struct SessionsResetParams: Codable, Sendable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
@@ -692,7 +692,7 @@ public struct SessionsResetParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsDeleteParams: Codable {
|
||||
public struct SessionsDeleteParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let deletetranscript: Bool?
|
||||
|
||||
@@ -709,7 +709,7 @@ public struct SessionsDeleteParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactParams: Codable {
|
||||
public struct SessionsCompactParams: Codable, Sendable {
|
||||
public let key: String
|
||||
public let maxlines: Int?
|
||||
|
||||
@@ -726,10 +726,10 @@ public struct SessionsCompactParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigGetParams: Codable {
|
||||
public struct ConfigGetParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ConfigSetParams: Codable {
|
||||
public struct ConfigSetParams: Codable, Sendable {
|
||||
public let raw: String
|
||||
|
||||
public init(
|
||||
@@ -742,10 +742,10 @@ public struct ConfigSetParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigSchemaParams: Codable {
|
||||
public struct ConfigSchemaParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ConfigSchemaResponse: Codable {
|
||||
public struct ConfigSchemaResponse: Codable, Sendable {
|
||||
public let schema: AnyCodable
|
||||
public let uihints: [String: AnyCodable]
|
||||
public let version: String
|
||||
@@ -770,7 +770,7 @@ public struct ConfigSchemaResponse: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStartParams: Codable {
|
||||
public struct WizardStartParams: Codable, Sendable {
|
||||
public let mode: AnyCodable?
|
||||
public let workspace: String?
|
||||
|
||||
@@ -787,7 +787,7 @@ public struct WizardStartParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardNextParams: Codable {
|
||||
public struct WizardNextParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let answer: [String: AnyCodable]?
|
||||
|
||||
@@ -804,7 +804,7 @@ public struct WizardNextParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardCancelParams: Codable {
|
||||
public struct WizardCancelParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
@@ -817,7 +817,7 @@ public struct WizardCancelParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStatusParams: Codable {
|
||||
public struct WizardStatusParams: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
@@ -830,7 +830,7 @@ public struct WizardStatusParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStep: Codable {
|
||||
public struct WizardStep: Codable, Sendable {
|
||||
public let id: String
|
||||
public let type: AnyCodable
|
||||
public let title: String?
|
||||
@@ -875,7 +875,7 @@ public struct WizardStep: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardNextResult: Codable {
|
||||
public struct WizardNextResult: Codable, Sendable {
|
||||
public let done: Bool
|
||||
public let step: [String: AnyCodable]?
|
||||
public let status: AnyCodable?
|
||||
@@ -900,7 +900,7 @@ public struct WizardNextResult: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStartResult: Codable {
|
||||
public struct WizardStartResult: Codable, Sendable {
|
||||
public let sessionid: String
|
||||
public let done: Bool
|
||||
public let step: [String: AnyCodable]?
|
||||
@@ -929,7 +929,7 @@ public struct WizardStartResult: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStatusResult: Codable {
|
||||
public struct WizardStatusResult: Codable, Sendable {
|
||||
public let status: AnyCodable
|
||||
public let error: String?
|
||||
|
||||
@@ -946,7 +946,7 @@ public struct WizardStatusResult: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkModeParams: Codable {
|
||||
public struct TalkModeParams: Codable, Sendable {
|
||||
public let enabled: Bool
|
||||
public let phase: String?
|
||||
|
||||
@@ -963,7 +963,7 @@ public struct TalkModeParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProvidersStatusParams: Codable {
|
||||
public struct ProvidersStatusParams: Codable, Sendable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
@@ -980,7 +980,7 @@ public struct ProvidersStatusParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginStartParams: Codable {
|
||||
public struct WebLoginStartParams: Codable, Sendable {
|
||||
public let force: Bool?
|
||||
public let timeoutms: Int?
|
||||
public let verbose: Bool?
|
||||
@@ -1001,7 +1001,7 @@ public struct WebLoginStartParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginWaitParams: Codable {
|
||||
public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
@@ -1014,7 +1014,7 @@ public struct WebLoginWaitParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelChoice: Codable {
|
||||
public struct ModelChoice: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let provider: String
|
||||
@@ -1043,10 +1043,10 @@ public struct ModelChoice: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelsListParams: Codable {
|
||||
public struct ModelsListParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ModelsListResult: Codable {
|
||||
public struct ModelsListResult: Codable, Sendable {
|
||||
public let models: [ModelChoice]
|
||||
|
||||
public init(
|
||||
@@ -1059,10 +1059,10 @@ public struct ModelsListResult: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsStatusParams: Codable {
|
||||
public struct SkillsStatusParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct SkillsInstallParams: Codable {
|
||||
public struct SkillsInstallParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let installid: String
|
||||
public let timeoutms: Int?
|
||||
@@ -1083,7 +1083,7 @@ public struct SkillsInstallParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsUpdateParams: Codable {
|
||||
public struct SkillsUpdateParams: Codable, Sendable {
|
||||
public let skillkey: String
|
||||
public let enabled: Bool?
|
||||
public let apikey: String?
|
||||
@@ -1108,7 +1108,7 @@ public struct SkillsUpdateParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronJob: Codable {
|
||||
public struct CronJob: Codable, Sendable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let description: String?
|
||||
@@ -1165,7 +1165,7 @@ public struct CronJob: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronListParams: Codable {
|
||||
public struct CronListParams: Codable, Sendable {
|
||||
public let includedisabled: Bool?
|
||||
|
||||
public init(
|
||||
@@ -1178,10 +1178,10 @@ public struct CronListParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronStatusParams: Codable {
|
||||
public struct CronStatusParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct CronAddParams: Codable {
|
||||
public struct CronAddParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
@@ -1222,7 +1222,7 @@ public struct CronAddParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronUpdateParams: Codable {
|
||||
public struct CronUpdateParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let patch: [String: AnyCodable]
|
||||
|
||||
@@ -1239,7 +1239,7 @@ public struct CronUpdateParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRemoveParams: Codable {
|
||||
public struct CronRemoveParams: Codable, Sendable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
@@ -1252,7 +1252,7 @@ public struct CronRemoveParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunParams: Codable {
|
||||
public struct CronRunParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let mode: AnyCodable?
|
||||
|
||||
@@ -1269,7 +1269,7 @@ public struct CronRunParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunsParams: Codable {
|
||||
public struct CronRunsParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1286,7 +1286,7 @@ public struct CronRunsParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable {
|
||||
public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
public let action: String
|
||||
@@ -1331,7 +1331,7 @@ public struct CronRunLogEntry: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable {
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1348,7 +1348,7 @@ public struct ChatHistoryParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatSendParams: Codable {
|
||||
public struct ChatSendParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
@@ -1385,7 +1385,7 @@ public struct ChatSendParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatAbortParams: Codable {
|
||||
public struct ChatAbortParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let runid: String
|
||||
|
||||
@@ -1402,7 +1402,7 @@ public struct ChatAbortParams: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatEvent: Codable {
|
||||
public struct ChatEvent: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let seq: Int
|
||||
@@ -1443,7 +1443,7 @@ public struct ChatEvent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TickEvent: Codable {
|
||||
public struct TickEvent: Codable, Sendable {
|
||||
public let ts: Int
|
||||
|
||||
public init(
|
||||
@@ -1456,7 +1456,7 @@ public struct TickEvent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ShutdownEvent: Codable {
|
||||
public struct ShutdownEvent: Codable, Sendable {
|
||||
public let reason: String
|
||||
public let restartexpectedms: Int?
|
||||
|
||||
@@ -1473,7 +1473,7 @@ public struct ShutdownEvent: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayFrame: Codable {
|
||||
public enum GatewayFrame: Codable, Sendable {
|
||||
case req(RequestFrame)
|
||||
case res(ResponseFrame)
|
||||
case event(EventFrame)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Testing
|
||||
import ClawdbotIPC
|
||||
import CoreLocation
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
@@ -22,4 +23,16 @@ struct PermissionManagerTests {
|
||||
let ensured = await PermissionManager.ensure(caps, interactive: false)
|
||||
#expect(ensured.keys.count == caps.count)
|
||||
}
|
||||
|
||||
@Test func locationStatusMatchesAuthorizationAlways() async {
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let results = await PermissionManager.status([.location])
|
||||
#expect(results[.location] == (status == .authorizedAlways))
|
||||
}
|
||||
|
||||
@Test func ensureLocationNonInteractiveMatchesAuthorizationAlways() async {
|
||||
let status = CLLocationManager().authorizationStatus
|
||||
let ensured = await PermissionManager.ensure([.location], interactive: false)
|
||||
#expect(ensured[.location] == (status == .authorizedAlways))
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ summary: "Agent runtime (embedded p-mono), workspace contract, and session boots
|
||||
read_when:
|
||||
- Changing agent runtime, workspace bootstrap, or session behavior
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Agent Runtime 🤖
|
||||
|
||||
CLAWDBOT runs a single embedded agent runtime derived from **p-mono** (internal name: **p**).
|
||||
@@ -101,4 +100,3 @@ At minimum, set:
|
||||
---
|
||||
|
||||
*Next: [Group Chats](./group-messages.md)* 🦞
|
||||
<!-- {% endraw %} -->
|
||||
@@ -3,6 +3,10 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mdx-content > h1:first-of-type {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
.markdown h2,
|
||||
.markdown h3,
|
||||
@@ -127,4 +131,3 @@
|
||||
font-family: var(--font-pixel);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ summary: "How inbound audio/voice notes are downloaded, transcribed, and injecte
|
||||
read_when:
|
||||
- Changing audio transcription or media handling
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Audio / Voice Notes — 2025-12-05
|
||||
|
||||
## What works
|
||||
@@ -47,4 +46,3 @@ Requires `OPENAI_API_KEY` in env and `openai` CLI installed:
|
||||
## Gotchas
|
||||
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
|
||||
- Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
|
||||
<!-- {% endraw %} -->
|
||||
@@ -4,7 +4,6 @@ read_when:
|
||||
- Onboarding a new assistant instance
|
||||
- Reviewing safety/permission implications
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Building a personal assistant with CLAWDBOT (Clawd-style)
|
||||
|
||||
CLAWDBOT is a WhatsApp + Telegram + Discord gateway for **Pi** agents. This guide is the “personal assistant” setup: one dedicated WhatsApp number that behaves like your always-on agent.
|
||||
@@ -196,4 +195,3 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`).
|
||||
- Cron + wakeups: [Cron + wakeups](./cron.md)
|
||||
- macOS menu bar companion: [Clawdbot macOS app](./clawdbot-mac.md)
|
||||
- Security: [Security](./security.md)
|
||||
<!-- {% endraw %} -->
|
||||
+74
-4
@@ -3,7 +3,6 @@ summary: "All configuration options for ~/.clawdbot/clawdbot.json with examples"
|
||||
read_when:
|
||||
- Adding or modifying config fields
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Configuration 🔧
|
||||
|
||||
CLAWDBOT reads an optional **JSON5** config from `~/.clawdbot/clawdbot.json` (comments + trailing commas allowed).
|
||||
@@ -60,6 +59,36 @@ To prevent the bot from responding to WhatsApp @-mentions in groups (only respon
|
||||
|
||||
## Common options
|
||||
|
||||
### Env vars + `.env`
|
||||
|
||||
CLAWDBOT reads env vars from the parent process (shell, launchd/systemd, CI, etc.).
|
||||
|
||||
Additionally, it loads:
|
||||
- `.env` from the current working directory (if present)
|
||||
- a global fallback `.env` from `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`)
|
||||
|
||||
Neither `.env` file overrides existing env vars.
|
||||
|
||||
### `env.shellEnv` (optional)
|
||||
|
||||
Opt-in convenience: if enabled and none of the expected keys are set yet, CLAWDBOT runs your login shell and imports only the missing expected keys (never overrides).
|
||||
This effectively sources your shell profile.
|
||||
|
||||
```json5
|
||||
{
|
||||
env: {
|
||||
shellEnv: {
|
||||
enabled: true,
|
||||
timeoutMs: 15000
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Env var equivalent:
|
||||
- `CLAWDBOT_LOAD_SHELL_ENV=1`
|
||||
- `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`
|
||||
|
||||
### `identity`
|
||||
|
||||
Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant.
|
||||
@@ -82,7 +111,7 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
{
|
||||
wizard: {
|
||||
lastRunAt: "2026-01-01T00:00:00.000Z",
|
||||
lastRunVersion: "2.0.0-beta5",
|
||||
lastRunVersion: "2026.1.4",
|
||||
lastRunCommit: "abc1234",
|
||||
lastRunCommand: "configure",
|
||||
lastRunMode: "local"
|
||||
@@ -430,6 +459,20 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist
|
||||
(omit to show the full catalog).
|
||||
`modelAliases` adds short names for `/model` (alias -> provider/model).
|
||||
`modelFallbacks` lists ordered fallback models to try when the default fails.
|
||||
`imageModel` selects an image-capable model for the `image` tool.
|
||||
`imageModelFallbacks` lists ordered fallback image models for the `image` tool.
|
||||
|
||||
Clawdbot also ships a few built-in `modelAliases` shorthands (when an `agent` section exists):
|
||||
|
||||
- `opus` -> `anthropic/claude-opus-4-5`
|
||||
- `sonnet` -> `anthropic/claude-sonnet-4-5`
|
||||
- `gpt` -> `openai/gpt-5.2`
|
||||
- `gpt-mini` -> `openai/gpt-5-mini`
|
||||
- `gemini` -> `google/gemini-3-pro-preview`
|
||||
- `gemini-flash` -> `google/gemini-3-flash-preview`
|
||||
|
||||
If you configure the same alias name (case-insensitive) yourself, your value wins (defaults never override).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -443,6 +486,14 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
Sonnet: "anthropic/claude-sonnet-4-1"
|
||||
},
|
||||
modelFallbacks: [
|
||||
"openrouter/deepseek/deepseek-r1:free",
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
|
||||
],
|
||||
imageModel: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
imageModelFallbacks: [
|
||||
"openrouter/google/gemini-2.0-flash-vision:free"
|
||||
],
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
elevatedDefault: "on",
|
||||
@@ -497,6 +548,20 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
||||
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
|
||||
|
||||
`agent.tools` configures a global tool allow/deny policy (deny wins).
|
||||
This is applied even when the Docker sandbox is **off**.
|
||||
|
||||
Example (disable browser/canvas everywhere):
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
tools: {
|
||||
deny: ["browser", "canvas"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`agent.elevated` controls elevated (host) bash access:
|
||||
- `enabled`: allow elevated mode (default true)
|
||||
- `allowFrom`: per-surface allowlists (empty = disabled)
|
||||
@@ -951,10 +1016,16 @@ Requires full Gateway restart:
|
||||
|
||||
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
|
||||
- `CLAWDBOT_CONFIG_PATH` (per-instance config)
|
||||
- `CLAWDBOT_STATE_DIR` (sessions/creds/logs)
|
||||
- `CLAWDBOT_STATE_DIR` (sessions/creds)
|
||||
- `agent.workspace` (memories)
|
||||
- `gateway.port` (unique per instance)
|
||||
|
||||
Convenience flags (CLI):
|
||||
- `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001`
|
||||
- `clawdbot --profile <name> …` → uses `~/.clawdbot-<name>` (port via config/env/flags)
|
||||
|
||||
See `docs/gateway.md` for the derived port mapping (gateway/bridge/browser/canvas).
|
||||
|
||||
Example:
|
||||
```bash
|
||||
CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json \
|
||||
@@ -1163,4 +1234,3 @@ Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron + wa
|
||||
---
|
||||
|
||||
*Next: [Agent Runtime](./agent.md)* 🦞
|
||||
<!-- {% endraw %} -->
|
||||
+104
@@ -35,6 +35,110 @@ Note: Discord does not provide a simple username → id lookup without extra gui
|
||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||
|
||||
## How to create your own bot
|
||||
|
||||
This is the “Discord Developer Portal” setup for running Clawdbot in a server (guild) channel like `#help`.
|
||||
|
||||
### 1) Create the Discord app + bot user
|
||||
1. Discord Developer Portal → **Applications** → **New Application**
|
||||
2. In your app:
|
||||
- **Bot** → **Add Bot**
|
||||
- Copy the **Bot Token** (this is what you put in `DISCORD_BOT_TOKEN`)
|
||||
|
||||
### 2) Enable the gateway intents Clawdbot needs
|
||||
Discord blocks “privileged intents” unless you explicitly enable them.
|
||||
|
||||
In **Bot** → **Privileged Gateway Intents**, enable:
|
||||
- **Message Content Intent** (required to read message text in most guilds; without it you’ll see “Used disallowed intents” or the bot will connect but not react to messages)
|
||||
- **Server Members Intent** (recommended; required for some member/user lookups and allowlist matching in guilds)
|
||||
|
||||
You usually do **not** need **Presence Intent**.
|
||||
|
||||
### 3) Generate an invite URL (OAuth2 URL Generator)
|
||||
In your app: **OAuth2** → **URL Generator**
|
||||
|
||||
**Scopes**
|
||||
- ✅ `bot`
|
||||
- ✅ `applications.commands` (only if you want slash commands; otherwise leave unchecked)
|
||||
|
||||
**Bot Permissions** (minimal baseline)
|
||||
- ✅ View Channels
|
||||
- ✅ Send Messages
|
||||
- ✅ Read Message History
|
||||
- ✅ Embed Links
|
||||
- ✅ Attach Files
|
||||
- ✅ Add Reactions (optional but recommended)
|
||||
- ✅ Use External Emojis / Stickers (optional; only if you want them)
|
||||
|
||||
Avoid **Administrator** unless you’re debugging and fully trust the bot.
|
||||
|
||||
Copy the generated URL, open it, pick your server, and install the bot.
|
||||
|
||||
### 4) Get the ids (guild/user/channel)
|
||||
Discord uses numeric ids everywhere; Clawdbot config prefers ids.
|
||||
|
||||
1. Discord (desktop/web) → **User Settings** → **Advanced** → enable **Developer Mode**
|
||||
2. Right-click:
|
||||
- Server name → **Copy Server ID** (guild id)
|
||||
- Channel (e.g. `#help`) → **Copy Channel ID**
|
||||
- Your user → **Copy User ID**
|
||||
|
||||
### 5) Configure Clawdbot
|
||||
|
||||
#### Token
|
||||
Set the bot token via env var (recommended on servers):
|
||||
- `DISCORD_BOT_TOKEN=...`
|
||||
|
||||
Or via config:
|
||||
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "YOUR_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Allowlist + channel routing
|
||||
Example “single server, only allow me, only allow #help”:
|
||||
|
||||
```json5
|
||||
{
|
||||
discord: {
|
||||
enabled: true,
|
||||
dm: { enabled: false },
|
||||
guilds: {
|
||||
"YOUR_GUILD_ID": {
|
||||
users: ["YOUR_USER_ID"],
|
||||
requireMention: true,
|
||||
channels: {
|
||||
help: { allow: true, requireMention: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `requireMention: true` means the bot only replies when mentioned (recommended for shared channels).
|
||||
- If `channels` is present, any channel not listed is denied by default.
|
||||
|
||||
### 6) Verify it works
|
||||
1. Start the gateway.
|
||||
2. In your server channel, send: `@Krill hello` (or whatever your bot name is).
|
||||
3. If nothing happens: check **Troubleshooting** below.
|
||||
|
||||
### Troubleshooting
|
||||
- **“Used disallowed intents”**: enable **Message Content Intent** (and likely **Server Members Intent**) in the Developer Portal, then restart the gateway.
|
||||
- **Bot connects but never replies in a guild channel**:
|
||||
- Missing **Message Content Intent**, or
|
||||
- The bot lacks channel permissions (View/Send/Read History), or
|
||||
- Your config requires mentions and you didn’t mention it, or
|
||||
- Your guild/channel allowlist denies the channel/user.
|
||||
- **DMs don’t work**: `discord.dm.enabled` may be `false` or `discord.dm.allowFrom` doesn’t include you.
|
||||
|
||||
## Capabilities & limits
|
||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||
- Typing indicators sent best-effort; message chunking honors Discord’s 2k character limit.
|
||||
|
||||
+9
-1
@@ -27,11 +27,19 @@
|
||||
"pages": [
|
||||
"index",
|
||||
"onboarding",
|
||||
"wizard",
|
||||
"clawd",
|
||||
"faq"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Installation",
|
||||
"pages": [
|
||||
"wizard",
|
||||
"nix",
|
||||
"docker",
|
||||
"setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Core Concepts",
|
||||
"pages": [
|
||||
|
||||
+69
-21
@@ -118,6 +118,24 @@ pnpm clawdbot login
|
||||
|
||||
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/` to your server. The auth is just a JSON file.
|
||||
|
||||
### How are env vars loaded?
|
||||
|
||||
CLAWDBOT reads env vars from the parent process (shell, launchd/systemd, CI, etc.). It also loads `.env` files:
|
||||
- `.env` in the current working directory
|
||||
- global fallback: `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`)
|
||||
|
||||
Neither `.env` file overrides existing env vars.
|
||||
|
||||
Optional convenience: import missing expected keys from your login shell env (sources your shell profile):
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { shellEnv: { enabled: true, timeoutMs: 15000 } }
|
||||
}
|
||||
```
|
||||
|
||||
Or set `CLAWDBOT_LOAD_SHELL_ENV=1` (timeout: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`).
|
||||
|
||||
### Does enterprise OAuth work?
|
||||
|
||||
**Not currently.** Enterprise accounts use SSO which requires a different auth flow that pi-coding-agent doesn't support yet.
|
||||
@@ -446,31 +464,54 @@ cd ~/path/to/clawdbot
|
||||
codex --full-auto "debug why clawdbot gateway won't start"
|
||||
```
|
||||
|
||||
### Processes keep restarting after I kill them (Linux)
|
||||
### Processes keep restarting after I kill them
|
||||
|
||||
Something is supervising them. Check:
|
||||
The gateway runs under a supervisor that auto-restarts it. You need to stop the supervisor, not just kill the process.
|
||||
|
||||
**macOS (launchd)**
|
||||
|
||||
```bash
|
||||
# systemd?
|
||||
systemctl list-units | grep -i clawdbot
|
||||
sudo systemctl stop clawdbot
|
||||
# Check if running
|
||||
launchctl list | grep clawdbot
|
||||
|
||||
# pm2?
|
||||
pm2 list
|
||||
pm2 delete all
|
||||
# Stop and disable
|
||||
launchctl disable gui/$UID/com.clawdbot.gateway
|
||||
launchctl bootout gui/$UID/com.clawdbot.gateway
|
||||
|
||||
# Re-enable later
|
||||
launchctl enable gui/$UID/com.clawdbot.gateway
|
||||
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.gateway.plist
|
||||
```
|
||||
|
||||
Stop the supervisor first, then the processes.
|
||||
**Linux (systemd)**
|
||||
|
||||
```bash
|
||||
# Check if running
|
||||
systemctl list-units | grep -i clawdbot
|
||||
|
||||
# Stop and disable
|
||||
sudo systemctl disable --now clawdbot
|
||||
```
|
||||
|
||||
**pm2 (if used)**
|
||||
|
||||
```bash
|
||||
pm2 list
|
||||
pm2 delete clawdbot
|
||||
```
|
||||
|
||||
### Clean uninstall (start fresh)
|
||||
|
||||
```bash
|
||||
# Stop processes
|
||||
pkill -f "clawdbot"
|
||||
# macOS: stop launchd service
|
||||
launchctl disable gui/$UID/com.clawdbot.gateway
|
||||
launchctl bootout gui/$UID/com.clawdbot.gateway 2>/dev/null
|
||||
|
||||
# If using systemd
|
||||
sudo systemctl stop clawdbot
|
||||
sudo systemctl disable clawdbot
|
||||
# Linux: stop systemd service
|
||||
sudo systemctl disable --now clawdbot
|
||||
|
||||
# Kill any remaining processes
|
||||
pkill -f "clawdbot"
|
||||
|
||||
# Remove data
|
||||
rm -rf ~/.clawdbot
|
||||
@@ -507,23 +548,30 @@ Use `/model` to switch without restarting:
|
||||
/model sonnet
|
||||
/model haiku
|
||||
/model opus
|
||||
/model gpt
|
||||
/model gpt-mini
|
||||
/model gemini
|
||||
/model gemini-flash
|
||||
```
|
||||
|
||||
Clawdbot ships a few default model shorthands (you can override them in config):
|
||||
`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`.
|
||||
|
||||
**Setup:** Configure allowed models and aliases in `clawdbot.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"model": "anthropic/claude-opus-4-5-20251022",
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"allowedModels": [
|
||||
"anthropic/claude-opus-4-5-20251022",
|
||||
"anthropic/claude-sonnet-4-5-20251022",
|
||||
"anthropic/claude-haiku-4-5-20251001"
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-haiku-4-5"
|
||||
],
|
||||
"modelAliases": {
|
||||
"opus": "anthropic/claude-opus-4-5-20251022",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5-20251022",
|
||||
"haiku": "anthropic/claude-haiku-4-5-20251001"
|
||||
"opus": "anthropic/claude-opus-4-5",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5",
|
||||
"haiku": "anthropic/claude-haiku-4-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,34 @@ pnpm gateway:watch
|
||||
|
||||
Supported if you isolate state + config and use unique ports.
|
||||
|
||||
### Dev profile (`--dev`)
|
||||
|
||||
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
|
||||
|
||||
```bash
|
||||
clawdbot --dev setup
|
||||
clawdbot --dev gateway --allow-unconfigured
|
||||
# then target the dev instance:
|
||||
clawdbot --dev status
|
||||
clawdbot --dev health
|
||||
```
|
||||
|
||||
Defaults (can be overridden via env/flags/config):
|
||||
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
||||
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
||||
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
|
||||
- `bridge.port=19002` (derived: `gateway.port+1`)
|
||||
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
|
||||
- `canvasHost.port=19005` (derived: `gateway.port+4`)
|
||||
- `agent.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
|
||||
|
||||
Derived ports (rules of thumb):
|
||||
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
|
||||
- `bridge.port = base + 1` (or `CLAWDBOT_BRIDGE_PORT` / config override)
|
||||
- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
|
||||
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
|
||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
|
||||
|
||||
Checklist per instance:
|
||||
- unique `gateway.port`
|
||||
- unique `CLAWDBOT_CONFIG_PATH`
|
||||
|
||||
@@ -3,7 +3,6 @@ summary: "Image and media handling rules for send, gateway, and agent replies"
|
||||
read_when:
|
||||
- Modifying media pipeline or attachments
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Image & Media Support — 2025-12-05
|
||||
|
||||
CLAWDBOT is now **web-only** (Baileys). This document captures the current media handling rules for send, gateway, and agent replies.
|
||||
@@ -49,4 +48,3 @@ CLAWDBOT is now **web-only** (Baileys). This document captures the current media
|
||||
- Cover send + reply flows for image/audio/document cases.
|
||||
- Validate recompression for images (size bound) and voice-note flag for audio.
|
||||
- Ensure multi-media replies fan out as sequential sends.
|
||||
<!-- {% endraw %} -->
|
||||
+3
-4
@@ -3,23 +3,23 @@ summary: "Top-level overview of Clawdbot, features, and purpose"
|
||||
read_when:
|
||||
- Introducing Clawdbot to newcomers
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# CLAWDBOT 🦞
|
||||
|
||||
> *"EXFOLIATE! EXFOLIATE!"* — A space lobster, probably
|
||||
|
||||
<p align="center">
|
||||
<img src="whatsapp-clawd.jpg" alt="CLAWDBOT" width="420">
|
||||
<img src="whatsapp-clawd.jpg" alt="CLAWDBOT" width="420" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>WhatsApp + Telegram + Discord + iMessage gateway for AI agents (Pi).</strong><br>
|
||||
<strong>WhatsApp + Telegram + Discord + iMessage gateway for AI agents (Pi).</strong><br />
|
||||
Send a message, get an agent response — from your pocket.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/clawdbot/clawdbot">GitHub</a> ·
|
||||
<a href="https://github.com/clawdbot/clawdbot/releases">Releases</a> ·
|
||||
<a href="https://docs.clawdbot.com/">Docs</a> ·
|
||||
<a href="./clawd.md">Clawd setup</a>
|
||||
</p>
|
||||
|
||||
@@ -161,7 +161,6 @@ Example:
|
||||
---
|
||||
|
||||
*"We're all just playing with our own prompts."* — an AI, probably high on tokens
|
||||
<!-- {% endraw %} -->
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
+1
-1
@@ -23,4 +23,4 @@ Shapes & sizes
|
||||
|
||||
Behavioral notes
|
||||
- No external CLI/broker toggle for ears/working; keep it internal to the app’s own signals to avoid accidental flapping.
|
||||
- Keep TTLs short (<10s) so the icon returns to baseline quickly if a job hangs.
|
||||
- Keep TTLs short (<10s) so the icon returns to baseline quickly if a job hangs.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
summary: "Plan for models CLI: scan, list, aliases, fallbacks, status"
|
||||
read_when:
|
||||
- Adding or modifying models CLI (models list/set/scan/aliases/fallbacks)
|
||||
- Changing model fallback behavior or selection UX
|
||||
- Updating model scan probes (tools/images)
|
||||
---
|
||||
# Models CLI plan
|
||||
|
||||
Goal: give clear model visibility + control (configured vs available), plus scan tooling
|
||||
that prefers tool-call + image-capable models and maintains ordered fallbacks.
|
||||
|
||||
## Command tree (draft)
|
||||
|
||||
- `clawdbot models list`
|
||||
- default: configured models only
|
||||
- flags: `--all` (full catalog), `--local`, `--provider <name>`, `--json`, `--plain`
|
||||
- `clawdbot models status`
|
||||
- show default model + aliases + fallbacks + allowlist
|
||||
- `clawdbot models set <modelOrAlias>`
|
||||
- writes `agent.model` in config
|
||||
- `clawdbot models set-image <modelOrAlias>`
|
||||
- writes `agent.imageModel` in config
|
||||
- `clawdbot models aliases list|add|remove`
|
||||
- writes `agent.modelAliases`
|
||||
- `clawdbot models fallbacks list|add|remove|clear`
|
||||
- writes `agent.modelFallbacks`
|
||||
- `clawdbot models image-fallbacks list|add|remove|clear`
|
||||
- writes `agent.imageModelFallbacks`
|
||||
- `clawdbot models scan`
|
||||
- OpenRouter :free scan; probe tool-call + image; interactive selection
|
||||
|
||||
## Config changes
|
||||
|
||||
- Add `agent.modelFallbacks: string[]` (ordered list of provider/model IDs).
|
||||
- Add `agent.imageModel?: string` (optional image-capable model for image tool).
|
||||
- Add `agent.imageModelFallbacks?: string[]` (ordered list for image tool).
|
||||
- Keep existing:
|
||||
- `agent.model` (default)
|
||||
- `agent.allowedModels` (list filter)
|
||||
- `agent.modelAliases` (shortcut names)
|
||||
|
||||
## Scan behavior (models scan)
|
||||
|
||||
Input
|
||||
- OpenRouter `/models` list (filter `:free`)
|
||||
- Requires `OPENROUTER_API_KEY` (or stored OpenRouter key in auth storage)
|
||||
- Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates`
|
||||
- Probe controls: `--timeout`, `--concurrency`
|
||||
|
||||
Probes (direct pi-ai complete)
|
||||
- Tool-call probe (required):
|
||||
- Provide a dummy tool, verify tool call emitted.
|
||||
- Image probe (preferred):
|
||||
- Prompt includes 1x1 PNG; success if no "unsupported image" error.
|
||||
|
||||
Scoring/selection
|
||||
- Prefer models passing tool + image for text/tool fallbacks.
|
||||
- Prefer image-only models for image tool fallback (even if tool probe fails).
|
||||
- Rank by: image ok, then lower tool latency, then larger context, then params.
|
||||
|
||||
Interactive selection (TTY)
|
||||
- Multiselect list with per-model stats:
|
||||
- model id, tool ok, image ok, median latency, context, inferred params.
|
||||
- Pre-select top N (default 6).
|
||||
- Non-TTY: auto-select; require `--yes`/`--no-input` to apply.
|
||||
|
||||
Output
|
||||
- Writes `agent.modelFallbacks` ordered.
|
||||
- Writes `agent.imageModelFallbacks` ordered (image-capable models).
|
||||
- Optional `--set-default` to set `agent.model`.
|
||||
- Optional `--set-image` to set `agent.imageModel`.
|
||||
|
||||
## Runtime fallback
|
||||
|
||||
- On model failure: try `agent.modelFallbacks` in order.
|
||||
- Ignore fallback entries not in `agent.allowedModels` (if allowlist set).
|
||||
- Persist last successful provider/model to session entry.
|
||||
- `/status` shows last used model (not just default).
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit: scan selection ordering + probe classification.
|
||||
- CLI: list/aliases/fallbacks add/remove + scan writes config.
|
||||
- Status: shows last used model + fallbacks.
|
||||
|
||||
## Docs
|
||||
|
||||
- Update `docs/configuration.md` with `agent.modelFallbacks`.
|
||||
- Keep this doc current when CLI surface or scan logic changes.
|
||||
+57
-11
@@ -1,26 +1,66 @@
|
||||
---
|
||||
summary: "Running Clawdbot under Nix (config, state, and packaging expectations)"
|
||||
summary: "Install Clawdbot declaratively with Nix"
|
||||
read_when:
|
||||
- Building Clawdbot with Nix
|
||||
- Debugging Nix-mode behavior
|
||||
- You want reproducible, rollback-able installs
|
||||
- You're already using Nix/NixOS/Home Manager
|
||||
- You want everything pinned and managed declaratively
|
||||
---
|
||||
# Nix mode
|
||||
|
||||
# Nix Installation
|
||||
|
||||
The recommended way to run Clawdbot with Nix is via **[nix-clawdbot](https://github.com/clawdbot/nix-clawdbot)** — a batteries-included Home Manager module.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Paste this to your AI agent (Claude, Cursor, etc.):
|
||||
|
||||
```text
|
||||
I want to set up nix-clawdbot on my Mac.
|
||||
Repository: github:clawdbot/nix-clawdbot
|
||||
|
||||
What I need you to do:
|
||||
1. Check if Determinate Nix is installed (if not, install it)
|
||||
2. Create a local flake at ~/code/clawdbot-local using templates/agent-first/flake.nix
|
||||
3. Help me create a Telegram bot (@BotFather) and get my chat ID (@userinfobot)
|
||||
4. Set up secrets (bot token, Anthropic key) - plain files at ~/.secrets/ is fine
|
||||
5. Fill in the template placeholders and run home-manager switch
|
||||
6. Verify: launchd running, bot responds to messages
|
||||
|
||||
Reference the nix-clawdbot README for module options.
|
||||
```
|
||||
|
||||
> **📦 Full guide: [github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot)**
|
||||
>
|
||||
> The nix-clawdbot repo is the source of truth for Nix installation. This page is just a quick overview.
|
||||
|
||||
## What you get
|
||||
|
||||
- Gateway + macOS app + tools (whisper, spotify, cameras) — all pinned
|
||||
- Launchd service that survives reboots
|
||||
- Plugin system with declarative config
|
||||
- Instant rollback: `home-manager switch --rollback`
|
||||
|
||||
---
|
||||
|
||||
## Nix Mode Runtime Behavior
|
||||
|
||||
When `CLAWDBOT_NIX_MODE=1` is set (automatic with nix-clawdbot):
|
||||
|
||||
Clawdbot supports a **Nix mode** that makes configuration deterministic and disables auto-install flows.
|
||||
Enable it by exporting:
|
||||
|
||||
```
|
||||
```bash
|
||||
CLAWDBOT_NIX_MODE=1
|
||||
```
|
||||
|
||||
On macOS, the GUI app does not automatically inherit shell env vars. You can
|
||||
also enable Nix mode via defaults:
|
||||
|
||||
```
|
||||
```bash
|
||||
defaults write com.clawdbot.mac clawdbot.nixMode -bool true
|
||||
```
|
||||
|
||||
## Config + state paths
|
||||
### Config + state paths
|
||||
|
||||
Clawdbot reads JSON5 config from `CLAWDBOT_CONFIG_PATH` and stores mutable data in `CLAWDBOT_STATE_DIR`.
|
||||
|
||||
@@ -30,11 +70,11 @@ Clawdbot reads JSON5 config from `CLAWDBOT_CONFIG_PATH` and stores mutable data
|
||||
When running under Nix, set these explicitly to Nix-managed locations so runtime state and config
|
||||
stay out of the immutable store.
|
||||
|
||||
## Runtime behavior in Nix mode
|
||||
### Runtime behavior in Nix mode
|
||||
|
||||
- Auto-install and self-mutation flows should be disabled.
|
||||
- Missing dependencies should surface Nix-specific remediation messages.
|
||||
- UI surfaces a read-only Nix mode banner when present.
|
||||
- Auto-install and self-mutation flows are disabled
|
||||
- Missing dependencies surface Nix-specific remediation messages
|
||||
- UI surfaces a read-only Nix mode banner when present
|
||||
|
||||
## Packaging note (macOS)
|
||||
|
||||
@@ -47,3 +87,9 @@ apps/macos/Sources/Clawdbot/Resources/Info.plist
|
||||
`scripts/package-mac-app.sh` copies this template into the app bundle and patches dynamic fields
|
||||
(bundle ID, version/build, Git SHA, Sparkle keys). This keeps the plist deterministic for SwiftPM
|
||||
packaging and Nix builds (which do not rely on a full Xcode toolchain).
|
||||
|
||||
## Related
|
||||
|
||||
- [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) — full setup guide
|
||||
- [Wizard](./wizard.md) — non-Nix CLI setup
|
||||
- [Docker](./docker.md) — containerized setup
|
||||
@@ -4,7 +4,6 @@ read_when:
|
||||
- Designing the macOS onboarding assistant
|
||||
- Implementing Anthropic auth or identity setup
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Onboarding (macOS app)
|
||||
|
||||
This doc describes the intended **first-run onboarding** for Clawdbot. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
|
||||
@@ -182,4 +181,3 @@ chmod 600 ~/.clawdbot/credentials/oauth.json
|
||||
| `accessToken` | `access` |
|
||||
| `refreshToken` | `refresh` |
|
||||
| `expiresAt` | `expires` |
|
||||
<!-- {% endraw %} -->
|
||||
@@ -3,7 +3,6 @@ summary: "WebChat session migration notes (Gateway WS-only)"
|
||||
read_when:
|
||||
- Changing WebChat Gateway methods/events
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# WebAgent session migration (WS-only)
|
||||
|
||||
Context: web chat currently lives in a WKWebView that loads the pi-web bundle. Sends go over HTTP `/rpc` to the webchat server, and updates come from `/socket` snapshots based on session JSONL file changes. The Gateway itself already speaks WebSocket to the webchat server, and Pi writes the session JSONL files. This doc tracks the plan to move WebChat to a single Gateway WebSocket and drop the HTTP shim/file-watching.
|
||||
@@ -43,4 +42,3 @@ Context: web chat currently lives in a WKWebView that loads the pi-web bundle. S
|
||||
- Streaming granularity: start with `state:"final"` only, or include token/tool deltas immediately?
|
||||
- Attachments over WS: text-only initially is OK; confirm before wiring binary/upload path.
|
||||
- Error shape: use `res ok:false` for validation/timeout, `chat state:"error"` for model/runtime failures.
|
||||
<!-- {% endraw %} -->
|
||||
@@ -111,7 +111,7 @@ Example:
|
||||
## Retain
|
||||
- W @Peter: Currently in Marrakech (Nov 27–Dec 1, 2025) for Andy’s birthday.
|
||||
- B @warelay: I fixed the Baileys WS crash by wrapping connection.update handlers in try/catch (see memory/2025-11-27.md).
|
||||
- O(c=0.95) @Peter: Prefers concise replies (<1500 chars) on WhatsApp; long content goes into files.
|
||||
- O(c=0.95) @Peter: Prefers concise replies (<1500 chars) on WhatsApp; long content goes into files.
|
||||
```
|
||||
|
||||
Minimal parsing:
|
||||
|
||||
@@ -4,7 +4,6 @@ read_when:
|
||||
- Adding or modifying skills
|
||||
- Changing skill gating or load rules
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Skills (Clawdbot)
|
||||
|
||||
Clawdbot uses **AgentSkills-compatible** skill folders to teach the agent how to use tools. Each skill is a directory containing a `SKILL.md` with YAML frontmatter and instructions. Clawdbot loads **bundled skills** plus optional local overrides, and filters them at load time based on environment, config, and binary presence.
|
||||
@@ -146,4 +145,3 @@ copy). Workspace skills are user-owned and override both on name conflicts.
|
||||
See `docs/skills-config.md` for the full configuration schema.
|
||||
|
||||
---
|
||||
<!-- {% endraw %} -->
|
||||
Vendored
+2
-2
@@ -103,7 +103,7 @@ When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every ti
|
||||
|
||||
**When to reach out:**
|
||||
- Important email arrived
|
||||
- Calendar event coming up (<2h)
|
||||
- Calendar event coming up (<2h)
|
||||
- Something interesting you found
|
||||
- It's been >8h since you said anything
|
||||
|
||||
@@ -111,7 +111,7 @@ When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every ti
|
||||
- Late night (23:00-08:00) unless urgent
|
||||
- Human is clearly busy
|
||||
- Nothing new since last check
|
||||
- You just checked <30 minutes ago
|
||||
- You just checked <30 minutes ago
|
||||
|
||||
**Proactive work you can do without asking:**
|
||||
- Read and organize memory files
|
||||
|
||||
@@ -11,6 +11,21 @@ Clawdbot exposes **first-class agent tools** for browser, canvas, nodes, and cro
|
||||
These replace the old `clawdbot-*` skills: the tools are typed, no shelling,
|
||||
and the agent should rely on them directly.
|
||||
|
||||
## Disabling tools
|
||||
|
||||
You can globally allow/deny tools via `agent.tools` in `clawdbot.json`
|
||||
(deny wins). This prevents disallowed tools from being sent to providers.
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
tools: {
|
||||
deny: ["browser"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool inventory
|
||||
|
||||
### `bash`
|
||||
@@ -101,6 +116,19 @@ Notes:
|
||||
- Videos return `FILE:<path>` (mp4).
|
||||
- Location returns a JSON payload (lat/lon/accuracy/timestamp).
|
||||
|
||||
### `image`
|
||||
Analyze an image with the configured image model.
|
||||
|
||||
Core parameters:
|
||||
- `image` (required path or URL)
|
||||
- `prompt` (optional; defaults to "Describe the image.")
|
||||
- `model` (optional override)
|
||||
- `maxBytesMb` (optional size cap)
|
||||
|
||||
Notes:
|
||||
- Only available when `agent.imageModel` or `agent.imageModelFallbacks` is set.
|
||||
- Uses the image model directly (independent of the main chat model).
|
||||
|
||||
### `cron`
|
||||
Manage Gateway cron jobs and wakeups.
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
|
||||
|
||||
**Tip:** The number only needs to receive one verification SMS. After that, WhatsApp Web sessions persist via `creds.json`.
|
||||
|
||||
**WhatsApp Business:** You can use WhatsApp Business on the same phone with a different number. This is a great option if you want to keep your personal WhatsApp separate — just install WhatsApp Business and register it with Clawdbot's dedicated number.
|
||||
|
||||
## Login + credentials
|
||||
- Login command: `clawdbot login` (QR via Linked Devices).
|
||||
- Credentials stored in `~/.clawdbot/credentials/creds.json`.
|
||||
|
||||
@@ -83,6 +83,7 @@ It does **not** install or change anything on the remote host.
|
||||
|
||||
9) **Finish**
|
||||
- Summary + next steps, including iOS/Android/macOS apps for extra features.
|
||||
- If no GUI is detected, the wizard prints SSH port-forward instructions for the Control UI instead of opening a browser.
|
||||
|
||||
## Remote mode
|
||||
|
||||
|
||||
+16
-8
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2.0.0-beta5",
|
||||
"version": "2026.1.5-3",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"clawdbot": "dist/index.js"
|
||||
"clawdbot": "dist/entry.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/agents/**",
|
||||
@@ -15,17 +15,25 @@
|
||||
"dist/cli/**",
|
||||
"dist/commands/**",
|
||||
"dist/config/**",
|
||||
"dist/control-ui/**",
|
||||
"dist/cron/**",
|
||||
"dist/daemon/**",
|
||||
"dist/discord/**",
|
||||
"dist/gateway/**",
|
||||
"dist/hooks/**",
|
||||
"dist/imessage/**",
|
||||
"dist/infra/**",
|
||||
"dist/macos/**",
|
||||
"dist/media/**",
|
||||
"dist/process/**",
|
||||
"dist/sessions/**",
|
||||
"dist/providers/**",
|
||||
"dist/signal/**",
|
||||
"dist/slack/**",
|
||||
"dist/telegram/**",
|
||||
"dist/tui/**",
|
||||
"dist/web/**",
|
||||
"dist/wizard/**",
|
||||
"dist/*.js",
|
||||
"dist/*.json",
|
||||
"docs/**",
|
||||
@@ -36,19 +44,19 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"dev": "tsx src/entry.ts",
|
||||
"docs:list": "tsx scripts/docs-list.ts",
|
||||
"docs:dev": "cd docs && mint dev",
|
||||
"docs:build": "cd docs && mint broken-links",
|
||||
"docs:build": "cd docs && pnpm dlx mint broken-links",
|
||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts",
|
||||
"release:check": "tsx scripts/release-check.ts",
|
||||
"ui:install": "pnpm -C ui install",
|
||||
"ui:dev": "pnpm -C ui dev",
|
||||
"ui:build": "pnpm -C ui build",
|
||||
"start": "tsx src/index.ts",
|
||||
"clawdbot": "tsx src/index.ts",
|
||||
"gateway:watch": "tsx watch --clear-screen=false --include 'src/**/*.ts' src/index.ts gateway --force",
|
||||
"clawdbot:rpc": "tsx src/index.ts agent --mode rpc --json",
|
||||
"start": "tsx src/entry.ts",
|
||||
"clawdbot": "tsx src/entry.ts",
|
||||
"gateway:watch": "tsx watch --clear-screen=false --include 'src/**/*.ts' src/entry.ts gateway --force",
|
||||
"clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
|
||||
"lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
|
||||
"lint:fix": "biome check --write --unsafe src && biome format --write src",
|
||||
|
||||
Generated
+20
@@ -200,9 +200,15 @@ importers:
|
||||
|
||||
ui:
|
||||
dependencies:
|
||||
dompurify:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
lit:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1
|
||||
devDependencies:
|
||||
'@vitest/browser-playwright':
|
||||
specifier: 4.0.16
|
||||
@@ -1627,6 +1633,9 @@ packages:
|
||||
dom-accessibility-api@0.5.16:
|
||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||
|
||||
dompurify@3.3.1:
|
||||
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
|
||||
|
||||
dotenv@17.2.3:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2200,6 +2209,11 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
marked@17.0.1:
|
||||
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4418,6 +4432,10 @@ snapshots:
|
||||
dom-accessibility-api@0.5.16:
|
||||
optional: true
|
||||
|
||||
dompurify@3.3.1:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dotenv@17.2.3: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -5032,6 +5050,8 @@ snapshots:
|
||||
|
||||
marked@16.4.2: {}
|
||||
|
||||
marked@17.0.1: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
@@ -251,6 +251,9 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
|
||||
fi
|
||||
rm -rf "$RELAY_BUILD_DIR"
|
||||
|
||||
echo "🧪 Smoke testing bundled relay QR modules"
|
||||
CLAWDBOT_SMOKE_QR=1 "$RELAY_OUT" >/dev/null
|
||||
|
||||
echo "🎨 Copying gateway A2UI host assets"
|
||||
rm -rf "$RELAY_DIR/a2ui"
|
||||
cp -R "$ROOT_DIR/src/canvas-host/a2ui" "$RELAY_DIR/a2ui"
|
||||
|
||||
@@ -27,7 +27,7 @@ const outPath = path.join(
|
||||
"GatewayModels.swift",
|
||||
);
|
||||
|
||||
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable {\n${Object.values(ErrorCodes)
|
||||
const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(ErrorCodes)
|
||||
.map((c) => ` case ${camelCase(c)} = "${c}"`)
|
||||
.join("\n")}\n}\n`;
|
||||
|
||||
@@ -106,7 +106,7 @@ function emitStruct(name: string, schema: JsonSchema): string {
|
||||
const props = schema.properties ?? {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
const lines: string[] = [];
|
||||
lines.push(`public struct ${name}: Codable {`);
|
||||
lines.push(`public struct ${name}: Codable, Sendable {`);
|
||||
if (Object.keys(props).length === 0) {
|
||||
lines.push("}\n");
|
||||
return lines.join("\n");
|
||||
@@ -188,7 +188,7 @@ function emitGatewayFrame(): string {
|
||||
`;
|
||||
|
||||
return [
|
||||
"public enum GatewayFrame: Codable {",
|
||||
"public enum GatewayFrame: Codable, Sendable {",
|
||||
...caseLines,
|
||||
" case unknown(type: String, raw: [String: AnyCodable])",
|
||||
initLines,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||
import { createCanvasTool } from "./tools/canvas-tool.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
import { createCronTool } from "./tools/cron-tool.js";
|
||||
import { createDiscordTool } from "./tools/discord-tool.js";
|
||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
import { createImageTool } from "./tools/image-tool.js";
|
||||
import { createNodesTool } from "./tools/nodes-tool.js";
|
||||
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
|
||||
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||
@@ -14,7 +16,9 @@ export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
agentSessionKey?: string;
|
||||
agentSurface?: string;
|
||||
config?: ClawdbotConfig;
|
||||
}): AnyAgentTool[] {
|
||||
const imageTool = createImageTool({ config: options?.config });
|
||||
return [
|
||||
createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
|
||||
createCanvasTool(),
|
||||
@@ -29,5 +33,6 @@ export function createClawdbotTools(options?: {
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentSurface: options?.agentSurface,
|
||||
}),
|
||||
...(imageTool ? [imageTool] : []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
type Api,
|
||||
getEnvApiKey,
|
||||
getOAuthApiKey,
|
||||
type Model,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
|
||||
const OAUTH_FILENAME = "oauth.json";
|
||||
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
|
||||
let oauthStorageConfigured = false;
|
||||
|
||||
type OAuthStorage = Record<string, OAuthCredentials>;
|
||||
|
||||
function resolveClawdbotOAuthPath(): string {
|
||||
const overrideDir =
|
||||
process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
|
||||
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
|
||||
}
|
||||
|
||||
function loadOAuthStorageAt(pathname: string): OAuthStorage | null {
|
||||
if (!fsSync.existsSync(pathname)) return null;
|
||||
try {
|
||||
const content = fsSync.readFileSync(pathname, "utf8");
|
||||
const json = JSON.parse(content) as OAuthStorage;
|
||||
if (!json || typeof json !== "object") return null;
|
||||
return json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnthropicOAuth(storage: OAuthStorage): boolean {
|
||||
const entry = storage.anthropic as
|
||||
| {
|
||||
refresh?: string;
|
||||
refresh_token?: string;
|
||||
refreshToken?: string;
|
||||
access?: string;
|
||||
access_token?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!entry) return false;
|
||||
const refresh =
|
||||
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
|
||||
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
|
||||
return Boolean(refresh.trim() && access.trim());
|
||||
}
|
||||
|
||||
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
|
||||
const dir = path.dirname(pathname);
|
||||
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fsSync.writeFileSync(
|
||||
pathname,
|
||||
`${JSON.stringify(storage, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fsSync.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
function legacyOAuthPaths(): string[] {
|
||||
const paths: string[] = [];
|
||||
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (piOverride) {
|
||||
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
|
||||
}
|
||||
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
function importLegacyOAuthIfNeeded(destPath: string): void {
|
||||
if (fsSync.existsSync(destPath)) return;
|
||||
for (const legacyPath of legacyOAuthPaths()) {
|
||||
const storage = loadOAuthStorageAt(legacyPath);
|
||||
if (!storage || !hasAnthropicOAuth(storage)) continue;
|
||||
saveOAuthStorageAt(destPath, storage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureOAuthStorage(): void {
|
||||
if (oauthStorageConfigured) return;
|
||||
oauthStorageConfigured = true;
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
importLegacyOAuthIfNeeded(oauthPath);
|
||||
}
|
||||
|
||||
function isOAuthProvider(provider: string): provider is OAuthProvider {
|
||||
return (
|
||||
provider === "anthropic" ||
|
||||
provider === "anthropic-oauth" ||
|
||||
provider === "google" ||
|
||||
provider === "openai" ||
|
||||
provider === "openai-compatible" ||
|
||||
provider === "github-copilot" ||
|
||||
provider === "google-gemini-cli" ||
|
||||
provider === "google-antigravity"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getApiKeyForModel(
|
||||
model: Model<Api>,
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
): Promise<string> {
|
||||
const storedKey = await authStorage.getApiKey(model.provider);
|
||||
if (storedKey) return storedKey;
|
||||
ensureOAuthStorage();
|
||||
if (model.provider === "anthropic") {
|
||||
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
if (oauthEnv?.trim()) return oauthEnv.trim();
|
||||
}
|
||||
const envKey = getEnvApiKey(model.provider);
|
||||
if (envKey) return envKey;
|
||||
if (isOAuthProvider(model.provider)) {
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
const storage = loadOAuthStorageAt(oauthPath);
|
||||
if (storage) {
|
||||
try {
|
||||
const result = await getOAuthApiKey(model.provider, storage);
|
||||
if (result?.apiKey) {
|
||||
storage[model.provider] = result.newCredentials;
|
||||
saveOAuthStorageAt(oauthPath, storage);
|
||||
return result.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`No API key found for provider "${model.provider}"`);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
parseModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "./model-selection.js";
|
||||
|
||||
type ModelCandidate = {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
type FallbackAttempt = {
|
||||
provider: string;
|
||||
model: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const name = "name" in err ? String(err.name) : "";
|
||||
if (name === "AbortError") return true;
|
||||
const message =
|
||||
"message" in err && typeof err.message === "string"
|
||||
? err.message.toLowerCase()
|
||||
: "";
|
||||
return message.includes("aborted");
|
||||
}
|
||||
|
||||
function buildAllowedModelKeys(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
defaultProvider: string,
|
||||
): Set<string> | null {
|
||||
const rawAllowlist = cfg?.agent?.allowedModels ?? [];
|
||||
if (rawAllowlist.length === 0) return null;
|
||||
const keys = new Set<string>();
|
||||
for (const raw of rawAllowlist) {
|
||||
const parsed = parseModelRef(String(raw ?? ""), defaultProvider);
|
||||
if (!parsed) continue;
|
||||
keys.add(modelKey(parsed.provider, parsed.model));
|
||||
}
|
||||
return keys.size > 0 ? keys : null;
|
||||
}
|
||||
|
||||
function resolveImageFallbackCandidates(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
defaultProvider: string;
|
||||
modelOverride?: string;
|
||||
}): ModelCandidate[] {
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg ?? {},
|
||||
defaultProvider: params.defaultProvider,
|
||||
});
|
||||
const allowlist = buildAllowedModelKeys(params.cfg, params.defaultProvider);
|
||||
const seen = new Set<string>();
|
||||
const candidates: ModelCandidate[] = [];
|
||||
|
||||
const addCandidate = (
|
||||
candidate: ModelCandidate,
|
||||
enforceAllowlist: boolean,
|
||||
) => {
|
||||
if (!candidate.provider || !candidate.model) return;
|
||||
const key = modelKey(candidate.provider, candidate.model);
|
||||
if (seen.has(key)) return;
|
||||
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
const addRaw = (raw: string, enforceAllowlist: boolean) => {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider: params.defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return;
|
||||
addCandidate(resolved.ref, enforceAllowlist);
|
||||
};
|
||||
|
||||
if (params.modelOverride?.trim()) {
|
||||
addRaw(params.modelOverride, false);
|
||||
} else if (params.cfg?.agent?.imageModel?.trim()) {
|
||||
addRaw(params.cfg.agent.imageModel, false);
|
||||
}
|
||||
|
||||
for (const raw of params.cfg?.agent?.imageModelFallbacks ?? []) {
|
||||
addRaw(raw, true);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolveFallbackCandidates(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
}): ModelCandidate[] {
|
||||
const provider = params.provider.trim() || DEFAULT_PROVIDER;
|
||||
const model = params.model.trim() || DEFAULT_MODEL;
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg ?? {},
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const allowlist = buildAllowedModelKeys(params.cfg, DEFAULT_PROVIDER);
|
||||
const seen = new Set<string>();
|
||||
const candidates: ModelCandidate[] = [];
|
||||
|
||||
const addCandidate = (
|
||||
candidate: ModelCandidate,
|
||||
enforceAllowlist: boolean,
|
||||
) => {
|
||||
if (!candidate.provider || !candidate.model) return;
|
||||
const key = modelKey(candidate.provider, candidate.model);
|
||||
if (seen.has(key)) return;
|
||||
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
addCandidate({ provider, model }, false);
|
||||
|
||||
for (const raw of params.cfg?.agent?.modelFallbacks ?? []) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) continue;
|
||||
addCandidate(resolved.ref, true);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export async function runWithModelFallback<T>(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<T>;
|
||||
onError?: (attempt: {
|
||||
provider: string;
|
||||
model: string;
|
||||
error: unknown;
|
||||
attempt: number;
|
||||
total: number;
|
||||
}) => void | Promise<void>;
|
||||
}): Promise<{
|
||||
result: T;
|
||||
provider: string;
|
||||
model: string;
|
||||
attempts: FallbackAttempt[];
|
||||
}> {
|
||||
const candidates = resolveFallbackCandidates(params);
|
||||
const attempts: FallbackAttempt[] = [];
|
||||
let lastError: unknown;
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i] as ModelCandidate;
|
||||
try {
|
||||
const result = await params.run(candidate.provider, candidate.model);
|
||||
return {
|
||||
result,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
attempts,
|
||||
};
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) throw err;
|
||||
lastError = err;
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
await params.onError?.({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: err,
|
||||
attempt: i + 1,
|
||||
total: candidates.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts.length <= 1 && lastError) throw lastError;
|
||||
const summary =
|
||||
attempts.length > 0
|
||||
? attempts
|
||||
.map(
|
||||
(attempt) =>
|
||||
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
|
||||
)
|
||||
.join(" | ")
|
||||
: "unknown";
|
||||
throw new Error(
|
||||
`All models failed (${attempts.length || candidates.length}): ${summary}`,
|
||||
{ cause: lastError instanceof Error ? lastError : undefined },
|
||||
);
|
||||
}
|
||||
|
||||
export async function runWithImageModelFallback<T>(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
modelOverride?: string;
|
||||
run: (provider: string, model: string) => Promise<T>;
|
||||
onError?: (attempt: {
|
||||
provider: string;
|
||||
model: string;
|
||||
error: unknown;
|
||||
attempt: number;
|
||||
total: number;
|
||||
}) => void | Promise<void>;
|
||||
}): Promise<{
|
||||
result: T;
|
||||
provider: string;
|
||||
model: string;
|
||||
attempts: FallbackAttempt[];
|
||||
}> {
|
||||
const candidates = resolveImageFallbackCandidates({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
modelOverride: params.modelOverride,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
"No image model configured. Set agent.imageModel or agent.imageModelFallbacks.",
|
||||
);
|
||||
}
|
||||
|
||||
const attempts: FallbackAttempt[] = [];
|
||||
let lastError: unknown;
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const candidate = candidates[i] as ModelCandidate;
|
||||
try {
|
||||
const result = await params.run(candidate.provider, candidate.model);
|
||||
return {
|
||||
result,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
attempts,
|
||||
};
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) throw err;
|
||||
lastError = err;
|
||||
attempts.push({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
await params.onError?.({
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: err,
|
||||
attempt: i + 1,
|
||||
total: candidates.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts.length <= 1 && lastError) throw lastError;
|
||||
const summary =
|
||||
attempts.length > 0
|
||||
? attempts
|
||||
.map(
|
||||
(attempt) =>
|
||||
`${attempt.provider}/${attempt.model}: ${attempt.error}`,
|
||||
)
|
||||
.join(" | ")
|
||||
: "unknown";
|
||||
throw new Error(
|
||||
`All image models failed (${attempts.length || candidates.length}): ${summary}`,
|
||||
{ cause: lastError instanceof Error ? lastError : undefined },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
type Context,
|
||||
complete,
|
||||
getEnvApiKey,
|
||||
getModel,
|
||||
type Model,
|
||||
type OpenAICompletionsOptions,
|
||||
type Tool,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
||||
const DEFAULT_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_CONCURRENCY = 3;
|
||||
|
||||
const BASE_IMAGE_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
|
||||
|
||||
const TOOL_PING: Tool = {
|
||||
name: "ping",
|
||||
description: "Return OK.",
|
||||
parameters: Type.Object({}),
|
||||
};
|
||||
|
||||
type OpenRouterModelMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
contextLength: number | null;
|
||||
maxCompletionTokens: number | null;
|
||||
supportedParametersCount: number;
|
||||
modality: string | null;
|
||||
inferredParamB: number | null;
|
||||
createdAtMs: number | null;
|
||||
};
|
||||
|
||||
export type ProbeResult = {
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
error?: string;
|
||||
skipped?: boolean;
|
||||
};
|
||||
|
||||
export type ModelScanResult = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
modelRef: string;
|
||||
contextLength: number | null;
|
||||
maxCompletionTokens: number | null;
|
||||
supportedParametersCount: number;
|
||||
modality: string | null;
|
||||
inferredParamB: number | null;
|
||||
createdAtMs: number | null;
|
||||
tool: ProbeResult;
|
||||
image: ProbeResult;
|
||||
};
|
||||
|
||||
export type OpenRouterScanOptions = {
|
||||
apiKey?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
concurrency?: number;
|
||||
minParamB?: number;
|
||||
maxAgeDays?: number;
|
||||
providerFilter?: string;
|
||||
};
|
||||
|
||||
type OpenAIModel = Model<"openai-completions">;
|
||||
|
||||
function normalizeCreatedAtMs(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
if (value <= 0) return null;
|
||||
if (value > 1e12) return Math.round(value);
|
||||
return Math.round(value * 1000);
|
||||
}
|
||||
|
||||
function inferParamBFromIdOrName(text: string): number | null {
|
||||
const raw = text.toLowerCase();
|
||||
const matches = raw.matchAll(
|
||||
/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g,
|
||||
);
|
||||
let best: number | null = null;
|
||||
for (const match of matches) {
|
||||
const numRaw = match[1];
|
||||
if (!numRaw) continue;
|
||||
const value = Number(numRaw);
|
||||
if (!Number.isFinite(value) || value <= 0) continue;
|
||||
if (best === null || value > best) best = value;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function parseModality(modality: string | null): Array<"text" | "image"> {
|
||||
if (!modality) return ["text"];
|
||||
const normalized = modality.toLowerCase();
|
||||
const parts = normalized.split(/[^a-z]+/).filter(Boolean);
|
||||
const hasImage = parts.includes("image");
|
||||
return hasImage ? ["text", "image"] : ["text"];
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
timeoutMs: number,
|
||||
fn: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fn(controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOpenRouterModels(
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<OpenRouterModelMeta[]> {
|
||||
const res = await fetchImpl(OPENROUTER_MODELS_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
|
||||
}
|
||||
const payload = (await res.json()) as { data?: unknown };
|
||||
const entries = Array.isArray(payload.data) ? payload.data : [];
|
||||
|
||||
return entries
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const id = typeof obj.id === "string" ? obj.id.trim() : "";
|
||||
if (!id) return null;
|
||||
const name =
|
||||
typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
|
||||
|
||||
const contextLength =
|
||||
typeof obj.context_length === "number" &&
|
||||
Number.isFinite(obj.context_length)
|
||||
? obj.context_length
|
||||
: null;
|
||||
|
||||
const maxCompletionTokens =
|
||||
typeof obj.max_completion_tokens === "number" &&
|
||||
Number.isFinite(obj.max_completion_tokens)
|
||||
? obj.max_completion_tokens
|
||||
: typeof obj.max_output_tokens === "number" &&
|
||||
Number.isFinite(obj.max_output_tokens)
|
||||
? obj.max_output_tokens
|
||||
: null;
|
||||
|
||||
const supportedParametersCount = Array.isArray(obj.supported_parameters)
|
||||
? obj.supported_parameters.length
|
||||
: 0;
|
||||
|
||||
const modality =
|
||||
typeof obj.modality === "string" && obj.modality.trim()
|
||||
? obj.modality.trim()
|
||||
: null;
|
||||
|
||||
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
|
||||
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
contextLength,
|
||||
maxCompletionTokens,
|
||||
supportedParametersCount,
|
||||
modality,
|
||||
inferredParamB,
|
||||
createdAtMs,
|
||||
} satisfies OpenRouterModelMeta;
|
||||
})
|
||||
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
|
||||
}
|
||||
|
||||
async function probeTool(
|
||||
model: OpenAIModel,
|
||||
apiKey: string,
|
||||
timeoutMs: number,
|
||||
): Promise<ProbeResult> {
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call the ping tool with {} and nothing else.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [TOOL_PING],
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const message = await withTimeout(timeoutMs, (signal) =>
|
||||
complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: 32,
|
||||
temperature: 0,
|
||||
toolChoice: "required",
|
||||
signal,
|
||||
} satisfies OpenAICompletionsOptions),
|
||||
);
|
||||
|
||||
const hasToolCall = message.content.some(
|
||||
(block) => block.type === "toolCall",
|
||||
);
|
||||
if (!hasToolCall) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: "No tool call returned",
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, latencyMs: Date.now() - startedAt };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function probeImage(
|
||||
model: OpenAIModel,
|
||||
apiKey: string,
|
||||
timeoutMs: number,
|
||||
): Promise<ProbeResult> {
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Reply with OK." },
|
||||
{ type: "image", data: BASE_IMAGE_PNG, mimeType: "image/png" },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await withTimeout(timeoutMs, (signal) =>
|
||||
complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: 16,
|
||||
temperature: 0,
|
||||
signal,
|
||||
} satisfies OpenAICompletionsOptions),
|
||||
);
|
||||
return { ok: true, latencyMs: Date.now() - startedAt };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ensureImageInput(model: OpenAIModel): OpenAIModel {
|
||||
if (model.input.includes("image")) return model;
|
||||
return {
|
||||
...model,
|
||||
input: Array.from(new Set([...model.input, "image"])),
|
||||
};
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const limit = Math.max(1, Math.floor(concurrency));
|
||||
const results = Array.from({ length: items.length }) as R[];
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const current = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (current >= items.length) return;
|
||||
results[current] = await fn(items[current] as T, current);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function scanOpenRouterModels(
|
||||
options: OpenRouterScanOptions = {},
|
||||
): Promise<ModelScanResult[]> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const apiKey = options.apiKey?.trim() || getEnvApiKey("openrouter") || "";
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"Missing OpenRouter API key. Set OPENROUTER_API_KEY to run models scan.",
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(
|
||||
1,
|
||||
Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
||||
);
|
||||
const concurrency = Math.max(
|
||||
1,
|
||||
Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY),
|
||||
);
|
||||
const minParamB = Math.max(0, Math.floor(options.minParamB ?? 0));
|
||||
const maxAgeDays = Math.max(0, Math.floor(options.maxAgeDays ?? 0));
|
||||
const providerFilter = options.providerFilter?.trim().toLowerCase() ?? "";
|
||||
|
||||
const catalog = await fetchOpenRouterModels(fetchImpl);
|
||||
const now = Date.now();
|
||||
|
||||
const filtered = catalog.filter((entry) => {
|
||||
if (!entry.id.endsWith(":free")) return false;
|
||||
if (providerFilter) {
|
||||
const prefix = entry.id.split("/")[0]?.toLowerCase() ?? "";
|
||||
if (prefix !== providerFilter) return false;
|
||||
}
|
||||
if (minParamB > 0) {
|
||||
const params = entry.inferredParamB ?? 0;
|
||||
if (params < minParamB) return false;
|
||||
}
|
||||
if (maxAgeDays > 0 && entry.createdAtMs) {
|
||||
const ageMs = now - entry.createdAtMs;
|
||||
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
||||
if (ageDays > maxAgeDays) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const baseModel = getModel("openrouter", "openrouter/auto") as OpenAIModel;
|
||||
|
||||
return mapWithConcurrency(filtered, concurrency, async (entry) => {
|
||||
const model: OpenAIModel = {
|
||||
...baseModel,
|
||||
id: entry.id,
|
||||
name: entry.name || entry.id,
|
||||
contextWindow: entry.contextLength ?? baseModel.contextWindow,
|
||||
maxTokens: entry.maxCompletionTokens ?? baseModel.maxTokens,
|
||||
input: parseModality(entry.modality),
|
||||
reasoning: baseModel.reasoning,
|
||||
};
|
||||
|
||||
const toolResult = await probeTool(model, apiKey, timeoutMs);
|
||||
const imageResult = model.input.includes("image")
|
||||
? await probeImage(ensureImageInput(model), apiKey, timeoutMs)
|
||||
: { ok: false, latencyMs: null, skipped: true };
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
provider: "openrouter",
|
||||
modelRef: `openrouter/${entry.id}`,
|
||||
contextLength: entry.contextLength,
|
||||
maxCompletionTokens: entry.maxCompletionTokens,
|
||||
supportedParametersCount: entry.supportedParametersCount,
|
||||
modality: entry.modality,
|
||||
inferredParamB: entry.inferredParamB,
|
||||
createdAtMs: entry.createdAtMs,
|
||||
tool: toolResult,
|
||||
image: imageResult,
|
||||
} satisfies ModelScanResult;
|
||||
});
|
||||
}
|
||||
|
||||
export { OPENROUTER_MODELS_URL };
|
||||
export type { OpenRouterModelMeta };
|
||||
@@ -1,18 +1,8 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
getEnvApiKey,
|
||||
getOAuthApiKey,
|
||||
type Model,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
buildSystemPrompt,
|
||||
createAgentSession,
|
||||
@@ -32,10 +22,11 @@ import {
|
||||
type enqueueCommand,
|
||||
enqueueCommandInLane,
|
||||
} from "../process/command-queue.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import type { BashElevatedDefaults } from "./bash-tools.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import { getApiKeyForModel } from "./model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
@@ -105,11 +96,6 @@ type EmbeddedRunWaiter = {
|
||||
};
|
||||
const EMBEDDED_RUN_WAITERS = new Map<string, Set<EmbeddedRunWaiter>>();
|
||||
|
||||
const OAUTH_FILENAME = "oauth.json";
|
||||
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
|
||||
let oauthStorageConfigured = false;
|
||||
|
||||
type OAuthStorage = Record<string, OAuthCredentials>;
|
||||
type EmbeddedSandboxInfo = {
|
||||
enabled: boolean;
|
||||
workspaceDir?: string;
|
||||
@@ -139,92 +125,6 @@ export function buildEmbeddedSandboxInfo(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClawdbotOAuthPath(): string {
|
||||
const overrideDir =
|
||||
process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
|
||||
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
|
||||
}
|
||||
|
||||
function loadOAuthStorageAt(pathname: string): OAuthStorage | null {
|
||||
if (!fsSync.existsSync(pathname)) return null;
|
||||
try {
|
||||
const content = fsSync.readFileSync(pathname, "utf8");
|
||||
const json = JSON.parse(content) as OAuthStorage;
|
||||
if (!json || typeof json !== "object") return null;
|
||||
return json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnthropicOAuth(storage: OAuthStorage): boolean {
|
||||
const entry = storage.anthropic as
|
||||
| {
|
||||
refresh?: string;
|
||||
refresh_token?: string;
|
||||
refreshToken?: string;
|
||||
access?: string;
|
||||
access_token?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!entry) return false;
|
||||
const refresh =
|
||||
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
|
||||
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
|
||||
return Boolean(refresh.trim() && access.trim());
|
||||
}
|
||||
|
||||
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
|
||||
const dir = path.dirname(pathname);
|
||||
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fsSync.writeFileSync(
|
||||
pathname,
|
||||
`${JSON.stringify(storage, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fsSync.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
function legacyOAuthPaths(): string[] {
|
||||
const paths: string[] = [];
|
||||
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (piOverride) {
|
||||
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
|
||||
}
|
||||
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
function importLegacyOAuthIfNeeded(destPath: string): void {
|
||||
if (fsSync.existsSync(destPath)) return;
|
||||
for (const legacyPath of legacyOAuthPaths()) {
|
||||
const storage = loadOAuthStorageAt(legacyPath);
|
||||
if (!storage || !hasAnthropicOAuth(storage)) continue;
|
||||
saveOAuthStorageAt(destPath, storage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOAuthStorage(): void {
|
||||
if (oauthStorageConfigured) return;
|
||||
oauthStorageConfigured = true;
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
importLegacyOAuthIfNeeded(oauthPath);
|
||||
}
|
||||
|
||||
function isOAuthProvider(provider: string): provider is OAuthProvider {
|
||||
return (
|
||||
provider === "anthropic" ||
|
||||
provider === "github-copilot" ||
|
||||
provider === "google-gemini-cli" ||
|
||||
provider === "google-antigravity"
|
||||
);
|
||||
}
|
||||
|
||||
export function queueEmbeddedPiMessage(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
@@ -327,38 +227,6 @@ function resolveModel(
|
||||
return { model, authStorage, modelRegistry };
|
||||
}
|
||||
|
||||
async function getApiKeyForModel(
|
||||
model: Model<Api>,
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
): Promise<string> {
|
||||
const storedKey = await authStorage.getApiKey(model.provider);
|
||||
if (storedKey) return storedKey;
|
||||
ensureOAuthStorage();
|
||||
if (model.provider === "anthropic") {
|
||||
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
if (oauthEnv?.trim()) return oauthEnv.trim();
|
||||
}
|
||||
const envKey = getEnvApiKey(model.provider);
|
||||
if (envKey) return envKey;
|
||||
if (isOAuthProvider(model.provider)) {
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
const storage = loadOAuthStorageAt(oauthPath);
|
||||
if (storage) {
|
||||
try {
|
||||
const result = await getOAuthApiKey(model.provider, storage);
|
||||
if (result?.apiKey) {
|
||||
storage[model.provider] = result.newCredentials;
|
||||
saveOAuthStorageAt(oauthPath, storage);
|
||||
return result.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`No API key found for provider "${model.provider}"`);
|
||||
}
|
||||
|
||||
function resolvePromptSkills(
|
||||
snapshot: SkillSnapshot,
|
||||
entries: SkillEntry[],
|
||||
@@ -496,6 +364,8 @@ export async function runEmbeddedPiAgent(params: {
|
||||
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
|
||||
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
|
||||
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
|
||||
const tools = createClawdbotCodingTools({
|
||||
bash: {
|
||||
...params.config?.agent?.bash,
|
||||
@@ -504,6 +374,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
sandbox,
|
||||
surface: params.surface,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
config: params.config,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
|
||||
@@ -182,6 +182,110 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
expect(subscription.assistantTexts).toEqual(["Hello block"]);
|
||||
});
|
||||
|
||||
it("does not duplicate when message_end flushes and a late text_end arrives", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onBlockReply = vi.fn();
|
||||
|
||||
const subscription = subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<
|
||||
typeof subscribeEmbeddedPiSession
|
||||
>[0]["session"],
|
||||
runId: "run",
|
||||
onBlockReply,
|
||||
blockReplyBreak: "text_end",
|
||||
});
|
||||
|
||||
handler?.({ type: "message_start", message: { role: "assistant" } });
|
||||
|
||||
handler?.({
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: {
|
||||
type: "text_delta",
|
||||
delta: "Hello block",
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello block" }],
|
||||
} as AssistantMessage;
|
||||
|
||||
// Simulate a provider that ends the message without emitting text_end.
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
expect(subscription.assistantTexts).toEqual(["Hello block"]);
|
||||
|
||||
// Some providers can still emit a late text_end; this must not re-emit.
|
||||
handler?.({
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: {
|
||||
type: "text_end",
|
||||
content: "Hello block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
expect(subscription.assistantTexts).toEqual(["Hello block"]);
|
||||
});
|
||||
|
||||
it("clears block reply state on message_start", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onBlockReply = vi.fn();
|
||||
|
||||
subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<
|
||||
typeof subscribeEmbeddedPiSession
|
||||
>[0]["session"],
|
||||
runId: "run",
|
||||
onBlockReply,
|
||||
blockReplyBreak: "text_end",
|
||||
});
|
||||
|
||||
handler?.({ type: "message_start", message: { role: "assistant" } });
|
||||
handler?.({
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "OK" },
|
||||
});
|
||||
handler?.({
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: { type: "text_end" },
|
||||
});
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
|
||||
// New assistant message with identical output should still emit.
|
||||
handler?.({ type: "message_start", message: { role: "assistant" } });
|
||||
handler?.({
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "OK" },
|
||||
});
|
||||
handler?.({
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: { type: "text_end" },
|
||||
});
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not emit duplicate block replies when text_end repeats", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
@@ -231,6 +335,33 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
expect(subscription.assistantTexts).toEqual(["Hello block"]);
|
||||
});
|
||||
|
||||
it("does not duplicate assistantTexts when message_end repeats", () => {
|
||||
let handler: SessionEventHandler | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const subscription = subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<
|
||||
typeof subscribeEmbeddedPiSession
|
||||
>[0]["session"],
|
||||
runId: "run",
|
||||
});
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
} as AssistantMessage;
|
||||
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
|
||||
expect(subscription.assistantTexts).toEqual(["Hello world"]);
|
||||
});
|
||||
|
||||
it("does not append when text_end content is a prefix of deltas", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
|
||||
@@ -317,6 +317,20 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
|
||||
const unsubscribe = params.session.subscribe(
|
||||
(evt: AgentEvent | { type: string; [k: string]: unknown }) => {
|
||||
if (evt.type === "message_start") {
|
||||
const msg = (evt as AgentEvent & { message: AgentMessage }).message;
|
||||
if (msg?.role === "assistant") {
|
||||
// Start-of-message is a safer reset point than message_end: some providers
|
||||
// may deliver late text_end updates after message_end, which would
|
||||
// otherwise re-trigger block replies.
|
||||
deltaBuffer = "";
|
||||
blockBuffer = "";
|
||||
lastStreamedAssistant = undefined;
|
||||
lastBlockReplyText = undefined;
|
||||
assistantTextBaseline = assistantTexts.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.type === "tool_execution_start") {
|
||||
const toolName = String(
|
||||
(evt as AgentEvent & { toolName: string }).toolName,
|
||||
@@ -557,7 +571,10 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
|
||||
const addedDuringMessage =
|
||||
assistantTexts.length > assistantTextBaseline;
|
||||
if (!addedDuringMessage && text) assistantTexts.push(text);
|
||||
if (!addedDuringMessage && text) {
|
||||
const last = assistantTexts.at(-1);
|
||||
if (!last || last !== text) assistantTexts.push(text);
|
||||
}
|
||||
assistantTextBaseline = assistantTexts.length;
|
||||
|
||||
if (
|
||||
@@ -582,7 +599,6 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
deltaBuffer = "";
|
||||
blockBuffer = "";
|
||||
lastStreamedAssistant = undefined;
|
||||
lastBlockReplyText = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
-36
@@ -5,8 +5,16 @@ import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||
|
||||
describe("createClawdbotCodingTools", () => {
|
||||
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
|
||||
const browser = createBrowserTool();
|
||||
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.anyOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges properties for union tool schemas", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const browser = tools.find((tool) => tool.name === "browser");
|
||||
@@ -23,54 +31,47 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(parameters.required ?? []).toContain("action");
|
||||
});
|
||||
|
||||
it("preserves union action values in merged schema", () => {
|
||||
it("preserves action enums in normalized schemas", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway"];
|
||||
|
||||
const collectActionValues = (
|
||||
schema: unknown,
|
||||
values: Set<string>,
|
||||
): void => {
|
||||
if (!schema || typeof schema !== "object") return;
|
||||
const record = schema as Record<string, unknown>;
|
||||
if (typeof record.const === "string") values.add(record.const);
|
||||
if (Array.isArray(record.enum)) {
|
||||
for (const value of record.enum) {
|
||||
if (typeof value === "string") values.add(value);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(record.anyOf)) {
|
||||
for (const variant of record.anyOf) {
|
||||
collectActionValues(variant, values);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const name of toolNames) {
|
||||
const tool = tools.find((candidate) => candidate.name === name);
|
||||
expect(tool).toBeDefined();
|
||||
const parameters = tool?.parameters as {
|
||||
anyOf?: Array<{ properties?: Record<string, unknown> }>;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
if (!Array.isArray(parameters.anyOf) || parameters.anyOf.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const actionValues = new Set<string>();
|
||||
for (const variant of parameters.anyOf ?? []) {
|
||||
const action = variant?.properties?.action as
|
||||
| { const?: unknown; enum?: unknown[] }
|
||||
| undefined;
|
||||
if (typeof action?.const === "string") actionValues.add(action.const);
|
||||
if (Array.isArray(action?.enum)) {
|
||||
for (const value of action.enum) {
|
||||
if (typeof value === "string") actionValues.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actionValues.size <= 1) {
|
||||
continue;
|
||||
}
|
||||
const mergedAction = parameters.properties?.action as
|
||||
const action = parameters.properties?.action as
|
||||
| { const?: unknown; enum?: unknown[] }
|
||||
| undefined;
|
||||
const mergedValues = new Set<string>();
|
||||
if (typeof mergedAction?.const === "string") {
|
||||
mergedValues.add(mergedAction.const);
|
||||
}
|
||||
if (Array.isArray(mergedAction?.enum)) {
|
||||
for (const value of mergedAction.enum) {
|
||||
if (typeof value === "string") mergedValues.add(value);
|
||||
}
|
||||
}
|
||||
const values = new Set<string>();
|
||||
collectActionValues(action, values);
|
||||
|
||||
expect(actionValues.size).toBeGreaterThan(1);
|
||||
expect(mergedValues.size).toBe(actionValues.size);
|
||||
for (const value of actionValues) {
|
||||
expect(mergedValues.has(value)).toBe(true);
|
||||
}
|
||||
const min =
|
||||
name === "gateway"
|
||||
? 1
|
||||
: // Most tools expose multiple actions; keep this signal so schemas stay useful to models.
|
||||
2;
|
||||
expect(values.size).toBeGreaterThanOrEqual(min);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -80,6 +81,25 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(tools.some((tool) => tool.name === "process")).toBe(true);
|
||||
});
|
||||
|
||||
it("provides top-level object schemas for all tools", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const offenders = tools
|
||||
.map((tool) => {
|
||||
const schema =
|
||||
tool.parameters && typeof tool.parameters === "object"
|
||||
? (tool.parameters as Record<string, unknown>)
|
||||
: null;
|
||||
return {
|
||||
name: tool.name,
|
||||
type: schema?.type,
|
||||
keys: schema ? Object.keys(schema).sort() : null,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.type !== "object");
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("scopes discord tool to discord surface", () => {
|
||||
const other = createClawdbotCodingTools({ surface: "whatsapp" });
|
||||
expect(other.some((tool) => tool.name === "discord")).toBe(false);
|
||||
@@ -195,4 +215,12 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(tools.some((tool) => tool.name === "read")).toBe(false);
|
||||
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters tools by agent tool policy even without sandbox", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { agent: { tools: { deny: ["browser"] } } },
|
||||
});
|
||||
expect(tools.some((tool) => tool.name === "bash")).toBe(true);
|
||||
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
|
||||
});
|
||||
});
|
||||
+53
-7
@@ -7,7 +7,7 @@ import {
|
||||
readTool,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
|
||||
import {
|
||||
@@ -217,6 +217,13 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
|
||||
: undefined;
|
||||
if (!schema) return tool;
|
||||
|
||||
// Provider quirks:
|
||||
// - Gemini rejects several JSON Schema keywords, so we scrub those.
|
||||
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
|
||||
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
|
||||
//
|
||||
// Normalize once here so callers can always pass `tools` through unchanged.
|
||||
|
||||
// If schema already has type + properties (no top-level anyOf to merge),
|
||||
// still clean it for Gemini compatibility
|
||||
if (
|
||||
@@ -230,12 +237,32 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(schema.anyOf)) return tool;
|
||||
// Some tool schemas (esp. unions) may omit `type` at the top-level. If we see
|
||||
// object-ish fields, force `type: "object"` so OpenAI accepts the schema.
|
||||
if (
|
||||
!("type" in schema) &&
|
||||
(typeof schema.properties === "object" || Array.isArray(schema.required)) &&
|
||||
!Array.isArray(schema.anyOf) &&
|
||||
!Array.isArray(schema.oneOf)
|
||||
) {
|
||||
return {
|
||||
...tool,
|
||||
parameters: cleanSchemaForGemini({ ...schema, type: "object" }),
|
||||
};
|
||||
}
|
||||
|
||||
const variantKey = Array.isArray(schema.anyOf)
|
||||
? "anyOf"
|
||||
: Array.isArray(schema.oneOf)
|
||||
? "oneOf"
|
||||
: null;
|
||||
if (!variantKey) return tool;
|
||||
const variants = schema[variantKey] as unknown[];
|
||||
const mergedProperties: Record<string, unknown> = {};
|
||||
const requiredCounts = new Map<string, number>();
|
||||
let objectVariants = 0;
|
||||
|
||||
for (const entry of schema.anyOf) {
|
||||
for (const entry of variants) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const props = (entry as { properties?: unknown }).properties;
|
||||
if (!props || typeof props !== "object") continue;
|
||||
@@ -276,9 +303,18 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
|
||||
const nextSchema: Record<string, unknown> = { ...schema };
|
||||
return {
|
||||
...tool,
|
||||
// Flatten union schemas into a single object schema:
|
||||
// - Gemini doesn't allow top-level `type` together with `anyOf`.
|
||||
// - OpenAI rejects schemas without top-level `type: "object"`.
|
||||
// Merging properties preserves useful enums like `action` while keeping schemas portable.
|
||||
parameters: cleanSchemaForGemini({
|
||||
...nextSchema,
|
||||
type: nextSchema.type ?? "object",
|
||||
type: "object",
|
||||
...(typeof nextSchema.title === "string"
|
||||
? { title: nextSchema.title }
|
||||
: {}),
|
||||
...(typeof nextSchema.description === "string"
|
||||
? { description: nextSchema.description }
|
||||
: {}),
|
||||
properties:
|
||||
Object.keys(mergedProperties).length > 0
|
||||
? mergedProperties
|
||||
@@ -452,6 +488,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
surface?: string;
|
||||
sandbox?: SandboxContext | null;
|
||||
sessionKey?: string;
|
||||
config?: ClawdbotConfig;
|
||||
}): AnyAgentTool[] {
|
||||
const bashToolName = "bash";
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
@@ -497,6 +534,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||
agentSessionKey: options?.sessionKey,
|
||||
agentSurface: options?.surface,
|
||||
config: options?.config,
|
||||
}),
|
||||
];
|
||||
const allowDiscord = shouldIncludeDiscordTool(options?.surface);
|
||||
@@ -506,8 +544,16 @@ export function createClawdbotCodingTools(options?: {
|
||||
if (tool.name === "slack") return allowSlack;
|
||||
return true;
|
||||
});
|
||||
const globallyFiltered =
|
||||
options?.config?.agent?.tools &&
|
||||
(options.config.agent.tools.allow?.length ||
|
||||
options.config.agent.tools.deny?.length)
|
||||
? filterToolsByPolicy(filtered, options.config.agent.tools)
|
||||
: filtered;
|
||||
const sandboxed = sandbox
|
||||
? filterToolsByPolicy(filtered, sandbox.tools)
|
||||
: filtered;
|
||||
? filterToolsByPolicy(globallyFiltered, sandbox.tools)
|
||||
: globallyFiltered;
|
||||
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
|
||||
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
|
||||
return sandboxed.map(normalizeToolParameters);
|
||||
}
|
||||
@@ -97,99 +97,44 @@ const BrowserActSchema = Type.Union([
|
||||
}),
|
||||
]);
|
||||
|
||||
const BrowserToolSchema = Type.Union([
|
||||
Type.Object({
|
||||
action: Type.Literal("status"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("start"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("stop"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("tabs"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("open"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetUrl: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("focus"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("close"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("snapshot"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
format: Type.Optional(
|
||||
Type.Union([Type.Literal("aria"), Type.Literal("ai")]),
|
||||
),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("screenshot"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
fullPage: Type.Optional(Type.Boolean()),
|
||||
ref: Type.Optional(Type.String()),
|
||||
element: Type.Optional(Type.String()),
|
||||
type: Type.Optional(
|
||||
Type.Union([Type.Literal("png"), Type.Literal("jpeg")]),
|
||||
),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("navigate"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetUrl: Type.String(),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("console"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
level: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("pdf"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("upload"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
paths: Type.Array(Type.String()),
|
||||
ref: Type.Optional(Type.String()),
|
||||
inputRef: Type.Optional(Type.String()),
|
||||
element: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("dialog"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
accept: Type.Boolean(),
|
||||
promptText: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("act"),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
request: BrowserActSchema,
|
||||
}),
|
||||
]);
|
||||
// IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`.
|
||||
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
|
||||
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
|
||||
const BrowserToolSchema = Type.Object({
|
||||
action: Type.Union([
|
||||
Type.Literal("status"),
|
||||
Type.Literal("start"),
|
||||
Type.Literal("stop"),
|
||||
Type.Literal("tabs"),
|
||||
Type.Literal("open"),
|
||||
Type.Literal("focus"),
|
||||
Type.Literal("close"),
|
||||
Type.Literal("snapshot"),
|
||||
Type.Literal("screenshot"),
|
||||
Type.Literal("navigate"),
|
||||
Type.Literal("console"),
|
||||
Type.Literal("pdf"),
|
||||
Type.Literal("upload"),
|
||||
Type.Literal("dialog"),
|
||||
Type.Literal("act"),
|
||||
]),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetUrl: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
format: Type.Optional(Type.Union([Type.Literal("aria"), Type.Literal("ai")])),
|
||||
fullPage: Type.Optional(Type.Boolean()),
|
||||
ref: Type.Optional(Type.String()),
|
||||
element: Type.Optional(Type.String()),
|
||||
type: Type.Optional(Type.Union([Type.Literal("png"), Type.Literal("jpeg")])),
|
||||
level: Type.Optional(Type.String()),
|
||||
paths: Type.Optional(Type.Array(Type.String())),
|
||||
inputRef: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
accept: Type.Optional(Type.Boolean()),
|
||||
promptText: Type.Optional(Type.String()),
|
||||
request: Type.Optional(BrowserActSchema),
|
||||
});
|
||||
|
||||
function resolveBrowserBaseUrl(controlUrl?: string) {
|
||||
const cfg = loadConfig();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
import { createCronTool } from "./cron-tool.js";
|
||||
|
||||
describe("cron tool", () => {
|
||||
beforeEach(() => {
|
||||
callGatewayMock.mockReset();
|
||||
callGatewayMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"update",
|
||||
{ action: "update", id: "job-1", patch: { foo: "bar" } },
|
||||
{ id: "job-1", patch: { foo: "bar" } },
|
||||
],
|
||||
["remove", { action: "remove", id: "job-1" }, { id: "job-1" }],
|
||||
["run", { action: "run", id: "job-1" }, { id: "job-1" }],
|
||||
["runs", { action: "runs", id: "job-1" }, { id: "job-1" }],
|
||||
])("%s sends id to gateway", async (action, args, expectedParams) => {
|
||||
const tool = createCronTool();
|
||||
await tool.execute("call1", args);
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
expect(call.method).toBe(`cron.${action}`);
|
||||
expect(call.params).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("rejects jobId params", async () => {
|
||||
const tool = createCronTool();
|
||||
await expect(
|
||||
tool.execute("call2", {
|
||||
action: "update",
|
||||
jobId: "job-1",
|
||||
patch: { foo: "bar" },
|
||||
}),
|
||||
).rejects.toThrow("id required");
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ const CronToolSchema = Type.Union([
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
jobId: Type.String(),
|
||||
id: Type.String(),
|
||||
patch: Type.Object({}, { additionalProperties: true }),
|
||||
}),
|
||||
Type.Object({
|
||||
@@ -37,21 +37,21 @@ const CronToolSchema = Type.Union([
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
jobId: Type.String(),
|
||||
id: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("run"),
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
jobId: Type.String(),
|
||||
id: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("runs"),
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
jobId: Type.String(),
|
||||
id: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("wake"),
|
||||
@@ -102,33 +102,33 @@ export function createCronTool(): AnyAgentTool {
|
||||
);
|
||||
}
|
||||
case "update": {
|
||||
const jobId = readStringParam(params, "jobId", { required: true });
|
||||
const id = readStringParam(params, "id", { required: true });
|
||||
if (!params.patch || typeof params.patch !== "object") {
|
||||
throw new Error("patch required");
|
||||
}
|
||||
return jsonResult(
|
||||
await callGatewayTool("cron.update", gatewayOpts, {
|
||||
jobId,
|
||||
id,
|
||||
patch: params.patch,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "remove": {
|
||||
const jobId = readStringParam(params, "jobId", { required: true });
|
||||
const id = readStringParam(params, "id", { required: true });
|
||||
return jsonResult(
|
||||
await callGatewayTool("cron.remove", gatewayOpts, { jobId }),
|
||||
await callGatewayTool("cron.remove", gatewayOpts, { id }),
|
||||
);
|
||||
}
|
||||
case "run": {
|
||||
const jobId = readStringParam(params, "jobId", { required: true });
|
||||
const id = readStringParam(params, "id", { required: true });
|
||||
return jsonResult(
|
||||
await callGatewayTool("cron.run", gatewayOpts, { jobId }),
|
||||
await callGatewayTool("cron.run", gatewayOpts, { id }),
|
||||
);
|
||||
}
|
||||
case "runs": {
|
||||
const jobId = readStringParam(params, "jobId", { required: true });
|
||||
const id = readStringParam(params, "id", { required: true });
|
||||
return jsonResult(
|
||||
await callGatewayTool("cron.runs", gatewayOpts, { jobId }),
|
||||
await callGatewayTool("cron.runs", gatewayOpts, { id }),
|
||||
);
|
||||
}
|
||||
case "wake": {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
complete,
|
||||
type Model,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { loadWebMedia } from "../../web/media.js";
|
||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||
import { getApiKeyForModel } from "../model-auth.js";
|
||||
import { runWithImageModelFallback } from "../model-fallback.js";
|
||||
import { ensureClawdbotModelsJson } from "../models-config.js";
|
||||
import { extractAssistantText } from "../pi-embedded-utils.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
|
||||
const DEFAULT_PROMPT = "Describe the image.";
|
||||
|
||||
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
|
||||
const primary = cfg?.agent?.imageModel?.trim();
|
||||
const fallbacks = cfg?.agent?.imageModelFallbacks ?? [];
|
||||
return Boolean(primary || fallbacks.length > 0);
|
||||
}
|
||||
|
||||
function pickMaxBytes(
|
||||
cfg?: ClawdbotConfig,
|
||||
maxBytesMb?: number,
|
||||
): number | undefined {
|
||||
if (
|
||||
typeof maxBytesMb === "number" &&
|
||||
Number.isFinite(maxBytesMb) &&
|
||||
maxBytesMb > 0
|
||||
) {
|
||||
return Math.floor(maxBytesMb * 1024 * 1024);
|
||||
}
|
||||
const configured = cfg?.agent?.mediaMaxMb;
|
||||
if (
|
||||
typeof configured === "number" &&
|
||||
Number.isFinite(configured) &&
|
||||
configured > 0
|
||||
) {
|
||||
return Math.floor(configured * 1024 * 1024);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildImageContext(
|
||||
prompt: string,
|
||||
base64: string,
|
||||
mimeType: string,
|
||||
): Context {
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "image", data: base64, mimeType },
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function runImagePrompt(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
modelOverride?: string;
|
||||
prompt: string;
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
}): Promise<{ text: string; provider: string; model: string }> {
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
await ensureClawdbotModelsJson(params.cfg);
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, agentDir);
|
||||
|
||||
const result = await runWithImageModelFallback({
|
||||
cfg: params.cfg,
|
||||
modelOverride: params.modelOverride,
|
||||
run: async (provider, modelId) => {
|
||||
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
|
||||
if (!model) {
|
||||
throw new Error(`Unknown model: ${provider}/${modelId}`);
|
||||
}
|
||||
if (!model.input?.includes("image")) {
|
||||
throw new Error(
|
||||
`Model does not support images: ${provider}/${modelId}`,
|
||||
);
|
||||
}
|
||||
const apiKey = await getApiKeyForModel(model, authStorage);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
const context = buildImageContext(
|
||||
params.prompt,
|
||||
params.base64,
|
||||
params.mimeType,
|
||||
);
|
||||
const message = (await complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: 512,
|
||||
temperature: 0,
|
||||
})) as AssistantMessage;
|
||||
return message;
|
||||
},
|
||||
});
|
||||
|
||||
const text = extractAssistantText(result.result);
|
||||
return {
|
||||
text: text || "(no text returned)",
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageTool(options?: {
|
||||
config?: ClawdbotConfig;
|
||||
}): AnyAgentTool | null {
|
||||
if (!ensureImageToolConfigured(options?.config)) return null;
|
||||
return {
|
||||
label: "Image",
|
||||
name: "image",
|
||||
description:
|
||||
"Analyze an image with the configured image model (agent.imageModel). Provide a prompt and image path or URL.",
|
||||
parameters: Type.Object({
|
||||
prompt: Type.Optional(Type.String()),
|
||||
image: Type.String(),
|
||||
model: Type.Optional(Type.String()),
|
||||
maxBytesMb: Type.Optional(Type.Number()),
|
||||
}),
|
||||
execute: async (_toolCallId, args) => {
|
||||
const record =
|
||||
args && typeof args === "object"
|
||||
? (args as Record<string, unknown>)
|
||||
: {};
|
||||
const imageRaw =
|
||||
typeof record.image === "string" ? record.image.trim() : "";
|
||||
if (!imageRaw) throw new Error("image required");
|
||||
const promptRaw =
|
||||
typeof record.prompt === "string" && record.prompt.trim()
|
||||
? record.prompt.trim()
|
||||
: DEFAULT_PROMPT;
|
||||
const modelOverride =
|
||||
typeof record.model === "string" && record.model.trim()
|
||||
? record.model.trim()
|
||||
: undefined;
|
||||
const maxBytesMb =
|
||||
typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
|
||||
const maxBytes = pickMaxBytes(options?.config, maxBytesMb);
|
||||
|
||||
const resolvedImage = imageRaw.startsWith("~")
|
||||
? resolveUserPath(imageRaw)
|
||||
: imageRaw;
|
||||
const media = await loadWebMedia(resolvedImage, maxBytes);
|
||||
if (media.kind !== "image") {
|
||||
throw new Error(`Unsupported media type: ${media.kind}`);
|
||||
}
|
||||
|
||||
const mimeType = media.contentType ?? "image/png";
|
||||
const base64 = media.buffer.toString("base64");
|
||||
const result = await runImagePrompt({
|
||||
cfg: options?.config,
|
||||
modelOverride,
|
||||
prompt: promptRaw,
|
||||
base64,
|
||||
mimeType,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: result.text }],
|
||||
details: {
|
||||
model: `${result.provider}/${result.model}`,
|
||||
image: resolvedImage,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+12
-1
@@ -5,7 +5,18 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(os.homedir(), "clawd");
|
||||
export function resolveDefaultAgentWorkspaceDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
): string {
|
||||
const profile = env.CLAWDBOT_PROFILE?.trim();
|
||||
if (profile && profile.toLowerCase() !== "default") {
|
||||
return path.join(homedir(), `clawd-${profile}`);
|
||||
}
|
||||
return path.join(homedir(), "clawd");
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENT_WORKSPACE_DIR = resolveDefaultAgentWorkspaceDir();
|
||||
export const DEFAULT_AGENTS_FILENAME = "AGENTS.md";
|
||||
export const DEFAULT_SOUL_FILENAME = "SOUL.md";
|
||||
export const DEFAULT_TOOLS_FILENAME = "TOOLS.md";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const CONTROL_COMMAND_RE =
|
||||
/(?:^|\s)\/(?:status|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i;
|
||||
|
||||
const CONTROL_COMMAND_EXACT = new Set([
|
||||
"status",
|
||||
"/status",
|
||||
"restart",
|
||||
"/restart",
|
||||
"activation",
|
||||
"/activation",
|
||||
"send",
|
||||
"/send",
|
||||
"reset",
|
||||
"/reset",
|
||||
"new",
|
||||
"/new",
|
||||
]);
|
||||
|
||||
export function hasControlCommand(text?: string): boolean {
|
||||
if (!text) return false;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (CONTROL_COMMAND_EXACT.has(lowered)) return true;
|
||||
return CONTROL_COMMAND_RE.test(text);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const webMocks = vi.hoisted(() => ({
|
||||
webAuthExists: vi.fn().mockResolvedValue(true),
|
||||
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
||||
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
|
||||
}));
|
||||
|
||||
vi.mock("../web/session.js", () => webMocks);
|
||||
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-typing-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
runEmbeddedPiAgentMock.mockClear();
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function makeCfg(home: string) {
|
||||
return {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getReplyFromConfig typing (heartbeat)", () => {
|
||||
it("starts typing for normal runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {},
|
||||
});
|
||||
const onReplyStart = vi.fn();
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
|
||||
{ onReplyStart, isHeartbeat: false },
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
expect(onReplyStart).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not start typing for heartbeat runs", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {},
|
||||
});
|
||||
const onReplyStart = vi.fn();
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "hi", From: "+1000", To: "+2000", Surface: "whatsapp" },
|
||||
{ onReplyStart, isHeartbeat: true },
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
expect(onReplyStart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,23 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports status when /status appears inline", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "please /status now",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Status");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows owner to set send policy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
@@ -485,7 +502,7 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores /activation from non-owners in groups", async () => {
|
||||
it("allows /activation from allowFrom in groups", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
const res = await getReplyFromConfig(
|
||||
@@ -500,7 +517,8 @@ describe("trigger handling", () => {
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("⚙️ Group activation set to mention.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+27
-7
@@ -20,6 +20,7 @@ import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import { getAbortMemory } from "./reply/abort.js";
|
||||
import { runReplyAgent } from "./reply/agent-runner.js";
|
||||
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
|
||||
@@ -252,11 +253,22 @@ export async function getReplyFromConfig(
|
||||
triggerBodyNormalized,
|
||||
} = sessionState;
|
||||
|
||||
const directives = parseInlineDirectives(
|
||||
sessionCtx.BodyStripped ?? sessionCtx.Body ?? "",
|
||||
);
|
||||
sessionCtx.Body = directives.cleaned;
|
||||
sessionCtx.BodyStripped = directives.cleaned;
|
||||
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||
const parsedDirectives = parseInlineDirectives(rawBody);
|
||||
const directives = commandAuthorized
|
||||
? parsedDirectives
|
||||
: {
|
||||
...parsedDirectives,
|
||||
hasThinkDirective: false,
|
||||
hasVerboseDirective: false,
|
||||
hasStatusDirective: false,
|
||||
hasModelDirective: false,
|
||||
hasQueueDirective: false,
|
||||
queueReset: false,
|
||||
};
|
||||
sessionCtx.Body = parsedDirectives.cleaned;
|
||||
sessionCtx.BodyStripped = parsedDirectives.cleaned;
|
||||
|
||||
const surfaceKey =
|
||||
sessionCtx.Surface?.trim().toLowerCase() ??
|
||||
@@ -305,7 +317,7 @@ export async function getReplyFromConfig(
|
||||
(sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
|
||||
"on")
|
||||
: "on";
|
||||
: "off";
|
||||
const resolvedBlockStreaming =
|
||||
agentCfg?.blockStreamingDefault === "off" ? "off" : "on";
|
||||
const resolvedBlockStreamingBreak: "text_end" | "message_end" =
|
||||
@@ -424,6 +436,7 @@ export async function getReplyFromConfig(
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
});
|
||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||
if (
|
||||
@@ -445,6 +458,7 @@ export async function getReplyFromConfig(
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
@@ -467,7 +481,8 @@ export async function getReplyFromConfig(
|
||||
const isFirstTurnInSession = isNewSession || !systemSent;
|
||||
const isGroupChat = sessionCtx.ChatType === "group";
|
||||
const wasMentioned = ctx.WasMentioned === true;
|
||||
const shouldEagerType = !isGroupChat || wasMentioned;
|
||||
const isHeartbeat = opts?.isHeartbeat === true;
|
||||
const shouldEagerType = (!isGroupChat || wasMentioned) && !isHeartbeat;
|
||||
const shouldInjectGroupIntro = Boolean(
|
||||
isGroupChat &&
|
||||
(isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro),
|
||||
@@ -483,6 +498,10 @@ export async function getReplyFromConfig(
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
||||
const baseBodyTrimmedRaw = baseBody.trim();
|
||||
if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) {
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
const isBareSessionReset =
|
||||
isNewSession &&
|
||||
baseBodyTrimmedRaw.length === 0 &&
|
||||
@@ -515,6 +534,7 @@ export async function getReplyFromConfig(
|
||||
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
|
||||
prefixedBodyBase = await prependSystemEvents({
|
||||
cfg,
|
||||
sessionKey,
|
||||
isMainSession,
|
||||
isNewSession,
|
||||
prefixedBodyBase,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { GetReplyOptions } from "../types.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
scheduleFollowupDrain: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { runReplyAgent } from "./agent-runner.js";
|
||||
|
||||
function createTyping(): TypingController {
|
||||
return {
|
||||
onReplyStart: vi.fn(async () => {}),
|
||||
startTypingLoop: vi.fn(async () => {}),
|
||||
startTypingOnText: vi.fn(async () => {}),
|
||||
refreshTypingTtl: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
type EmbeddedPiAgentParams = {
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function createMinimalRun(params?: { opts?: GetReplyOptions }) {
|
||||
const typing = createTyping();
|
||||
const opts = params?.opts;
|
||||
const sessionCtx = {
|
||||
Surface: "whatsapp",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const followupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
surface: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as unknown as FollowupRun;
|
||||
|
||||
return {
|
||||
typing,
|
||||
opts,
|
||||
run: () =>
|
||||
runReplyAgent({
|
||||
commandBody: "hello",
|
||||
followupRun,
|
||||
queueKey: "main",
|
||||
resolvedQueue,
|
||||
shouldSteer: false,
|
||||
shouldFollowup: false,
|
||||
isActive: false,
|
||||
isStreaming: false,
|
||||
opts,
|
||||
typing,
|
||||
sessionCtx,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
resolvedVerboseLevel: "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
shouldInjectGroupIntro: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("runReplyAgent typing (heartbeat)", () => {
|
||||
it("signals typing for normal runs", async () => {
|
||||
const onPartialReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: false, onPartialReply },
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(onPartialReply).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).toHaveBeenCalledWith("hi");
|
||||
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never signals typing for heartbeat runs", async () => {
|
||||
const onPartialReply = vi.fn();
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams) => {
|
||||
await params.onPartialReply?.({ text: "hi" });
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const { run, typing } = createMinimalRun({
|
||||
opts: { isHeartbeat: true, onPartialReply },
|
||||
});
|
||||
await run();
|
||||
|
||||
expect(onPartialReply).toHaveBeenCalled();
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import {
|
||||
queueEmbeddedPiMessage,
|
||||
runEmbeddedPiAgent,
|
||||
@@ -83,6 +84,8 @@ export async function runReplyAgent(params: {
|
||||
shouldInjectGroupIntro,
|
||||
} = params;
|
||||
|
||||
const isHeartbeat = opts?.isHeartbeat === true;
|
||||
|
||||
const shouldEmitToolResult = () => {
|
||||
if (!sessionKey || !storePath) {
|
||||
return resolvedVerboseLevel === "on";
|
||||
@@ -170,131 +173,164 @@ export async function runReplyAgent(params: {
|
||||
registerAgentRunContext(runId, { sessionKey });
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = followupRun.run.provider;
|
||||
let fallbackModel = followupRun.run.model;
|
||||
try {
|
||||
runResult = await runEmbeddedPiAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
config: followupRun.run.config,
|
||||
skillsSnapshot: followupRun.run.skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
extraSystemPrompt: followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: followupRun.run.enforceFinalTag,
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: followupRun.run.config,
|
||||
provider: followupRun.run.provider,
|
||||
model: followupRun.run.model,
|
||||
thinkLevel: followupRun.run.thinkLevel,
|
||||
verboseLevel: followupRun.run.verboseLevel,
|
||||
bashElevated: followupRun.run.bashElevated,
|
||||
timeoutMs: followupRun.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: resolvedBlockStreamingBreak,
|
||||
blockReplyChunking,
|
||||
onPartialReply: opts?.onPartialReply
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
if (
|
||||
stripped.shouldSkip &&
|
||||
(payload.mediaUrls?.length ?? 0) === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
await typing.startTypingOnText(text);
|
||||
await opts.onPartialReply?.({
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onBlockReply:
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
run: (provider, model) =>
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: followupRun.run.sessionId,
|
||||
sessionKey,
|
||||
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
config: followupRun.run.config,
|
||||
skillsSnapshot: followupRun.run.skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
extraSystemPrompt: followupRun.run.extraSystemPrompt,
|
||||
ownerNumbers: followupRun.run.ownerNumbers,
|
||||
enforceFinalTag: followupRun.run.enforceFinalTag,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: followupRun.run.thinkLevel,
|
||||
verboseLevel: followupRun.run.verboseLevel,
|
||||
bashElevated: followupRun.run.bashElevated,
|
||||
timeoutMs: followupRun.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: resolvedBlockStreamingBreak,
|
||||
blockReplyChunking,
|
||||
onPartialReply: opts?.onPartialReply
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose(
|
||||
"Stripped stray HEARTBEAT_OK token from reply",
|
||||
);
|
||||
}
|
||||
if (
|
||||
stripped.shouldSkip &&
|
||||
(payload.mediaUrls?.length ?? 0) === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return;
|
||||
text = stripped.text;
|
||||
}
|
||||
const tagResult = extractReplyToTag(
|
||||
text,
|
||||
sessionCtx.MessageSid,
|
||||
);
|
||||
const cleaned = tagResult.cleaned || undefined;
|
||||
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (!cleaned && !hasMedia) return;
|
||||
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) return;
|
||||
const blockPayload: ReplyPayload = {
|
||||
text: cleaned,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
mediaUrl: payload.mediaUrls?.[0],
|
||||
replyToId: tagResult.replyToId,
|
||||
};
|
||||
const payloadKey = buildPayloadKey(blockPayload);
|
||||
if (
|
||||
streamedPayloadKeys.has(payloadKey) ||
|
||||
pendingStreamedPayloadKeys.has(payloadKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pendingStreamedPayloadKeys.add(payloadKey);
|
||||
const task = (async () => {
|
||||
await typing.startTypingOnText(cleaned);
|
||||
await opts.onBlockReply?.(blockPayload);
|
||||
})()
|
||||
.then(() => {
|
||||
streamedPayloadKeys.add(payloadKey);
|
||||
didStreamBlockReply = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
logVerbose(`block reply delivery failed: ${String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingStreamedPayloadKeys.delete(payloadKey);
|
||||
if (!isHeartbeat) {
|
||||
await typing.startTypingOnText(text);
|
||||
}
|
||||
await opts.onPartialReply?.({
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
pendingBlockTasks.add(task);
|
||||
void task.finally(() => pendingBlockTasks.delete(task));
|
||||
}
|
||||
: undefined,
|
||||
shouldEmitToolResult,
|
||||
onToolResult: opts?.onToolResult
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
|
||||
}
|
||||
if (
|
||||
stripped.shouldSkip &&
|
||||
(payload.mediaUrls?.length ?? 0) === 0
|
||||
) {
|
||||
return;
|
||||
: undefined,
|
||||
onBlockReply:
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose(
|
||||
"Stripped stray HEARTBEAT_OK token from reply",
|
||||
);
|
||||
}
|
||||
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (stripped.shouldSkip && !hasMedia) return;
|
||||
text = stripped.text;
|
||||
}
|
||||
const tagResult = extractReplyToTag(
|
||||
text,
|
||||
sessionCtx.MessageSid,
|
||||
);
|
||||
const cleaned = tagResult.cleaned || undefined;
|
||||
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (!cleaned && !hasMedia) return;
|
||||
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia)
|
||||
return;
|
||||
const blockPayload: ReplyPayload = {
|
||||
text: cleaned,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
mediaUrl: payload.mediaUrls?.[0],
|
||||
replyToId: tagResult.replyToId,
|
||||
};
|
||||
const payloadKey = buildPayloadKey(blockPayload);
|
||||
if (
|
||||
streamedPayloadKeys.has(payloadKey) ||
|
||||
pendingStreamedPayloadKeys.has(payloadKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
pendingStreamedPayloadKeys.add(payloadKey);
|
||||
const task = (async () => {
|
||||
if (!isHeartbeat) {
|
||||
await typing.startTypingOnText(cleaned);
|
||||
}
|
||||
await opts.onBlockReply?.(blockPayload);
|
||||
})()
|
||||
.then(() => {
|
||||
streamedPayloadKeys.add(payloadKey);
|
||||
didStreamBlockReply = true;
|
||||
})
|
||||
.catch((err) => {
|
||||
logVerbose(
|
||||
`block reply delivery failed: ${String(err)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingStreamedPayloadKeys.delete(payloadKey);
|
||||
});
|
||||
pendingBlockTasks.add(task);
|
||||
void task.finally(() => pendingBlockTasks.delete(task));
|
||||
}
|
||||
: undefined,
|
||||
shouldEmitToolResult,
|
||||
onToolResult: opts?.onToolResult
|
||||
? async (payload) => {
|
||||
let text = payload.text;
|
||||
if (!isHeartbeat && text?.includes("HEARTBEAT_OK")) {
|
||||
const stripped = stripHeartbeatToken(text, {
|
||||
mode: "message",
|
||||
});
|
||||
if (stripped.didStrip && !didLogHeartbeatStrip) {
|
||||
didLogHeartbeatStrip = true;
|
||||
logVerbose(
|
||||
"Stripped stray HEARTBEAT_OK token from reply",
|
||||
);
|
||||
}
|
||||
if (
|
||||
stripped.shouldSkip &&
|
||||
(payload.mediaUrls?.length ?? 0) === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
if (!isHeartbeat) {
|
||||
await typing.startTypingOnText(text);
|
||||
}
|
||||
await opts.onToolResult?.({
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
});
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
await typing.startTypingOnText(text);
|
||||
await opts.onToolResult?.({ text, mediaUrls: payload.mediaUrls });
|
||||
}
|
||||
: undefined,
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const isContextOverflow =
|
||||
@@ -328,7 +364,7 @@ export async function runReplyAgent(params: {
|
||||
await Promise.allSettled(pendingBlockTasks);
|
||||
}
|
||||
|
||||
const sanitizedPayloads = opts?.isHeartbeat
|
||||
const sanitizedPayloads = isHeartbeat
|
||||
? payloadArray
|
||||
: payloadArray.flatMap((payload) => {
|
||||
const text = payload.text;
|
||||
@@ -382,13 +418,18 @@ export async function runReplyAgent(params: {
|
||||
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
if (shouldSignalTyping) {
|
||||
if (shouldSignalTyping && !isHeartbeat) {
|
||||
await typing.startTypingLoop();
|
||||
}
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const providerUsed =
|
||||
runResult.meta.agentMeta?.provider ??
|
||||
fallbackProvider ??
|
||||
followupRun.run.provider;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
@@ -408,6 +449,7 @@ export async function runReplyAgent(params: {
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: providerUsed,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
@@ -422,6 +464,7 @@ export async function runReplyAgent(params: {
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
modelProvider: providerUsed ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
};
|
||||
|
||||
@@ -20,13 +20,14 @@ import type { MsgContext } from "../templating.js";
|
||||
import type { ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { stripMentions } from "./mentions.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
isWhatsAppSurface: boolean;
|
||||
ownerList: string[];
|
||||
isOwnerSender: boolean;
|
||||
isAuthorizedSender: boolean;
|
||||
senderE164?: string;
|
||||
abortKey?: string;
|
||||
rawBodyNormalized: string;
|
||||
@@ -41,8 +42,16 @@ export function buildCommandContext(params: {
|
||||
sessionKey?: string;
|
||||
isGroup: boolean;
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandContext {
|
||||
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
|
||||
const {
|
||||
ctx,
|
||||
cfg,
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
} = params;
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" ||
|
||||
@@ -54,16 +63,12 @@ export function buildCommandContext(params: {
|
||||
: undefined;
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const defaultAllowFrom =
|
||||
isWhatsAppSurface &&
|
||||
(!configuredAllowFrom || configuredAllowFrom.length === 0) &&
|
||||
to
|
||||
? [to]
|
||||
: undefined;
|
||||
const allowFrom =
|
||||
configuredAllowFrom && configuredAllowFrom.length > 0
|
||||
? configuredAllowFrom
|
||||
: defaultAllowFrom;
|
||||
const allowFromList =
|
||||
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
|
||||
const allowAll =
|
||||
!isWhatsAppSurface ||
|
||||
allowFromList.length === 0 ||
|
||||
allowFromList.some((entry) => entry.trim() === "*");
|
||||
|
||||
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
@@ -71,23 +76,28 @@ export function buildCommandContext(params: {
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg)
|
||||
: rawBodyNormalized;
|
||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||
const ownerCandidates = isWhatsAppSurface
|
||||
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
|
||||
const ownerCandidates =
|
||||
isWhatsAppSurface && !allowAll
|
||||
? allowFromList.filter((entry) => entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
const isOwnerSender =
|
||||
Boolean(senderE164) && ownerList.includes(senderE164 ?? "");
|
||||
const isOwner =
|
||||
!isWhatsAppSurface ||
|
||||
allowAll ||
|
||||
ownerList.length === 0 ||
|
||||
(senderE164 ? ownerList.includes(senderE164) : false);
|
||||
const isAuthorizedSender = commandAuthorized && isOwner;
|
||||
|
||||
return {
|
||||
surface,
|
||||
isWhatsAppSurface,
|
||||
ownerList,
|
||||
isOwnerSender,
|
||||
isAuthorizedSender,
|
||||
senderE164: senderE164 || undefined,
|
||||
abortKey,
|
||||
rawBodyNormalized,
|
||||
@@ -101,6 +111,7 @@ export async function handleCommands(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
command: CommandContext;
|
||||
directives: InlineDirectives;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
@@ -122,6 +133,7 @@ export async function handleCommands(params: {
|
||||
const {
|
||||
cfg,
|
||||
command,
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
@@ -151,9 +163,22 @@ export async function handleCommands(params: {
|
||||
reply: { text: "⚙️ Group activation only applies to group chats." },
|
||||
};
|
||||
}
|
||||
if (!command.isOwnerSender) {
|
||||
const activationOwnerList = command.ownerList;
|
||||
const activationSenderE164 = command.senderE164
|
||||
? normalizeE164(command.senderE164)
|
||||
: "";
|
||||
const isActivationOwner =
|
||||
!command.isWhatsAppSurface || activationOwnerList.length === 0
|
||||
? command.isAuthorizedSender
|
||||
: Boolean(activationSenderE164) &&
|
||||
activationOwnerList.includes(activationSenderE164);
|
||||
|
||||
if (
|
||||
!command.isAuthorizedSender ||
|
||||
(command.isWhatsAppSurface && !isActivationOwner)
|
||||
) {
|
||||
logVerbose(
|
||||
`Ignoring /activation from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
`Ignoring /activation from unauthorized sender in group: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
@@ -179,9 +204,9 @@ export async function handleCommands(params: {
|
||||
}
|
||||
|
||||
if (sendPolicyCommand.hasCommand) {
|
||||
if (!command.isOwnerSender) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /send from non-owner: ${command.senderE164 || "<unknown>"}`,
|
||||
`Ignoring /send from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
@@ -220,9 +245,9 @@ export async function handleCommands(params: {
|
||||
command.commandBodyNormalized === "restart" ||
|
||||
command.commandBodyNormalized.startsWith("/restart ")
|
||||
) {
|
||||
if (isGroup && !command.isOwnerSender) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /restart from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
`Ignoring /restart from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
@@ -235,14 +260,15 @@ export async function handleCommands(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
const statusRequested =
|
||||
directives.hasStatusDirective ||
|
||||
command.commandBodyNormalized === "/status" ||
|
||||
command.commandBodyNormalized === "status" ||
|
||||
command.commandBodyNormalized.startsWith("/status ")
|
||||
) {
|
||||
if (isGroup && !command.isOwnerSender) {
|
||||
command.commandBodyNormalized.startsWith("/status ");
|
||||
if (statusRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from non-owner in group: ${command.senderE164 || "<unknown>"}`,
|
||||
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
type ElevatedLevel,
|
||||
extractElevatedDirective,
|
||||
extractStatusDirective,
|
||||
extractThinkDirective,
|
||||
extractVerboseDirective,
|
||||
type ThinkLevel,
|
||||
@@ -49,6 +50,7 @@ export type InlineDirectives = {
|
||||
hasElevatedDirective: boolean;
|
||||
elevatedLevel?: ElevatedLevel;
|
||||
rawElevatedLevel?: string;
|
||||
hasStatusDirective: boolean;
|
||||
hasModelDirective: boolean;
|
||||
rawModelDirective?: string;
|
||||
hasQueueDirective: boolean;
|
||||
@@ -83,11 +85,13 @@ export function parseInlineDirectives(body: string): InlineDirectives {
|
||||
rawLevel: rawElevatedLevel,
|
||||
hasDirective: hasElevatedDirective,
|
||||
} = extractElevatedDirective(verboseCleaned);
|
||||
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
|
||||
extractStatusDirective(elevatedCleaned);
|
||||
const {
|
||||
cleaned: modelCleaned,
|
||||
rawModel,
|
||||
hasDirective: hasModelDirective,
|
||||
} = extractModelDirective(elevatedCleaned);
|
||||
} = extractModelDirective(statusCleaned);
|
||||
const {
|
||||
cleaned: queueCleaned,
|
||||
queueMode,
|
||||
@@ -114,6 +118,7 @@ export function parseInlineDirectives(body: string): InlineDirectives {
|
||||
hasElevatedDirective,
|
||||
elevatedLevel,
|
||||
rawElevatedLevel,
|
||||
hasStatusDirective,
|
||||
hasModelDirective,
|
||||
rawModelDirective: rawModel,
|
||||
hasQueueDirective,
|
||||
|
||||
@@ -74,4 +74,19 @@ export function extractElevatedDirective(body?: string): {
|
||||
};
|
||||
}
|
||||
|
||||
export function extractStatusDirective(body?: string): {
|
||||
cleaned: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const match = body.match(/(?:^|\s)\/status(?=$|\s|:)\b/i);
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
return {
|
||||
cleaned,
|
||||
hasDirective: !!match,
|
||||
};
|
||||
}
|
||||
|
||||
export type { ElevatedLevel, ThinkLevel, VerboseLevel };
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
@@ -61,28 +62,39 @@ export function createFollowupRunner(params: {
|
||||
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
||||
}
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = queued.run.provider;
|
||||
let fallbackModel = queued.run.model;
|
||||
try {
|
||||
runResult = await runEmbeddedPiAgent({
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
surface: queued.run.surface,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
skillsSnapshot: queued.run.skillsSnapshot,
|
||||
prompt: queued.prompt,
|
||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||
ownerNumbers: queued.run.ownerNumbers,
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: queued.run.config,
|
||||
provider: queued.run.provider,
|
||||
model: queued.run.model,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
bashElevated: queued.run.bashElevated,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
run: (provider, model) =>
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
surface: queued.run.surface,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
skillsSnapshot: queued.run.skillsSnapshot,
|
||||
prompt: queued.prompt,
|
||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||
ownerNumbers: queued.run.ownerNumbers,
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
bashElevated: queued.run.bashElevated,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
}),
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
|
||||
@@ -121,7 +133,8 @@ export function createFollowupRunner(params: {
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
@@ -141,6 +154,7 @@ export function createFollowupRunner(params: {
|
||||
outputTokens: output,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
@@ -154,6 +168,7 @@ export function createFollowupRunner(params: {
|
||||
if (entry) {
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
modelProvider: fallbackProvider ?? entry.modelProvider,
|
||||
model: modelUsed ?? entry.model,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
};
|
||||
|
||||
@@ -8,12 +8,11 @@ import { drainSystemEvents } from "../../infra/system-events.js";
|
||||
|
||||
export async function prependSystemEvents(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
sessionKey: string;
|
||||
isMainSession: boolean;
|
||||
isNewSession: boolean;
|
||||
prefixedBodyBase: string;
|
||||
}): Promise<string> {
|
||||
if (!params.isMainSession) return params.prefixedBodyBase;
|
||||
|
||||
const compactSystemEvent = (line: string): string | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -27,11 +26,11 @@ export async function prependSystemEvents(params: {
|
||||
};
|
||||
|
||||
const systemLines: string[] = [];
|
||||
const queued = drainSystemEvents();
|
||||
const queued = drainSystemEvents(params.sessionKey);
|
||||
systemLines.push(
|
||||
...queued.map(compactSystemEvent).filter((v): v is string => Boolean(v)),
|
||||
);
|
||||
if (params.isNewSession) {
|
||||
if (params.isMainSession && params.isNewSession) {
|
||||
const summary = await buildProviderSummary(params.cfg);
|
||||
if (summary.length > 0) systemLines.unshift(...summary);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const provider =
|
||||
entry?.modelProvider ?? resolved.provider ?? DEFAULT_PROVIDER;
|
||||
let model = entry?.model ?? resolved.model ?? DEFAULT_MODEL;
|
||||
let contextTokens =
|
||||
entry?.contextTokens ??
|
||||
@@ -204,7 +206,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
|
||||
const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel} (set with /think <level>, /verbose on|off, /elevated on|off, /model <id>)`;
|
||||
|
||||
const modelLabel = model ? `${resolved.provider}/${model}` : "unknown";
|
||||
const modelLabel = model ? `${provider}/${model}` : "unknown";
|
||||
|
||||
const agentLine = `Agent: embedded pi • ${modelLabel}`;
|
||||
|
||||
|
||||
@@ -17,11 +17,13 @@ export type MsgContext = {
|
||||
GroupSpace?: string;
|
||||
GroupMembers?: string;
|
||||
SenderName?: string;
|
||||
SenderId?: string;
|
||||
SenderUsername?: string;
|
||||
SenderTag?: string;
|
||||
SenderE164?: string;
|
||||
Surface?: string;
|
||||
WasMentioned?: boolean;
|
||||
CommandAuthorized?: boolean;
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
|
||||
@@ -19,6 +19,24 @@ describe("browser config", () => {
|
||||
expect(profile?.cdpIsLoopback).toBe(true);
|
||||
});
|
||||
|
||||
it("derives default ports from CLAWDBOT_GATEWAY_PORT when unset", () => {
|
||||
const prev = process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = "19001";
|
||||
try {
|
||||
const resolved = resolveBrowserConfig(undefined);
|
||||
expect(resolved.controlPort).toBe(19003);
|
||||
const profile = resolveProfile(resolved, resolved.defaultProfile);
|
||||
expect(profile?.cdpPort).toBe(19012);
|
||||
expect(profile?.cdpUrl).toBe("http://127.0.0.1:19012");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes hex colors", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://localhost:18791",
|
||||
|
||||
+25
-2
@@ -1,4 +1,8 @@
|
||||
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
|
||||
import {
|
||||
deriveDefaultBrowserCdpPortRange,
|
||||
deriveDefaultBrowserControlPort,
|
||||
} from "../config/port-defaults.js";
|
||||
import {
|
||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||
@@ -89,11 +93,12 @@ function ensureDefaultProfile(
|
||||
profiles: Record<string, BrowserProfileConfig> | undefined,
|
||||
defaultColor: string,
|
||||
legacyCdpPort?: number,
|
||||
derivedDefaultCdpPort?: number,
|
||||
): Record<string, BrowserProfileConfig> {
|
||||
const result = { ...profiles };
|
||||
if (!result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME]) {
|
||||
result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME] = {
|
||||
cdpPort: legacyCdpPort ?? CDP_PORT_RANGE_START,
|
||||
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START,
|
||||
color: defaultColor,
|
||||
};
|
||||
}
|
||||
@@ -103,13 +108,30 @@ export function resolveBrowserConfig(
|
||||
cfg: BrowserConfig | undefined,
|
||||
): ResolvedBrowserConfig {
|
||||
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
||||
const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
|
||||
const derivedControlPort = (() => {
|
||||
const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim();
|
||||
if (!raw) return null;
|
||||
const gatewayPort = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null;
|
||||
return deriveDefaultBrowserControlPort(gatewayPort);
|
||||
})();
|
||||
const derivedControlUrl = derivedControlPort
|
||||
? `http://127.0.0.1:${derivedControlPort}`
|
||||
: null;
|
||||
|
||||
const controlInfo = parseHttpUrl(
|
||||
cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||
cfg?.controlUrl ??
|
||||
envControlUrl ??
|
||||
derivedControlUrl ??
|
||||
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||
"browser.controlUrl",
|
||||
);
|
||||
const controlPort = controlInfo.port;
|
||||
const defaultColor = normalizeHexColor(cfg?.color);
|
||||
|
||||
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
|
||||
|
||||
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
|
||||
let cdpInfo:
|
||||
| {
|
||||
@@ -149,6 +171,7 @@ export function resolveBrowserConfig(
|
||||
cfg?.profiles,
|
||||
defaultColor,
|
||||
legacyCdpPort,
|
||||
derivedCdpRange.start,
|
||||
);
|
||||
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
|
||||
import type { BrowserProfileConfig, ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
|
||||
import { resolveClawdUserDataDir } from "./chrome.js";
|
||||
import { parseHttpUrl, resolveProfile } from "./config.js";
|
||||
import {
|
||||
@@ -79,7 +80,10 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
profileConfig = { cdpUrl: parsed.normalized, color: profileColor };
|
||||
} else {
|
||||
const usedPorts = getUsedPorts(resolvedProfiles);
|
||||
const cdpPort = allocateCdpPort(usedPorts);
|
||||
const range = deriveDefaultBrowserCdpPortRange(
|
||||
state.resolved.controlPort,
|
||||
);
|
||||
const cdpPort = allocateCdpPort(usedPorts, range);
|
||||
if (cdpPort === null) {
|
||||
throw new Error("no available CDP ports in range");
|
||||
}
|
||||
|
||||
@@ -64,6 +64,17 @@ describe("port allocation", () => {
|
||||
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
|
||||
});
|
||||
|
||||
it("allocates within an explicit range", () => {
|
||||
const usedPorts = new Set<number>();
|
||||
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(
|
||||
20000,
|
||||
);
|
||||
usedPorts.add(20000);
|
||||
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(
|
||||
20001,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips used ports and returns next available", () => {
|
||||
const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]);
|
||||
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2);
|
||||
|
||||
+18
-3
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* CDP port allocation for browser profiles.
|
||||
*
|
||||
* Port range: 18800-18899 (100 profiles max)
|
||||
* Default port range: 18800-18899 (100 profiles max)
|
||||
* Ports are allocated once at profile creation and persisted in config.
|
||||
* Multi-instance: callers may pass an explicit range to avoid collisions.
|
||||
*
|
||||
* Reserved ports (do not use for CDP):
|
||||
* 18789 - Gateway WebSocket
|
||||
@@ -21,8 +22,22 @@ export function isValidProfileName(name: string): boolean {
|
||||
return PROFILE_NAME_REGEX.test(name);
|
||||
}
|
||||
|
||||
export function allocateCdpPort(usedPorts: Set<number>): number | null {
|
||||
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
|
||||
export function allocateCdpPort(
|
||||
usedPorts: Set<number>,
|
||||
range?: { start: number; end: number },
|
||||
): number | null {
|
||||
const start = range?.start ?? CDP_PORT_RANGE_START;
|
||||
const end = range?.end ?? CDP_PORT_RANGE_END;
|
||||
if (
|
||||
!Number.isFinite(start) ||
|
||||
!Number.isFinite(end) ||
|
||||
start <= 0 ||
|
||||
end <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (start > end) return null;
|
||||
for (let port = start; port <= end; port++) {
|
||||
if (!usedPorts.has(port)) return port;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("browser screenshot normalization", () => {
|
||||
expect(Number(meta.height)).toBeLessThanOrEqual(2000);
|
||||
expect(normalized.buffer[0]).toBe(0xff);
|
||||
expect(normalized.buffer[1]).toBe(0xd8);
|
||||
}, 30_000);
|
||||
}, 120_000);
|
||||
|
||||
it("keeps already-small screenshots unchanged", async () => {
|
||||
const jpeg = await sharp({
|
||||
|
||||
@@ -128,14 +128,17 @@ vi.mock("./screenshot.js", () => ({
|
||||
}));
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const port = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(port)));
|
||||
while (true) {
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once("error", reject);
|
||||
s.listen(0, "127.0.0.1", () => {
|
||||
const assigned = (s.address() as AddressInfo).port;
|
||||
s.close((err) => (err ? reject(err) : resolve(assigned)));
|
||||
});
|
||||
});
|
||||
});
|
||||
if (port < 65535) return port;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
68f18193053997f3dee16de6b0be0bcd97dc70ff8200c77f687479e8b19b78e1
|
||||
||||||| Stash base
|
||||
7daf1cbf58ef395b74c2690c439ac7b3cb536e8eb124baf72ad41da4f542204d
|
||||
@@ -24,6 +24,28 @@ const defaultRuntime = {
|
||||
},
|
||||
};
|
||||
|
||||
async function withEnvOverride<T>(
|
||||
overrides: Record<string, string | undefined>,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const key of Object.keys(overrides)) {
|
||||
saved[key] = process.env[key];
|
||||
if (overrides[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = overrides[key];
|
||||
}
|
||||
vi.resetModules();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(saved)) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
vi.resetModules();
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGateway(opts),
|
||||
randomIdempotencyKey: () => randomIdempotencyKey(),
|
||||
@@ -205,4 +227,26 @@ describe("gateway-cli coverage", () => {
|
||||
process.removeListener("SIGINT", listener);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses env/config port when --port is omitted", async () => {
|
||||
await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "19001" }, async () => {
|
||||
runtimeLogs.length = 0;
|
||||
runtimeErrors.length = 0;
|
||||
startGatewayServer.mockClear();
|
||||
|
||||
const { registerGatewayCli } = await import("./gateway-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGatewayCli(program);
|
||||
|
||||
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
|
||||
await expect(
|
||||
program.parseAsync(["gateway", "--allow-unconfigured"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(startGatewayServer).toHaveBeenCalledWith(19001, expect.anything());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -153,7 +153,7 @@ export function registerGatewayCli(program: Command) {
|
||||
program
|
||||
.command("gateway-daemon")
|
||||
.description("Run the WebSocket Gateway as a long-lived daemon")
|
||||
.option("--port <port>", "Port for the gateway WebSocket", "18789")
|
||||
.option("--port <port>", "Port for the gateway WebSocket")
|
||||
.option(
|
||||
"--bind <mode>",
|
||||
'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).',
|
||||
@@ -298,7 +298,7 @@ export function registerGatewayCli(program: Command) {
|
||||
const gateway = program
|
||||
.command("gateway")
|
||||
.description("Run the WebSocket Gateway")
|
||||
.option("--port <port>", "Port for the gateway WebSocket", "18789")
|
||||
.option("--port <port>", "Port for the gateway WebSocket")
|
||||
.option(
|
||||
"--bind <mode>",
|
||||
'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).',
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("gateway SIGTERM", () => {
|
||||
child = null;
|
||||
});
|
||||
|
||||
it("exits 0 on SIGTERM", { timeout: 90_000 }, async () => {
|
||||
it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
const stateDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "clawdbot-gateway-test-"),
|
||||
@@ -127,7 +127,7 @@ describe("gateway SIGTERM", () => {
|
||||
child.stdout?.on("data", (d) => out.push(String(d)));
|
||||
child.stderr?.on("data", (d) => err.push(String(d)));
|
||||
|
||||
await waitForPortOpen(proc, out, err, port, 75_000);
|
||||
await waitForPortOpen(proc, out, err, port, 150_000);
|
||||
|
||||
proc.kill("SIGTERM");
|
||||
|
||||
@@ -138,7 +138,10 @@ describe("gateway SIGTERM", () => {
|
||||
proc.once("exit", (code, signal) => resolve({ code, signal })),
|
||||
);
|
||||
|
||||
if (result.code !== 0) {
|
||||
if (
|
||||
result.code !== 0 &&
|
||||
!(result.code === null && result.signal === "SIGTERM")
|
||||
) {
|
||||
const stdout = out.join("");
|
||||
const stderr = err.join("");
|
||||
throw new Error(
|
||||
@@ -146,6 +149,7 @@ describe("gateway SIGTERM", () => {
|
||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
||||
);
|
||||
}
|
||||
if (result.code === null && result.signal === "SIGTERM") return;
|
||||
expect(result.signal).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import {
|
||||
modelsAliasesAddCommand,
|
||||
modelsAliasesListCommand,
|
||||
modelsAliasesRemoveCommand,
|
||||
modelsFallbacksAddCommand,
|
||||
modelsFallbacksClearCommand,
|
||||
modelsFallbacksListCommand,
|
||||
modelsFallbacksRemoveCommand,
|
||||
modelsImageFallbacksAddCommand,
|
||||
modelsImageFallbacksClearCommand,
|
||||
modelsImageFallbacksListCommand,
|
||||
modelsImageFallbacksRemoveCommand,
|
||||
modelsListCommand,
|
||||
modelsScanCommand,
|
||||
modelsSetCommand,
|
||||
modelsSetImageCommand,
|
||||
modelsStatusCommand,
|
||||
} from "../commands/models.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
export function registerModelsCli(program: Command) {
|
||||
const models = program
|
||||
.command("models")
|
||||
.description("Model discovery, scanning, and configuration");
|
||||
|
||||
models
|
||||
.command("list")
|
||||
.description("List models (configured by default)")
|
||||
.option("--all", "Show full model catalog", false)
|
||||
.option("--local", "Filter to local models", false)
|
||||
.option("--provider <name>", "Filter by provider")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--plain", "Plain line output", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsListCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
models
|
||||
.command("status")
|
||||
.description("Show configured model state")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--plain", "Plain output", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsStatusCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
models
|
||||
.command("set")
|
||||
.description("Set the default model")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
try {
|
||||
await modelsSetCommand(model, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
models
|
||||
.command("set-image")
|
||||
.description("Set the image model")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
try {
|
||||
await modelsSetImageCommand(model, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const aliases = models.command("aliases").description("Manage model aliases");
|
||||
|
||||
aliases
|
||||
.command("list")
|
||||
.description("List model aliases")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--plain", "Plain output", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsAliasesListCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
aliases
|
||||
.command("add")
|
||||
.description("Add or update a model alias")
|
||||
.argument("<alias>", "Alias name")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (alias: string, model: string) => {
|
||||
try {
|
||||
await modelsAliasesAddCommand(alias, model, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
aliases
|
||||
.command("remove")
|
||||
.description("Remove a model alias")
|
||||
.argument("<alias>", "Alias name")
|
||||
.action(async (alias: string) => {
|
||||
try {
|
||||
await modelsAliasesRemoveCommand(alias, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const fallbacks = models
|
||||
.command("fallbacks")
|
||||
.description("Manage model fallback list");
|
||||
|
||||
fallbacks
|
||||
.command("list")
|
||||
.description("List fallback models")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--plain", "Plain output", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsFallbacksListCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
fallbacks
|
||||
.command("add")
|
||||
.description("Add a fallback model")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
try {
|
||||
await modelsFallbacksAddCommand(model, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
fallbacks
|
||||
.command("remove")
|
||||
.description("Remove a fallback model")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
try {
|
||||
await modelsFallbacksRemoveCommand(model, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
fallbacks
|
||||
.command("clear")
|
||||
.description("Clear all fallback models")
|
||||
.action(async () => {
|
||||
try {
|
||||
await modelsFallbacksClearCommand(defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const imageFallbacks = models
|
||||
.command("image-fallbacks")
|
||||
.description("Manage image model fallback list");
|
||||
|
||||
imageFallbacks
|
||||
.command("list")
|
||||
.description("List image fallback models")
|
||||
.option("--json", "Output JSON", false)
|
||||
.option("--plain", "Plain output", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsImageFallbacksListCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
imageFallbacks
|
||||
.command("add")
|
||||
.description("Add an image fallback model")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
try {
|
||||
await modelsImageFallbacksAddCommand(model, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
imageFallbacks
|
||||
.command("remove")
|
||||
.description("Remove an image fallback model")
|
||||
.argument("<model>", "Model id or alias")
|
||||
.action(async (model: string) => {
|
||||
try {
|
||||
await modelsImageFallbacksRemoveCommand(model, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
imageFallbacks
|
||||
.command("clear")
|
||||
.description("Clear all image fallback models")
|
||||
.action(async () => {
|
||||
try {
|
||||
await modelsImageFallbacksClearCommand(defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
models
|
||||
.command("scan")
|
||||
.description("Scan OpenRouter free models for tools + images")
|
||||
.option("--min-params <b>", "Minimum parameter size (billions)")
|
||||
.option("--max-age-days <days>", "Skip models older than N days")
|
||||
.option("--provider <name>", "Filter by provider prefix")
|
||||
.option("--max-candidates <n>", "Max fallback candidates", "6")
|
||||
.option("--timeout <ms>", "Per-probe timeout in ms")
|
||||
.option("--concurrency <n>", "Probe concurrency")
|
||||
.option("--yes", "Accept defaults without prompting", false)
|
||||
.option("--no-input", "Disable prompts (use defaults)")
|
||||
.option("--set-default", "Set agent.model to the first selection", false)
|
||||
.option(
|
||||
"--set-image",
|
||||
"Set agent.imageModel to the first image selection",
|
||||
false,
|
||||
)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await modelsScanCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
models.action(async () => {
|
||||
try {
|
||||
await modelsStatusCommand({}, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
|
||||
|
||||
describe("parseCliProfileArgs", () => {
|
||||
it("strips --dev anywhere in argv", () => {
|
||||
const res = parseCliProfileArgs([
|
||||
"node",
|
||||
"clawdbot",
|
||||
"gateway",
|
||||
"--dev",
|
||||
"--allow-unconfigured",
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
expect(res.profile).toBe("dev");
|
||||
expect(res.argv).toEqual([
|
||||
"node",
|
||||
"clawdbot",
|
||||
"gateway",
|
||||
"--allow-unconfigured",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses --profile value and strips it", () => {
|
||||
const res = parseCliProfileArgs([
|
||||
"node",
|
||||
"clawdbot",
|
||||
"--profile",
|
||||
"work",
|
||||
"status",
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
expect(res.profile).toBe("work");
|
||||
expect(res.argv).toEqual(["node", "clawdbot", "status"]);
|
||||
});
|
||||
|
||||
it("rejects missing profile value", () => {
|
||||
const res = parseCliProfileArgs(["node", "clawdbot", "--profile"]);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects combining --dev with --profile (dev first)", () => {
|
||||
const res = parseCliProfileArgs([
|
||||
"node",
|
||||
"clawdbot",
|
||||
"--dev",
|
||||
"--profile",
|
||||
"work",
|
||||
"status",
|
||||
]);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects combining --dev with --profile (profile first)", () => {
|
||||
const res = parseCliProfileArgs([
|
||||
"node",
|
||||
"clawdbot",
|
||||
"--profile",
|
||||
"work",
|
||||
"--dev",
|
||||
"status",
|
||||
]);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCliProfileEnv", () => {
|
||||
it("fills env defaults for dev profile", () => {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
applyCliProfileEnv({
|
||||
profile: "dev",
|
||||
env,
|
||||
homedir: () => "/home/peter",
|
||||
});
|
||||
expect(env.CLAWDBOT_PROFILE).toBe("dev");
|
||||
expect(env.CLAWDBOT_STATE_DIR).toBe("/home/peter/.clawdbot-dev");
|
||||
expect(env.CLAWDBOT_CONFIG_PATH).toBe(
|
||||
path.join("/home/peter/.clawdbot-dev", "clawdbot.json"),
|
||||
);
|
||||
expect(env.CLAWDBOT_GATEWAY_PORT).toBe("19001");
|
||||
});
|
||||
|
||||
it("does not override explicit env values", () => {
|
||||
const env: Record<string, string | undefined> = {
|
||||
CLAWDBOT_STATE_DIR: "/custom",
|
||||
CLAWDBOT_GATEWAY_PORT: "19099",
|
||||
};
|
||||
applyCliProfileEnv({
|
||||
profile: "dev",
|
||||
env,
|
||||
homedir: () => "/home/peter",
|
||||
});
|
||||
expect(env.CLAWDBOT_STATE_DIR).toBe("/custom");
|
||||
expect(env.CLAWDBOT_GATEWAY_PORT).toBe("19099");
|
||||
expect(env.CLAWDBOT_CONFIG_PATH).toBe(
|
||||
path.join("/custom", "clawdbot.json"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type CliProfileParseResult =
|
||||
| { ok: true; profile: string | null; argv: string[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function takeValue(
|
||||
raw: string,
|
||||
next: string | undefined,
|
||||
): {
|
||||
value: string | null;
|
||||
consumedNext: boolean;
|
||||
} {
|
||||
if (raw.includes("=")) {
|
||||
const [, value] = raw.split("=", 2);
|
||||
const trimmed = (value ?? "").trim();
|
||||
return { value: trimmed || null, consumedNext: false };
|
||||
}
|
||||
const trimmed = (next ?? "").trim();
|
||||
return { value: trimmed || null, consumedNext: Boolean(next) };
|
||||
}
|
||||
|
||||
function isValidProfileName(value: string): boolean {
|
||||
if (!value) return false;
|
||||
// Keep it path-safe + shell-friendly.
|
||||
return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(value);
|
||||
}
|
||||
|
||||
export function parseCliProfileArgs(argv: string[]): CliProfileParseResult {
|
||||
if (argv.length < 2) return { ok: true, profile: null, argv };
|
||||
|
||||
const out: string[] = argv.slice(0, 2);
|
||||
let profile: string | null = null;
|
||||
let sawDev = false;
|
||||
|
||||
const args = argv.slice(2);
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === undefined) continue;
|
||||
|
||||
if (arg === "--dev") {
|
||||
if (profile && profile !== "dev") {
|
||||
return { ok: false, error: "Cannot combine --dev with --profile" };
|
||||
}
|
||||
sawDev = true;
|
||||
profile = "dev";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--profile" || arg.startsWith("--profile=")) {
|
||||
if (sawDev) {
|
||||
return { ok: false, error: "Cannot combine --dev with --profile" };
|
||||
}
|
||||
const next = args[i + 1];
|
||||
const { value, consumedNext } = takeValue(arg, next);
|
||||
if (consumedNext) i += 1;
|
||||
if (!value) return { ok: false, error: "--profile requires a value" };
|
||||
if (!isValidProfileName(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Invalid --profile (use letters, numbers, "_", "-" only)',
|
||||
};
|
||||
}
|
||||
profile = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(arg);
|
||||
}
|
||||
|
||||
return { ok: true, profile, argv: out };
|
||||
}
|
||||
|
||||
function resolveProfileStateDir(
|
||||
profile: string,
|
||||
homedir: () => string,
|
||||
): string {
|
||||
const suffix = profile.toLowerCase() === "default" ? "" : `-${profile}`;
|
||||
return path.join(homedir(), `.clawdbot${suffix}`);
|
||||
}
|
||||
|
||||
export function applyCliProfileEnv(params: {
|
||||
profile: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
homedir?: () => string;
|
||||
}) {
|
||||
const env = params.env ?? (process.env as Record<string, string | undefined>);
|
||||
const homedir = params.homedir ?? os.homedir;
|
||||
const profile = params.profile.trim();
|
||||
if (!profile) return;
|
||||
|
||||
// Convenience only: fill defaults, never override explicit env values.
|
||||
env.CLAWDBOT_PROFILE = profile;
|
||||
|
||||
const stateDir =
|
||||
env.CLAWDBOT_STATE_DIR?.trim() || resolveProfileStateDir(profile, homedir);
|
||||
if (!env.CLAWDBOT_STATE_DIR?.trim()) env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
|
||||
if (!env.CLAWDBOT_CONFIG_PATH?.trim()) {
|
||||
env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json");
|
||||
}
|
||||
|
||||
if (profile === "dev" && !env.CLAWDBOT_GATEWAY_PORT?.trim()) {
|
||||
env.CLAWDBOT_GATEWAY_PORT = "19001";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendCommand = vi.fn();
|
||||
const statusCommand = vi.fn();
|
||||
const configureCommand = vi.fn();
|
||||
const loginWeb = vi.fn();
|
||||
const callGateway = vi.fn();
|
||||
|
||||
@@ -16,6 +17,7 @@ const runtime = {
|
||||
|
||||
vi.mock("../commands/send.js", () => ({ sendCommand }));
|
||||
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||
vi.mock("../commands/configure.js", () => ({ configureCommand }));
|
||||
vi.mock("../runtime.js", () => ({ defaultRuntime: runtime }));
|
||||
vi.mock("../provider-web.js", () => ({
|
||||
loginWeb,
|
||||
@@ -49,6 +51,12 @@ describe("cli program", () => {
|
||||
expect(statusCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs config alias as configure", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["config"], { from: "user" });
|
||||
expect(configureCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs nodes list and calls node.pair.list", async () => {
|
||||
callGateway.mockResolvedValue({ pending: [], paired: [] });
|
||||
const program = buildProgram();
|
||||
|
||||
+19
-1
@@ -22,6 +22,7 @@ import { createDefaultDeps } from "./deps.js";
|
||||
import { registerDnsCli } from "./dns-cli.js";
|
||||
import { registerGatewayCli } from "./gateway-cli.js";
|
||||
import { registerHooksCli } from "./hooks-cli.js";
|
||||
import { registerModelsCli } from "./models-cli.js";
|
||||
import { registerNodesCli } from "./nodes-cli.js";
|
||||
import { forceFreePort } from "./ports.js";
|
||||
import { registerTuiCli } from "./tui-cli.js";
|
||||
@@ -34,7 +35,18 @@ export function buildProgram() {
|
||||
const TAGLINE =
|
||||
"Send, receive, and auto-reply on WhatsApp (web) and Telegram (bot).";
|
||||
|
||||
program.name("clawdbot").description("").version(PROGRAM_VERSION);
|
||||
program
|
||||
.name("clawdbot")
|
||||
.description("")
|
||||
.version(PROGRAM_VERSION)
|
||||
.option(
|
||||
"--dev",
|
||||
"Dev profile: isolate state under ~/.clawdbot-dev, default gateway port 19001, and shift derived ports (bridge/browser/canvas)",
|
||||
)
|
||||
.option(
|
||||
"--profile <name>",
|
||||
"Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-<name>)",
|
||||
);
|
||||
|
||||
const formatIntroLine = (version: string, rich = true) => {
|
||||
const base = `📡 clawdbot ${version} — ${TAGLINE}`;
|
||||
@@ -95,6 +107,10 @@ export function buildProgram() {
|
||||
"Send via your web session and print JSON result.",
|
||||
],
|
||||
["clawdbot gateway --port 18789", "Run the WebSocket Gateway locally."],
|
||||
[
|
||||
"clawdbot --dev gateway",
|
||||
"Run a dev Gateway (isolated state/config) on ws://127.0.0.1:19001.",
|
||||
],
|
||||
[
|
||||
"clawdbot gateway --force",
|
||||
"Kill anything bound to the default gateway port, then start it.",
|
||||
@@ -231,6 +247,7 @@ export function buildProgram() {
|
||||
|
||||
program
|
||||
.command("configure")
|
||||
.alias("config")
|
||||
.description(
|
||||
"Interactive wizard to update models, providers, skills, and gateway",
|
||||
)
|
||||
@@ -399,6 +416,7 @@ Examples:
|
||||
|
||||
registerCanvasCli(program);
|
||||
registerGatewayCli(program);
|
||||
registerModelsCli(program);
|
||||
registerNodesCli(program);
|
||||
registerTuiCli(program);
|
||||
registerCronCli(program);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { loadDotEnv } from "../infra/dotenv.js";
|
||||
import { normalizeEnv } from "../infra/env.js";
|
||||
import { isMainModule } from "../infra/is-main.js";
|
||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||
import { enableConsoleCapture } from "../logging.js";
|
||||
|
||||
export async function runCli(argv: string[] = process.argv) {
|
||||
loadDotEnv({ quiet: true });
|
||||
normalizeEnv();
|
||||
ensureClawdbotCliOnPath();
|
||||
|
||||
// Capture all console output into structured logs while keeping stdout/stderr behavior.
|
||||
enableConsoleCapture();
|
||||
|
||||
// Enforce the minimum supported runtime before doing any work.
|
||||
assertSupportedRuntime();
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
const program = buildProgram();
|
||||
|
||||
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
|
||||
// These log the error and exit gracefully instead of crashing without trace.
|
||||
process.on("unhandledRejection", (reason, _promise) => {
|
||||
console.error(
|
||||
"[clawdbot] Unhandled promise rejection:",
|
||||
reason instanceof Error ? (reason.stack ?? reason.message) : reason,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error(
|
||||
"[clawdbot] Uncaught exception:",
|
||||
error.stack ?? error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await program.parseAsync(argv);
|
||||
}
|
||||
|
||||
export function isCliMainModule(): boolean {
|
||||
return isMainModule({ currentFile: fileURLToPath(import.meta.url) });
|
||||
}
|
||||
+38
-23
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../agents/defaults.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runWithModelFallback } from "../agents/model-fallback.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
modelKey,
|
||||
@@ -364,6 +365,8 @@ export async function agentCommand(
|
||||
});
|
||||
|
||||
let result: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = provider;
|
||||
let fallbackModel = model;
|
||||
try {
|
||||
const surface =
|
||||
opts.surface?.trim().toLowerCase() ||
|
||||
@@ -372,32 +375,41 @@ export async function agentCommand(
|
||||
if (!raw) return undefined;
|
||||
return raw === "imsg" ? "imessage" : raw;
|
||||
})();
|
||||
result = await runEmbeddedPiAgent({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
surface,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
skillsSnapshot,
|
||||
prompt: body,
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: resolvedThinkLevel,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
timeoutMs,
|
||||
runId,
|
||||
lane: opts.lane,
|
||||
abortSignal: opts.abortSignal,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
onAgentEvent: (evt) => {
|
||||
emitAgentEvent({
|
||||
run: (providerOverride, modelOverride) =>
|
||||
runEmbeddedPiAgent({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
surface,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
skillsSnapshot,
|
||||
prompt: body,
|
||||
provider: providerOverride,
|
||||
model: modelOverride,
|
||||
thinkLevel: resolvedThinkLevel,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
timeoutMs,
|
||||
runId,
|
||||
stream: evt.stream,
|
||||
data: evt.data,
|
||||
});
|
||||
},
|
||||
lane: opts.lane,
|
||||
abortSignal: opts.abortSignal,
|
||||
extraSystemPrompt: opts.extraSystemPrompt,
|
||||
onAgentEvent: (evt) => {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: evt.stream,
|
||||
data: evt.data,
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
result = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "job",
|
||||
@@ -431,7 +443,9 @@ export async function agentCommand(
|
||||
// Update token+model fields in the session store.
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = result.meta.agentMeta?.usage;
|
||||
const modelUsed = result.meta.agentMeta?.model ?? model;
|
||||
const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? model;
|
||||
const providerUsed =
|
||||
result.meta.agentMeta?.provider ?? fallbackProvider ?? provider;
|
||||
const contextTokens =
|
||||
agentCfg?.contextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
@@ -445,6 +459,7 @@ export async function agentCommand(
|
||||
...entry,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
modelProvider: providerUsed,
|
||||
model: modelUsed,
|
||||
contextTokens,
|
||||
};
|
||||
|
||||
+48
-17
@@ -21,6 +21,7 @@ import {
|
||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
@@ -38,7 +39,9 @@ import {
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
DEFAULT_WORKSPACE,
|
||||
detectBrowserOpenSupport,
|
||||
ensureWorkspaceAndSessions,
|
||||
formatControlUiSshHint,
|
||||
guardCancel,
|
||||
openUrl,
|
||||
printWizardHeader,
|
||||
@@ -176,7 +179,7 @@ async function promptGatewayConfig(
|
||||
...next,
|
||||
gateway: {
|
||||
...next.gateway,
|
||||
auth: { ...next.gateway?.auth, mode: "token" },
|
||||
auth: { ...next.gateway?.auth, mode: "token", token: gatewayToken },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -453,7 +456,8 @@ export async function runConfigureWizard(
|
||||
const localUrl = "ws://127.0.0.1:18789";
|
||||
const localProbe = await probeGatewayReachable({
|
||||
url: localUrl,
|
||||
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
token:
|
||||
baseConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
password:
|
||||
baseConfig.gateway?.auth?.password ??
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
||||
@@ -615,6 +619,11 @@ export async function runConfigureWizard(
|
||||
}
|
||||
}
|
||||
|
||||
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
|
||||
if (!controlUiAssets.ok && controlUiAssets.message) {
|
||||
runtime.error(controlUiAssets.message);
|
||||
}
|
||||
|
||||
note(
|
||||
(() => {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
@@ -630,21 +639,43 @@ export async function runConfigureWizard(
|
||||
"Control UI",
|
||||
);
|
||||
|
||||
const wantsOpen = guardCancel(
|
||||
await confirm({
|
||||
message: "Open Control UI now?",
|
||||
initialValue: false,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
await openUrl(links.httpUrl);
|
||||
const browserSupport = await detectBrowserOpenSupport();
|
||||
if (!browserSupport.ok) {
|
||||
note(
|
||||
formatControlUiSshHint({
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
token: gatewayToken,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
} else {
|
||||
const wantsOpen = guardCancel(
|
||||
await confirm({
|
||||
message: "Open Control UI now?",
|
||||
initialValue: false,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
if (wantsOpen) {
|
||||
const bind = nextConfig.gateway?.bind ?? "loopback";
|
||||
const links = resolveControlUiLinks({
|
||||
bind,
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
});
|
||||
const opened = await openUrl(links.httpUrl);
|
||||
if (!opened) {
|
||||
note(
|
||||
formatControlUiSshHint({
|
||||
port: gatewayPort,
|
||||
basePath: nextConfig.gateway?.controlUi?.basePath,
|
||||
token: gatewayToken,
|
||||
}),
|
||||
"Open Control UI",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outro("Configure complete.");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
modelsAliasesAddCommand,
|
||||
modelsAliasesListCommand,
|
||||
modelsAliasesRemoveCommand,
|
||||
} from "./models/aliases.js";
|
||||
export {
|
||||
modelsFallbacksAddCommand,
|
||||
modelsFallbacksClearCommand,
|
||||
modelsFallbacksListCommand,
|
||||
modelsFallbacksRemoveCommand,
|
||||
} from "./models/fallbacks.js";
|
||||
export {
|
||||
modelsImageFallbacksAddCommand,
|
||||
modelsImageFallbacksClearCommand,
|
||||
modelsImageFallbacksListCommand,
|
||||
modelsImageFallbacksRemoveCommand,
|
||||
} from "./models/image-fallbacks.js";
|
||||
export { modelsListCommand, modelsStatusCommand } from "./models/list.js";
|
||||
export { modelsScanCommand } from "./models/scan.js";
|
||||
export { modelsSetCommand } from "./models/set.js";
|
||||
export { modelsSetImageCommand } from "./models/set-image.js";
|
||||
@@ -0,0 +1,89 @@
|
||||
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import {
|
||||
ensureFlagCompatibility,
|
||||
normalizeAlias,
|
||||
resolveModelTarget,
|
||||
updateConfig,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function modelsAliasesListCommand(
|
||||
opts: { json?: boolean; plain?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const aliases = cfg.agent?.modelAliases ?? {};
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ aliases }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (opts.plain) {
|
||||
for (const [alias, target] of Object.entries(aliases)) {
|
||||
runtime.log(`${alias} ${target}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.log(`Aliases (${Object.keys(aliases).length}):`);
|
||||
if (Object.keys(aliases).length === 0) {
|
||||
runtime.log("- none");
|
||||
return;
|
||||
}
|
||||
for (const [alias, target] of Object.entries(aliases)) {
|
||||
runtime.log(`- ${alias} -> ${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function modelsAliasesAddCommand(
|
||||
aliasRaw: string,
|
||||
modelRaw: string,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const alias = normalizeAlias(aliasRaw);
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const nextAliases = { ...cfg.agent?.modelAliases };
|
||||
nextAliases[alias] = `${resolved.provider}/${resolved.model}`;
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
modelAliases: nextAliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Alias ${alias} -> ${updated.agent?.modelAliases?.[alias]}`);
|
||||
}
|
||||
|
||||
export async function modelsAliasesRemoveCommand(
|
||||
aliasRaw: string,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const alias = normalizeAlias(aliasRaw);
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const nextAliases = { ...cfg.agent?.modelAliases };
|
||||
if (!nextAliases[alias]) {
|
||||
throw new Error(`Alias not found: ${alias}`);
|
||||
}
|
||||
delete nextAliases[alias];
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
modelAliases: nextAliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
if (
|
||||
!updated.agent?.modelAliases ||
|
||||
Object.keys(updated.agent.modelAliases).length === 0
|
||||
) {
|
||||
runtime.log("No aliases configured.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import {
|
||||
DEFAULT_PROVIDER,
|
||||
ensureFlagCompatibility,
|
||||
modelKey,
|
||||
resolveModelTarget,
|
||||
updateConfig,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function modelsFallbacksListCommand(
|
||||
opts: { json?: boolean; plain?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const fallbacks = cfg.agent?.modelFallbacks ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ fallbacks }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (opts.plain) {
|
||||
for (const entry of fallbacks) runtime.log(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.log(`Fallbacks (${fallbacks.length}):`);
|
||||
if (fallbacks.length === 0) {
|
||||
runtime.log("- none");
|
||||
return;
|
||||
}
|
||||
for (const entry of fallbacks) runtime.log(`- ${entry}`);
|
||||
}
|
||||
|
||||
export async function modelsFallbacksAddCommand(
|
||||
modelRaw: string,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const targetKey = modelKey(resolved.provider, resolved.model);
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const existing = cfg.agent?.modelFallbacks ?? [];
|
||||
const existingKeys = existing
|
||||
.map((entry) =>
|
||||
resolveModelRefFromString({
|
||||
raw: String(entry ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
}),
|
||||
)
|
||||
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
|
||||
.map((entry) => modelKey(entry.ref.provider, entry.ref.model));
|
||||
|
||||
if (existingKeys.includes(targetKey)) return cfg;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
modelFallbacks: [...existing, targetKey],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`);
|
||||
}
|
||||
|
||||
export async function modelsFallbacksRemoveCommand(
|
||||
modelRaw: string,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const targetKey = modelKey(resolved.provider, resolved.model);
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const existing = cfg.agent?.modelFallbacks ?? [];
|
||||
const filtered = existing.filter((entry) => {
|
||||
const resolvedEntry = resolveModelRefFromString({
|
||||
raw: String(entry ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolvedEntry) return true;
|
||||
return (
|
||||
modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !==
|
||||
targetKey
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length === existing.length) {
|
||||
throw new Error(`Fallback not found: ${targetKey}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
modelFallbacks: filtered,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`);
|
||||
}
|
||||
|
||||
export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
|
||||
await updateConfig((cfg) => ({
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
modelFallbacks: [],
|
||||
},
|
||||
}));
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log("Fallback list cleared.");
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import {
|
||||
DEFAULT_PROVIDER,
|
||||
ensureFlagCompatibility,
|
||||
modelKey,
|
||||
resolveModelTarget,
|
||||
updateConfig,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function modelsImageFallbacksListCommand(
|
||||
opts: { json?: boolean; plain?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const fallbacks = cfg.agent?.imageModelFallbacks ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ fallbacks }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (opts.plain) {
|
||||
for (const entry of fallbacks) runtime.log(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.log(`Image fallbacks (${fallbacks.length}):`);
|
||||
if (fallbacks.length === 0) {
|
||||
runtime.log("- none");
|
||||
return;
|
||||
}
|
||||
for (const entry of fallbacks) runtime.log(`- ${entry}`);
|
||||
}
|
||||
|
||||
export async function modelsImageFallbacksAddCommand(
|
||||
modelRaw: string,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const targetKey = modelKey(resolved.provider, resolved.model);
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const existing = cfg.agent?.imageModelFallbacks ?? [];
|
||||
const existingKeys = existing
|
||||
.map((entry) =>
|
||||
resolveModelRefFromString({
|
||||
raw: String(entry ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
}),
|
||||
)
|
||||
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
|
||||
.map((entry) => modelKey(entry.ref.provider, entry.ref.model));
|
||||
|
||||
if (existingKeys.includes(targetKey)) return cfg;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
imageModelFallbacks: [...existing, targetKey],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(
|
||||
`Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function modelsImageFallbacksRemoveCommand(
|
||||
modelRaw: string,
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const targetKey = modelKey(resolved.provider, resolved.model);
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const existing = cfg.agent?.imageModelFallbacks ?? [];
|
||||
const filtered = existing.filter((entry) => {
|
||||
const resolvedEntry = resolveModelRefFromString({
|
||||
raw: String(entry ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolvedEntry) return true;
|
||||
return (
|
||||
modelKey(resolvedEntry.ref.provider, resolvedEntry.ref.model) !==
|
||||
targetKey
|
||||
);
|
||||
});
|
||||
|
||||
if (filtered.length === existing.length) {
|
||||
throw new Error(`Image fallback not found: ${targetKey}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
imageModelFallbacks: filtered,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(
|
||||
`Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) {
|
||||
await updateConfig((cfg) => ({
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
imageModelFallbacks: [],
|
||||
},
|
||||
}));
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log("Image fallback list cleared.");
|
||||
}
|
||||
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user