diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..46ee3da04 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Report a problem or unexpected behavior in Clawdbot. +title: "[Bug]: " +labels: bug +--- + +## Summary +What went wrong? + +## Steps to reproduce +1. +2. +3. + +## Expected behavior +What did you expect to happen? + +## Actual behavior +What actually happened? + +## Environment +- Clawdbot version: +- OS: +- Install method (pnpm/npx/docker/etc): + +## Logs or screenshots +Paste relevant logs or add screenshots (redact secrets). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..26c896f06 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Onboarding + url: https://discord.gg/clawd + about: New to Clawdbot? Join Discord for setup guidance from Krill in #help. + - name: Support + url: https://discord.gg/clawd + about: Get help from Krill and the community on Discord in #help. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..742bf184e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature request +about: Suggest an idea or improvement for Clawdbot. +title: "[Feature]: " +labels: enhancement +--- + +## Summary +Describe the problem you are trying to solve or the opportunity you see. + +## Proposed solution +What would you like Clawdbot to do? + +## Alternatives considered +Any other approaches you have considered? + +## Additional context +Links, screenshots, or related issues. diff --git a/.gitignore b/.gitignore index 8bc3ebb67..85b83cb81 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ node_modules dist *.bun-build pnpm-lock.yaml +bun.lock +bun.lockb coverage .pnpm-store .worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 56ab3d72a..a2b576c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,10 @@ - Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`). - Group related changes; avoid bundling unrelated refactors. - PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. +- When working on a PR: add a changelog entry with the PR ID and thank the contributor. +- When working on an issue: reference the issue in the changelog entry. +- When merging a PR: leave a PR comment that explains exactly what we did. +- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list. ## Security & Configuration Tips - Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out. diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a727498..051868266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,36 @@ ## Unreleased +### Breaking +- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only). +- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup. +- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context. +- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. + ### Fixes +- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. +- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure. +- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins. +- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp. +- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270. +- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266. +- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272. +- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75. +- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178. +- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley - Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step. -- Linux: prompt to enable systemd lingering when installing/restarting the gateway user service (prevents logout/idle shutdowns). +- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth. +- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup). +- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running. +- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229. +- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order. +- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save. +- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256. +- Docs: add group chat participation guidance to the AGENTS template. +- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use). +- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237. - TUI: migrate key handling to the updated pi-tui Key matcher API. +- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns). - macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable). - macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. - macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets. @@ -16,22 +42,44 @@ - Model: `/model` list shows auth source (masked key or OAuth email) per provider. - Model: `/model list` is an alias for `/model`. - Model: `/model` output now includes auth source location (env/auth.json/models.json). +- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output. +- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`. - Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. - Control UI: show a reading indicator bubble while the assistant is responding. - Control UI: animate reading indicator dots (honors reduced-motion). - Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping). +- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268. - Status: show runtime (docker/direct) and move shortcuts to `/help`. - Status: show model auth source (api-key/oauth). - Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split. - Block streaming: preserve leading indentation in block replies (lists, indented fences). - Docs: document systemd lingering and logged-in session requirements on macOS/Windows. -- Auto-reply: unify tool/block/final delivery across providers and apply consistent heartbeat/prefix handling. Thanks @MSch for PR #225 (superseded commit 92c953d0749143eb2a3f31f3cd6ad0e8eabf48c3). +- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225. +- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman +- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271. +- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs. +- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251. +- Discord: surface missing-permission hints (muted/role overrides) when replies fail. +- Discord: use channel IDs for DMs instead of user IDs. Thanks @VACInc for PR #261. +- Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235. +- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249. +- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242. +- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241. +- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220. +- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194. +- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs. +- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions. +- Telegram: send GIF media as animations (auto-play) and improve filename sniffing. +- Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202. ### Maintenance - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. - Skills: add CodexBar model usage helper with macOS requirement metadata. +- Skills: add 1Password CLI skill with op examples. - Lint: organize imports and wrap long lines in reply commands. +- Refactor: centralize group allowlist/mention policy across providers. +- Deps: update to latest across the repo. ## 2026.1.5-3 @@ -57,6 +105,7 @@ - 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). +- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`). ### Fixes - Control UI: render Markdown in tool result cards. @@ -80,6 +129,11 @@ - 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. +- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode. + +## 2026.1.5 + +### Fixes - Control UI: render Markdown in chat messages (sanitized). diff --git a/README.md b/README.md index 48bd07f11..9bbc44217 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,20 @@

**Clawdbot** is a *personal AI assistant* you run on your own devices. -It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, iMessage, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. +It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -Website: [clawdbot.com](https://clawdbot.com) · Docs: [docs.clawdbot.com](https://docs.clawdbot.com/) · FAQ: [FAQ](https://docs.clawdbot.com/faq) · Wizard: [Wizard](https://docs.clawdbot.com/wizard) · Nix: [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [Docker](https://docs.clawdbot.com/docker) · Discord: [discord.gg/clawd](https://discord.gg/clawd) +Website: [https://clawdbot.com](https://clawdbot.com) · Docs: [https://docs.clawdbot.com](https://docs.clawdbot.com/) · Showcase: [https://docs.clawdbot.com/showcase](https://docs.clawdbot.com/showcase) · FAQ: [https://docs.clawdbot.com/faq](https://docs.clawdbot.com/faq) · Wizard: [https://docs.clawdbot.com/wizard](https://docs.clawdbot.com/wizard) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://docs.clawdbot.com/docker](https://docs.clawdbot.com/docker) · Discord: [https://discord.gg/clawd](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**. +Works with npm, pnpm, or bun. -Subscriptions: **Anthropic (Claude Pro/Max)** and **OpenAI (ChatGPT/Codex)** are supported via OAuth. See [Onboarding](https://docs.clawdbot.com/onboarding). +**Subscriptions (OAuth):** +- **Anthropic** (Claude Pro/Max) +- **OpenAI** (ChatGPT/Codex) + +Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawdbot.com/onboarding). ## Recommended setup (from source) @@ -43,101 +48,168 @@ pnpm clawdbot onboard ## Quick start (from source) -Runtime: **Node ≥22** + **pnpm**. +Runtime: **Node ≥22**. + +From source, **pnpm** is the default workflow. Bun is supported as an optional local workflow; see [`docs/bun.md`](docs/bun.md). ```bash -pnpm install -pnpm build -pnpm ui:build +# Install deps (no Bun lockfile) +bun install --no-save + +# Build TypeScript +bun run build + +# Build Control UI +bun install --cwd ui --no-save +bun run --cwd ui build # Recommended: run the onboarding wizard -pnpm clawdbot onboard +bun run clawdbot onboard # Link WhatsApp (stores creds in ~/.clawdbot/credentials) -pnpm clawdbot login +bun run clawdbot login # Start the gateway -pnpm clawdbot gateway --port 18789 --verbose +bun run clawdbot gateway --port 18789 --verbose # Dev loop (auto-reload on TS changes) -pnpm gateway:watch +bun run gateway:watch # Send a message -pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot" +bun run clawdbot send --to +1234567890 --message "Hello from Clawdbot" # Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord) -pnpm clawdbot agent --message "Ship checklist" --thinking high +bun run clawdbot agent --message "Ship checklist" --thinking high ``` Upgrading? `clawdbot doctor`. -If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`). +If you run from source, prefer `bun run clawdbot …` or `pnpm clawdbot …` (not global `clawdbot`). ## Highlights -- **Local-first Gateway** — single control plane for sessions, providers, tools, and events. -- **Multi-surface inbox** — WhatsApp, Telegram, Slack, Discord, iMessage, WebChat, macOS, iOS/Android. -- **Voice Wake + Talk Mode** — always-on speech for macOS/iOS/Android with ElevenLabs. -- **Live Canvas** — agent-driven visual workspace with A2UI. -- **First-class tools** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. -- **Companion apps** — macOS menu bar app + iOS/Android nodes. -- **Onboarding + skills** — wizard-driven setup with bundled/managed/workspace skills. +- **[Local-first Gateway](https://docs.clawdbot.com/gateway)** — single control plane for sessions, providers, tools, and events. +- **[Multi-surface inbox](https://docs.clawdbot.com/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android. +- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs. +- **[Live Canvas](https://docs.clawdbot.com/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui). +- **[First-class tools](https://docs.clawdbot.com/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions. +- **[Companion apps](https://docs.clawdbot.com/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawdbot.com/nodes). +- **[Onboarding](https://docs.clawdbot.com/wizard) + [skills](https://docs.clawdbot.com/skills)** — wizard-driven setup with bundled/managed/workspace skills. ## Everything we built so far ### Core platform -- Gateway WS control plane with sessions, presence, config, cron, webhooks, control UI, and Canvas host. -- CLI surface: gateway, agent, send, wizard, doctor/update, and TUI. -- Pi agent runtime in RPC mode with tool streaming and block streaming. -- Session model: `main` for direct chats, group isolation, activation modes, queue modes, reply-back. -- Media pipeline: images/audio/video, transcription hooks, size caps, temp file lifecycle. +- [Gateway WS control plane](https://docs.clawdbot.com/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawdbot.com/web), and [Canvas host](https://docs.clawdbot.com/refactor/canvas-a2ui). +- [CLI surface](https://docs.clawdbot.com/agent-send): gateway, agent, send, [wizard](https://docs.clawdbot.com/wizard), and [doctor](https://docs.clawdbot.com/doctor). +- [Pi agent runtime](https://docs.clawdbot.com/agent) in RPC mode with tool streaming and block streaming. +- [Session model](https://docs.clawdbot.com/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawdbot.com/groups). +- [Media pipeline](https://docs.clawdbot.com/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawdbot.com/audio). ### Surfaces + providers -- WhatsApp (Baileys), Telegram (grammY), Slack (Bolt), Discord (discord.js), Signal (signal-cli), iMessage (imsg), WebChat. -- Group mention gating, reply tags, per-surface chunking and routing. +- [Providers](https://docs.clawdbot.com/surface): [WhatsApp](https://docs.clawdbot.com/whatsapp) (Baileys), [Telegram](https://docs.clawdbot.com/telegram) (grammY), [Slack](https://docs.clawdbot.com/slack) (Bolt), [Discord](https://docs.clawdbot.com/discord) (discord.js), [Signal](https://docs.clawdbot.com/signal) (signal-cli), [iMessage](https://docs.clawdbot.com/imessage) (imsg), [WebChat](https://docs.clawdbot.com/webchat). +- [Group routing](https://docs.clawdbot.com/group-messages): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://docs.clawdbot.com/surface). ### Apps + nodes -- macOS app: menu bar control plane, Voice Wake/PTT, Talk Mode overlay, WebChat, Debug tools, SSH remote gateway control. -- iOS node: Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing. -- Android node: Canvas, Talk Mode, camera, screen recording, optional SMS. -- macOS node mode: system.run/notify + canvas/camera exposure. +- [macOS app](https://docs.clawdbot.com/macos): menu bar control plane, [Voice Wake](https://docs.clawdbot.com/voicewake)/PTT, [Talk Mode](https://docs.clawdbot.com/talk) overlay, [WebChat](https://docs.clawdbot.com/webchat), debug tools, [remote gateway](https://docs.clawdbot.com/remote) control. +- [iOS node](https://docs.clawdbot.com/ios): [Canvas](https://docs.clawdbot.com/mac/canvas), [Voice Wake](https://docs.clawdbot.com/voicewake), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, Bonjour pairing. +- [Android node](https://docs.clawdbot.com/android): [Canvas](https://docs.clawdbot.com/mac/canvas), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, optional SMS. +- [macOS node mode](https://docs.clawdbot.com/nodes): system.run/notify + canvas/camera exposure. ### Tools + automation -- Browser control: dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. -- Canvas: A2UI push/reset, eval, snapshot. -- Nodes: camera snap/clip, screen record, location.get, notifications. -- Cron + wakeups; webhooks; Gmail Pub/Sub triggers. -- Skills platform: bundled, managed, and workspace skills with install gating + UI. +- [Browser control](https://docs.clawdbot.com/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles. +- [Canvas](https://docs.clawdbot.com/mac/canvas): [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui) push/reset, eval, snapshot. +- [Nodes](https://docs.clawdbot.com/nodes): camera snap/clip, screen record, [location.get](https://docs.clawdbot.com/location-command), notifications. +- [Cron + wakeups](https://docs.clawdbot.com/cron); [webhooks](https://docs.clawdbot.com/webhook); [Gmail Pub/Sub](https://docs.clawdbot.com/gmail-pubsub). +- [Skills platform](https://docs.clawdbot.com/skills): bundled, managed, and workspace skills with install gating + UI. ### Ops + packaging -- Control UI + WebChat served directly from the Gateway. -- Tailscale Serve/Funnel or SSH tunnels with token/password auth. -- Nix mode for declarative config; Docker-based installs. -- Health, doctor migrations, structured logging, release tooling. +- [Control UI](https://docs.clawdbot.com/web) + [WebChat](https://docs.clawdbot.com/webchat) served directly from the Gateway. +- [Tailscale Serve/Funnel](https://docs.clawdbot.com/tailscale) or [SSH tunnels](https://docs.clawdbot.com/remote) with token/password auth. +- [Nix mode](https://docs.clawdbot.com/nix) for declarative config; [Docker](https://docs.clawdbot.com/docker)-based installs. +- [Doctor](https://docs.clawdbot.com/doctor) migrations, [logging](https://docs.clawdbot.com/logging). ## How it works (short) ``` -Your surfaces - │ - ▼ +WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat + │ + ▼ ┌───────────────────────────────┐ │ Gateway │ ws://127.0.0.1:18789 -│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge) +│ (control plane) │ bridge: tcp://0.0.0.0:18790 └──────────────┬────────────────┘ │ ├─ Pi agent (RPC) ├─ CLI (clawdbot …) - ├─ WebChat (browser) - ├─ macOS app (Clawdbot.app) - └─ iOS node (Canvas + voice) + ├─ WebChat UI + ├─ macOS app + └─ iOS/Android nodes ``` +## Key subsystems + +- **[Gateway WebSocket network](https://docs.clawdbot.com/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawdbot.com/gateway)). +- **[Tailscale exposure](https://docs.clawdbot.com/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawdbot.com/remote)). +- **[Browser control](https://docs.clawdbot.com/browser)** — clawd‑managed Chrome/Chromium with CDP control. +- **[Canvas + A2UI](https://docs.clawdbot.com/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui)). +- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always‑on speech and continuous conversation. +- **[Nodes](https://docs.clawdbot.com/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`. + +## Tailscale access (Gateway dashboard) + +Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`: + +- `off`: no Tailscale automation (default). +- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default). +- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth). + +Notes: +- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this). +- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`. +- Funnel refuses to start unless `gateway.auth.mode: "password"` is set. +- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown. + +Details: [Tailscale guide](https://docs.clawdbot.com/tailscale) · [Web surfaces](https://docs.clawdbot.com/web) + +## Remote Gateway (Linux is great) + +It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed. + +- **Gateway host** runs the bash tool and provider connections by default. +- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`. +In short: bash runs where the Gateway lives; device actions run where the device lives. + +Details: [Remote access](https://docs.clawdbot.com/remote) · [Nodes](https://docs.clawdbot.com/nodes) · [Security](https://docs.clawdbot.com/security) + +## macOS permissions via the Gateway protocol + +The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`: + +- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`). +- `system.notify` posts a user notification and fails if notifications are denied. +- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status. + +Elevated bash (host permissions) is separate from macOS TCC: + +- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted. +- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`. + +Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.clawdbot.com/macos) · [Gateway protocol](https://docs.clawdbot.com/architecture) + +## Agent to Agent (sessions_* tools) + +- Use these to coordinate work across sessions without jumping between chat surfaces. +- `sessions_list` — discover active sessions (agents) and their metadata. +- `sessions_history` — fetch transcript logs for a session. +- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`). + +Details: [Session tools](https://docs.clawdbot.com/session-tool) + ## 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 +https://ClawdHub.com ## Chat commands @@ -145,6 +217,7 @@ Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only): - `/status` — health + session info (group shows activation mode) - `/new` or `/reset` — reset the session +- `/compact` — compact session context (summary) - `/think ` — off|minimal|low|medium|high - `/verbose on|off` - `/restart` — restart the gateway (owner-only in groups) @@ -154,6 +227,13 @@ Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only): The Gateway alone delivers a great experience. All apps are optional and add extra features. +If you plan to build/run companion apps, initialize submodules first: + +```bash +git submodule update --init --recursive +./scripts/restart-mac.sh +``` + ### macOS (Clawdbot.app) (optional) - Menu bar control for the Gateway and health. @@ -161,7 +241,7 @@ The Gateway alone delivers a great experience. All apps are optional and add ext - WebChat + debug tools. - Remote gateway control over SSH. -Build/run: `./scripts/restart-mac.sh` (packages + launches). +Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`). ### iOS node (optional) @@ -169,13 +249,13 @@ Build/run: `./scripts/restart-mac.sh` (packages + launches). - Voice trigger forwarding + Canvas surface. - Controlled via `clawdbot nodes …`. -Runbook: [iOS connect](https://docs.clawdbot.com/ios/connect). +Runbook: [iOS connect](https://docs.clawdbot.com/ios). ### Android node (optional) - Pairs via the same Bridge + pairing flow as iOS. - Exposes Canvas, Camera, and Screen capture commands. -- Runbook: [Android connect](https://docs.clawdbot.com/android/connect). +- Runbook: [Android connect](https://docs.clawdbot.com/android). ## Agent workspace + skills @@ -197,19 +277,24 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults): [Full configuration reference (all keys + examples).](https://docs.clawdbot.com/configuration) -### WhatsApp +## Security model (important) -[Read the WhatsApp provider guide in docs/whatsapp.md.](docs/whatsapp.md) +- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you. +- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions. +- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`. + +Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandboxing](https://docs.clawdbot.com/docker) · [Sandbox config](https://docs.clawdbot.com/configuration) + +### [WhatsApp](https://docs.clawdbot.com/whatsapp) - Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`). - Allowlist who can talk to the assistant via `whatsapp.allowFrom`. +- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all. -### Telegram - -[Read the Telegram provider guide in docs/telegram.md.](docs/telegram.md) +### [Telegram](https://docs.clawdbot.com/telegram) - Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins). -- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed. +- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed. ```json5 { @@ -219,15 +304,11 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults): } ``` -### Slack - -[Read the Slack provider guide in docs/slack.md.](docs/slack.md) +### [Slack](https://docs.clawdbot.com/slack) - Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`). -### Discord - -[Read the Discord provider guide in docs/discord.md.](docs/discord.md) +### [Discord](https://docs.clawdbot.com/discord) - Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). - Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed. @@ -240,21 +321,16 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults): } ``` -### Signal - -[Read the Signal provider guide in docs/signal.md.](docs/signal.md) +### [Signal](https://docs.clawdbot.com/signal) - Requires `signal-cli` and a `signal` config section. -### iMessage - -[Read the iMessage provider guide in docs/imessage.md.](docs/imessage.md) +### [iMessage](https://docs.clawdbot.com/imessage) - macOS only; Messages must be signed in. +- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all. -### WebChat - -[Read the WebChat guide in docs/webchat.md.](docs/webchat.md) +### [WebChat](https://docs.clawdbot.com/webchat) - Uses the Gateway WebSocket; no separate WebChat port/config. @@ -272,6 +348,7 @@ Browser control (optional): ## Docs +Use these when you’re past the onboarding flow and want the deeper reference. - [Start with the docs index for navigation and “what’s where.”](https://docs.clawdbot.com/) - [Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture) - [Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration) @@ -281,10 +358,57 @@ Browser control (optional): - [Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard) - [Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook) - [Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub) -- [Learn the macOS menu bar companion details.](https://clawdbot.com/clawdbot-mac.html) +- [Learn the macOS menu bar companion details.](https://docs.clawdbot.com/mac/menu-bar) +- [Platform guides: Windows](https://docs.clawdbot.com/windows), [Linux](https://docs.clawdbot.com/linux), [macOS](https://docs.clawdbot.com/macos), [iOS](https://docs.clawdbot.com/ios), [Android](https://docs.clawdbot.com/android) - [Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting) - [Review security guidance before exposing anything.](https://docs.clawdbot.com/security) +## Advanced docs (discovery + control) + +- [Discovery + transports](https://docs.clawdbot.com/discovery) +- [Bonjour/mDNS](https://docs.clawdbot.com/bonjour) +- [Gateway pairing](https://docs.clawdbot.com/gateway/pairing) +- [Remote gateway README](https://docs.clawdbot.com/remote-gateway-readme) +- [Control UI](https://docs.clawdbot.com/control-ui) +- [Dashboard](https://docs.clawdbot.com/dashboard) + +## Operations & troubleshooting + +- [Health checks](https://docs.clawdbot.com/health) +- [Gateway lock](https://docs.clawdbot.com/gateway-lock) +- [Background process](https://docs.clawdbot.com/background-process) +- [Browser troubleshooting (Linux)](https://docs.clawdbot.com/browser-linux-troubleshooting) +- [Logging](https://docs.clawdbot.com/logging) + +## Deep dives + +- [Agent loop](https://docs.clawdbot.com/agent-loop) +- [Presence](https://docs.clawdbot.com/presence) +- [TypeBox schemas](https://docs.clawdbot.com/typebox) +- [RPC adapters](https://docs.clawdbot.com/rpc) +- [Queue](https://docs.clawdbot.com/queue) + +## Workspace & skills + +- [Skills config](https://docs.clawdbot.com/skills-config) +- [Default AGENTS](https://docs.clawdbot.com/AGENTS.default) +- [Templates: AGENTS](https://docs.clawdbot.com/templates/AGENTS) +- [Templates: BOOTSTRAP](https://docs.clawdbot.com/templates/BOOTSTRAP) +- [Templates: IDENTITY](https://docs.clawdbot.com/templates/IDENTITY) +- [Templates: SOUL](https://docs.clawdbot.com/templates/SOUL) +- [Templates: TOOLS](https://docs.clawdbot.com/templates/TOOLS) +- [Templates: USER](https://docs.clawdbot.com/templates/USER) + +## Platform internals + +- [macOS dev setup](https://docs.clawdbot.com/mac/dev-setup) +- [macOS menu bar](https://docs.clawdbot.com/mac/menu-bar) +- [macOS voice wake](https://docs.clawdbot.com/mac/voicewake) +- [iOS node](https://docs.clawdbot.com/ios) +- [Android node](https://docs.clawdbot.com/android) +- [Windows app](https://docs.clawdbot.com/windows) +- [Linux app](https://docs.clawdbot.com/linux) + ## Email hooks (Gmail) [Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawdbot.com/gmail-pubsub) @@ -310,11 +434,13 @@ by Peter Steinberger and the community. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs. AI/vibe-coded PRs welcome! 🤖 -Thanks to everyone who has contributed: +Thanks to all clawtributors:

steipete thewilloftheshadow mcinteerj joshp123 joaohlisboa petter-b mukhtharcm dan-dr Nachx639 jeffersonwarrior mbelinky julianengel CashWilliams omniwired jverdi Syhids meaningfool rafaelreis-r wstock vsabavat scald sreekaransrinath ratulsarna osolmaz conhecendocontato hrdwdmrbl jayhickey jamesgroat gtsifrikas djangonavarro220 azade-c andranik-sahakyan + adamgall jalehman jarvis-medmatic mneves75 regenrek tobiasbischoff MSch obviyus dbhurley + Asleep123 Iamadig imfing kitze nachoiacovino VACInc

diff --git a/apps/android/app/src/main/assets/tool-display.json b/apps/android/app/src/main/assets/tool-display.json index b6a28f60f..9c0e57fc6 100644 --- a/apps/android/app/src/main/assets/tool-display.json +++ b/apps/android/app/src/main/assets/tool-display.json @@ -12,7 +12,7 @@ "element", "node", "nodeId", - "jobId", + "id", "requestId", "to", "channelId", @@ -136,10 +136,10 @@ "label": "add", "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] }, - "update": { "label": "update", "detailKeys": ["jobId"] }, - "remove": { "label": "remove", "detailKeys": ["jobId"] }, - "run": { "label": "run", "detailKeys": ["jobId"] }, - "runs": { "label": "runs", "detailKeys": ["jobId"] }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, "wake": { "label": "wake", "detailKeys": ["text", "mode"] } } }, diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift index ea40e9303..093978ebb 100644 --- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift @@ -323,6 +323,9 @@ struct CronJobEditor: View { Text("whatsapp").tag(GatewayAgentChannel.whatsapp) Text("telegram").tag(GatewayAgentChannel.telegram) Text("discord").tag(GatewayAgentChannel.discord) + Text("slack").tag(GatewayAgentChannel.slack) + Text("signal").tag(GatewayAgentChannel.signal) + Text("imessage").tag(GatewayAgentChannel.imessage) } .labelsHidden() .pickerStyle(.segmented) diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index ac8e6f1ff..d176be624 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -10,6 +10,9 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { case whatsapp case telegram case discord + case slack + case signal + case imessage case webchat init(raw: String?) { diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 49690fbe3..85ee13fdb 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -369,6 +369,43 @@ public struct SendParams: Codable, Sendable { } } +public struct PollParams: Codable, Sendable { + public let to: String + public let question: String + public let options: [String] + public let maxselections: Int? + public let durationhours: Int? + public let provider: String? + public let idempotencykey: String + + public init( + to: String, + question: String, + options: [String], + maxselections: Int?, + durationhours: Int?, + provider: String?, + idempotencykey: String + ) { + self.to = to + self.question = question + self.options = options + self.maxselections = maxselections + self.durationhours = durationhours + self.provider = provider + self.idempotencykey = idempotencykey + } + private enum CodingKeys: String, CodingKey { + case to + case question + case options + case maxselections = "maxSelections" + case durationhours = "durationHours" + case provider + case idempotencykey = "idempotencyKey" + } +} + public struct AgentParams: Codable, Sendable { public let message: String public let to: String? diff --git a/apps/shared/ClawdbotKit/Resources/tool-display.json b/apps/shared/ClawdbotKit/Resources/tool-display.json index b6a28f60f..9c0e57fc6 100644 --- a/apps/shared/ClawdbotKit/Resources/tool-display.json +++ b/apps/shared/ClawdbotKit/Resources/tool-display.json @@ -12,7 +12,7 @@ "element", "node", "nodeId", - "jobId", + "id", "requestId", "to", "channelId", @@ -136,10 +136,10 @@ "label": "add", "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] }, - "update": { "label": "update", "detailKeys": ["jobId"] }, - "remove": { "label": "remove", "detailKeys": ["jobId"] }, - "run": { "label": "run", "detailKeys": ["jobId"] }, - "runs": { "label": "runs", "detailKeys": ["jobId"] }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, "wake": { "label": "wake", "detailKeys": ["text", "mode"] } } }, diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index a1044ba0d..cdbb83258 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -8,26 +8,26 @@ read_when: ## First run (recommended) -Clawdbot uses a dedicated workspace directory for the agent. Default: `~/.clawdbot/workspace`. +Clawdbot uses a dedicated workspace directory for the agent. Default: `~/clawd` (configurable via `agent.workspace`). 1) Create the workspace (if it doesn’t already exist): ```bash -mkdir -p ~/.clawdbot/workspace +mkdir -p ~/clawd ``` 2) Copy the default workspace templates into the workspace: ```bash -cp docs/templates/AGENTS.md ~/.clawdbot/workspace/AGENTS.md -cp docs/templates/SOUL.md ~/.clawdbot/workspace/SOUL.md -cp docs/templates/TOOLS.md ~/.clawdbot/workspace/TOOLS.md +cp docs/templates/AGENTS.md ~/clawd/AGENTS.md +cp docs/templates/SOUL.md ~/clawd/SOUL.md +cp docs/templates/TOOLS.md ~/clawd/TOOLS.md ``` 3) Optional: if you want the personal assistant skill roster, replace AGENTS.md with this file: ```bash -cp docs/AGENTS.default.md ~/.clawdbot/workspace/AGENTS.md +cp docs/AGENTS.default.md ~/clawd/AGENTS.md ``` 4) Optional: choose a different workspace by setting `agent.workspace` (supports `~`): @@ -73,7 +73,7 @@ cp docs/AGENTS.default.md ~/.clawdbot/workspace/AGENTS.md If you treat this workspace as Clawd’s “memory”, make it a git repo (ideally private) so `AGENTS.md` and your memory files are backed up. ```bash -cd ~/.clawdbot/workspace +cd ~/clawd git init git add AGENTS.md git commit -m "Add Clawd workspace" diff --git a/docs/_config.yml b/docs/_config.yml index 35036163c..cf13847ad 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,5 +1,5 @@ title: "CLAWDBOT Docs" -description: "A TypeScript/Node gateway + macOS/iOS companions for WhatsApp (web) and Telegram (bot)." +description: "A TypeScript/Node gateway + macOS/iOS/Android companions for WhatsApp (web) and Telegram (bot)." markdown: kramdown highlighter: rouge @@ -35,9 +35,11 @@ nav: - title: "WebChat" url: "/webchat.html" - title: "macOS App" - url: "/clawdbot-mac.html" - - title: "iOS Node" - url: "/ios/connect.html" + url: "/macos.html" + - title: "iOS App" + url: "/ios.html" + - title: "Android App" + url: "/android.html" - title: "Telegram" url: "/telegram.html" - title: "Security" diff --git a/docs/android/connect.md b/docs/android.md similarity index 89% rename from docs/android/connect.md rename to docs/android.md index 75964a729..ea09269e5 100644 --- a/docs/android/connect.md +++ b/docs/android.md @@ -1,18 +1,20 @@ --- -summary: "Runbook: connect/pair the Android node to a Clawdbot Gateway and use Canvas/Chat/Camera" +summary: "Android app (node): connection runbook + Canvas/Chat/Camera" read_when: - Pairing or reconnecting the Android node - Debugging Android bridge discovery or auth - Verifying chat history parity across clients --- -# Android Node Connection Runbook +# Android App (Node) + +## Connection Runbook Android node app ⇄ (mDNS/NSD + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway** The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Android talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing. -## Prerequisites +### Prerequisites - You can run the Gateway on the “master” machine. - Android device/emulator can reach the gateway bridge: @@ -21,7 +23,7 @@ The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Android talk - Manual bridge host/port (fallback) - You can run the CLI (`clawdbot`) on the gateway machine (or via SSH). -## 1) Start the Gateway (with bridge enabled) +### 1) Start the Gateway (with bridge enabled) Bridge is enabled by default (disable via `CLAWDBOT_BRIDGE_ENABLED=0`). @@ -37,7 +39,7 @@ For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to - Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json` on the gateway host. - Restart the Gateway / macOS menubar app. -## 2) Verify discovery (optional) +### 2) Verify discovery (optional) From the gateway machine: @@ -47,7 +49,7 @@ dns-sd -B _clawdbot-bridge._tcp local. More debugging notes: `docs/bonjour.md`. -### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD +#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead: @@ -56,7 +58,7 @@ Android NSD/mDNS discovery won’t cross networks. If your Android node and the Details and example CoreDNS config: `docs/bonjour.md`. -## 3) Connect from Android +### 3) Connect from Android In the Android app: @@ -69,7 +71,7 @@ After the first successful pairing, Android auto-reconnects on launch: - Manual endpoint (if enabled), otherwise - The last discovered bridge (best-effort). -## 4) Approve pairing (CLI) +### 4) Approve pairing (CLI) On the gateway machine: @@ -80,7 +82,7 @@ clawdbot nodes approve Pairing details: `docs/gateway/pairing.md`. -## 5) Verify the node is connected +### 5) Verify the node is connected - Via nodes status: ```bash @@ -91,7 +93,7 @@ Pairing details: `docs/gateway/pairing.md`. clawdbot gateway call node.list --params "{}" ``` -## 6) Chat + history +### 6) Chat + history The Android node’s Chat sheet uses the gateway’s **primary session key** (`main`), so history and replies are shared with WebChat and other clients: @@ -99,9 +101,9 @@ The Android node’s Chat sheet uses the gateway’s **primary session key** (`m - Send: `chat.send` - Push updates (best-effort): `chat.subscribe` → `event:"chat"` -## 7) Canvas + camera +### 7) Canvas + camera -### Gateway Canvas Host (recommended for web content) +#### Gateway Canvas Host (recommended for web content) If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point the node at the Gateway canvas host. diff --git a/docs/architecture.md b/docs/architecture.md index 720450732..ee98631ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,6 +32,12 @@ Last updated: 2026-01-05 - Canvas + actions: `WKWebView` with A2UI action bridge; accepts actions from local-network or trusted file URLs; intercepts `clawdbot://` deep links and forwards `agent.request` to the bridge. - Voice/talk: voice wake sends `voice.transcript` events and syncs triggers via `voicewake.get` + `voicewake.changed`; Talk Mode attaches to the bridge. +### Android node (`apps/android`) +- Discovery + pairing: `BridgeDiscovery` uses mDNS/NSD to find `_clawdbot-bridge._tcp`, with manual host/port fallback. +- Auto-connect: `NodeRuntime` restores a stored token, performs `pair-and-hello`, and reconnects to the last discovered or manual bridge. +- Bridge runtime: `BridgeSession` owns the TCP JSONL session (`hello`/`hello-ok`, ping/pong, `req/res`, `event`, `invoke`); stores `canvasHostUrl`. +- Commands: `NodeRuntime` executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, and chat/session events; foreground-only for canvas/camera. + ## Components and flows - **Gateway (daemon)** - Maintains WhatsApp (Baileys), Telegram (grammY), Slack (Bolt), Discord (discord.js), Signal (signal-cli), and iMessage (imsg) connections. @@ -40,7 +46,7 @@ Last updated: 2026-01-05 - **Clients (mac app / CLI / web admin)** - One WS connection per client. - Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`). - - On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see `docs/clawdbot-mac.md`). + - On macOS, the app can also be invoked via deep links (`clawdbot://agent?...`) which translate into the same Gateway `agent` request path (see `docs/macos.md`). - **Agent process (Pi)** - Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection. - **WebChat** diff --git a/docs/assets/terminal.css b/docs/assets/terminal.css index 58bb59578..00242acb1 100644 --- a/docs/assets/terminal.css +++ b/docs/assets/terminal.css @@ -29,40 +29,40 @@ html[data-theme="auto"] { } html[data-theme="dark"] { - --bg0: #06141f; - --bg1: #031019; - --panel: #061a16; - --panel2: #071f19; - --text: #d6f6ea; - --muted: #95c9b9; - --faint: #66a391; - --link: #79ffd0; - --link2: #ff775f; + --bg0: #0b1a22; + --bg1: #0a1720; + --panel: #0e231f; + --panel2: #102a24; + --text: #c9eadc; + --muted: #8ab8aa; + --faint: #699b8d; + --link: #6fe8c7; + --link2: #ff7b63; --accent: #ff4f40; - --accent2: #67ff9b; - --frame-border: #b7ffe6; - --code-bg: #04110d; - --code-fg: #dcfff1; - --code-accent: #67ff9b; + --accent2: #5fdfa2; + --frame-border: #6fbfa8; + --code-bg: #091814; + --code-fg: #d7f5e8; + --code-accent: #5fdfa2; } @media (prefers-color-scheme: dark) { html[data-theme="auto"] { - --bg0: #06141f; - --bg1: #031019; - --panel: #061a16; - --panel2: #071f19; - --text: #d6f6ea; - --muted: #95c9b9; - --faint: #66a391; - --link: #79ffd0; - --link2: #ff775f; + --bg0: #0b1a22; + --bg1: #0a1720; + --panel: #0e231f; + --panel2: #102a24; + --text: #c9eadc; + --muted: #8ab8aa; + --faint: #699b8d; + --link: #6fe8c7; + --link2: #ff7b63; --accent: #ff4f40; - --accent2: #67ff9b; - --frame-border: #b7ffe6; - --code-bg: #04110d; - --code-fg: #dcfff1; - --code-accent: #67ff9b; + --accent2: #5fdfa2; + --frame-border: #6fbfa8; + --code-bg: #091814; + --code-fg: #d7f5e8; + --code-accent: #5fdfa2; } } @@ -87,39 +87,9 @@ body { overflow-x: hidden; } -body::before { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - opacity: 0.45; - background-image: - linear-gradient(to right, color-mix(in oklab, var(--text) 10%, transparent) 1px, transparent 1px), - linear-gradient(to bottom, color-mix(in oklab, var(--text) 10%, transparent) 1px, transparent 1px); - background-size: 28px 28px; - mix-blend-mode: overlay; -} - +body::before, body::after { - content: ""; - position: fixed; - inset: 0; - pointer-events: none; - background: repeating-linear-gradient( - to bottom, - rgba(0, 0, 0, var(--scanline-opacity)), - rgba(0, 0, 0, var(--scanline-opacity)) 1px, - transparent 1px, - transparent var(--scanline-size) - ); - opacity: 0.8; - mix-blend-mode: multiply; -} - -@media (prefers-reduced-motion: reduce) { - body::after { - display: none; - } + display: none; } .skip-link { diff --git a/docs/browser-linux-troubleshooting.md b/docs/browser-linux-troubleshooting.md index 33643634a..3cbd4dbe1 100644 --- a/docs/browser-linux-troubleshooting.md +++ b/docs/browser-linux-troubleshooting.md @@ -1,3 +1,8 @@ +--- +summary: "Fix Chrome/Chromium CDP startup issues for Clawdbot browser control on Linux" +read_when: "Browser control fails on Linux, especially with snap Chromium" +--- + # Browser Troubleshooting (Linux) ## Problem: "Failed to start Chrome CDP on port 18800" diff --git a/docs/bun.md b/docs/bun.md new file mode 100644 index 000000000..a3350357a --- /dev/null +++ b/docs/bun.md @@ -0,0 +1,56 @@ +# Bun (optional) + +Goal: allow running this repo with Bun without maintaining a Bun lockfile or losing pnpm patch behavior. + +## Status + +- pnpm remains the primary package manager/runtime for this repo. +- Bun can be used for local installs/builds/tests, but Bun currently **cannot use** `pnpm-lock.yaml` and will ignore it. + +## Install (no Bun lockfile) + +Use Bun without writing `bun.lock`/`bun.lockb`: + +```sh +bun install --no-save +``` + +This avoids maintaining two lockfiles. (`bun.lock`/`bun.lockb` are gitignored.) + +## Build / Test (Bun) + +```sh +bun run build +bun run vitest run +``` + +## pnpm patchedDependencies under Bun + +pnpm supports `package.json#pnpm.patchedDependencies` and records it in `pnpm-lock.yaml`. +Bun does not support pnpm patches, so we apply them in `postinstall` when Bun is detected: + +- `scripts/postinstall.js` runs only for Bun installs and applies every entry from `package.json#pnpm.patchedDependencies` into `node_modules/...` using `git apply` (idempotent). + +To add a new patch that works in both pnpm + Bun: + +1. Add an entry to `package.json#pnpm.patchedDependencies` +2. Add the patch file under `patches/` +3. Run `pnpm install` (updates `pnpm-lock.yaml` patch hash) + +## Bun lifecycle scripts (blocked by default) + +Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`). +For this repo, the commonly blocked scripts are not required: + +- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (we run Node 22+). +- `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts). + +If you hit a real runtime issue that requires these scripts, trust them explicitly: + +```sh +bun pm trust @whiskeysockets/baileys protobufjs +``` + +## Caveats + +- Some scripts still hardcode pnpm (e.g. `docs:build`, `ui:*`, `protocol:check`). Run those via pnpm for now. diff --git a/docs/clawd.md b/docs/clawd.md index 7bbadfaad..044c89536 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -147,12 +147,13 @@ Example: - Session files: `~/.clawdbot/sessions/{{SessionId}}.jsonl` - Session metadata (token usage, last route, etc): `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`) - `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, the agent replies with a short hello to confirm the reset. +- `/compact [instructions]` compacts the session context and reports the remaining context budget. ## Heartbeats (proactive mode) When `agent.heartbeat.every` is set to a positive interval, CLAWDBOT periodically runs a heartbeat prompt (default: `HEARTBEAT`). -- If the agent replies with `HEARTBEAT_OK` (exact token), CLAWDBOT suppresses outbound delivery for that heartbeat. +- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agent.heartbeat.ackMaxChars`), CLAWDBOT suppresses outbound delivery for that heartbeat. ```json5 { @@ -193,5 +194,9 @@ Logs live under `/tmp/clawdbot/` (default: `clawdbot-YYYY-MM-DD.log`). - WebChat: [WebChat](./webchat.md) - Gateway ops: [Gateway runbook](./gateway.md) - Cron + wakeups: [Cron + wakeups](./cron.md) -- macOS menu bar companion: [Clawdbot macOS app](./clawdbot-mac.md) +- macOS menu bar companion: [Clawdbot macOS app](./macos.md) +- iOS node app: [iOS app](./ios.md) +- Android node app: [Android app](./android.md) +- Windows status: [Windows app](./windows.md) +- Linux status: [Linux app](./linux.md) - Security: [Security](./security.md) diff --git a/docs/configuration.md b/docs/configuration.md index 6d1ad7563..64766931f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ CLAWDBOT reads an optional **JSON5** config from `~/.clawdbot/clawdbot.json` (co If the file is missing, CLAWDBOT uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to: - restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.) -- control group mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`) +- control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`) - customize message prefixes (`messages`) - set the agent's workspace (`agent.workspace`) - tune the embedded agent (`agent`) and session behavior (`session`) @@ -91,26 +91,48 @@ Env var equivalent: ### Auth storage (OAuth + API keys) -Clawdbot keeps subscription OAuth tokens + API keys in the **agent auth store**: -- `~/.clawdbot/agent/auth.json` +Clawdbot stores **auth profiles** (OAuth + API keys) in: +- `~/.clawdbot/agent/auth-profiles.json` -The agent directory can be overridden with: -- `CLAWDBOT_AGENT_DIR` (preferred) -- `PI_CODING_AGENT_DIR` (legacy) +Legacy OAuth imports: +- `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`) -Legacy OAuth storage is still supported for migration: -- Default: `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`) -- Override: `CLAWDBOT_OAUTH_DIR` +The embedded Pi agent maintains a runtime cache at: +- `~/.clawdbot/agent/auth.json` (managed automatically; don’t edit manually) -On first use, Clawdbot auto‑migrates legacy `oauth.json` entries into `auth.json`. +Overrides: +- OAuth dir (legacy import only): `CLAWDBOT_OAUTH_DIR` +- Agent dir: `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy) + +On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`. + +### `auth` + +Optional metadata for auth profiles. This does **not** store secrets; it maps +profile IDs to a provider + mode (and optional email) and defines the provider +rotation order used for failover. + +```json5 +{ + auth: { + profiles: { + "anthropic:default": { provider: "anthropic", mode: "oauth", email: "me@example.com" }, + "anthropic:work": { provider: "anthropic", mode: "api_key" } + }, + order: { + anthropic: ["anthropic:default", "anthropic:work"] + } + } +} +``` ### `identity` Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant. If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly): -- `messages.responsePrefix` from `identity.emoji` -- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups) +- `messages.ackReaction` from `identity.emoji` (falls back to 👀) +- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) ```json5 { @@ -141,6 +163,9 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). - Console output can be tuned separately via: - `logging.consoleLevel` (defaults to `info`, bumps to `debug` when `--verbose`) - `logging.consoleStyle` (`pretty` | `compact` | `json`) +- Tool summaries can be redacted to avoid leaking secrets: + - `logging.redactSensitive` (`off` | `tools`, default: `tools`) + - `logging.redactPatterns` (array of regex strings; overrides defaults) ```json5 { @@ -148,7 +173,13 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`). level: "info", file: "/tmp/clawdbot/clawdbot.log", consoleLevel: "info", - consoleStyle: "pretty" + consoleStyle: "pretty", + redactSensitive: "tools", + redactPatterns: [ + // Example: override defaults with your own rules. + "\\bTOKEN\\b\\s*[=:]\\s*([\"']?)([^\\s\"']+)\\1", + "/\\bsk-[A-Za-z0-9_-]{8,}\\b/gi" + ] } } ``` @@ -174,6 +205,7 @@ Group messages default to **require mention** (either metadata mention or regex **Mention types:** - **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`). - **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode. +- Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`). ```json5 { @@ -186,7 +218,7 @@ Group messages default to **require mention** (either metadata mention or regex } ``` -Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). +Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). When `*.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups. To respond **only** to specific text triggers (ignoring native @-mentions): ```json5 @@ -432,16 +464,28 @@ Default: `~/clawd`. If `agent.sandbox` is enabled, non-main sessions can override this with their own per-session workspaces under `agent.sandbox.workspaceRoot`. +### `agent.userTimezone` + +Sets the user’s timezone for **system prompt context** (not for timestamps in +message envelopes). If unset, Clawdbot uses the host timezone at runtime. + +```json5 +{ + agent: { userTimezone: "America/Chicago" } +} +``` + ### `messages` -Controls inbound/outbound prefixes and timestamps. +Controls inbound/outbound prefixes and optional ack reactions. ```json5 { messages: { messagePrefix: "[clawdbot]", responsePrefix: "🦞", - timestampPrefix: "Europe/London" + ackReaction: "👀", + ackReactionScope: "group-mentions" } } ``` @@ -449,6 +493,16 @@ Controls inbound/outbound prefixes and timestamps. `responsePrefix` is applied to **all outbound replies** (tool summaries, block streaming, final replies) across providers unless already present. +`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages +on providers that support reactions (Slack/Discord/Telegram). Defaults to the +configured `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable. + +`ackReactionScope` controls when reactions fire: +- `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned +- `group-all`: all group/room messages +- `direct`: direct messages only +- `all`: all messages + ### `talk` Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset. @@ -474,14 +528,12 @@ Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_V ### `agent` 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. +`agent.models` defines the configured model catalog (and acts as the allowlist for `/model`). +`agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers. +`agent.imageModel` is optional and is **only used if the primary model lacks image input**. -Clawdbot also ships a few built-in `modelAliases` shorthands (when an `agent` section exists): +Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model +is already present in `agent.models`: - `opus` -> `anthropic/claude-opus-4-5` - `sonnet` -> `anthropic/claude-sonnet-4-5` @@ -495,23 +547,24 @@ If you configure the same alias name (case-insensitive) yourself, your value win ```json5 { agent: { - model: "anthropic/claude-opus-4-5", - allowedModels: [ - "anthropic/claude-opus-4-5", - "anthropic/claude-sonnet-4-1" - ], - modelAliases: { - Opus: "anthropic/claude-opus-4-5", - Sonnet: "anthropic/claude-sonnet-4-1" + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, + "openrouter/deepseek/deepseek-r1:free": {} + }, + model: { + primary: "anthropic/claude-opus-4-5", + fallbacks: [ + "openrouter/deepseek/deepseek-r1:free", + "openrouter/meta-llama/llama-3.3-70b-instruct:free" + ] + }, + imageModel: { + primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free", + fallbacks: [ + "openrouter/google/gemini-2.0-flash-vision:free" + ] }, - 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", @@ -546,8 +599,8 @@ Block streaming: } ``` -`agent.model` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). -If `modelAliases` is configured, you may also use the alias key (e.g. `Opus`). +`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`). +Aliases come from `agent.models.*.alias` (e.g. `Opus`). If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary deprecation fallback. Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require @@ -560,6 +613,7 @@ Z.AI models are available as `zai/` (e.g. `zai/glm-4.7`) and require - `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `imessage`, `none`). Default: `last`. - `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram). - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). +- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30). `agent.bash` configures background bash defaults: - `backgroundMs`: time before auto-background (ms, default 10000) @@ -708,11 +762,16 @@ When `models.providers` is present, Clawdbot writes/merges a `models.json` into - default behavior: **merge** (keeps existing providers, overrides on name) - set `models.mode: "replace"` to overwrite the file contents -Select the model via `agent.model` (provider/model). +Select the model via `agent.model.primary` (provider/model). ```json5 { - agent: { model: "custom-proxy/llama-3.1-8b" }, + agent: { + model: { primary: "custom-proxy/llama-3.1-8b" }, + models: { + "custom-proxy/llama-3.1-8b": {} + } + }, models: { mode: "merge", providers: { @@ -745,14 +804,10 @@ via **LM Studio** using the **Responses API**. ```json5 { agent: { - model: "Minimax", - allowedModels: [ - "anthropic/claude-opus-4-5", - "lmstudio/minimax-m2.1-gs32" - ], - modelAliases: { - Opus: "anthropic/claude-opus-4-5", - Minimax: "lmstudio/minimax-m2.1-gs32" + model: { primary: "lmstudio/minimax-m2.1-gs32" }, + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } } }, models: { diff --git a/docs/cron.md b/docs/cron.md index dbc9a2bcd..374086e68 100644 --- a/docs/cron.md +++ b/docs/cron.md @@ -216,6 +216,17 @@ Retention: Each log line includes (at minimum) job id, status/error, timing, and a `summary` string (systemEvent text for main jobs, and the last agent text output for isolated jobs). +## Compatibility policy (cron.add/cron.update) + +To keep older clients working, the Gateway applies **best-effort normalization** for `cron.add` and `cron.update`: +- Accepts wrapped payloads under `data` or `job` and unwraps them. +- Infers `schedule.kind` from `atMs`, `everyMs`, or `expr` if missing. +- Infers `payload.kind` from `text` (systemEvent) or `message` (agentTurn) if missing. +- Defaults `wakeMode` to `"next-heartbeat"` when omitted. +- Defaults `sessionTarget` based on payload kind (`systemEvent` → `"main"`, `agentTurn` → `"isolated"`). + +Normalization is **compat-only**. New clients should send the full schema (including `kind`, `sessionTarget`, and `wakeMode`) to avoid ambiguity. Unknown fields are still rejected by schema validation. + ## Gateway API New methods (names can be bikeshed; `cron.*` is suggested): @@ -264,7 +275,7 @@ Add a `cron` command group (all commands should also support `--json` where sens - `--wake now|next-heartbeat` - payload flags (choose one): - `--system-event ""` - - `--message "" [--deliver] [--channel last|whatsapp|telegram|discord|signal|imessage] [--to ]` + - `--message "" [--deliver] [--channel last|whatsapp|telegram|discord|slack|signal|imessage] [--to ]` - `clawdbot cron edit ...` (patch-by-flags, non-interactive) - `clawdbot cron rm ` diff --git a/docs/discord.md b/docs/discord.md index 7c68ef72b..db76e325e 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -123,6 +123,7 @@ Example “single server, only allow me, only allow #help”: Notes: - `requireMention: true` means the bot only replies when mentioned (recommended for shared channels). +- `routing.groupChat.mentionPatterns` also count as mentions for guild messages. - If `channels` is present, any channel not listed is denied by default. ### 6) Verify it works @@ -202,6 +203,9 @@ Notes: } ``` +Ack reactions are controlled globally via `messages.ackReaction` + +`messages.ackReactionScope`. + - `dm.enabled`: set `false` to ignore all DMs (default `true`). - `dm.allowFrom`: DM allowlist (user ids or names). Omit or set to `["*"]` to allow any DM sender. - `dm.groupEnabled`: enable group DMs (default `false`). diff --git a/docs/docker.md b/docs/docker.md index 4d553536e..c587233c6 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -59,6 +59,12 @@ docker compose exec clawdbot-gateway node dist/index.js health --token "$CLAWDBO scripts/e2e/onboard-docker.sh ``` +### QR import smoke test (Docker) + +```bash +pnpm test:docker:qr +``` + ### Notes - Gateway bind defaults to `lan` for container use. diff --git a/docs/docs.json b/docs/docs.json index 2a6c4b5a2..26ceef64b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -26,6 +26,8 @@ "group": "Getting Started", "pages": [ "index", + "showcase", + "hubs", "onboarding", "clawd", "faq" @@ -70,8 +72,9 @@ "mac/dev-setup", "mac/menu-bar", "mac/voicewake", - "ios/connect", - "android/connect", + "macos", + "ios", + "android", "webchat", "web" ] diff --git a/docs/doctor.md b/docs/doctor.md index e07f15229..51292f5fa 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -27,8 +27,13 @@ Doctor will: - Show the migration it applied. - Rewrite `~/.clawdbot/clawdbot.json` with the updated schema. +The Gateway also auto-runs doctor migrations on startup when it detects a legacy +config format, so stale configs are repaired without manual intervention. + Current migrations: - `routing.allowFrom` → `whatsapp.allowFrom` +- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` + → `agent.models` + `agent.model.primary/fallbacks` + `agent.imageModel.primary/fallbacks` ## Usage diff --git a/docs/faq.md b/docs/faq.md index 6ec58618f..f51fe824d 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -14,9 +14,10 @@ Everything lives under `~/.clawdbot/`: | Path | Purpose | |------|---------| | `~/.clawdbot/clawdbot.json` | Main config (JSON5) | -| `~/.clawdbot/agent/auth.json` | OAuth + API key store (Anthropic/OpenAI, etc.) | +| `~/.clawdbot/credentials/oauth.json` | OAuth credentials (Anthropic/OpenAI, etc.) | +| `~/.clawdbot/agent/auth-profiles.json` | Auth profiles (OAuth + API keys) | +| `~/.clawdbot/agent/auth.json` | Runtime API key cache (managed automatically) | | `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens | -| `~/.clawdbot/credentials/oauth.json` | Legacy OAuth store (auto‑migrated) | | `~/.clawdbot/sessions/` | Conversation history & state | | `~/.clawdbot/sessions/sessions.json` | Session metadata | @@ -42,10 +43,10 @@ Some features are platform-specific: - **CPU:** 1 core is fine for personal use - **Disk:** ~500MB for Clawdbot + deps, plus space for logs/media -The gateway is just shuffling messages around. A Raspberry Pi 4 can run it. You can also use **Bun** instead of Node for even lower memory footprint: +The gateway is just shuffling messages around. A Raspberry Pi 4 can run it. For the CLI, prefer the Node runtime (most stable): ```bash -bun clawdbot gateway +pnpm clawdbot gateway ``` ### How do I install on Linux without Homebrew? @@ -78,7 +79,7 @@ This creates `~/.clawdbot/clawdbot.json` with your API keys, workspace path, and cp -r ~/.clawdbot ~/.clawdbot-backup # Remove config and credentials -rm -rf ~/.clawdbot +trash ~/.clawdbot # Re-run onboarding pnpm clawdbot onboard @@ -118,7 +119,7 @@ They're **separate billing**! An API key does NOT use your subscription. pnpm clawdbot login ``` -**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/agent/auth.json` to your server. The auth is just a JSON file. +**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdbot/credentials/oauth.json` to your server. The auth is just a JSON file. ### How are env vars loaded? @@ -148,7 +149,7 @@ Or set `CLAWDBOT_LOAD_SHELL_ENV=1` (timeout: `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=1500 OAuth needs the callback to reach the machine running the CLI. Options: -1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/agent/auth.json` to the container. +1. **Copy auth manually** — Run OAuth on your laptop, copy `~/.clawdbot/credentials/oauth.json` to the container. 2. **SSH tunnel** — `ssh -L 18789:localhost:18789 user@server` 3. **Tailscale** — Put both machines on your tailnet. @@ -229,7 +230,7 @@ Yes! The terminal QR code login works fine over SSH. For long-running operation: ### bun binary vs Node runtime? Clawdbot can run as: -- **bun binary** — Single executable, easy distribution, auto-restarts via launchd +- **bun binary (macOS app)** — Single executable, easy distribution, auto-restarts via launchd - **Node runtime** (`pnpm clawdbot gateway`) — More stable for WhatsApp If you see WebSocket errors like `ws.WebSocket 'upgrade' event is not implemented`, use Node instead of the bun binary. Bun's WebSocket implementation has edge cases that can break WhatsApp (Baileys). @@ -301,7 +302,7 @@ Claude Opus has a 200k token context window, and Clawdbot uses **autocompaction* Practical tips: - Keep `AGENTS.md` focused, not bloated. -- Use `/new` to reset the session when context gets stale. +- Use `/compact` to shrink older context or `/new` to reset when it gets stale. - For large memory/notes collections, use search tools like `qmd` rather than loading everything. ### Where are my memory files? @@ -471,7 +472,7 @@ codex --full-auto "debug why clawdbot gateway won't start" Linux installs use a systemd **user** service. By default, systemd stops user services on logout/idle, which kills the Gateway. -Fix: +Onboarding attempts to enable lingering; if it’s still off, run: ```bash sudo loginctl enable-linger $USER ``` @@ -491,6 +492,9 @@ The gateway runs under a supervisor that auto-restarts it. You need to stop the # Check if running launchctl list | grep clawdbot +# Stop (disable does NOT stop a running job) +clawdbot gateway stop + # Stop and disable launchctl disable gui/$UID/com.clawdbot.gateway launchctl bootout gui/$UID/com.clawdbot.gateway @@ -498,6 +502,9 @@ 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 + +# Or just restart +clawdbot gateway restart ``` **Linux (systemd)** @@ -507,7 +514,11 @@ launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.clawdbot.gateway.plist systemctl list-units | grep -i clawdbot # Stop and disable -sudo systemctl disable --now clawdbot +clawdbot gateway stop +systemctl --user disable --now clawdbot-gateway.service + +# Or just restart +clawdbot gateway restart ``` **pm2 (if used)** @@ -531,10 +542,10 @@ sudo systemctl disable --now clawdbot pkill -f "clawdbot" # Remove data -rm -rf ~/.clawdbot +trash ~/.clawdbot # Remove repo and re-clone -rm -rf ~/clawdbot +trash ~/clawdbot git clone https://github.com/clawdbot/clawdbot.git cd clawdbot && pnpm install && pnpm build pnpm clawdbot onboard @@ -550,6 +561,9 @@ Quick reference (send these in chat): |---------|--------| | `/status` | Health + session info | | `/new` or `/reset` | Reset the session | +| `/compact` | Compact session context | + +Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authorization on other surfaces). | `/think ` | Set thinking level (off\|minimal\|low\|medium\|high) | | `/verbose on\|off` | Toggle verbose mode | | `/elevated on\|off` | Toggle elevated bash mode (approved senders only) | @@ -576,21 +590,16 @@ List available models with `/model`, `/model list`, or `/model status`. 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`: +**Setup:** Configure models and aliases in `clawdbot.json`: ```json { "agent": { - "model": "anthropic/claude-opus-4-5", - "allowedModels": [ - "anthropic/claude-opus-4-5", - "anthropic/claude-sonnet-4-5", - "anthropic/claude-haiku-4-5" - ], - "modelAliases": { - "opus": "anthropic/claude-opus-4-5", - "sonnet": "anthropic/claude-sonnet-4-5", - "haiku": "anthropic/claude-haiku-4-5" + "model": { "primary": "anthropic/claude-opus-4-5" }, + "models": { + "anthropic/claude-opus-4-5": { "alias": "opus" }, + "anthropic/claude-sonnet-4-5": { "alias": "sonnet" }, + "anthropic/claude-haiku-4-5": { "alias": "haiku" } } } } @@ -606,7 +615,8 @@ If you don't want to use Anthropic directly, you can use alternative providers: ```json5 { agent: { - model: "openrouter/anthropic/claude-sonnet-4", + model: { primary: "openrouter/anthropic/claude-sonnet-4" }, + models: { "openrouter/anthropic/claude-sonnet-4": {} }, env: { OPENROUTER_API_KEY: "sk-or-..." } } } @@ -616,7 +626,8 @@ If you don't want to use Anthropic directly, you can use alternative providers: ```json5 { agent: { - model: "zai/glm-4.7", + model: { primary: "zai/glm-4.7" }, + models: { "zai/glm-4.7": {} }, env: { ZAI_API_KEY: "..." } } } diff --git a/docs/gateway.md b/docs/gateway.md index e20badea3..6a3eb4e54 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -159,6 +159,8 @@ See also: `docs/presence.md` for how presence is produced/deduped and why `insta Bundled mac app: - Clawdbot.app can bundle a bun-compiled gateway binary and install a per-user LaunchAgent labeled `com.clawdbot.gateway`. +- To stop it cleanly, use `clawdbot gateway stop` (or `launchctl bootout gui/$UID/com.clawdbot.gateway`). +- To restart, use `clawdbot gateway restart` (or `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`). ## Supervision (systemd user unit) Create `~/.config/systemd/user/clawdbot-gateway.service`: @@ -182,12 +184,21 @@ Enable lingering (required so the user service survives logout/idle): ``` sudo loginctl enable-linger youruser ``` -Requires sudo (writes `/var/lib/systemd/linger`). +Onboarding runs this on Linux (may prompt for sudo; writes `/var/lib/systemd/linger`). Then enable the service: ``` systemctl --user enable --now clawdbot-gateway.service ``` +**Alternative (system service)** - for always-on or multi-user servers, you can +install a systemd **system** unit instead of a user unit (no lingering needed). +Create `/etc/systemd/system/clawdbot-gateway.service` (copy the unit above, +switch `WantedBy=multi-user.target`, set `User=` + `WorkingDirectory=`), then: +``` +sudo systemctl daemon-reload +sudo systemctl enable --now clawdbot-gateway.service +``` + ## Supervision (Windows scheduled task) - Onboarding installs a Scheduled Task named `Clawdbot Gateway` (runs on user logon). - Requires a logged-in user session; for headless setups use a system service or a task configured to run without a logged-in user (not shipped). @@ -208,6 +219,7 @@ systemctl --user enable --now clawdbot-gateway.service - `clawdbot gateway send --to --message "hi" [--media-url ...]` — send via Gateway (idempotent). - `clawdbot gateway agent --message "hi" [--to ...]` — run an agent turn (waits for final by default). - `clawdbot gateway call --params '{"k":"v"}'` — raw method invoker for debugging. +- `clawdbot gateway stop|restart` — stop/restart the supervised gateway service (launchd/systemd/schtasks). - Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one. ## Migration guidance diff --git a/docs/grammy.md b/docs/grammy.md index fb212f3ec..7e0c3366a 100644 --- a/docs/grammy.md +++ b/docs/grammy.md @@ -18,7 +18,7 @@ Updated: 2025-12-07 - **Proxy:** optional `telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `telegram.webhookUrl` is set (otherwise it long-polls). - **Sessions:** direct chats map to `main`; groups map to `telegram:group:`; replies route back to the same surface. -- **Config knobs:** `telegram.botToken`, `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`. +- **Config knobs:** `telegram.botToken`, `telegram.groups` (allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. Open questions diff --git a/docs/group-messages.md b/docs/group-messages.md index 00563d39a..254439124 100644 --- a/docs/group-messages.md +++ b/docs/group-messages.md @@ -1,5 +1,5 @@ --- -summary: "Behavior and config for WhatsApp group message handling" +summary: "Behavior and config for WhatsApp group message handling (mentionPatterns are shared across surfaces)" read_when: - Changing group message rules or mentions --- @@ -7,9 +7,11 @@ read_when: Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. +Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior. + ## What’s implemented (2025-12-03) -- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. -- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies. +- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`. When `whatsapp.groups` is set, it also acts as a group allowlist (include `"*"` to allow all). +- Group allowlist: `whatsapp.groups` gates which group JIDs are allowed; `whatsapp.allowFrom` still gates participants for direct chats. - Per-group sessions: session keys look like `whatsapp:group:` so commands such as `/verbose on` or `/think:high` are scoped to that group; personal DM state is untouched. Heartbeats are skipped for group threads. - Context injection: last N (default 50) group messages are prefixed under `[Chat messages since your last reply - for context]`, with the triggering line under `[Current message - respond to this]`. - Sender surfacing: every group batch now ends with `[from: Sender Name (+E164)]` so Pi knows who is speaking. @@ -56,7 +58,7 @@ Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own 1) Add Clawd UK (`+447700900123`) to the group. 2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it. 3) The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person. -4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`) apply only to that group’s session; your personal DM session remains independent. +4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; your personal DM session remains independent. ## Testing / verification - Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix). diff --git a/docs/groups.md b/docs/groups.md index c80c02a4a..cd9a9f13b 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -16,6 +16,33 @@ Clawdbot treats group chats consistently across surfaces: WhatsApp, Telegram, Di - UI labels use `displayName` when available, formatted as `surface:`. - `#room` is reserved for rooms/channels; group chats use `g-` (lowercase, spaces -> `-`, keep `#@+._-`). +## Group policy (WhatsApp & Telegram) +Both WhatsApp and Telegram support a `groupPolicy` config to control how group messages are handled: + +```json5 +{ + whatsapp: { + allowFrom: ["+15551234567"], + groupPolicy: "disabled" // "open" | "disabled" | "allowlist" + }, + telegram: { + allowFrom: ["123456789", "@username"], + groupPolicy: "disabled" // "open" | "disabled" | "allowlist" + } +} +``` + +| Policy | Behavior | +|--------|----------| +| `"open"` | Default. Groups bypass `allowFrom`, only mention-gating applies. | +| `"disabled"` | Block all group messages entirely. | +| `"allowlist"` | Only allow group messages from senders listed in `allowFrom`. | + +Notes: +- `allowFrom` filters DMs by default. With `groupPolicy: "allowlist"`, it also filters group message senders. +- `groupPolicy` is separate from mention-gating (which requires @mentions). +- For Telegram `allowlist`, the sender can be matched by user ID (e.g., `"123456789"`, `"telegram:123456789"`, or `"tg:123456789"`; prefixes are case-insensitive) or username (e.g., `"@alice"` or `"alice"`). + ## Mention gating (default) Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`. @@ -51,8 +78,12 @@ Group messages require a mention unless overridden per group. Defaults live per Notes: - `mentionPatterns` are case-insensitive regexes. - Surfaces that provide explicit mentions still pass; patterns are a fallback. +- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Discord defaults live in `discord.guilds."*"` (overridable per guild/channel). +## Group allowlists +When `whatsapp.groups`, `telegram.groups`, or `imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior. + ## Activation (owner-only) Group owners can toggle per-group activation: - `/activation mention` diff --git a/docs/health.md b/docs/health.md index d880e8955..761dcd3aa 100644 --- a/docs/health.md +++ b/docs/health.md @@ -22,7 +22,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## When something fails - `logged out` or status 409–515 → relink with `clawdbot logout` then `clawdbot login`. - Gateway unreachable → start it: `clawdbot gateway --port 18789` (use `--force` if the port is busy). -- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure mention rules match (`routing.groupChat.mentionPatterns` and `whatsapp.groups`). +- No inbound messages → confirm linked phone is online and the sender is allowed (`whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`whatsapp.groups`, `routing.groupChat.mentionPatterns`). ## Dedicated "health" command `clawdbot health --json` asks the running Gateway for its health snapshot (no direct Baileys socket from the CLI). It reports linked creds, auth age, Baileys connect result/status code, session-store summary, and a probe duration. It exits non-zero if the Gateway is unreachable or the probe fails/timeouts. Use `--timeout ` to override the 10s default. diff --git a/docs/heartbeat.md b/docs/heartbeat.md index 4a2ab923b..fee828592 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -10,10 +10,10 @@ surface anything that needs attention without spamming the user. ## Prompt contract - Heartbeat body defaults to `HEARTBEAT` (configurable via `agent.heartbeat.prompt`). -- If nothing needs attention, the model should reply **exactly** `HEARTBEAT_OK`. +- If nothing needs attention, the model should reply `HEARTBEAT_OK`. - During heartbeat runs, Clawdbot treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. Clawdbot strips the token and discards the - reply if the remaining content is **≤ 30 characters**. + reply if the remaining content is **≤ `ackMaxChars`** (default: 30). - If `HEARTBEAT_OK` is in the **middle** of a reply, it is not treated specially. - For alerts, do **not** include `HEARTBEAT_OK`; return only the alert text. @@ -39,7 +39,8 @@ and final replies: model: "anthropic/claude-opus-4-5", target: "last", // last | whatsapp | telegram | none to: "+15551234567", // optional override for whatsapp/telegram - prompt: "HEARTBEAT" // optional override + prompt: "HEARTBEAT", // optional override + ackMaxChars: 30 // max chars allowed after HEARTBEAT_OK } } } @@ -55,6 +56,7 @@ and final replies: - `none`: do not deliver externally; output stays in the session (WebChat-visible). - `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram). - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). +- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30). ## Behavior - Runs in the main session (`main`, or `global` when scope is global). diff --git a/docs/hubs.md b/docs/hubs.md new file mode 100644 index 000000000..cd9a9e16d --- /dev/null +++ b/docs/hubs.md @@ -0,0 +1,148 @@ +--- +summary: "Hubs that link to every Clawdbot doc" +read_when: + - You want a complete map of the documentation +--- +# Docs hubs + +Use these hubs to discover every page, including deep dives and reference docs that don’t appear in the left nav. + +## Start here + +- [Index](./index.md) +- [Onboarding](./onboarding.md) +- [Wizard](./wizard.md) +- [Setup](./setup.md) +- [FAQ](./faq.md) +- [Configuration](./configuration.md) +- [Clawd (personal assistant)](./clawd.md) +- [Lore](./lore.md) + +## Installation + distribution + +- [Docker](./docker.md) +- [Nix](./nix.md) + +## Core concepts + +- [Architecture](./architecture.md) +- [Agent runtime](./agent.md) +- [Agent loop](./agent-loop.md) +- [Sessions](./session.md) +- [Sessions (alias)](./sessions.md) +- [Session tools](./session-tool.md) +- [Queue](./queue.md) +- [RPC adapters](./rpc.md) +- [TypeBox schemas](./typebox.md) +- [Presence](./presence.md) +- [Discovery + transports](./discovery.md) +- [Bonjour](./bonjour.md) +- [Surface routing](./surface.md) +- [Groups](./groups.md) +- [Group messages](./group-messages.md) + +## Providers + ingress + +- [WhatsApp](./whatsapp.md) +- [Telegram](./telegram.md) +- [Telegram (grammY notes)](./grammy.md) +- [Slack](./slack.md) +- [Discord](./discord.md) +- [Signal](./signal.md) +- [iMessage](./imessage.md) +- [WebChat](./webchat.md) +- [Webhooks](./webhook.md) +- [Gmail Pub/Sub](./gmail-pubsub.md) + +## Gateway + operations + +- [Gateway runbook](./gateway.md) +- [Gateway pairing](./gateway/pairing.md) +- [Gateway lock](./gateway-lock.md) +- [Background process](./background-process.md) +- [Health](./health.md) +- [Heartbeat](./heartbeat.md) +- [Doctor](./doctor.md) +- [Logging](./logging.md) +- [Dashboard](./dashboard.md) +- [Control UI](./control-ui.md) +- [Control API (legacy)](./control-api.md) +- [Remote access](./remote.md) +- [Remote gateway README](./remote-gateway-readme.md) +- [Tailscale](./tailscale.md) +- [Security](./security.md) +- [Troubleshooting](./troubleshooting.md) + +## Tools + automation + +- [Tools surface](./tools.md) +- [Bash tool](./bash.md) +- [Elevated mode](./elevated.md) +- [Cron + wakeups](./cron.md) +- [Thinking + verbose](./thinking.md) +- [Models](./models.md) +- [Agent send CLI](./agent-send.md) +- [Terminal UI](./tui.md) +- [Browser control](./browser.md) +- [Browser (Linux troubleshooting)](./browser-linux-troubleshooting.md) + +## Nodes, media, voice + +- [Nodes overview](./nodes.md) +- [Camera](./camera.md) +- [Images](./images.md) +- [Audio](./audio.md) +- [Location command](./location-command.md) +- [Voice wake](./voicewake.md) +- [Talk mode](./talk.md) + +## Platforms + +- [macOS app overview](./macos.md) +- [macOS dev setup](./mac/dev-setup.md) +- [macOS menu bar](./mac/menu-bar.md) +- [macOS voice wake](./mac/voicewake.md) +- [macOS voice overlay](./mac/voice-overlay.md) +- [macOS WebChat](./mac/webchat.md) +- [macOS Canvas](./mac/canvas.md) +- [macOS child process](./mac/child-process.md) +- [macOS health](./mac/health.md) +- [macOS icon](./mac/icon.md) +- [macOS logging](./mac/logging.md) +- [macOS permissions](./mac/permissions.md) +- [macOS remote](./mac/remote.md) +- [macOS signing](./mac/signing.md) +- [macOS release](./mac/release.md) +- [macOS bun gateway](./mac/bun.md) +- [macOS XPC](./mac/xpc.md) +- [macOS skills](./mac/skills.md) +- [macOS Peekaboo plan](./mac/peekaboo.md) +- [iOS node](./ios.md) +- [Android node](./android.md) +- [Windows app](./windows.md) +- [Linux app](./linux.md) +- [Web surfaces](./web.md) + +## Workspace + templates + +- [Skills](./skills.md) +- [Skills config](./skills-config.md) +- [Default AGENTS](./AGENTS.default.md) +- [Templates: AGENTS](./templates/AGENTS.md) +- [Templates: BOOTSTRAP](./templates/BOOTSTRAP.md) +- [Templates: IDENTITY](./templates/IDENTITY.md) +- [Templates: SOUL](./templates/SOUL.md) +- [Templates: TOOLS](./templates/TOOLS.md) +- [Templates: USER](./templates/USER.md) + +## Experiments + proposals + +- [Onboarding config protocol](./onboarding-config-protocol.md) +- [Research: memory](./research/memory.md) +- [Proposal: model config](./proposals/model-config.md) + +## Testing + release + +- [Testing](./test.md) +- [Release checklist](./RELEASING.md) +- [Device models](./device-models.md) diff --git a/docs/imessage.md b/docs/imessage.md index 54d391f87..611858f6f 100644 --- a/docs/imessage.md +++ b/docs/imessage.md @@ -55,7 +55,7 @@ imsg chats --limit 20 ## Group chat behavior - Group messages set `ChatType=group`, `GroupSubject`, and `GroupMembers`. -- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns`. +- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns` (patterns are required to detect mentions on iMessage). When `imessage.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups. - Replies go back to the same `chat_id` (group or direct). ## Troubleshooting diff --git a/docs/index.md b/docs/index.md index ea92072ac..ef0abd887 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,7 +42,8 @@ WhatsApp / Telegram / Discord ├─ CLI (clawdbot …) ├─ Chat UI (SwiftUI) ├─ macOS app (Clawdbot.app) - └─ iOS node via Bridge + pairing + ├─ iOS node via Bridge + pairing + └─ Android node via Bridge + pairing ``` Most operations flow through the **Gateway** (`clawdbot gateway`), a single long-running process that owns provider connections and the WebSocket control plane. @@ -70,6 +71,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long - 🎤 **Voice notes** — Optional transcription hook - 🖥️ **WebChat + macOS app** — Local UI + menu bar companion for ops and voice wake - 📱 **iOS node** — Pairs as a node and exposes a Canvas surface +- 📱 **Android node** — Pairs as a node and exposes Canvas + Chat + Camera Note: legacy Claude/Codex/Gemini/Opencode paths have been removed; Pi is the only coding-agent path. @@ -126,6 +128,7 @@ Example: ## Docs - Start here: + - [Docs hubs (all pages linked)](./hubs.md) - [FAQ](./faq.md) ← *common questions answered* - [Configuration](./configuration.md) - [Nix mode](./nix.md) @@ -149,6 +152,12 @@ Example: - [WhatsApp group messages](./group-messages.md) - [Media: images](./images.md) - [Media: audio](./audio.md) +- Companion apps: + - [macOS app](./macos.md) + - [iOS app](./ios.md) + - [Android app](./android.md) + - [Windows app](./windows.md) + - [Linux app](./linux.md) - Ops and safety: - [Sessions](./session.md) - [Cron + wakeups](./cron.md) @@ -172,6 +181,7 @@ Example: ## Core Contributors - **Maxim Vovshin** (@Hyaxia, 36747317+Hyaxia@users.noreply.github.com) — Blogwatcher skill +- **Nacho Iacovino** (@nachoiacovino, nacho.iacovino@gmail.com) — Location parsing (Telegram + WhatsApp) ## License diff --git a/docs/ios/spec.md b/docs/ios.md similarity index 54% rename from docs/ios/spec.md rename to docs/ios.md index 0baf04e52..ecdf3db01 100644 --- a/docs/ios/spec.md +++ b/docs/ios.md @@ -1,17 +1,182 @@ --- -summary: "Plan for an iOS voice + canvas node that connects via a secure Bonjour-discovered macOS bridge" +summary: "iOS app (node): architecture + connection runbook" read_when: + - Pairing or reconnecting the iOS node + - Debugging iOS bridge discovery or auth + - Sending screen/canvas commands to iOS - Designing iOS node + gateway integration - Extending the Gateway protocol for node/canvas commands - Implementing Bonjour pairing or transport security --- -# iOS Node (internal) — Voice Trigger + Canvas +# iOS App (Node) Status: prototype implemented (internal) · Date: 2025-12-13 -Runbook (how to connect/pair + drive Canvas): `docs/ios/connect.md` +## Connection Runbook -## Goals +This is the practical “how do I connect the iOS node” guide: + +**iOS app** ⇄ (Bonjour + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway** + +The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). The iOS node talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing. + +### Prerequisites + +- You can run the Gateway on the “master” machine. +- iOS node app can reach the gateway bridge: + - Same LAN with Bonjour/mDNS, **or** + - Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or** + - Manual bridge host/port (fallback) +- You can run the CLI (`clawdbot`) on the gateway machine (or via SSH). + +### 1) Start the Gateway (with bridge enabled) + +Bridge is enabled by default (disable via `CLAWDBOT_BRIDGE_ENABLED=0`). + +```bash +pnpm clawdbot gateway --port 18789 --verbose +``` + +Confirm in logs you see something like: +- `bridge listening on tcp://0.0.0.0:18790 (node)` + +For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead: + +- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json` on the gateway host. +- Restart the Gateway / macOS menubar app. + +### 2) Verify Bonjour discovery (optional but recommended) + +From the gateway machine: + +```bash +dns-sd -B _clawdbot-bridge._tcp local. +``` + +You should see your gateway advertising `_clawdbot-bridge._tcp`. + +If browse works, but the iOS node can’t connect, try resolving one instance: + +```bash +dns-sd -L "" _clawdbot-bridge._tcp local. +``` + +More debugging notes: `docs/bonjour.md`. + +#### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD + +If the iOS node and the gateway are on different networks but connected via Tailscale, multicast mDNS won’t cross the boundary. Use Wide-Area Bonjour / unicast DNS-SD instead: + +1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records. +2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server. + +Details and example CoreDNS config: `docs/bonjour.md`. + +### 3) Connect from the iOS node app + +In the iOS node app: +- Pick the discovered bridge (or hit refresh). +- If not paired yet, it will initiate pairing automatically. +- After the first successful pairing, it will auto-reconnect **strictly to the last discovered gateway** on launch (including after reinstall), as long as the iOS Keychain entry is still present. + +#### Connection indicator (always visible) + +The Settings tab icon shows a small status dot: +- **Green**: connected to the bridge +- **Yellow**: connecting (subtle pulse) +- **Red**: not connected / error + +### 4) Approve pairing (CLI) + +On the gateway machine: + +```bash +clawdbot nodes pending +``` + +Approve the request: + +```bash +clawdbot nodes approve +``` + +After approval, the iOS node receives/stores the token and reconnects authenticated. + +Pairing details: `docs/gateway/pairing.md`. + +### 5) Verify the node is connected + +- In the macOS app: **Instances** tab should show something like `iOS Node (...)` with a green “Active” presence dot shortly after connect. +- Via nodes status (paired + connected): + ```bash + clawdbot nodes status + ``` +- Via Gateway (paired + connected): + ```bash + clawdbot gateway call node.list --params "{}" + ``` +- Via Gateway presence (legacy-ish, still useful): + ```bash + clawdbot gateway call system-presence --params "{}" + ``` + Look for the node `instanceId` (often a UUID). + +### 6) Drive the iOS Canvas (draw / snapshot) + +The iOS node runs a WKWebView “Canvas” scaffold which exposes: +- `window.__clawdbot.canvas` +- `window.__clawdbot.ctx` (2D context) +- `window.__clawdbot.setStatus(title, subtitle)` + +#### Gateway Canvas Host (recommended for web content) + +If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point it at the Gateway canvas host. + +Note: nodes always use the standalone canvas host on `canvasHost.port` (default `18793`), bound to the bridge interface. + +1) Create `~/clawd/canvas/index.html` on the gateway host. + +2) Navigate the node to it (LAN): + +```bash +clawdbot nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://.local:18793/__clawdbot__/canvas/"}' +``` + +Notes: +- The server injects a live-reload client into HTML and reloads on file changes. +- A2UI is hosted on the same canvas host at `http://:18793/__clawdbot__/a2ui/`. +- Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://:18793/__clawdbot__/canvas/`. +- iOS may require App Transport Security allowances to load plain `http://` URLs; if it fails to load, prefer HTTPS or adjust the iOS app’s ATS config. + +#### Draw with `canvas.eval` + +```bash +clawdbot nodes invoke --node "iOS Node" --command canvas.eval --params "$(cat <<'JSON' +{"javaScript":"(() => { const {ctx,setStatus} = window.__clawdbot; setStatus('Drawing','…'); ctx.clearRect(0,0,innerWidth,innerHeight); ctx.lineWidth=6; ctx.strokeStyle='#ff2d55'; ctx.beginPath(); ctx.moveTo(40,40); ctx.lineTo(innerWidth-40, innerHeight-40); ctx.stroke(); setStatus(null,null); return 'ok'; })()"} +JSON +)" +``` + +#### Snapshot with `canvas.snapshot` + +```bash +clawdbot nodes invoke --node 192.168.0.88 --command canvas.snapshot --params '{"maxWidth":900}' +``` + +The response includes `{ format, base64 }` image data (default `format="jpeg"`; pass `{"format":"png"}` when you specifically need lossless PNG). + +### Common gotchas + +- **iOS in background:** all `canvas.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring the iOS node app to foreground). +- **Return to default scaffold:** `canvas.navigate` with `{"url":""}` or `{"url":"/"}` returns to the built-in scaffold page. +- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`). +- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you. +- **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), the node must pair again; approve a new pending request. +- **App reinstall but no reconnect:** the node restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once. + +## Design + Architecture + +### Goals - Build an **iOS app** that acts as a **remote node** for Clawdbot: - **Voice trigger** (wake-word / always-listening intent) that forwards transcripts to the Gateway `agent` method. - **Canvas** surface that the agent can control: navigate, draw/render, evaluate JS, snapshot. @@ -28,13 +193,13 @@ Non-goals (v1): - Supporting arbitrary third-party “plugins” on iOS. - Perfect App Store compliance; this is **internal-only** initially. -## Current repo reality (constraints we respect) +### Current repo reality (constraints we respect) - The Gateway WebSocket server binds to `127.0.0.1:18789` (`src/gateway/server.ts`) with an optional `CLAWDBOT_GATEWAY_TOKEN`. - The Gateway exposes a Canvas file server (`canvasHost`) on `canvasHost.port` (default `18793`), so nodes can `canvas.navigate` to `http://:18793/__clawdbot__/canvas/` and auto-reload on file changes (`docs/configuration.md`). - macOS “Canvas” is controlled via the Gateway node protocol (`canvas.*`), matching iOS/Android (`docs/mac/canvas.md`). - Voice wake forwards via `GatewayChannel` to Gateway `agent` (mac app: `VoiceWakeForwarder` → `GatewayConnection.sendAgent`). -## Recommended topology (B): Gateway-owned Bridge + loopback Gateway +### Recommended topology (B): Gateway-owned Bridge + loopback Gateway Keep the Node gateway loopback-only; expose a dedicated **gateway-owned bridge** to the LAN/tailnet. **iOS App** ⇄ (TLS + pairing) ⇄ **Bridge (in gateway)** ⇄ (loopback) ⇄ **Gateway WS** (`ws://127.0.0.1:18789`) @@ -44,12 +209,12 @@ Why: - Centralizes auth, rate limiting, and allowlisting in the bridge. - Lets us unify “canvas node” semantics across mac + iOS without exposing raw gateway methods. -## Security plan (internal, but still robust) -### Transport +### Security plan (internal, but still robust) +#### Transport - **Current (v0):** bridge is a LAN-facing **TCP** listener with token-based auth after pairing. - **Next:** wrap the bridge in **TLS** and prefer key-pinned or mTLS-like auth after pairing. -### Pairing +#### Pairing - Bonjour discovery shows a candidate “Clawdbot Bridge” on the LAN. - First connection: 1) iOS generates a keypair (Secure Enclave if available). @@ -62,7 +227,7 @@ Why: - Subsequent connections: - The bridge requires the paired identity. Unpaired clients get a structured “not paired” error and no access. -#### Gateway-owned pairing (Option B details) +##### Gateway-owned pairing (Option B details) Pairing decisions must be owned by the Gateway (`clawd` / Node) so nodes can be approved without the macOS app running. Key idea: @@ -79,7 +244,7 @@ CLI (headless approvals): - `clawdbot nodes approve ` - `clawdbot nodes reject ` -### Authorization / scope control (bridge-side ACL) +#### Authorization / scope control (bridge-side ACL) The bridge must not be a raw proxy to every gateway method. - Allow by default: @@ -93,15 +258,15 @@ The bridge must not be a raw proxy to every gateway method. - voice forwards per minute - snapshot frequency / payload size -## Protocol unification: add “node/canvas” to Gateway protocol -### Principle +### Protocol unification: add “node/canvas” to Gateway protocol +#### Principle Unify mac Canvas + iOS Canvas under a single conceptual surface: - The agent talks to the Gateway using a stable method set (typed protocol). - The Gateway routes node-targeted requests to: - local mac Canvas implementation, or - remote iOS node via the bridge -### Minimal protocol additions (v1) +#### Minimal protocol additions (v1) Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models): **Identity** @@ -117,7 +282,7 @@ Add to `src/gateway/protocol/schema.ts` (and regenerate Swift models): - `node.event` → async node status/errors - e.g. background/foreground transitions, voice availability, canvas availability -### Node command set (canvas) +#### Node command set (canvas) These are values for `node.invoke.command`: - `canvas.present` / `canvas.hide` - `canvas.navigate` with `{ url }` (loads a URL; use `""` or `"/"` to return to the default scaffold) @@ -133,7 +298,7 @@ Result pattern: - Request is a standard `req/res` with `ok` / `error`. - Long operations (loads, streaming drawing, etc.) may also emit `node.event` progress. -#### Current (implemented) +##### Current (implemented) As of 2025-12-13, the Gateway supports `node.invoke` for bridge-connected nodes. Example: draw a diagonal line on the iOS Canvas: @@ -199,38 +364,9 @@ open Clawdbot.xcodeproj - Credentials: - Keychain (paired identity + bridge trust anchor) -### macOS -- Keep current Canvas root (already implemented): - - `~/Library/Application Support/Clawdbot/canvas//...` -- Bridge state: - - No local pairing store (pairing is gateway-owned). - - Any local bridge-only state should remain private under Application Support. +## Related docs -### Gateway (node) -- Pairing (source of truth): - - `~/.clawdbot/nodes/paired.json` - - `~/.clawdbot/nodes/pending.json` (or `pending/*.json` for auditability) - -## Rollout plan (phased) -1) **Bridge discovery + pairing (mac + iOS)** - - Bonjour browse + resolve - - Approve prompt on mac - - Persist pairing in Keychain/App Support -2) **Voice-only node** - - iOS voice wake toggle - - Forward transcript to Gateway `agent` via bridge - - Presence beacons via `system-event` (or node.event) -3) **Protocol additions for nodes** - - Add `node.list` / `node.invoke` / `node.event` to Gateway - - Implement bridge routing + ACLs -4) **iOS canvas** - - WKWebView canvas surface - - `canvas.navigate/eval/snapshot` - - Background fast-fail for `canvas.*` -5) **Unify mac Canvas under the same node.invoke** - - Keep existing implementation, but expose it through the unified protocol path so the agent uses one API. - -## Open questions -- Should `connect.params.client.mode` be `"node"` with `platform="ios ..."` or a distinct mode `"ios-node"`? (Presence filtering currently excludes `"cli"` only.) -- Do we want a “permissions” model per node (voice only vs voice+canvas) at pairing time? -- Should loading arbitrary websites via `canvas.navigate` allow any https URL, or enforce an allowlist to reduce risk? +- `docs/gateway.md` (gateway runbook) +- `docs/gateway/pairing.md` (approval + storage) +- `docs/bonjour.md` (discovery debugging) +- `docs/discovery.md` (LAN vs tailnet vs SSH) diff --git a/docs/ios/connect.md b/docs/ios/connect.md deleted file mode 100644 index b6a6f9ada..000000000 --- a/docs/ios/connect.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -summary: "Runbook: connect/pair the iOS node to a Clawdbot Gateway and drive its Canvas" -read_when: - - Pairing or reconnecting the iOS node - - Debugging iOS bridge discovery or auth - - Sending screen/canvas commands to iOS ---- - -# iOS Node Connection Runbook - -This is the practical “how do I connect the iOS node” guide: - -**iOS app** ⇄ (Bonjour + TCP bridge) ⇄ **Gateway bridge** ⇄ (loopback WS) ⇄ **Gateway** - -The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). The iOS node talks to the LAN-facing **bridge** (default `tcp://0.0.0.0:18790`) and uses Gateway-owned pairing. - -## Prerequisites - -- You can run the Gateway on the “master” machine. -- iOS node app can reach the gateway bridge: - - Same LAN with Bonjour/mDNS, **or** - - Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or** - - Manual bridge host/port (fallback) -- You can run the CLI (`clawdbot`) on the gateway machine (or via SSH). - -## 1) Start the Gateway (with bridge enabled) - -Bridge is enabled by default (disable via `CLAWDBOT_BRIDGE_ENABLED=0`). - -```bash -pnpm clawdbot gateway --port 18789 --verbose -``` - -Confirm in logs you see something like: -- `bridge listening on tcp://0.0.0.0:18790 (node)` - -For tailnet-only setups (recommended for Vienna ⇄ London), bind the bridge to the gateway machine’s Tailscale IP instead: - -- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json` on the gateway host. -- Restart the Gateway / macOS menubar app. - -## 2) Verify Bonjour discovery (optional but recommended) - -From the gateway machine: - -```bash -dns-sd -B _clawdbot-bridge._tcp local. -``` - -You should see your gateway advertising `_clawdbot-bridge._tcp`. - -If browse works, but the iOS node can’t connect, try resolving one instance: - -```bash -dns-sd -L "" _clawdbot-bridge._tcp local. -``` - -More debugging notes: `docs/bonjour.md`. - -### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD - -If the iOS node and the gateway are on different networks but connected via Tailscale, multicast mDNS won’t cross the boundary. Use Wide-Area Bonjour / unicast DNS-SD instead: - -1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-bridge._tcp` records. -2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server. - -Details and example CoreDNS config: `docs/bonjour.md`. - -## 3) Connect from the iOS node app - -In the iOS node app: -- Pick the discovered bridge (or hit refresh). -- If not paired yet, it will initiate pairing automatically. -- After the first successful pairing, it will auto-reconnect **strictly to the last discovered gateway** on launch (including after reinstall), as long as the iOS Keychain entry is still present. - -### Connection indicator (always visible) - -The Settings tab icon shows a small status dot: -- **Green**: connected to the bridge -- **Yellow**: connecting (subtle pulse) -- **Red**: not connected / error - -## 4) Approve pairing (CLI) - -On the gateway machine: - -```bash -clawdbot nodes pending -``` - -Approve the request: - -```bash -clawdbot nodes approve -``` - -After approval, the iOS node receives/stores the token and reconnects authenticated. - -Pairing details: `docs/gateway/pairing.md`. - -## 5) Verify the node is connected - -- In the macOS app: **Instances** tab should show something like `iOS Node (...)` with a green “Active” presence dot shortly after connect. -- Via nodes status (paired + connected): - ```bash - clawdbot nodes status - ``` -- Via Gateway (paired + connected): - ```bash - clawdbot gateway call node.list --params "{}" - ``` -- Via Gateway presence (legacy-ish, still useful): - ```bash - clawdbot gateway call system-presence --params "{}" - ``` - Look for the node `instanceId` (often a UUID). - -## 6) Drive the iOS Canvas (draw / snapshot) - -The iOS node runs a WKWebView “Canvas” scaffold which exposes: -- `window.__clawdbot.canvas` -- `window.__clawdbot.ctx` (2D context) -- `window.__clawdbot.setStatus(title, subtitle)` - -### Gateway Canvas Host (recommended for web content) - -If you want the node to show real HTML/CSS/JS that the agent can edit on disk, point it at the Gateway canvas host. - -Note: nodes always use the standalone canvas host on `canvasHost.port` (default `18793`), bound to the bridge interface. - -1) Create `~/clawd/canvas/index.html` on the gateway host. - -2) Navigate the node to it (LAN): - -```bash -clawdbot nodes invoke --node "iOS Node" --command canvas.navigate --params '{"url":"http://.local:18793/__clawdbot__/canvas/"}' -``` - -Notes: -- The server injects a live-reload client into HTML and reloads on file changes. -- A2UI is hosted on the same canvas host at `http://:18793/__clawdbot__/a2ui/`. -- Tailnet (optional): if both devices are on Tailscale, use a MagicDNS name or tailnet IP instead of `.local`, e.g. `http://:18793/__clawdbot__/canvas/`. -- iOS may require App Transport Security allowances to load plain `http://` URLs; if it fails to load, prefer HTTPS or adjust the iOS app’s ATS config. - -### Draw with `canvas.eval` - -```bash -clawdbot nodes invoke --node "iOS Node" --command canvas.eval --params "$(cat <<'JSON' -{"javaScript":"(() => { const {ctx,setStatus} = window.__clawdbot; setStatus('Drawing','…'); ctx.clearRect(0,0,innerWidth,innerHeight); ctx.lineWidth=6; ctx.strokeStyle='#ff2d55'; ctx.beginPath(); ctx.moveTo(40,40); ctx.lineTo(innerWidth-40, innerHeight-40); ctx.stroke(); setStatus(null,null); return 'ok'; })()"} -JSON -)" -``` - -### Snapshot with `canvas.snapshot` - -```bash -clawdbot nodes invoke --node 192.168.0.88 --command canvas.snapshot --params '{"maxWidth":900}' -``` - -The response includes `{ format, base64 }` image data (default `format="jpeg"`; pass `{"format":"png"}` when you specifically need lossless PNG). - -## Common gotchas - -- **iOS in background:** all `canvas.*` commands fail fast with `NODE_BACKGROUND_UNAVAILABLE` (bring the iOS node app to foreground). -- **Return to default scaffold:** `canvas.navigate` with `{"url":""}` or `{"url":"/"}` returns to the built-in scaffold page. -- **mDNS blocked:** some networks block multicast; use a different LAN or plan a tailnet-capable bridge (see `docs/discovery.md`). -- **Wrong node selector:** `--node` can be the node id (UUID), display name (e.g. `iOS Node`), IP, or an unambiguous prefix. If it’s ambiguous, the CLI will tell you. -- **Stale pairing / Keychain cleared:** if the pairing token is missing (or iOS Keychain was wiped), the node must pair again; approve a new pending request. -- **App reinstall but no reconnect:** the node restores `instanceId` + last bridge preference from Keychain; if it still comes up “unpaired”, verify Keychain persistence on your device/simulator and re-pair once. - -## Related docs - -- `docs/ios/spec.md` (design + architecture) -- `docs/gateway.md` (gateway runbook) -- `docs/gateway/pairing.md` (approval + storage) -- `docs/bonjour.md` (discovery debugging) -- `docs/discovery.md` (LAN vs tailnet vs SSH) diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 000000000..b5e27e4cb --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,11 @@ +--- +summary: "Linux app status + contribution call" +read_when: + - Looking for Linux companion app status + - Planning platform coverage or contributions +--- +# Linux App + +Clawdbot core is fully supported on Linux. The core is written in TypeScript, so it runs anywhere Node runs. + +We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen. diff --git a/docs/location.md b/docs/location.md new file mode 100644 index 000000000..7d610e7ff --- /dev/null +++ b/docs/location.md @@ -0,0 +1,46 @@ +--- +summary: "Inbound provider location parsing (Telegram + WhatsApp) and context fields" +read_when: + - Adding or modifying provider location parsing + - Using location context fields in agent prompts or tools +--- + +# Provider location parsing + +Clawdbot normalizes shared locations from chat providers into: +- human-readable text appended to the inbound body, and +- structured fields in the auto-reply context payload. + +Currently supported: +- **Telegram** (location pins + venues + live locations) +- **WhatsApp** (locationMessage + liveLocationMessage) + +## Text formatting +Locations are rendered as friendly lines without brackets: + +- Pin: + - `📍 48.858844, 2.294351 ±12m` +- Named place: + - `📍 Eiffel Tower — Champ de Mars, Paris (48.858844, 2.294351 ±12m)` +- Live share: + - `🛰 Live location: 48.858844, 2.294351 ±12m` + +If the provider includes a caption/comment, it is appended on the next line: +``` +📍 48.858844, 2.294351 ±12m +Meet here +``` + +## Context fields +When a location is present, these fields are added to `ctx`: +- `LocationLat` (number) +- `LocationLon` (number) +- `LocationAccuracy` (number, meters; optional) +- `LocationName` (string; optional) +- `LocationAddress` (string; optional) +- `LocationSource` (`pin | place | live`) +- `LocationIsLive` (boolean) + +## Provider notes +- **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`. +- **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line. diff --git a/docs/logging.md b/docs/logging.md index fdc7ad251..89ffab3a7 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -42,6 +42,17 @@ You can tune console verbosity independently via: - `logging.consoleLevel` (default `info`) - `logging.consoleStyle` (`pretty` | `compact` | `json`) +## Tool summary redaction + +Verbose tool summaries (e.g. `🛠️ bash: ...`) can mask sensitive tokens before they hit the +console stream. This is **tools-only** and does not alter file logs. + +- `logging.redactSensitive`: `off` | `tools` (default: `tools`) +- `logging.redactPatterns`: array of regex strings (overrides defaults) + - Use raw regex strings (auto `gi`), or `/pattern/flags` if you need custom flags. + - Matches are masked by keeping the first 6 + last 4 chars (length >= 18), otherwise `***`. + - Defaults cover common key assignments, CLI flags, JSON fields, bearer headers, PEM blocks, and popular token prefixes. + ## Gateway WebSocket logs The gateway prints WebSocket protocol logs in two modes: diff --git a/docs/mac/canvas.md b/docs/mac/canvas.md index 56fcf9428..3dc2458aa 100644 --- a/docs/mac/canvas.md +++ b/docs/mac/canvas.md @@ -81,7 +81,7 @@ Canvas is exposed via the Gateway **node bridge**, so the agent can: This should be modeled after `WebChatManager`/`WebChatSwiftUIWindowController` but targeting `clawdbot-canvas://…` URLs. Related: -- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdbot://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See `docs/clawdbot-mac.md`. +- For “invoke the agent again from UI” flows, prefer the macOS deep link scheme (`clawdbot://agent?...`) so *any* UI surface (Canvas, WebChat, native views) can trigger a new agent run. See `docs/macos.md`. ## Agent commands (current) diff --git a/docs/mac/peekaboo.md b/docs/mac/peekaboo.md index 913d5545b..50511bea6 100644 --- a/docs/mac/peekaboo.md +++ b/docs/mac/peekaboo.md @@ -23,7 +23,7 @@ Peekaboo’s privileged execution moved from “CLI → XPC helper” to “CLI - It lets us piggyback on **either** Peekaboo.app’s permissions **or** Clawdbot.app’s permissions (whichever is running). - It avoids “two apps with two TCC bubbles” unless needed. -Reference (Peekaboo submodule): `docs/bridge-host.md`. +Reference (Peekaboo submodule): `Peekaboo/docs/bridge-host.md`. ## Architecture ### Processes diff --git a/docs/clawdbot-mac.md b/docs/macos.md similarity index 98% rename from docs/clawdbot-mac.md rename to docs/macos.md index 318f3c741..7412a2fc8 100644 --- a/docs/clawdbot-mac.md +++ b/docs/macos.md @@ -94,7 +94,7 @@ Notes: - In remote mode, Clawdbot will use the configured remote tunnel/endpoint. ## Build & dev workflow (native) -- `cd native && swift build` (debug) / `swift build -c release`. +- `cd apps/macos && swift build` (debug) / `swift build -c release`. - Run app for dev: `swift run Clawdbot` (or Xcode scheme). - Package app + CLI: `scripts/package-mac-app.sh` (builds bun CLI + gateway). - Tests: add Swift Testing suites under `apps/macos/Tests`. diff --git a/docs/models.md b/docs/models.md index 32f6d0863..cf88065a5 100644 --- a/docs/models.md +++ b/docs/models.md @@ -16,35 +16,32 @@ that prefers tool-call + image-capable models and maintains ordered fallbacks. - default: configured models only - flags: `--all` (full catalog), `--local`, `--provider `, `--json`, `--plain` - `clawdbot models status` - - show default model + aliases + fallbacks + allowlist + - show default model + aliases + fallbacks + configured models - `clawdbot models set ` - - writes `agent.model` in config + - writes `agent.model.primary` and ensures `agent.models` entry - `clawdbot models set-image ` - - writes `agent.imageModel` in config + - writes `agent.imageModel.primary` and ensures `agent.models` entry - `clawdbot models aliases list|add|remove` - - writes `agent.modelAliases` + - writes `agent.models.*.alias` - `clawdbot models fallbacks list|add|remove|clear` - - writes `agent.modelFallbacks` + - writes `agent.model.fallbacks` - `clawdbot models image-fallbacks list|add|remove|clear` - - writes `agent.imageModelFallbacks` + - writes `agent.imageModel.fallbacks` - `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) +- `agent.models` (configured model catalog + aliases). +- `agent.model.primary` + `agent.model.fallbacks`. +- `agent.imageModel.primary` + `agent.imageModel.fallbacks` (optional). +- `auth.profiles` + `auth.order` for per-provider auth failover. ## Scan behavior (models scan) Input - OpenRouter `/models` list (filter `:free`) -- Requires `OPENROUTER_API_KEY` (or stored OpenRouter key in auth storage) +- Requires OpenRouter API key from auth profiles or `OPENROUTER_API_KEY` - Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates` - Probe controls: `--timeout`, `--concurrency` @@ -66,17 +63,20 @@ Interactive selection (TTY) - 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`. +- Writes `agent.model.fallbacks` ordered. +- Writes `agent.imageModel.fallbacks` ordered (image-capable models). +- Ensures `agent.models` entries exist for selected models. +- Optional `--set-default` to set `agent.model.primary`. +- Optional `--set-image` to set `agent.imageModel.primary`. ## 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). +- On model failure: try `agent.model.fallbacks` in order. +- Per-provider auth failover uses `auth.order` (or stored profile order) **before** + moving to the next model. +- Image routing uses `agent.imageModel` **only when configured** and the primary + model lacks image input. +- Persist last successful provider/model to session entry; auth profile success is global. ## Tests @@ -86,5 +86,5 @@ Output ## Docs -- Update `docs/configuration.md` with `agent.modelFallbacks`. +- Update `docs/configuration.md` with `agent.models` + `agent.model` + `agent.imageModel`. - Keep this doc current when CLI surface or scan logic changes. diff --git a/docs/onboarding-config-protocol.md b/docs/onboarding-config-protocol.md index 64da823b6..58eb2fa1e 100644 --- a/docs/onboarding-config-protocol.md +++ b/docs/onboarding-config-protocol.md @@ -1,3 +1,8 @@ +--- +summary: "RPC protocol notes for onboarding wizard and config schema" +read_when: "Changing onboarding wizard steps or config schema endpoints" +--- + # Onboarding + Config Protocol Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI. diff --git a/docs/onboarding.md b/docs/onboarding.md index 39edd2278..bc897e5e3 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -19,7 +19,7 @@ This doc describes the intended **first-run onboarding** for Clawdbot. The goal First question: where does the **Gateway** run? -- **Local (this Mac):** onboarding can run OAuth flows and write the Clawdbot auth store locally. +- **Local (this Mac):** onboarding can run OAuth flows and write OAuth credentials locally. - **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**. Gateway auth tip: @@ -38,10 +38,10 @@ The macOS app should: - Start the Anthropic OAuth (PKCE) flow in the user’s browser. - Ask the user to paste the `code#state` value. - Exchange it for tokens and write credentials to: - - `~/.clawdbot/agent/auth.json` (file mode `0600`, directory mode `0700`) + - `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`) -Why this location matters: it’s the Clawdbot-owned auth store (OAuth + API keys). -Clawdbot auto-migrates legacy OAuth tokens from `~/.clawdbot/credentials/oauth.json` (and older pi/Claude locations) into `auth.json` on first use. +Why this location matters: it’s the Clawdbot-owned OAuth store. +Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on first use. ### Recommended: OAuth (OpenAI Codex) @@ -49,7 +49,8 @@ The macOS app should: - Start the OpenAI Codex OAuth (PKCE) flow in the user’s browser. - Auto-capture the callback on `http://127.0.0.1:1455/auth/callback` when possible. - If the callback fails, prompt the user to paste the redirect URL or code. -- Store credentials in `~/.clawdbot/agent/auth.json` (same auth store as Anthropic). +- Store credentials in `~/.clawdbot/credentials/oauth.json` (same OAuth store as Anthropic). +- Set `agent.model` to `openai-codex/gpt-5.2` when the model is unset or `openai/*`. ### Alternative: API key (instructions only) @@ -102,7 +103,7 @@ Once setup is complete, the user can switch to the normal chat (`main`) via the We no longer collect identity in the onboarding wizard. Instead, the **first agent run** performs a playful bootstrap ritual using files in the workspace: -- Workspace is created implicitly (default `~/.clawdbot/workspace`) when local is selected, +- Workspace is created implicitly (default `~/clawd`, configurable via `agent.workspace`) when local is selected, but only if the folder is empty or already contains `AGENTS.md`. - Files are seeded: `AGENTS.md`, `BOOTSTRAP.md`, `IDENTITY.md`, `USER.md`. - `BOOTSTRAP.md` tells the agent to keep it conversational: @@ -131,7 +132,7 @@ The workspace is created automatically as part of agent bootstrap (no dedicated Recommendation: treat the workspace as the agent’s “memory” and make it a git repo (ideally private) so identity + memories are backed up: ```bash -cd ~/.clawdbot/workspace +cd ~/clawd git init git add AGENTS.md git commit -m "Add agent workspace" @@ -148,12 +149,12 @@ If the Gateway runs on another machine, OAuth credentials must be created/stored For now, remote onboarding should: - explain why OAuth isn't shown -- point the user at the credential location (`~/.clawdbot/agent/auth.json`) and the workspace location on the gateway host +- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on the gateway host - mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files) ### Manual credential setup -On the gateway host, create `~/.clawdbot/agent/auth.json` with this exact format: +On the gateway host, create `~/.clawdbot/credentials/oauth.json` with this exact format: ```json { @@ -162,7 +163,7 @@ On the gateway host, create `~/.clawdbot/agent/auth.json` with this exact format } ``` -Set permissions: `chmod 600 ~/.clawdbot/agent/auth.json` +Set permissions: `chmod 600 ~/.clawdbot/credentials/oauth.json` **Note:** Clawdbot auto-imports from legacy pi-coding-agent paths (`~/.pi/agent/oauth.json`, etc.) but this does NOT work with Claude Code credentials — different file and format. @@ -177,8 +178,8 @@ cat ~/.claude/.credentials.json | jq '{ refresh: .claudeAiOauth.refreshToken, expires: .claudeAiOauth.expiresAt } -}' > ~/.clawdbot/agent/auth.json -chmod 600 ~/.clawdbot/agent/auth.json +}' > ~/.clawdbot/credentials/oauth.json +chmod 600 ~/.clawdbot/credentials/oauth.json ``` | Claude Code field | Clawdbot field | diff --git a/docs/plans/cron-add-hardening.md b/docs/plans/cron-add-hardening.md new file mode 100644 index 000000000..f1c6fa6ea --- /dev/null +++ b/docs/plans/cron-add-hardening.md @@ -0,0 +1,72 @@ +--- +summary: "Harden cron.add input handling, align schemas, and improve cron UI/agent tooling" +owner: "clawdbot" +status: "complete" +last_updated: "2026-01-05" +--- + +# Cron Add Hardening & Schema Alignment + +## Context +Recent gateway logs show repeated `cron.add` failures with invalid parameters (missing `sessionTarget`, `wakeMode`, `payload`, and malformed `schedule`). This indicates that at least one client (likely the agent tool call path) is sending wrapped or partially specified job payloads. Separately, there is drift between cron channel enums in TypeScript, gateway schema, CLI flags, and UI form types, plus a UI mismatch for `cron.status` (expects `jobCount` while gateway returns `jobs`). + +## Goals +- Stop `cron.add` INVALID_REQUEST spam by normalizing common wrapper payloads and inferring missing `kind` fields. +- Align cron channel lists across gateway schema, cron types, CLI docs, and UI forms. +- Make agent cron tool schema explicit so the LLM produces correct job payloads. +- Fix the Control UI cron status job count display. +- Add tests to cover normalization and tool behavior. + +## Non-goals +- Change cron scheduling semantics or job execution behavior. +- Add new schedule kinds or cron expression parsing. +- Overhaul the UI/UX for cron beyond the necessary field fixes. + +## Findings (current gaps) +- `CronPayloadSchema` in gateway excludes `signal` + `imessage`, while TS types include them. +- Control UI CronStatus expects `jobCount`, but gateway returns `jobs`. +- Agent cron tool schema allows arbitrary `job` objects, enabling malformed inputs. +- Gateway strictly validates `cron.add` with no normalization, so wrapped payloads fail. + +## Proposed Approach +1. **Normalize** incoming `cron.add` payloads (unwrap `data`/`job`, infer `schedule.kind` and `payload.kind`, default `wakeMode` + `sessionTarget` when safe). +2. **Harden** the agent cron tool schema using the canonical gateway `CronAddParamsSchema` and normalize before sending to the gateway. +3. **Align** channel enums and cron status fields across gateway schema, TS types, CLI descriptions, and UI form controls. +4. **Test** normalization in gateway tests and tool behavior in agent tests. + +## Multi-phase Execution Plan + +### Phase 1 — Schema + type alignment +- [x] Expand gateway `CronPayloadSchema` channel enum to include `signal` and `imessage`. +- [x] Update CLI `--channel` descriptions to include `slack` (already supported by gateway). +- [x] Update UI Cron payload/channel union types to include all supported channels. +- [x] Fix UI CronStatus type to match gateway (`jobs` instead of `jobCount`). +- [x] Update cron UI channel select to include Discord/Slack/Signal/iMessage. +- [x] Update macOS CronJobEditor channel picker + enum to include Slack/Signal/iMessage. +- [x] Document cron compatibility normalization policy in `docs/cron.md`. + +### Phase 2 — Input normalization + tooling hardening +- [x] Add shared cron input normalization helpers (`normalizeCronJobCreate`/`normalizeCronJobPatch`). +- [x] Apply normalization in gateway `cron.add` (and patch normalization in `cron.update`). +- [x] Tighten agent cron tool schema to `CronAddParamsSchema` and normalize job/patch before sending. + +### Phase 3 — Tests +- [x] Add gateway test covering wrapped `cron.add` payload normalization. +- [x] Add cron tool test to assert normalization and defaulting for `cron.add`. +- [x] Add gateway test covering `cron.update` normalization. +- [x] Add UI + Swift conformance test for cron channels + status fields. + +### Phase 4 — Verification +- [x] Run tests (full suite executed via `pnpm test -- cron-tool`). + +## Rollout/Monitoring +- Watch gateway logs for reduced `cron.add` INVALID_REQUEST errors. +- Confirm Control UI cron status shows job count after refresh. +- If errors persist, extend normalization for additional common shapes (e.g., `schedule.at`, `payload.message` without `kind`). + +## Optional Follow-ups +- Manual Control UI smoke: add cron job per channel + verify status job count. + +## Open Questions +- Should `cron.add` accept explicit `state` from clients (currently disallowed by schema)? +- Should we allow `webchat` as an explicit delivery channel (currently filtered in delivery resolution)? diff --git a/docs/plans/group-policy-hardening.md b/docs/plans/group-policy-hardening.md new file mode 100644 index 000000000..684ff7f77 --- /dev/null +++ b/docs/plans/group-policy-hardening.md @@ -0,0 +1,121 @@ +# Engineering Execution Spec: groupPolicy Hardening (Telegram Allowlist Parity) + +**Date**: 2026-01-05 +**Status**: Complete +**PR**: #216 (feat/whatsapp-group-policy) + +--- + +## Executive Summary + +Follow-up hardening work ensures Telegram allowlists behave consistently across inbound group/DM filtering and outbound send normalization. The focus is on prefix parity (`telegram:` / `tg:`), case-insensitive matching for prefixes, and resilience to accidental whitespace in config entries. Documentation and tests were updated to reflect and lock in this behavior. + +--- + +## Findings Analysis + +### [MED] F1: Telegram Allowlist Prefix Handling Is Case-Sensitive and Excludes `tg:` + +**Location**: `src/telegram/bot.ts` + +**Problem**: Inbound allowlist normalization only stripped a lowercase `telegram:` prefix. This rejected `TG:123` / `Telegram:123` and did not accept the `tg:` shorthand even though outbound send normalization already accepts `tg:` and case-insensitive prefixes. + +**Impact**: +- DMs and group allowlists fail when users copy/paste prefixed IDs from logs or existing send format. +- Behavior is inconsistent between inbound filtering and outbound send normalization. + +**Fix**: Normalize allowlist entries by trimming whitespace and stripping `telegram:` / `tg:` prefixes case-insensitively at pre-compute time. + +--- + +### [LOW] F2: Allowlist Entries Are Not Trimmed + +**Location**: `src/telegram/bot.ts` + +**Problem**: Allowlist entries are not trimmed; accidental whitespace causes mismatches. + +**Fix**: Trim and drop empty entries while normalizing allowlist inputs. + +--- + +## Implementation Phases + +### Phase 1: Normalize Telegram Allowlist Inputs + +**File**: `src/telegram/bot.ts` + +**Changes**: +1. Trim allowlist entries and drop empty values. +2. Strip `telegram:` / `tg:` prefixes case-insensitively. +3. Simplify DM allowlist check to rely on normalized values. + +--- + +### Phase 2: Add Coverage for Prefix + Whitespace + +**File**: `src/telegram/bot.test.ts` + +**Add Tests**: +- DM allowlist accepts `TG:` prefix with surrounding whitespace. +- Group allowlist accepts `TG:` prefix case-insensitively. + +--- + +### Phase 3: Documentation Updates + +**Files**: +- `docs/groups.md` +- `docs/telegram.md` + +**Changes**: +- Document `tg:` alias and case-insensitive prefixes for Telegram allowlists. + +--- + +### Phase 4: Verification + +1. Run targeted Telegram tests (`pnpm test -- src/telegram/bot.test.ts`). +2. If time allows, run full suite (`pnpm test`). + +--- + +## Files Modified + +| File | Change Type | Description | +|------|-------------|-------------| +| `src/telegram/bot.ts` | Fix | Trim allowlist values; strip `telegram:` / `tg:` prefixes case-insensitively | +| `src/telegram/bot.test.ts` | Test | Add DM + group allowlist coverage for `TG:` prefix + whitespace | +| `docs/groups.md` | Docs | Mention `tg:` alias + case-insensitive prefixes | +| `docs/telegram.md` | Docs | Mention `tg:` alias + case-insensitive prefixes | + +--- + +## Success Criteria + +- [x] Telegram allowlist accepts `telegram:` / `tg:` prefixes case-insensitively. +- [x] Telegram allowlist tolerates whitespace in config entries. +- [x] DM and group allowlist tests cover prefixed cases. +- [x] Docs updated to reflect allowlist formats. +- [x] Targeted tests pass. +- [x] Full test suite passes. + +--- + +## Risk Assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Behavior change for malformed entries | Low | Normalization is additive and trims only whitespace | +| Test fragility | Low | Isolated unit tests; no external dependencies | +| Doc drift | Low | Updated docs alongside code | + +--- + +## Estimated Complexity + +- **Phase 1**: Low (normalization helpers) +- **Phase 2**: Low (2 new tests) +- **Phase 3**: Low (doc edits) +- **Phase 4**: Low (verification) + +**Total**: ~20 minutes diff --git a/docs/poll.md b/docs/poll.md new file mode 100644 index 000000000..f00269512 --- /dev/null +++ b/docs/poll.md @@ -0,0 +1,52 @@ +--- +summary: "Poll sending via gateway + CLI" +read_when: + - Adding or modifying poll support + - Debugging poll sends from the CLI or gateway +--- +# Polls + +Updated: 2026-01-06 + +## Supported providers +- WhatsApp (web provider) +- Discord + +## CLI + +```bash +# WhatsApp +clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe" +clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2 + +# Discord +clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord +clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48 +``` + +Options: +- `--provider`: `whatsapp` (default) or `discord` +- `--max-selections`: how many choices a voter can select (default: 1) +- `--duration-hours`: Discord-only (defaults to 24 when omitted) + +## Gateway RPC + +Method: `poll` + +Params: +- `to` (string, required) +- `question` (string, required) +- `options` (string[], required) +- `maxSelections` (number, optional) +- `durationHours` (number, optional) +- `provider` (string, optional, default: `whatsapp`) +- `idempotencyKey` (string, required) + +## Provider differences +- WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`. +- Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count. + +## Agent tool (Discord) +The Discord tool action `poll` still uses `question`, `answers`, optional `allowMultiselect`, `durationHours`, and `content`. The gateway/CLI poll model maps `allowMultiselect` to `maxSelections > 1`. + +Note: Discord has no “pick exactly N” mode; `maxSelections` is treated as a boolean (`> 1` = multiselect). diff --git a/docs/proposals/model-config.md b/docs/proposals/model-config.md index 7de0d54d6..b7488378d 100644 --- a/docs/proposals/model-config.md +++ b/docs/proposals/model-config.md @@ -87,7 +87,7 @@ Model listing - alias - provider - auth order (from `auth.order`) - - auth source for the current provider (env/auth.json/models.json) + - auth source for the current provider (auth-profiles.json/env/shell env/models.json) ## Fallback behavior (global) @@ -121,19 +121,20 @@ Support detection ## Migration (doctor + gateway auto-run) Inputs -- `agent.model` (string) -- `agent.modelFallbacks` (string[]) -- `agent.imageModel` (string) -- `agent.imageModelFallbacks` (string[]) -- `agent.allowedModels` (string[]) -- `agent.modelAliases` (record) +- Legacy keys (pre-migration): + - `agent.model` (string) + - `agent.modelFallbacks` (string[]) + - `agent.imageModel` (string) + - `agent.imageModelFallbacks` (string[]) + - `agent.allowedModels` (string[]) + - `agent.modelAliases` (record) Outputs - `agent.models` map with keys for all referenced models - `agent.model.primary/fallbacks` - `agent.imageModel.primary/fallbacks` -- `auth.profiles` seeded from current auth.json + env (as `provider:default`) -- `auth.order` seeded with `["provider:default"]` +- Auth profile store seeded from current auth-profiles.json/auth.json + oauth.json + env (as `provider:default`) +- `auth.order` seeded with `["provider:default"]` when config is updated Auto-run - Gateway start detects legacy keys and runs doctor migration. diff --git a/docs/refactor/agent-loop.md b/docs/refactor/agent-loop.md deleted file mode 100644 index 6693d6086..000000000 --- a/docs/refactor/agent-loop.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -summary: "Refactor plan: unify agent lifecycle events and wait semantics" -read_when: - - Refactoring agent lifecycle events or wait behavior ---- -# Refactor: Agent Loop - -Goal: align Clawdis run lifecycle with pi/mom semantics, remove ambiguity between "job" and "agent_end". - -## Problem -- Two lifecycles today: - - `job` (gateway wrapper) => used by `agent.wait` + chat final - - pi-agent `agent_end` (inner loop) => only logged -- This can finalize early (job done) while late assistant deltas still arrive. -- `afterMs` and timeouts can cause false timeouts in `agent.wait`. - -## Reference (mom) -- Single lifecycle: `agent_start`/`agent_end` from pi-agent-core event stream. -- `waitForIdle()` resolves on `agent_end`. -- No separate job state exposed to clients. - -## Proposed refactor (breaking allowed) -1) Replace public `job` stream with `lifecycle` stream - - `stream: "lifecycle"` - - `data: { phase: "start" | "end" | "error", startedAt, endedAt, error? }` -2) `agent.wait` waits on lifecycle end/error only - - remove `afterMs` - - return `{ runId, status, startedAt, endedAt, error? }` -3) Chat final emitted on lifecycle end only - - deltas still from `assistant` stream -4) Centralize run registry - - one map keyed by runId: sessionKey, startedAt, lastSeq, bufferedText - - clear on lifecycle end - -## Implementation outline -- `src/agents/pi-embedded-subscribe.ts` - - emit lifecycle start/end events (translate pi `agent_start`/`agent_end`) -- `src/infra/agent-events.ts` - - add `"lifecycle"` to stream type -- `src/gateway/protocol/schema.ts` - - update AgentEvent schema; update AgentWait params (remove afterMs, add status) -- `src/gateway/server-methods/agent-job.ts` - - rename to `agent-wait.ts` or similar; wait on lifecycle end/error -- `src/gateway/server-chat.ts` - - finalize on lifecycle end (not job) -- `src/commands/agent.ts` - - stop emitting `job` externally (keep internal log if needed) - -## Migration notes (breaking) -- Update all callers of `agent.wait` to new response shape. -- Update tests that expect `timeout` based on job events. -- If any UI relies on job state, map lifecycle instead. - -## Risks -- If lifecycle events are dropped, wait/chat could hang; add timeout in `agent.wait` to fail fast. -- Late deltas after lifecycle end should be ignored; keep seq tracking + drop. - -## Acceptance -- One lifecycle visible to clients. -- `agent.wait` resolves when agent loop ends, not wrapper completion. -- Chat final never emits before last assistant delta. - -## Rollout (if we wanted safety) -- Gate with config flag `agent.lifecycleMode = "legacy"|"refactor"`. -- Remove legacy after one release. diff --git a/docs/refactor/browser-control-simplification.md b/docs/refactor/browser-control-simplification.md deleted file mode 100644 index 834ac03b8..000000000 --- a/docs/refactor/browser-control-simplification.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -summary: "Refactor: simplify browser control API + implementation" -read_when: - - Refactoring browser control routes, client, or CLI - - Auditing agent-facing browser tool surface -date: 2025-12-20 ---- - -# Refactor: Browser control simplification - -Goal: make the browser-control surface **small, stable, and agent-oriented**, and remove “implementation-shaped” APIs (Playwright/CDP specifics, one-off endpoints, and debugging helpers). - -## Why - -- The previous API accreted many narrow endpoints (`/click`, `/type`, `/press`, …) plus debug utilities. -- Some actions are inherently racy when modeled as “do X *when* the event is already visible” (file chooser, dialogs). -- We want a single, coherent contract that keeps “how it’s implemented” private. - -## Target contract (vNext) - -**Basics** -- `GET /` status -- `POST /start`, `POST /stop` -- `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId` - -**Agent actions** -- `POST /navigate` `{ url, targetId? }` -- `POST /act` `{ kind, targetId?, ... }` where `kind` is one of: - - `click`, `type`, `press`, `hover`, `drag`, `select`, `fill`, `wait`, `resize`, `close`, `evaluate` -- `POST /screenshot` `{ targetId?, fullPage?, ref?, element?, type? }` -- `GET /snapshot` `?format=ai|aria&targetId?&limit?` -- `GET /console` `?level?&targetId?` -- `POST /pdf` `{ targetId? }` - -**Hooks (pre-setup / arming)** -- `POST /hooks/file-chooser` `{ targetId?, paths, timeoutMs? }` -- `POST /hooks/dialog` `{ targetId?, accept, promptText?, timeoutMs? }` - -Semantics: -- Hook endpoints **arm** the next matching event within `timeoutMs` (default 2 minutes, clamped to max 2 minutes). -- Last arm wins per page (new arm replaces previous). - -## Work checklist - -- [x] Replace action endpoints with `POST /act` -- [x] Remove legacy endpoints (`/click`, `/type`, `/wait`, …) and any CLI wrappers that no longer make sense -- [x] Remove `/back` and any history-specific routes -- [x] Convert `upload` + `dialog` to hook/arming endpoints -- [x] Unify screenshots behind `POST /screenshot` (no GET variant) -- [x] Trim inspect/debug endpoints (`/query`, `/dom`) unless explicitly needed -- [x] Update docs/browser.md to describe contract without implementation details -- [x] Update tests (server + client) to cover vNext contract - -## Notes / decisions - -- Keep Playwright as an internal implementation detail for now. -- Prefer ref-based interactions (`aria-ref`) over coordinate-based ones. -- Keep the code split “routes vs. engine” small and obvious; avoid scattering logic across too many files. diff --git a/docs/refactor/canvas-a2ui.md b/docs/refactor/canvas-a2ui.md deleted file mode 100644 index d79ce1733..000000000 --- a/docs/refactor/canvas-a2ui.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -summary: "Refactor: host A2UI from the Gateway (HTTP), remove app-bundled shells" -read_when: - - Refactoring Canvas/A2UI ownership or assets - - Moving UI rendering from native bundles into the Gateway - - Updating node canvas navigation or A2UI command flows ---- - -# Canvas / A2UI — HTTP-hosted from Gateway - -Status: Implemented · Date: 2025-12-20 - -## Goal -- Make the **Gateway (TypeScript)** the single owner of A2UI. -- Remove **app-bundled A2UI shells** (macOS, iOS, Android). -- A2UI renders only when the **Gateway is reachable** (acceptable failure mode). - -## Decision -All A2UI HTML/JS assets are **served by the Gateway canvas host** on -`canvasHost.port` (default `18793`), bound to the **bridge interface**. Nodes -(mac/iOS/Android) **navigate to the advertised `canvasHostUrl`** before applying -A2UI messages. No local custom-scheme or bundled fallback remains. - -## Why -- One source of truth (TS) for A2UI rendering. -- Faster iteration (no app release required for A2UI updates). -- iOS/Android/macOS all behave identically. - -## New behavior (summary) -1) `canvas.a2ui.*` on any node: - - Ensure Canvas is visible. - - Navigate the node WebView to the Gateway A2UI URL. - - Apply/reset A2UI messages once the page is ready. -2) If Gateway is unreachable: - - A2UI fails with an explicit error (no fallback). - -## Gateway changes - -### Serve A2UI assets -Add A2UI HTML/JS to the Gateway Canvas host (standalone HTTP server on -`canvasHost.port`), e.g.: - -``` -/__clawdbot__/a2ui/ -> index.html -/__clawdbot__/a2ui/a2ui.bundle.js -> bundled A2UI runtime -``` - -Serve Canvas files at `/__clawdbot__/canvas/` and A2UI at `/__clawdbot__/a2ui/`. -Use the shared Canvas host handler (`src/canvas-host/server.ts`) to serve these -assets and inject the action bridge + live reload if desired. - -### Canonical host URL -The Gateway exposes a **canonical** `canvasHostUrl` in hello/bridge payloads -so nodes don’t need to guess. - -## Node changes (mac/iOS/Android) - -### Navigation path -Before applying A2UI: -- Navigate to `${canvasHostUrl}/__clawdbot__/a2ui/`. - -### Remove bundled shells -Remove all fallback logic that serves A2UI from local bundles: -- macOS: remove custom-scheme fallback for `/__clawdbot__/a2ui/` -- iOS/Android: remove packaged A2UI assets and "default scaffold" assumptions - -### Error behavior -If `canvasHostUrl` is missing or unreachable: -- `canvas.a2ui.push/reset` returns a clear error: - - `A2UI_HOST_UNAVAILABLE` or `A2UI_HOST_NOT_CONFIGURED` - -## Security / transport -- For non-TLS Gateway URLs (http), iOS/Android will need ATS exceptions. -- For TLS (https), prefer WSS + HTTPS with a valid cert. - -## Implementation plan -1) Gateway - - Add A2UI assets under `src/canvas-host/`. - - Serve them at `/__clawdbot__/a2ui/` (align with existing naming). - - Serve Canvas files at `/__clawdbot__/canvas/` on `canvasHost.port`. - - Expose `canvasHostUrl` in handshake + bridge hello payloads. -2) Node runtimes - - Update `canvas.a2ui.*` to navigate to `canvasHostUrl`. - - Remove custom-scheme A2UI fallback and bundled assets. -3) Tests - - TS: verify `/__clawdbot__/a2ui/` responds with HTML + JS. - - Node: verify A2UI fails when host is unreachable and succeeds when reachable. -4) Docs - - Update `docs/mac/canvas.md`, `docs/ios/spec.md`, `docs/android/connect.md` - to remove local fallback assumptions and point to gateway-hosted A2UI. - -## Notes -- iOS/Android may still require ATS exceptions for `http://` canvas hosts. diff --git a/docs/refactor/cli-unification.md b/docs/refactor/cli-unification.md deleted file mode 100644 index 4f094f0d4..000000000 --- a/docs/refactor/cli-unification.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -summary: "Refactor: unify on the clawdbot CLI + gateway-first control; retire clawdbot-mac" -read_when: - - Removing or replacing the macOS CLI helper - - Adding node capabilities or permissions metadata - - Updating macOS app packaging/install flows ---- - -# CLI unification (clawdbot-only) - -Status: active refactor · Date: 2025-12-20 - -## Goals -- **Single CLI**: use `clawdbot` for all automation (local + remote). Retire `clawdbot-mac`. -- **Gateway-first**: all agent actions flow through the Gateway WebSocket + node.invoke. -- **Permission awareness**: nodes advertise permission state so the agent can decide what to run. -- **No duplicate paths**: remove macOS control socket + Swift CLI surface. - -## Non-goals -- Keep legacy `clawdbot-mac` compatibility. -- Support agent control when no Gateway is running. - -## Key decisions -1) **No Gateway → no control** - - If the macOS app is running but the Gateway is not, remote commands (canvas/run/notify) are unavailable. - - This is acceptable to keep one network surface. - -2) **Remove ensure-permissions CLI** - - Permissions are **advertised by the node** (e.g., screen recording granted/denied). - - Commands will still fail with explicit errors when permissions are missing. - -3) **Mac app installs/symlinks `clawdbot`** - - Bundle a standalone `clawdbot` binary in the app (bun-compiled). - - Install/symlink that binary to `/usr/local/bin/clawdbot` and `/opt/homebrew/bin/clawdbot`. - - No `clawdbot-mac` helper remains. - -4) **Canvas parity across node types** - - Use `node.invoke` commands consistently (`canvas.present|navigate|eval|snapshot|a2ui.*`). - - The TS CLI provides convenient wrappers so agents never have to craft raw `node.invoke` calls. - -## Command surface (new/normalized) -- `clawdbot nodes invoke --command canvas.*` remains valid. -- New CLI wrappers for convenience: - - `clawdbot canvas present|navigate|eval|snapshot|a2ui push|a2ui reset` -- New node commands (mac-only initially): - - `system.run` (shell execution) - - `system.notify` (local notifications) - -## Permission advertising -- Node hello/pairing includes a `permissions` map: - - Example keys: `screenRecording`, `accessibility`, `microphone`, `notifications`, `speechRecognition`. - - Values: boolean (`true` = granted, `false` = not granted). -- Gateway `node.list` / `node.describe` surfaces the map. - -## Gateway mode + config -- Gateways should only auto-start when explicitly configured for **local** mode. -- When config is missing or explicitly remote, `clawdbot gateway` should refuse to auto-start unless forced. - -## Implementation checklist -- Add bun-compiled `clawdbot` binary to macOS app bundle; update codesign + install flows. -- Remove `ClawdbotCLI` target and control socket server. -- Add node command(s) for `system.run` and `system.notify` on macOS. -- Add permission map to node hello/pairing + gateway responses. -- Update TS CLI + docs to use `clawdbot` only. diff --git a/docs/refactor/gateway-client.md b/docs/refactor/gateway-client.md deleted file mode 100644 index fb9e5c6a9..000000000 --- a/docs/refactor/gateway-client.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -summary: "Refactor notes for the macOS gateway client typed API migration (Dec 2025)." -read_when: - - Refactoring macOS gateway client or typed gateway methods - - Auditing agent routing or channel semantics ---- - -# Gateway Client Refactor (Dec 2025) - -Goal: remove stringly-typed gateway calls from the macOS app, centralize routing/channel semantics, and improve error handling. - -## Progress - -- [x] Fold legacy “AgentRPC” into `GatewayConnection` (single layer; no separate client object). -- [x] Typed gateway API: `GatewayConnection.Method` + `requestDecoded/requestVoid` + typed helpers (status/agent/chat/cron/etc). -- [x] Centralize agent routing/channel semantics via `GatewayAgentChannel` + `GatewayAgentInvocation`. -- [x] Improve gateway error model (structured `GatewayResponseError` + decoding errors include method). -- [x] Migrate mac call sites to typed helpers (leave only intentionally dynamic forwarding paths). -- [x] Convert remaining UI raw channel strings to `GatewayAgentChannel` (Cron editor). -- [x] Cleanup naming: rename remaining tests/docs that still reference “RPC/AgentRPC”. - -### Notes - -- Intentionally string-based: - - `BridgeServer` dynamic request forwarding (method is data-driven). - - `ControlChannel` request wrapper (generic escape hatch). - -## Notes / Non-goals - -- No functional behavior changes intended (beyond better errors and removing “magic strings”). -- Keep changes incremental: introduce typed APIs first, then migrate call sites, then remove old helpers. diff --git a/docs/refactor/gateway.md b/docs/refactor/gateway.md deleted file mode 100644 index 2d55c8cf6..000000000 --- a/docs/refactor/gateway.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -summary: "Refactor notes for the macOS gateway client: single shared websocket + follow-ups" -read_when: - - Investigating duplicate/stale Gateway WS connections - - Refactoring macOS gateway client architecture - - Debugging noisy reconnect storms on gateway restart ---- -# Gateway Refactor Notes (macOS client) - -Last updated: 2025-12-12 - -This document captures the rationale and outcome of the macOS app’s Gateway client refactor: **one shared websocket connection per app process**, with an in-process event bus for server push frames. - -Related docs: -- `docs/refactor/new-arch.md` (overall gateway protocol/server plan) -- `docs/gateway.md` (gateway operations/runbook) -- `docs/presence.md` (presence semantics and dedupe) -- `docs/mac/webchat.md` (WebChat surfaces and debugging) - ---- - -## Background: what was wrong - -Symptoms: -- Restarting the gateway produced a *storm* of reconnects/log spam (`gateway/ws in connect`, `hello`, `hello-ok`) and elevated `clients=` counts. -- Even with “one panel open”, the mac app could hold tens of websocket connections to `ws://127.0.0.1:18789`. - -Root cause (historical bug): -- The mac app was repeatedly “reconfiguring” a gateway client on a timer (via health polling), creating a new websocket owner each time. -- Old websocket owners were not fully torn down and could keep watchdog/tick tasks alive, leading to **connection accumulation** over time. - ---- - -## What changed - -- **One socket owner:** `GatewayConnection.shared` is the only supported entry point for gateway RPC. -- **No global notifications:** server push frames are delivered via `GatewayConnection.shared.subscribe(...) -> AsyncStream` (no `NotificationCenter` fan-out). -- **No tunnel side effects:** `GatewayConnection` does not create/ensure SSH tunnels in remote mode; it consumes the already-established forwarded port. - ---- - -## Current architecture (as of 2025-12-12) - -Goal: enforce the invariant **“one gateway websocket per app process (per effective config)”**. - -Key elements: -- `GatewayConnection.shared` owns the one websocket and is the *only* supported entry point for app code that needs gateway RPC. -- Consumers (e.g. Control UI, agent invocations, SwiftUI WebChat) call `GatewayConnection.shared.request(...)` and do not create their own sockets. -- If the effective connection config changes (local ↔ remote tunnel port, token change), `GatewayConnection` replaces the underlying connection. -- The transport (`GatewayChannelActor`) is an internal detail and forwards push frames back into `GatewayConnection`. -- Server-push frames are delivered via `GatewayConnection.shared.subscribe(...) -> AsyncStream` (in-process event bus). - -Notes: -- Remote mode requires an SSH control tunnel. `GatewayConnection` **does not** start tunnels; it consumes the already-established forwarded port (owned by `ConnectionModeCoordinator` / `RemoteTunnelManager`). - ---- - -## Design constraints / principles - -- **Single ownership:** Exactly one component owns the actual socket and reconnect policy. -- **Explicit config changes:** Recreate/reconnect only when config changes, not as a side effect of periodic work. -- **No implicit fan-out sockets:** Adding new UI features must not accidentally add new persistent gateway connections. -- **Testable seams:** Connection config and websocket session creation should be overridable in tests. - ---- - -## Status / remaining work - -- ✅ One shared websocket per app process (per config) -- ✅ Event streaming moved into `GatewayConnection` (`AsyncStream`) and replays latest snapshot to new subscribers -- ✅ `NotificationCenter` removed for in-process gateway events (ControlChannel / Instances / WebChatSwiftUI) -- ✅ Remote tunnel lifecycle is not started implicitly by random RPC calls -- ✅ Payload decoding helpers extracted so UI adapters stay thin -- ✅ Dedicated resolved-endpoint publisher for remote mode (`GatewayEndpointStore`) - ---- - -## Testing strategy (what we want to cover) - -Minimum invariants: -- Repeated requests under the same config do **not** create additional websocket tasks. -- Concurrent requests still create **exactly one** websocket and reuse it. -- Shutdown prevents any reconnect loop after failures. -- Config changes (token / endpoint) cancel the old socket and reconnect once. - -Nice-to-have integration coverage: -- Multiple “consumers” (Control UI + agent invocations + SwiftUI WebChat) all call through the shared connection and still produce only one websocket. - -Additional coverage added (macOS): -- Subscribing after connect replays the latest snapshot. -- Sequence gaps emit an explicit `GatewayPush.seqGap(...)` before the corresponding event. - ---- - -## Debug notes (operational) - -When diagnosing “too many connections”: -- Prefer counting actual TCP connections on port 18789 and grouping by PID to see which process is holding sockets. -- Gateway `--verbose` prints *every* connect/hello and event broadcast; use it only when needed and filter output if you’re just sanity-checking. diff --git a/docs/refactor/new-arch.md b/docs/refactor/new-arch.md deleted file mode 100644 index 27371b4b3..000000000 --- a/docs/refactor/new-arch.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -summary: "Implementation plan for the new gateway architecture and protocol" -read_when: - - Executing the gateway refactor ---- -# New Gateway Architecture – Implementation Plan (detailed) - -Last updated: 2025-12-09 - -Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway, typed protocol, and first-frame snapshot. No backward compatibility. - ---- - -## Phase 0 — Foundations -- **Naming**: CLI subcommand `clawdbot gateway`; internal namespace `Gateway`. -- **Protocol folder**: create `protocol/` for schemas and build artifacts. ✅ `src/gateway/protocol`. -- **Schema tooling**: - - Prefer **TypeBox** (or ArkType) as source-of-truth types. ✅ TypeBox in `schema.ts`. - - `pnpm protocol:gen`: emits JSON Schema (`dist/protocol.schema.json`). ✅ - - `pnpm protocol:gen:swift`: generates Swift `Codable` models (`apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift`). ✅ - - AJV compile step for server validators. ✅ -- **CI**: add a job that fails if schema or generated Swift is stale. ✅ `pnpm protocol:check` (runs gen + git diff). - -## Phase 1 — Protocol specification -- Frames (WS text JSON, all with explicit `type`): - - `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}` - - `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close) - - `hello-ok {type:"hello-ok", protocol:, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}` - - `req {type:"req", id, method, params?}` - - `res {type:"res", id, ok, payload?, error?}` where `error` = `{code,message,details?,retryable?,retryAfterMs?}` - - `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent) - - `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart) -- Payload types: - - `PresenceEntry {host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}` - - `HealthSnapshot` (match existing `clawdbot health --json` fields) - - `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`) - - `TickEvent {ts}` - - `ShutdownEvent {reason, restartExpectedMs?}` - - Error codes: `NOT_LINKED`, `AGENT_TIMEOUT`, `INVALID_REQUEST`, `UNAVAILABLE`. -- Error shape: `{code, message, details?, retryable?, retryAfterMs?}` -- Rules: - - First frame must be `req` with `method:"connect"`; otherwise close. Add handshake timeout (e.g., 3s) for silent clients. - - Negotiate protocol: server picks within `[minProtocol,maxProtocol]`; if none, reply `res ok:false` and close. - - Protocol version bump on breaking changes; `hello-ok` must include `minClient` when needed. - - `stateVersion` increments for presence/health to drop stale deltas. - - Stable IDs: client sends `instanceId`; server issues per-connection `connId` in `hello-ok`; presence entries may include `instanceId` to dedupe reconnects. - - Token-based auth: bearer token in `auth.token`; required except for loopback development. - - Presence is primarily connection-derived; client may add hints (e.g., lastInputSeconds); entries expire via TTL to keep the map bounded (e.g., 5m TTL, max 200 entries). - - Idempotency keys: required for `send` and `agent` to safely retry after disconnects. - - Size limits: bound first-frame size by `maxPayload`; reject early if exceeded. - - Close on any non-JSON or wrong `type` before connect. - - Per-op idempotency keys: client SHOULD supply an explicit key per `send`/`agent`; if omitted, server may derive a scoped key from `instanceId+connId`, but explicit keys are safer across reconnects. - - Locale/userAgent are informational; server may log them for analytics but must not rely on them for access control. - -## Phase 2 — Gateway WebSocket server -- New module `src/gateway/server.ts`: - - Bind 127.0.0.1:18789 (configurable). - - On connect: validate `connect` params, send snapshot payload, start event pump. - - Per-connection queues with backpressure (bounded; drop oldest non-critical). - - WS-level caps: set `maxPayload` to cap frame size before JSON parse. - - Emit `tick` every N seconds when idle (or WS ping/pong if adequate). - - Emit `shutdown` before exit; then close sockets. -- Methods implemented: - - `health`, `status`, `system-presence`, `system-event`, `send`, `agent`. - - Optional: `set-heartbeats` removed/renamed if heartbeat concept is retired. -- Events implemented: - - `agent`, `presence` (deltas, with `stateVersion`), `tick`, `shutdown`. - - All events include `seq` for loss/out-of-order detection. -- Logging: structured logs on connect/close/error; include client fingerprint. -- Slow consumer policy: - - Per-connection outbound queue limit (bytes/messages). If exceeded, drop non-critical events (presence/tick) or close with a policy violation / retryable code; clients reconnect with backoff. -- Handshake edge cases: - - Close on handshake timeout. - - Close on over-limit first frame (maxPayload). - - Close immediately on non-JSON or wrong `type` before connect. - - Default guardrails: `maxPayload` ~512 KB, handshake timeout ~3 s, outbound buffered amount cap ~1.5 MB (tune as you implement). -- Dedupe cache: bound TTL (~5m) and max size (~1000 entries); evict oldest first (LRU) to prevent memory growth. - -## Phase 3 — Gateway CLI entrypoint -- Add `clawdbot gateway` command in CLI program: - - Reads config (port, WS options). - - Foreground process; exit non-zero on fatal errors. - - Flags: `--port`, `--no-tick` (optional), `--log-json` (optional). -- System supervision docs for launchd/systemd (see `gateway.md`). - -## Phase 4 — Presence/health snapshot & stateVersion -- `hello-ok.snapshot` includes: - - `presence[]` (current list) - - `health` (full snapshot) - - `stateVersion {presence:int, health:int}` - - `uptimeMs` - - `policy {maxPayload, maxBufferedBytes, tickIntervalMs}` -- Emit `presence` deltas with updated `stateVersion.presence`. -- Emit `tick` to indicate liveness when no other events occur. -- Keep `health` method for manual refresh; not required after connect. - - Presence expiry: prune entries older than TTL; enforce a max map size; include `stateVersion` in presence events. - -## Phase 5 — Clients migration -- **macOS app**: - - Replace stdio/SSH RPC with WS client (tunneled via SSH/Tailscale for remote). ✅ GatewayConnection/ControlChannel now use Gateway WS. - - Implement handshake, snapshot hydration, subscriptions to `presence`, `tick`, `agent`, `shutdown`. ✅ snapshot + presence events broadcast to InstancesStore; agent events still to wire to UI if desired. - - Remove immediate `health/system-presence` fetch on connect. ✅ presence hydrated from snapshot; periodic refresh kept as fallback. - - Handle connect failures (`res ok:false`) and retry with backoff if version/token mismatched. ✅ macOS GatewayChannel reconnects with exponential backoff. -- **CLI**: -- Add lightweight WS client helper for `status/health/send/agent` when Gateway is up. ✅ `gateway` subcommands use the Gateway over WS. - - Consider a “local only” flag to avoid accidental remote connects. (optional; not needed with tunnel-first model.) -- **WebChat backend**: - - Single WS to Gateway; seed UI from snapshot; forward `presence/tick/agent` to browser. ✅ implemented via the WebChat gateway client in `webchat/server.ts`. - - Fail fast if handshake fails; no fallback transports. ✅ (webchat returns gateway unavailable) - -## Phase 6 — Send/agent path hardening -- Ensure only the Gateway can open Baileys; no IPC fallback. -- `send` executes in-process; respond with explicit result/error, not via heartbeat. -- `agent` spawns Pi; respond quickly with `{runId,status:"accepted"}` (ack); stream `event:agent {runId, seq, stream, data, ts}`; final `res:agent {runId, status:"ok"|"error", summary}` completes request (idempotent via key). -- Idempotency: side-effecting methods (`send`, `agent`) accept an idempotency key; keep a short-lived dedupe cache to avoid double-send on client retries. Client retry flow: on timeout/close, retry with same key; Gateway returns cached result when available; cache TTL ~5m and bounded. -- Agent stream ordering: enforce monotonic `seq` per runId; if gap detected by server, terminate stream with error; if detected by client, issue a retry with same idempotency key. - - Send response shape: `{messageId?, toJid?, error?}` and always include `runId` when available for traceability. - -## Phase 7 — Keepalive and shutdown semantics -- Keepalive: `tick` events (or WS ping/pong) at fixed interval; clients treat missing ticks as disconnect and reconnect. -- Shutdown: send `event:shutdown {reason, restartExpectedMs?}` then close sockets; clients auto-reconnect. -- Restart semantics: close sockets with a standard retryable close code; on reconnect, `hello-ok` snapshot must be sufficient to rebuild UI without event replay. - - Use a standard close code (e.g., 1012 service restart or 1001 going away) for planned restart; 1008 policy violation for slow consumers. - - Include `policy` in `hello-ok` so clients know the tick interval and buffer limits to tune their expectations. - -## Phase 8 — Cleanup and deprecation -- Retire `clawdbot rpc` as default path; keep only if explicitly requested (documented as legacy). -- Remove reliance on `src/infra/control-channel.ts` for new clients; mark as legacy or delete after migration. ✅ file removed; mac app now uses Gateway WS. -- Update README, docs (`architecture.md`, `gateway.md`, `webchat.md`) to final shapes; remove `control-api.md` references if obsolete. -- Presence hygiene: - - Presence derived primarily from connection (server-fills host/ip/version/connId/instanceId); allow client hints (e.g., lastInputSeconds). - - Add TTL/expiry; prune to keep map bounded (e.g., 5m TTL, max 200 entries). - -## Edge cases and ordering -- Event ordering: all events carry `seq`; clients detect gaps and should re-fetch snapshot (or targeted refresh) on gap. -- Partial handshakes: if client connects and never sends `req:connect`, server closes after handshake timeout. -- Garbage/oversize first frame: bounded by `maxPayload`; server closes immediately on parse failure. -- Duplicate delivery on reconnect: clients must send idempotency keys; Gateway dedupe cache prevents double-send/agent execution. -- Snapshot sufficiency: `hello-ok.snapshot` must contain enough to render UI after reconnect without event replay. -- Client reconnect guidance: exponential backoff with jitter; reuse same `instanceId` across reconnects to avoid duplicate presence; resend idempotency keys for in-flight sends/agents; on seq gap, issue `health`/`system-presence` refresh. -- Presence TTL/defaults: set a concrete TTL (e.g., 5 minutes) and prune periodically; cap the presence map size with LRU if needed. -- Replay policy: if seq gap detected, server does not replay; clients must pull fresh `health` + `system-presence` and continue. - -## Phase 9 — Testing & validation -- Unit: frame validation, handshake failure, auth/token, stateVersion on presence events, agent stream fanout, send dedupe. ✅ -- Integration: connect → snapshot → req/res → streaming agent → shutdown. ✅ Covered in gateway WS tests (connect/health/status/presence, agent ack+final, shutdown broadcast). -- Load: multiple concurrent WS clients; backpressure behavior under burst. ✅ Basic fanout test with 3 clients receiving presence broadcast; heavier soak still recommended. -- Mac app smoke: presence/health render from snapshot; reconnect on tick loss. (Manual: open Instances tab, verify snapshot after connect, induce seq gap by toggling wifi, ensure UI refreshes.) -- WebChat smoke: snapshot seed + event updates; tunnel scenario. ✅ Offline snapshot harness in `src/webchat/server.test.ts` (mock gateway) now passes; live tunnel still recommended for manual. -- Idempotency tests: retry send/agent with same key after forced disconnect; expect deduped result. ✅ send + agent dedupe + reconnect retry covered in gateway tests. -- Seq-gap handling: ✅ clients now detect seq gaps (WebChat gateway client + mac `GatewayConnection/GatewayChannel`) and refresh health/presence (webchat) or trigger UI refresh (mac). Load-test still optional. - -## Phase 10 — Rollout -- Version bump; release notes: breaking change to control plane (WS only). -- Ship launchd/systemd templates for `clawdbot gateway`. -- Recommend Tailscale/SSH tunnel for remote access; no additional auth layer assumed in this model. - ---- - -- Quick checklist -- [x] Protocol types & schemas (TS + JSON Schema + Swift via quicktype) -- [x] AJV validators wired -- [x] WS server with connect → snapshot → events -- [x] Tick + shutdown events -- [x] stateVersion + presence deltas -- [x] Gateway CLI command -- [x] macOS app WS client (Gateway WS for control; presence events live; agent stream UI pending) -- [x] WebChat WS client -- [x] Remove legacy stdin/TCP paths from default flows (file removed; mac app/CLI on Gateway) -- [x] Tests (unit/integration/load) — unit + integration + basic fanout/reconnect; heavier load/soak optional -- [x] Docs updated and legacy docs flagged diff --git a/docs/refactor/tui.md b/docs/refactor/tui.md deleted file mode 100644 index f1177be4a..000000000 --- a/docs/refactor/tui.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -summary: "Refactor plan: Gateway TUI parity with pi-mono interactive UI" -read_when: - - Building or refactoring the Gateway TUI - - Syncing TUI slash commands with Clawdbot behavior ---- -# Gateway TUI refactor plan - -Updated: 2026-01-03 - -## Goals -- Match pi-mono interactive TUI feel (editor, streaming, tool cards, selectors). -- Keep Clawdbot semantics: Gateway WS only, session store owns state, no branching/export. -- Work locally or remotely via Gateway URL/token. - -## Non-goals -- Branching, export, OAuth flows, or hook UIs. -- File-system operations on the Gateway host from the TUI. - -## Checklist -- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads). -- [x] Gateway TUI client: add session/model helpers + stricter typing. -- [x] TUI UI kit: theme + components (editor, message feed, tool cards, selectors). -- [x] TUI controller: keybindings + Clawdbot slash commands + history/stream wiring. -- [x] Docs + changelog updated for the new TUI behavior. -- [x] Gate: lint, build, tests, docs list. diff --git a/docs/refactor/web-gateway-troubleshooting.md b/docs/refactor/web-gateway-troubleshooting.md deleted file mode 100644 index 93db611ee..000000000 --- a/docs/refactor/web-gateway-troubleshooting.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -summary: "Troubleshooting guide for the web gateway/Baileys stack" -read_when: - - Diagnosing web gateway socket or login issues ---- -# Web Gateway Troubleshooting (Nov 26, 2025) - -## Symptoms & quick fixes -- **Stream Errored / Conflict / status 409–515:** WhatsApp closed the socket because another session is active or creds went stale. Run `clawdbot logout`, then `clawdbot login`, then restart the Gateway. -- **Logged out:** Console prints “session logged out”; re-link with `clawdbot login`. -- **Repeated retries then exit:** Tune reconnect behavior via config `web.reconnect` and restart the Gateway. -- **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection. -- **Status 515 right after pairing:** The QR login flow now auto-restarts once; you should not need a manual gateway restart after scanning. -- **Fast nuke:** From an allowed WhatsApp sender you can send `/restart` to request a supervised restart (launchd/mac app setups); wait a few seconds for it to come back. - -## Helpful commands -- Start the Gateway: `clawdbot gateway --verbose` -- Logout (clear creds): `clawdbot logout` -- Relink (show QR): `clawdbot login --verbose` -- Tail logs (default): `tail -f /tmp/clawdbot/clawdbot-*.log` - -## Reading the logs -- `web-reconnect`: close reasons, retry/backoff, max-attempt exit. -- `web-heartbeat`: connectionId, messagesHandled, authAgeMs, uptimeMs (every 60s by default). -- `web-auto-reply`: inbound/outbound message records with correlation IDs. - -## When to tweak knobs -- High churn networks: increase `web.reconnect.maxAttempts`. -- Slow links: raise `web.reconnect.maxMs` to give more headroom before bailing. -- Chatty monitors: increase `web.heartbeatSeconds` if log volume is high. - -## If it keeps failing -1) `clawdbot logout` → `clawdbot login` (fresh QR link). -2) Ensure no other device/browser is using the same WA Web session. -3) Check WhatsApp mobile app is online and not in low-power mode. -4) If status is 515, let the client restart once after pairing (already handled automatically). -5) Capture the last `web-reconnect` entry and the status code before escalating. diff --git a/docs/refactor/webagent-session.md b/docs/refactor/webagent-session.md deleted file mode 100644 index ad769c193..000000000 --- a/docs/refactor/webagent-session.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -summary: "WebChat session migration notes (Gateway WS-only)" -read_when: - - Changing WebChat Gateway methods/events ---- -# 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. - -## Target state -- Gateway WS adds methods: - - `chat.history { sessionKey }` → `{ sessionKey, messages[], thinkingLevel }` (reads the existing JSONL + session store). - - `chat.send { sessionKey, message, attachments?, thinking?, deliver?, timeoutMs<=30000, idempotencyKey }` → `res { runId, status:"accepted" }` or `res ok:false` on validation/timeout. -- Gateway WS emits `chat` events `{ runId, sessionKey, seq, state:"delta"|"final"|"error", message?, errorMessage?, usage?, stopReason? }`. Streaming is optional; minimum is a single `state:"final"` per send. -- Client consumes only WS: bootstrap via `chat.history`, send via `chat.send`, live updates via `chat` events. No file watchers. -- Health gate: client subscribes to `health` and blocks send when health is not OK; 30s client-side timeout for sends. -- Tunneling: only the Gateway WS port needs to be forwarded; HTTP server remains for static assets but no RPC endpoints. - -## Server work (Node) -- Implement `chat.history` and `chat.send` handlers in `src/gateway/server.ts`; update protocol schemas/tests. -- Emit `chat` events by plumbing `agentCommand`/`emitAgentEvent` outputs; include assistant text/tool results. -- Remove `/rpc` and `/socket` routes + file-watch broadcast from `src/webchat/server.ts`; leave static host only. - -## Client work (pi-web bundle) -- Replace `NativeTransport` with a Gateway WS client: - - `connect` → `chat.history` for initial state. - - Listen to `chat/presence/tick/health`; update UI from events only. - - Send via `chat.send`; mark pending until `chat state:final|error`. - - Enforce health gate + 30s timeout. -- Remove reliance on session file snapshots and `/rpc`. - -## Persistence -- Keep passing `--session <.../.clawdbot/sessions/{{SessionId}}.jsonl>` to Pi so it continues writing JSONL. The WS history reader uses the same file; no new store introduced. - -## Docs to update when shipping -- `docs/webchat.md` (WS-only flow, methods/events, health gate, tunnel WS port). -- `docs/mac/webchat.md` (WKWebView now talks Gateway WS; `/rpc`/file-watch removed). -- `docs/architecture.md` / `typebox.md` if protocol methods are listed. -- Optional: add a concise Gateway chat protocol appendix if needed. - -## Open decisions -- 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. diff --git a/docs/remote-gateway-readme.md b/docs/remote-gateway-readme.md index 98193a866..039955a6d 100644 --- a/docs/remote-gateway-readme.md +++ b/docs/remote-gateway-readme.md @@ -1,3 +1,8 @@ +--- +summary: "SSH tunnel setup for Clawdbot.app connecting to a remote gateway" +read_when: "Connecting the macOS app to a remote gateway over SSH" +--- + # Running Clawdbot.app with a Remote Gateway Clawdbot.app uses SSH tunneling to connect to a remote gateway. This guide shows you how to set it up. diff --git a/docs/session.md b/docs/session.md index d038aab37..6cc7a3396 100644 --- a/docs/session.md +++ b/docs/session.md @@ -77,6 +77,7 @@ Runtime override (owner only): - `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active `). - `pnpm clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). - Send `/status` in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). +- Send `/compact` (optional instructions) to summarize older context and free up window space. - JSONL transcripts can be opened directly to review full turns. ## Tips diff --git a/docs/setup.md b/docs/setup.md index 9a187e111..d053da7e3 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -109,10 +109,23 @@ pnpm clawdbot health - Keep `~/clawd` and `~/.clawdbot/` as “your stuff”; don’t put personal prompts/config into the `clawdbot` repo. - Updating source: `git pull` + `pnpm install` (when lockfile changed) + keep using `pnpm gateway:watch`. +## Linux (systemd user service) + +Linux installs use a systemd **user** service. By default, systemd stops user +services on logout/idle, which kills the Gateway. Onboarding attempts to enable +lingering for you (may prompt for sudo). If it’s still off, run: + +```bash +sudo loginctl enable-linger $USER +``` + +For always-on or multi-user servers, consider a **system** service instead of a +user service (no lingering needed). See `docs/gateway.md` for the systemd notes. + ## Related docs - `docs/gateway.md` (Gateway runbook; flags, supervision, ports) - `docs/configuration.md` (config schema + examples) - `docs/discord.md` and `docs/telegram.md` (reply tags + replyToMode settings) - `docs/clawd.md` (personal assistant setup) -- `docs/clawdbot-mac.md` (macOS app behavior; gateway lifecycle + “Attach only”) +- `docs/macos.md` (macOS app behavior; gateway lifecycle + “Attach only”) diff --git a/docs/showcase.md b/docs/showcase.md new file mode 100644 index 000000000..1e64dc7aa --- /dev/null +++ b/docs/showcase.md @@ -0,0 +1,36 @@ +--- +summary: "Real-world showcases of what Clawdbot can do" +read_when: + - You want inspiration or proof of capability +--- +# Showcase + +Real projects from the community. Highlights from #showcase (Jan 2–5, 2026). + +## Automation & real-world outcomes +- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill +- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api +- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn +- **Accounting intake** — Collect PDFs from email, prep for tax consultant (monthly accounting batch). (No link shared.) + +## Knowledge & memory systems +- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.) +- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search +- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.) + +## Voice, docs, and assistants on the phone +- **Clawdia phone bridge** — Vapi voice assistant ↔ Clawdis HTTP bridge; near‑real‑time phone calls. https://github.com/alejandroOPI/clawdia-bridge +- **Google Docs edit skill** — Rich‑text editing skill built fast with Claude Code. (No link shared.) +- **OpenRouter transcription skill** — Multi‑lingual audio transcription via OpenRouter (Gemini etc). ClawdHub: https://clawdhub.com/obviyus/openrouter-transcribe (user/slug link) + +## Infrastructure & deployment +- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon +- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant +- **Nix packaging** — Batteries‑included nixified clawdis config. https://github.com/joshp123/nix-clawdis +- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar + +## Home + hardware +- **Roborock integration** — Plugin for robot vacuum control. https://github.com/joshp123/gohome/tree/main/plugins/roborock + +## Community builds (non‑Clawdis but made with/around it) +- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/ diff --git a/docs/slack.md b/docs/slack.md index 22f6654ba..c8154266d 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -1,3 +1,8 @@ +--- +summary: "Slack socket mode setup and Clawdbot config" +read_when: "Setting up Slack or debugging Slack socket mode" +--- + # Slack (socket mode) ## Setup @@ -50,8 +55,14 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i "channels:history", "channels:read", "groups:history", + "groups:read", + "groups:write", "im:history", + "im:read", + "im:write", "mpim:history", + "mpim:read", + "mpim:write", "users:read", "app_mentions:read", "reactions:read", @@ -87,6 +98,44 @@ Use this Slack app manifest to create the app quickly (adjust the name/command i } ``` +## Scopes (current vs optional) +Slack's Conversations API is type-scoped: you only need the scopes for the +conversation types you actually touch (channels, groups, im, mpim). See +https://api.slack.com/docs/conversations-api for the overview. + +### Required by current code +- `chat:write` (send/update/delete messages via `chat.postMessage`) + https://api.slack.com/methods/chat.postMessage +- `im:write` (open DMs via `conversations.open` for user DMs) + https://api.slack.com/methods/conversations.open +- `channels:history`, `groups:history`, `im:history`, `mpim:history` + (`conversations.history` in `src/slack/actions.ts`) + https://api.slack.com/methods/conversations.history +- `channels:read`, `groups:read`, `im:read`, `mpim:read` + (`conversations.info` in `src/slack/monitor.ts`) + https://api.slack.com/methods/conversations.info +- `users:read` (`users.info` in `src/slack/monitor.ts` + `src/slack/actions.ts`) + https://api.slack.com/methods/users.info +- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`) + https://api.slack.com/methods/reactions.get + https://api.slack.com/methods/reactions.add +- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`) + https://api.slack.com/scopes/pins:read + https://api.slack.com/scopes/pins:write +- `emoji:read` (`emoji.list`) + https://api.slack.com/scopes/emoji:read +- `files:write` (uploads via `files.uploadV2`) + https://api.slack.com/messaging/files/uploading + +### Not needed today (but likely future) +- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`) +- `groups:write` (only if we add private-channel management: create/rename/invite/archive) +- `chat:write.public` (only if we want to post to channels the bot isn't in) + https://api.slack.com/scopes/chat:write.public +- `users:read.email` (only if we need email fields from `users.info`) + https://api.slack.com/changelog/2017-04-narrowing-email-access +- `files:read` (only if we start listing/reading file metadata) + ## Config Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: @@ -131,6 +180,9 @@ Tokens can also be supplied via env vars: - `SLACK_BOT_TOKEN` - `SLACK_APP_TOKEN` +Ack reactions are controlled globally via `messages.ackReaction` + +`messages.ackReactionScope`. + ## Sessions + routing - DMs share the `main` session (like WhatsApp/Telegram). - Channels map to `slack:channel:` sessions. @@ -153,6 +205,6 @@ Slack tool actions can be gated with `slack.actions.*`: | emojiList | enabled | Custom emoji list | ## Notes -- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`). +- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. - Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). - Attachments are downloaded to the media store when permitted and under the size limit. diff --git a/docs/telegram.md b/docs/telegram.md index d6f868314..67c5ccac4 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -24,8 +24,8 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup - The webhook listener currently binds to `0.0.0.0:8787` and serves `POST /telegram-webhook` by default. - If you need a different public port/host, set `telegram.webhookUrl` to the externally reachable URL and use a reverse proxy to forward to `:8787`. 4) Direct chats: user sends the first message; all subsequent turns land in the shared `main` session (default, no extra config). -5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:` and require mention/command by default (override via `telegram.groups`). -6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789` or `telegram:123456789`). +5) Groups: add the bot, disable privacy mode (or make it admin) so it can read messages; group threads stay on `telegram:group:`. When `telegram.groups` is set, it becomes a group allowlist (use `"*"` to allow all). Mention/command gating defaults come from `telegram.groups`. +6) Optional allowlist: use `telegram.allowFrom` for direct chats by chat id (`123456789`, `telegram:123456789`, or `tg:123456789`; prefixes are case-insensitive). ## Capabilities & limits (Bot API) - Sees only messages sent after it’s added to a chat; no pre-history access. @@ -35,9 +35,10 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup ## Planned implementation details - Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits. -- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention by default (override per chat in config). +- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config). - Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort. -- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. +- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups` (group allowlist + mention defaults), `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported. + - Ack reactions are controlled globally via `messages.ackReaction` + `messages.ackReactionScope`. - Mention gating precedence (most specific wins): `telegram.groups..requireMention` → `telegram.groups."*".requireMention` → default `true`. Example config: @@ -48,7 +49,7 @@ Example config: botToken: "123:abc", replyToMode: "off", groups: { - "*": { requireMention: true }, + "*": { requireMention: true }, // allow all groups "123456789": { requireMention: false } // group chat id }, allowFrom: ["123456789"], // direct chat ids allowed (or "*") @@ -65,7 +66,7 @@ Example config: ## Group etiquette - Keep privacy mode off if you expect the bot to read all messages; with privacy on, it only sees commands/mentions. - Make the bot an admin if you need it to send in restricted groups or channels. -- Mention the bot (`@yourbot`) or use commands to trigger; per-group overrides live in `telegram.groups` if you want always-on behavior. +- Mention the bot (`@yourbot`) or use a `routing.groupChat.mentionPatterns` trigger; per-group overrides live in `telegram.groups` if you want always-on behavior. If `telegram.groups` is set, add `"*"` to keep existing allow-all behavior. ## Reply tags To request a threaded reply, the model can include one tag in its output: diff --git a/docs/templates/AGENTS.md b/docs/templates/AGENTS.md index 121756b8f..f8e3ed40c 100644 --- a/docs/templates/AGENTS.md +++ b/docs/templates/AGENTS.md @@ -16,18 +16,28 @@ If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out w Before doing anything else: 1. Read `SOUL.md` — this is who you are 2. Read `USER.md` — this is who you're helping -3. Read `memory.md` + today's and yesterday's files in `memory/` +3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context +4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md` Don't ask permission. Just do it. ## Memory You wake up fresh each session. These files are your continuity: -- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) -- **Long-term:** `memory.md` for durable facts, preferences, open loops +- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened +- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them. +### 🧠 MEMORY.md - Your Long-Term Memory +- **ONLY load in main session** (direct chats with your human) +- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people) +- This is for **security** — contains personal context that shouldn't leak to strangers +- You can **read, edit, and update** MEMORY.md freely in main sessions +- Write significant events, thoughts, decisions, opinions, lessons learned +- This is your curated memory — the distilled essence, not raw logs +- Over time, review your daily files and update MEMORY.md with what's worth keeping + ### 📝 Write It Down - No "Mental Notes"! - **Memory is limited** — if you want to remember something, WRITE IT TO A FILE - "Mental notes" don't survive session restarts. Files do. @@ -69,6 +79,29 @@ Vectors + BM25 + reranking finds things even with different wording. You have access to your human's stuff. That doesn't mean you *share* their stuff. In groups, you're a participant — not their voice, not their proxy. Think before you speak. +### 💬 Know When to Speak! +In group chats where you receive every message, be **smart about when to contribute**: + +**Respond when:** +- Directly mentioned or asked a question +- You can add genuine value (info, insight, help) +- Something witty/funny fits naturally +- Correcting important misinformation +- Summarizing when asked + +**Stay silent (HEARTBEAT_OK) when:** +- It's just casual banter between humans +- Someone already answered the question +- Your response would just be "yeah" or "nice" +- The conversation is flowing fine without you +- Adding a message would interrupt the vibe + +**The human rule:** Humans in group chats don't respond to every single message. Neither should you. Quality > quantity. If you wouldn't send it in a real group chat with friends, don't send it. + +**Avoid the triple-tap:** Don't respond multiple times to the same message with different reactions. One thoughtful response beats three fragments. + +Participate, don't dominate. + ## Tools Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`. @@ -118,6 +151,16 @@ When you receive a `HEARTBEAT` message, don't just reply `HEARTBEAT_OK` every ti - Check on projects (git status, etc.) - Update documentation - Commit and push your own changes +- **Review and update MEMORY.md** (see below) + +### 🔄 Memory Maintenance (During Heartbeats) +Periodically (every few days), use a heartbeat to: +1. Read through recent `memory/YYYY-MM-DD.md` files +2. Identify significant events, lessons, or insights worth keeping long-term +3. Update `MEMORY.md` with distilled learnings +4. Remove outdated info from MEMORY.md that's no longer relevant + +Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom. The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time. diff --git a/docs/test.md b/docs/test.md index cb0bcbe19..b31a57fbb 100644 --- a/docs/test.md +++ b/docs/test.md @@ -33,3 +33,11 @@ scripts/e2e/onboard-docker.sh ``` This script drives the interactive wizard via a pseudo-tty, verifies config/workspace/session files, then starts the gateway and runs `clawdbot health`. + +## QR import smoke (Docker) + +Ensures `qrcode-terminal` loads under Node 22+ in Docker: + +```bash +pnpm test:docker:qr +``` diff --git a/docs/timezone.md b/docs/timezone.md new file mode 100644 index 000000000..8a9d0ca6a --- /dev/null +++ b/docs/timezone.md @@ -0,0 +1,40 @@ +--- +summary: "Timezone handling for agents, envelopes, and prompts" +read_when: + - You need to understand how timestamps are normalized for the model + - Configuring the user timezone for system prompts +--- + +# Timezones + +Clawdbot standardizes timestamps so the model sees a **single reference time**. + +## Message envelopes (UTC) + +Inbound messages are wrapped in an envelope like: + +``` +[Surface ... 2026-01-05T21:26Z] message text +``` + +The timestamp in the envelope is **always UTC**, with minutes precision. + +## Tool payloads (raw provider data) + +Tool calls (`discord.readMessages`, `slack.readMessages`, etc.) return **raw provider timestamps**. +These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them. + +## User timezone for the system prompt + +Set `agent.userTimezone` to tell the model the user's local time zone. If it is +unset, Clawdbot resolves the **host timezone at runtime** (no config write). + +```json5 +{ + agent: { userTimezone: "America/Chicago" } +} +``` + +The system prompt includes: +- `User timezone: America/Chicago` +- `Current user time: 2026-01-05 15:26` diff --git a/docs/tools.md b/docs/tools.md index 9cbbdc218..47815a386 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -73,7 +73,7 @@ Common parameters: - `controlUrl` (defaults from config) - `profile` (optional; defaults to `browser.defaultProfile`) Notes: -- Requires `browser.enabled=true` in `~/.clawdbot/clawdbot.json`. +- Requires `browser.enabled=true` (default is `true`; set `false` to disable). - Uses `browser.controlUrl` unless `controlUrl` is passed explicitly. - All actions accept optional `profile` parameter for multi-instance support. - When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd"). @@ -126,7 +126,7 @@ Core parameters: - `maxBytesMb` (optional size cap) Notes: -- Only available when `agent.imageModel` or `agent.imageModelFallbacks` is set. +- Only available when `agent.imageModel` is configured (primary or fallbacks). - Uses the image model directly (independent of the main chat model). ### `cron` @@ -139,7 +139,7 @@ Core actions: Notes: - `add` expects a full cron job object (same schema as `cron.add` RPC). -- `update` uses `{ jobId, patch }`. +- `update` uses `{ id, patch }`. ### `gateway` Restart the running Gateway process (in-place). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 357d8b5ee..c8cbcab88 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -29,8 +29,8 @@ cat ~/.clawdbot/clawdbot.json | jq '.whatsapp.allowFrom' **Check 2:** For group chats, is mention required? ```bash -# The message must match mentionPatterns or explicit mentions; defaults live in whatsapp.groups -cat ~/.clawdbot/clawdbot.json | jq '.routing.groupChat, .whatsapp.groups' +# The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds. +cat ~/.clawdbot/clawdbot.json | jq '.routing.groupChat, .whatsapp.groups, .telegram.groups, .imessage.groups, .discord.guilds' ``` **Check 3:** Check the logs @@ -100,7 +100,7 @@ If you’re logged out / unlinked: ```bash clawdbot logout -rm -rf ~/.clawdbot/credentials # if logout can't cleanly remove everything +trash ~/.clawdbot/credentials # if logout can't cleanly remove everything clawdbot login --verbose # re-scan QR ``` @@ -160,6 +160,13 @@ lsof -nP -i :18789 kill -9 ``` +If the gateway is supervised by launchd, killing the PID will just respawn it. +Stop the supervisor instead: +```bash +clawdbot gateway stop +# Or: launchctl bootout gui/$UID/com.clawdbot.gateway +``` + **Fix 2: Check embedded gateway** Ensure the gateway relay was properly bundled. Run `./scripts/package-mac-app.sh` and ensure `bun` is installed. @@ -203,7 +210,7 @@ tail -20 /tmp/clawdbot/clawdbot-*.log Nuclear option: ```bash -rm -rf ~/.clawdbot +trash ~/.clawdbot clawdbot login # re-pair WhatsApp clawdbot gateway # start the Gateway again ``` diff --git a/docs/tui.md b/docs/tui.md index fbfbae82c..de0479788 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -48,13 +48,14 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/help` - `/status` - `/session ` (or `/sessions`) -- `/model ` (or `/models`) +- `/model ` (or `/model list`, `/models`) - `/think ` - `/verbose ` - `/elevated ` - `/activation ` - `/deliver ` - `/new` or `/reset` +- `/compact [instructions]` - `/abort` - `/settings` - `/exit` diff --git a/docs/whatsapp.md b/docs/whatsapp.md index e646f62b3..454e83a21 100644 --- a/docs/whatsapp.md +++ b/docs/whatsapp.md @@ -118,7 +118,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Config quick map - `whatsapp.allowFrom` (DM allowlist). -- `whatsapp.groups` (group mention gating defaults/overrides) +- `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) - `routing.groupChat.mentionPatterns` - `routing.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) @@ -136,7 +136,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number ## Logs + troubleshooting - Subsystems: `whatsapp/inbound`, `whatsapp/outbound`, `web-heartbeat`, `web-reconnect`. - Log file: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (configurable). -- Troubleshooting guide: `docs/refactor/web-gateway-troubleshooting.md`. +- Troubleshooting guide: `docs/troubleshooting.md`. ## Tests - `src/web/auto-reply.test.ts` (mention gating, history injection, reply flow) diff --git a/docs/windows.md b/docs/windows.md new file mode 100644 index 000000000..5987541a3 --- /dev/null +++ b/docs/windows.md @@ -0,0 +1,11 @@ +--- +summary: "Windows app status + contribution call" +read_when: + - Looking for Windows companion app status + - Planning platform coverage or contributions +--- +# Windows App + +Clawdbot core is fully supported on Windows. The core is written in TypeScript, so it runs anywhere Node runs. + +We do not have a Windows companion app yet. It is planned, and we would love contributions to make it happen. diff --git a/docs/wizard.md b/docs/wizard.md index d4ec67028..883f4e003 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -49,10 +49,12 @@ It does **not** install or change anything on the remote host. 2) **Model/Auth** - **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`. - **OpenAI Codex OAuth**: browser flow; paste the `code#state`. + - Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. - **API key**: stores the key for you. - **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint. - **Skip**: no auth configured yet. - - OAuth + API keys are stored in `~/.clawdbot/agent/auth.json`. + - Wizard runs a model check and warns if the configured model is unknown or missing auth. + - OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agent/auth-profiles.json` (API keys + OAuth). 3) **Workspace** - Default `~/clawd` (configurable). @@ -74,8 +76,8 @@ It does **not** install or change anything on the remote host. - macOS: LaunchAgent - Requires a logged-in user session; for headless, use a custom LaunchDaemon (not shipped). - Linux: systemd user unit - - Wizard enables lingering via `loginctl enable-linger ` so the Gateway stays up after logout. - - Requires sudo (writes `/var/lib/systemd/linger`). + - Wizard attempts to enable lingering via `loginctl enable-linger ` so the Gateway stays up after logout. + - May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first. - Windows: Scheduled Task - Runs on user logon; headless/system services are not configured by default. diff --git a/package.json b/package.json index d94352b1f..61c60d401 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ ], "scripts": { "dev": "tsx src/entry.ts", + "postinstall": "node scripts/postinstall.js", "docs:list": "tsx scripts/docs-list.ts", "docs:dev": "cd docs && mint dev", "docs:build": "cd docs && pnpm dlx mint broken-links", @@ -67,6 +68,7 @@ "test:force": "tsx scripts/test-force.ts", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "protocol:gen": "tsx scripts/protocol-gen.ts", "protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift", @@ -83,10 +85,10 @@ "@clack/prompts": "^0.11.0", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", - "@mariozechner/pi-agent-core": "^0.36.0", - "@mariozechner/pi-ai": "^0.36.0", - "@mariozechner/pi-coding-agent": "^0.36.0", - "@mariozechner/pi-tui": "^0.36.0", + "@mariozechner/pi-agent-core": "^0.37.2", + "@mariozechner/pi-ai": "^0.37.2", + "@mariozechner/pi-coding-agent": "^0.37.2", + "@mariozechner/pi-tui": "^0.37.2", "@sinclair/typebox": "0.34.46", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.13.0", @@ -108,11 +110,12 @@ "json5": "^2.2.3", "long": "5.3.2", "playwright-core": "1.57.0", + "proper-lockfile": "^4.1.2", "qrcode-terminal": "^0.12.0", "sharp": "^0.34.5", "tslog": "^4.10.2", - "undici": "^7.16.0", - "ws": "^8.18.3", + "undici": "^7.18.0", + "ws": "^8.19.0", "zod": "^4.3.5" }, "devDependencies": { @@ -124,6 +127,7 @@ "@types/express": "^5.0.6", "@types/markdown-it": "^14.1.2", "@types/node": "^25.0.3", + "@types/proper-lockfile": "^4.1.4", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", "@vitest/coverage-v8": "^4.0.16", @@ -133,7 +137,7 @@ "lucide": "^0.562.0", "markdown-it": "^14.1.0", "ollama": "^0.6.3", - "oxlint": "^1.36.0", + "oxlint": "^1.37.0", "oxlint-tsgolint": "^0.10.1", "quicktype-core": "^23.2.6", "rolldown": "1.0.0-beta.58", @@ -148,7 +152,8 @@ "@sinclair/typebox": "0.34.46" }, "patchedDependencies": { - "@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch" + "@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch", + "qrcode-terminal": "patches/qrcode-terminal.patch" } }, "vitest": { diff --git a/patches/@mariozechner__pi-ai.patch b/patches/@mariozechner__pi-ai.patch index b4cdf8e51..aa03fc55a 100644 --- a/patches/@mariozechner__pi-ai.patch +++ b/patches/@mariozechner__pi-ai.patch @@ -1,8 +1,52 @@ diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js -index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a6dd394ec 100644 +index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..56866774e47444b5d333961c9b20fce582363124 100644 --- a/dist/providers/google-shared.js +++ b/dist/providers/google-shared.js -@@ -51,9 +51,19 @@ export function convertMessages(model, context) { +@@ -10,13 +10,27 @@ import { transformMessages } from "./transorm-messages.js"; + export function convertMessages(model, context) { + const contents = []; + const transformedMessages = transformMessages(context.messages, model); ++ ++ /** ++ * Helper to add content while merging consecutive messages of the same role. ++ * Gemini/Cloud Code Assist requires strict role alternation (user/model/user/model). ++ * Consecutive messages of the same role cause "function call turn" errors. ++ */ ++ function addContent(role, parts) { ++ if (parts.length === 0) return; ++ const lastContent = contents[contents.length - 1]; ++ if (lastContent?.role === role) { ++ // Merge into existing message of same role ++ lastContent.parts.push(...parts); ++ } else { ++ contents.push({ role, parts }); ++ } ++ } ++ + for (const msg of transformedMessages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { +- contents.push({ +- role: "user", +- parts: [{ text: sanitizeSurrogates(msg.content) }], +- }); ++ addContent("user", [{ text: sanitizeSurrogates(msg.content) }]); + } + else { + const parts = msg.content.map((item) => { +@@ -35,10 +49,7 @@ export function convertMessages(model, context) { + const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts; + if (filteredParts.length === 0) + continue; +- contents.push({ +- role: "user", +- parts: filteredParts, +- }); ++ addContent("user", filteredParts); + } + } + else if (msg.role === "assistant") { +@@ -51,9 +62,19 @@ export function convertMessages(model, context) { parts.push({ text: sanitizeSurrogates(block.text) }); } else if (block.type === "thinking") { @@ -25,7 +69,7 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a parts.push({ thought: true, text: sanitizeSurrogates(block.thinking), -@@ -61,6 +71,7 @@ export function convertMessages(model, context) { +@@ -61,6 +82,7 @@ export function convertMessages(model, context) { }); } else { @@ -33,7 +77,44 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a parts.push({ text: `\n${sanitizeSurrogates(block.thinking)}\n`, }); -@@ -146,6 +157,77 @@ export function convertMessages(model, context) { +@@ -85,10 +107,7 @@ export function convertMessages(model, context) { + } + if (parts.length === 0) + continue; +- contents.push({ +- role: "model", +- parts, +- }); ++ addContent("model", parts); + } + else if (msg.role === "toolResult") { + // Extract text and image content +@@ -125,27 +144,94 @@ export function convertMessages(model, context) { + } + // Cloud Code Assist API requires all function responses to be in a single user turn. + // Check if the last content is already a user turn with function responses and merge. ++ // Use addContent for proper role alternation handling. + const lastContent = contents[contents.length - 1]; + if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) { + lastContent.parts.push(functionResponsePart); + } + else { +- contents.push({ +- role: "user", +- parts: [functionResponsePart], +- }); ++ addContent("user", [functionResponsePart]); + } + // For older models, add images in a separate user message ++ // Note: This may create consecutive user messages, but addContent will merge them + if (hasImages && !supportsMultimodalFunctionResponse) { +- contents.push({ +- role: "user", +- parts: [{ text: "Tool result image:" }, ...imageParts], +- }); ++ addContent("user", [{ text: "Tool result image:" }, ...imageParts]); + } + } } return contents; } @@ -111,7 +192,7 @@ index 7bc0a9f5d6241f191cd607ecb37b3acac8d58267..76166a34784cbc0718d4b9bd1fa6336a /** * Convert tools to Gemini function declarations format. */ -@@ -157,7 +239,7 @@ export function convertTools(tools) { +@@ -157,7 +243,7 @@ export function convertTools(tools) { functionDeclarations: tools.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/patches/@mariozechner__pi-coding-agent@0.32.3.patch b/patches/@mariozechner__pi-coding-agent@0.32.3.patch deleted file mode 100644 index a56be808a..000000000 --- a/patches/@mariozechner__pi-coding-agent@0.32.3.patch +++ /dev/null @@ -1,17 +0,0 @@ -diff --git a/dist/config.js b/dist/config.js -index 7caa66d2676933b102431ec8d92c571eb9d6d82c..77103b9d9573e56c26014c8c7c918e1f853afcdc 100644 ---- a/dist/config.js -+++ b/dist/config.js -@@ -10,8 +10,11 @@ const __dirname = dirname(__filename); - /** - * Detect if we're running as a Bun compiled binary. - * Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path) -+ * Some packaging workflows keep import.meta.url as a file:// path, so fall back to execPath next to package.json. - */ --export const isBunBinary = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN"); -+const bunBinaryByUrl = import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN"); -+const bunBinaryByExecPath = existsSync(join(dirname(process.execPath), "package.json")); -+export const isBunBinary = bunBinaryByUrl || bunBinaryByExecPath; - // ============================================================================= - // Package Asset Paths (shipped with executable) - // ============================================================================= diff --git a/patches/qrcode-terminal.patch b/patches/qrcode-terminal.patch new file mode 100644 index 000000000..96929c20b --- /dev/null +++ b/patches/qrcode-terminal.patch @@ -0,0 +1,12 @@ +diff --git a/lib/main.js b/lib/main.js +index 488cc1aea9802b3d6ae13aee27556403bec55d1c..3de1f934868d81e8204f00e6a4bf2696a05f7340 100644 +--- a/lib/main.js ++++ b/lib/main.js +@@ -1,5 +1,5 @@ +-var QRCode = require('./../vendor/QRCode'), +- QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'), ++var QRCode = require('./../vendor/QRCode/index.js'), ++ QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'), + black = "\033[40m \033[0m", + white = "\033[47m \033[0m", + toCell = function (isBlack) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54d27a002..82dcf8793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,11 @@ overrides: patchedDependencies: '@mariozechner/pi-ai': - hash: 628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5 + hash: b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a path: patches/@mariozechner__pi-ai.patch + qrcode-terminal: + hash: ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12 + path: patches/qrcode-terminal.patch importers: @@ -26,17 +29,17 @@ importers: specifier: ^1.3.4 version: 1.3.4 '@mariozechner/pi-agent-core': - specifier: ^0.36.0 - version: 0.36.0(ws@8.18.3)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': - specifier: ^0.36.0 - version: 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': - specifier: ^0.36.0 - version: 0.36.0(ws@8.18.3)(zod@4.3.5) + specifier: ^0.37.2 + version: 0.37.2(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': - specifier: ^0.36.0 - version: 0.36.0 + specifier: ^0.37.2 + version: 0.37.2 '@sinclair/typebox': specifier: 0.34.46 version: 0.34.46 @@ -100,9 +103,12 @@ importers: playwright-core: specifier: 1.57.0 version: 1.57.0 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 qrcode-terminal: specifier: ^0.12.0 - version: 0.12.0 + version: 0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12) sharp: specifier: ^0.34.5 version: 0.34.5 @@ -110,11 +116,11 @@ importers: specifier: ^4.10.2 version: 4.10.2 undici: - specifier: ^7.16.0 - version: 7.16.0 + specifier: ^7.18.0 + version: 7.18.0 ws: - specifier: ^8.18.3 - version: 8.18.3 + specifier: ^8.19.0 + version: 8.19.0 zod: specifier: ^4.3.5 version: 4.3.5 @@ -143,6 +149,9 @@ importers: '@types/node': specifier: ^25.0.3 version: 25.0.3 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/qrcode-terminal': specifier: ^0.12.2 version: 0.12.2 @@ -171,8 +180,8 @@ importers: specifier: ^0.6.3 version: 0.6.3 oxlint: - specifier: ^1.36.0 - version: 1.36.0(oxlint-tsgolint@0.10.1) + specifier: ^1.37.0 + version: 1.37.0(oxlint-tsgolint@0.10.1) oxlint-tsgolint: specifier: ^0.10.1 version: 0.10.1 @@ -801,22 +810,22 @@ packages: peerDependencies: lit: ^3.3.1 - '@mariozechner/pi-agent-core@0.36.0': - resolution: {integrity: sha512-86BI1/j/MLxQHSWRXVLz8+NuSmDvLQebNb40+lFDI9XI9YBh8+r5fkYgU43u4j2TvANZ7iW6SFFnhWhzy8y6dg==} + '@mariozechner/pi-agent-core@0.37.2': + resolution: {integrity: sha512-GAN1lDVmlY1yH/FCfvpH29f2WBoqqMQkda7zKthOJO9l8tagxnlCWtq078CjzUGYlTDhKSf388XlOuDByBGYLA==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.36.0': - resolution: {integrity: sha512-xkzTgvdMzAZ/L/TgMH8z9Zi+aH0EWc54l5ygiafwvCgDk7xvfbylQG6pa9yn5zEn9T4NF9byJNk+nMHnycZvMQ==} + '@mariozechner/pi-ai@0.37.2': + resolution: {integrity: sha512-IhhvlPrgkdrlbS7QnV+qJPmlzKyae/aI1kenclG18/dXCypxUU50OuzGoVwrXvXw/RIHRwodhd7w4IH38Z7W4Q==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.36.0': - resolution: {integrity: sha512-lKdpuGE0yVs/96GnDhrPLEEFhRteHRtnkfX04KIBpcsEXXg2vyAlpxtjtZ9nlhYqLLIY7qJRkeyjbhcFFfbAAA==} + '@mariozechner/pi-coding-agent@0.37.2': + resolution: {integrity: sha512-wRFqcyY76h4mONO1si2oAn9WVKnhmVV28dPHjQXVPrl7uSwMCLn+Fcde/nmbL29pYfiU1il4GmUR+iSyoxBUVQ==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-tui@0.36.0': - resolution: {integrity: sha512-4n+nmTd36q0AVCbqWmjtTHTjIEwlGayKKhc+4QbpN9U3Z9jyQQa8Za1P2OHRmi6Jeu+ISuf4VBDvgmgCaxPZYg==} + '@mariozechner/pi-tui@0.37.2': + resolution: {integrity: sha512-XNV+jEeWJxQ8U3r5njRotVs6DnEIunkLHSA4nnF4OaRRgrcsafD8M4Pm/3RywSucclVK8P7+KoGiBB2Lokkmuw==} engines: {node: '>=20.0.0'} '@mistralai/mistralai@1.10.0': @@ -870,43 +879,43 @@ packages: cpu: [x64] os: [win32] - '@oxlint/darwin-arm64@1.36.0': - resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==} + '@oxlint/darwin-arm64@1.37.0': + resolution: {integrity: sha512-qDa8qf4Th3sbk6P6wRbsv5paGeZ8EEOy8PtnT2IkAYSzjDHavw8nMK/lQvf6uS7LArjcmOfM1Y3KnZUFoNZZqg==} cpu: [arm64] os: [darwin] - '@oxlint/darwin-x64@1.36.0': - resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==} + '@oxlint/darwin-x64@1.37.0': + resolution: {integrity: sha512-FM0h0KyOQ4HCdhIX1ne6d80BxRra75h1ORce0jYNwQ49HT4RU8+9ywSMC7rQ79xWsmaahvkQPB7tMPyfjsQwAg==} cpu: [x64] os: [darwin] - '@oxlint/linux-arm64-gnu@1.36.0': - resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==} + '@oxlint/linux-arm64-gnu@1.37.0': + resolution: {integrity: sha512-2axK0lftGwM6Q7wOuY2sassUqa4MKrG3iemVVyEpXzJ6g5QosxhCoFPp9v81/gmLT5kAdd2gskoDcfpDJliDNw==} cpu: [arm64] os: [linux] - '@oxlint/linux-arm64-musl@1.36.0': - resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==} + '@oxlint/linux-arm64-musl@1.37.0': + resolution: {integrity: sha512-f3YROyGMIdUeXx0yD7RsAUBzBvD222D4l2GQRYF3AMxyp9mya17Rq/3wNLR4JDnAnboOul3DAEKNm+09lo3uZw==} cpu: [arm64] os: [linux] - '@oxlint/linux-x64-gnu@1.36.0': - resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==} + '@oxlint/linux-x64-gnu@1.37.0': + resolution: {integrity: sha512-FANOdOVQ2c4acYLM0dvtSoKELHSSnDBxDdm8OlXNzSRanQILrNpLgUqCXHFsfiHipFfNzz3Z417PxV6X4aBYog==} cpu: [x64] os: [linux] - '@oxlint/linux-x64-musl@1.36.0': - resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==} + '@oxlint/linux-x64-musl@1.37.0': + resolution: {integrity: sha512-eYnSKT9knXdOQ9h+6nSjEHSx0+pW8PkGwtMNGXtCYR+/ZPKYIbtZVS0nZsFy+qizP+TRVSJrgc/JY3Xr0wjcQg==} cpu: [x64] os: [linux] - '@oxlint/win32-arm64@1.36.0': - resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==} + '@oxlint/win32-arm64@1.37.0': + resolution: {integrity: sha512-2oHxNc4jcocfNWGWVVWQdEG+reZ5ncBZsmDoICJQ1rbCDx4Yimx8VUf1Ub9cCoJRcPiSLBxMqaeMaDClKixJIQ==} cpu: [arm64] os: [win32] - '@oxlint/win32-x64@1.36.0': - resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==} + '@oxlint/win32-x64@1.37.0': + resolution: {integrity: sha512-w+pBuTjGmGCGPhDjFhj/97K2tlGyq5LKAU6S7FHxROPuJRWJD6uio1L75Lsb8fKhwtw2rm54LLOX30Yi+nILxw==} cpu: [x64] os: [win32] @@ -1269,6 +1278,9 @@ packages: '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode-terminal@0.12.2': resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} @@ -1281,6 +1293,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -1951,8 +1966,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hookified@1.14.0: - resolution: {integrity: sha512-pi1ynXIMFx/uIIwpWJ/5CEtOHLGtnUB0WhGeeYT+fKcQ+WCQbm3/rrkAXnpfph++PgepNqPdTC2WTj8A6k6zoQ==} + hookified@1.15.0: + resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==} html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2418,8 +2433,8 @@ packages: resolution: {integrity: sha512-EEHNdo5cW2w1xwYdBQ7d3IXDqWAtMkfVFrh+9gQ4kYbYJwygY4QXSh1eH80/xVipZdVKujAwBgg/nNNHk56kxQ==} hasBin: true - oxlint@1.36.0: - resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==} + oxlint@1.37.0: + resolution: {integrity: sha512-MAw0JH8M5/vt9E2WxSsmJu53bVLmG6qNlVw1OXFenJYItTPbMBtW7j3n53+tgNhNuxFPundM1DR7V8E39qOOrg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2436,8 +2451,8 @@ packages: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} - p-queue@9.0.1: - resolution: {integrity: sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==} + p-queue@9.1.0: + resolution: {integrity: sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==} engines: {node: '>=20'} p-retry@4.6.2: @@ -2933,8 +2948,8 @@ packages: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} engines: {node: '>=18.17'} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + undici@7.18.0: + resolution: {integrity: sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ==} engines: {node: '>=20.18.1'} unicode-properties@1.4.1: @@ -3073,8 +3088,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -3186,13 +3201,13 @@ snapshots: dependencies: '@cacheable/utils': 2.3.3 '@keyv/bigmap': 1.3.0(keyv@5.5.5) - hookified: 1.14.0 + hookified: 1.15.0 keyv: 5.5.5 '@cacheable/node-cache@1.7.6': dependencies: cacheable: 2.3.1 - hookified: 1.14.0 + hookified: 1.15.0 keyv: 5.5.5 '@cacheable/utils@2.3.3': @@ -3290,7 +3305,7 @@ snapshots: '@vladfrangu/async_event_emitter': 2.4.7 discord-api-types: 0.38.37 tslib: 2.8.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -3397,7 +3412,7 @@ snapshots: '@google/genai@1.34.0': dependencies: google-auth-library: 10.5.0 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - supports-color @@ -3548,7 +3563,7 @@ snapshots: '@keyv/bigmap@1.3.0(keyv@5.5.5)': dependencies: hashery: 1.4.0 - hookified: 1.14.0 + hookified: 1.15.0 keyv: 5.5.5 '@keyv/serialize@1.1.1': {} @@ -3585,10 +3600,10 @@ snapshots: transitivePeerDependencies: - tailwindcss - '@mariozechner/pi-agent-core@0.36.0(ws@8.18.3)(zod@4.3.5)': + '@mariozechner/pi-agent-core@0.37.2(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5) - '@mariozechner/pi-tui': 0.36.0 + '@mariozechner/pi-ai': 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-tui': 0.37.2 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -3597,7 +3612,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5)': + '@mariozechner/pi-ai@0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@google/genai': 1.34.0 @@ -3606,7 +3621,7 @@ snapshots: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) chalk: 5.6.2 - openai: 6.10.0(ws@8.18.3)(zod@4.3.5) + openai: 6.10.0(ws@8.19.0)(zod@4.3.5) partial-json: 0.1.7 zod-to-json-schema: 3.25.1(zod@4.3.5) transitivePeerDependencies: @@ -3617,12 +3632,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.36.0(ws@8.18.3)(zod@4.3.5)': + '@mariozechner/pi-coding-agent@0.37.2(ws@8.19.0)(zod@4.3.5)': dependencies: '@crosscopy/clipboard': 0.2.8 - '@mariozechner/pi-agent-core': 0.36.0(ws@8.18.3)(zod@4.3.5) - '@mariozechner/pi-ai': 0.36.0(patch_hash=628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5)(ws@8.18.3)(zod@4.3.5) - '@mariozechner/pi-tui': 0.36.0 + '@mariozechner/pi-agent-core': 0.37.2(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.37.2(patch_hash=b49275c3e2023970d8248ababef6df60e093e58a3ba3127c2ba4de1df387d06a)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-tui': 0.37.2 chalk: 5.6.2 cli-highlight: 2.1.11 diff: 8.0.2 @@ -3630,6 +3645,7 @@ snapshots: glob: 11.1.0 jiti: 2.6.1 marked: 15.0.12 + proper-lockfile: 4.1.2 sharp: 0.34.5 transitivePeerDependencies: - '@modelcontextprotocol/sdk' @@ -3639,7 +3655,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.36.0': + '@mariozechner/pi-tui@0.37.2': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -3691,28 +3707,28 @@ snapshots: '@oxlint-tsgolint/win32-x64@0.10.1': optional: true - '@oxlint/darwin-arm64@1.36.0': + '@oxlint/darwin-arm64@1.37.0': optional: true - '@oxlint/darwin-x64@1.36.0': + '@oxlint/darwin-x64@1.37.0': optional: true - '@oxlint/linux-arm64-gnu@1.36.0': + '@oxlint/linux-arm64-gnu@1.37.0': optional: true - '@oxlint/linux-arm64-musl@1.36.0': + '@oxlint/linux-arm64-musl@1.37.0': optional: true - '@oxlint/linux-x64-gnu@1.36.0': + '@oxlint/linux-x64-gnu@1.37.0': optional: true - '@oxlint/linux-x64-musl@1.36.0': + '@oxlint/linux-x64-musl@1.37.0': optional: true - '@oxlint/win32-arm64@1.36.0': + '@oxlint/win32-arm64@1.37.0': optional: true - '@oxlint/win32-x64@1.36.0': + '@oxlint/win32-x64@1.37.0': optional: true '@pinojs/redact@0.4.0': {} @@ -3907,7 +3923,7 @@ snapshots: '@types/node': 25.0.3 '@types/ws': 8.18.1 eventemitter3: 5.0.1 - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - debug @@ -4035,6 +4051,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/qrcode-terminal@0.12.2': {} '@types/qs@6.14.0': {} @@ -4043,6 +4063,8 @@ snapshots: '@types/retry@0.12.0': {} + '@types/retry@0.12.5': {} + '@types/send@1.2.1': dependencies: '@types/node': 25.0.3 @@ -4094,7 +4116,7 @@ snapshots: sirv: 3.0.2 tinyrainbow: 3.0.3 vitest: 4.0.16(@types/node@25.0.3)(@vitest/browser-playwright@4.0.16)(@vitest/browser-preview@4.0.16)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - ws: 8.18.3 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - msw @@ -4196,11 +4218,11 @@ snapshots: libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' lru-cache: 11.2.4 music-metadata: 11.10.4 - p-queue: 9.0.1 + p-queue: 9.1.0 pino: 9.14.0 protobufjs: 7.5.4 sharp: 0.34.5 - ws: 8.18.3 + ws: 8.19.0 optionalDependencies: audio-decode: 2.2.3 transitivePeerDependencies: @@ -4361,7 +4383,7 @@ snapshots: dependencies: '@cacheable/memory': 2.0.7 '@cacheable/utils': 2.3.3 - hookified: 1.14.0 + hookified: 1.15.0 keyv: 5.5.5 qified: 0.5.3 @@ -4838,7 +4860,7 @@ snapshots: hashery@1.4.0: dependencies: - hookified: 1.14.0 + hookified: 1.15.0 hasown@2.0.2: dependencies: @@ -4848,7 +4870,7 @@ snapshots: highlight.js@11.11.1: {} - hookified@1.14.0: {} + hookified@1.15.0: {} html-escaper@2.0.2: {} @@ -5256,9 +5278,9 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.10.0(ws@8.18.3)(zod@4.3.5): + openai@6.10.0(ws@8.19.0)(zod@4.3.5): optionalDependencies: - ws: 8.18.3 + ws: 8.19.0 zod: 4.3.5 opus-decoder@0.7.11: @@ -5275,16 +5297,16 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.10.1 '@oxlint-tsgolint/win32-x64': 0.10.1 - oxlint@1.36.0(oxlint-tsgolint@0.10.1): + oxlint@1.37.0(oxlint-tsgolint@0.10.1): optionalDependencies: - '@oxlint/darwin-arm64': 1.36.0 - '@oxlint/darwin-x64': 1.36.0 - '@oxlint/linux-arm64-gnu': 1.36.0 - '@oxlint/linux-arm64-musl': 1.36.0 - '@oxlint/linux-x64-gnu': 1.36.0 - '@oxlint/linux-x64-musl': 1.36.0 - '@oxlint/win32-arm64': 1.36.0 - '@oxlint/win32-x64': 1.36.0 + '@oxlint/darwin-arm64': 1.37.0 + '@oxlint/darwin-x64': 1.37.0 + '@oxlint/linux-arm64-gnu': 1.37.0 + '@oxlint/linux-arm64-musl': 1.37.0 + '@oxlint/linux-x64-gnu': 1.37.0 + '@oxlint/linux-x64-musl': 1.37.0 + '@oxlint/win32-arm64': 1.37.0 + '@oxlint/win32-x64': 1.37.0 oxlint-tsgolint: 0.10.1 p-finally@1.0.0: {} @@ -5294,7 +5316,7 @@ snapshots: eventemitter3: 4.0.7 p-timeout: 3.2.0 - p-queue@9.0.1: + p-queue@9.1.0: dependencies: eventemitter3: 5.0.1 p-timeout: 7.0.1 @@ -5453,14 +5475,14 @@ snapshots: qified@0.5.3: dependencies: - hookified: 1.14.0 + hookified: 1.15.0 qoa-format@1.0.1: dependencies: '@thi.ng/bitstream': 2.4.37 optional: true - qrcode-terminal@0.12.0: {} + qrcode-terminal@0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12): {} qs@6.14.1: dependencies: @@ -5876,7 +5898,7 @@ snapshots: undici@6.21.3: {} - undici@7.16.0: {} + undici@7.18.0: {} unicode-properties@1.4.1: dependencies: @@ -5995,7 +6017,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.3: {} + ws@8.19.0: {} y18n@5.0.8: {} diff --git a/scripts/e2e/Dockerfile.qr-import b/scripts/e2e/Dockerfile.qr-import new file mode 100644 index 000000000..c2370044d --- /dev/null +++ b/scripts/e2e/Dockerfile.qr-import @@ -0,0 +1,9 @@ +FROM node:22-bookworm + +RUN corepack enable + +WORKDIR /app + +COPY . . + +RUN pnpm install --frozen-lockfile diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 42f9c3f7e..f132b82f7 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -10,6 +10,7 @@ docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" echo "Running onboarding E2E..." docker run --rm -t "$IMAGE_NAME" bash -lc ' set -euo pipefail + trap "" PIPE export TERM=xterm-256color # Provide a minimal trash shim to avoid noisy "missing trash" logs in containers. @@ -37,7 +38,7 @@ TRASH local delay="${2:-0.4}" # Let prompts render before sending keystrokes. sleep "$delay" - printf "%b" "$payload" >&3 + printf "%b" "$payload" >&3 2>/dev/null || true } start_gateway() { @@ -134,6 +135,8 @@ TRASH send $'"'"'\e[B'"'"' 0.6 send $'"'"'\e[B'"'"' 0.6 send $'"'"'\e[B'"'"' 0.6 + send $'"'"'\e[B'"'"' 0.6 + send $'"'"'\e[B'"'"' 0.6 send $'"'"'\r'"'"' 0.6 send $'"'"'\r'"'"' 0.5 send $'"'"'\r'"'"' 0.5 @@ -170,7 +173,7 @@ TRASH # Configure providers now? (default Yes) send $'"'"'\r'"'"' 0.8 send "" 0.8 - # Select Telegram, Discord, Signal. + # Select Telegram, Discord, Slack. send $'"'"'\e[B'"'"' 0.4 send $'"'"' '"'"' 0.4 send $'"'"'\e[B'"'"' 0.4 @@ -178,11 +181,14 @@ TRASH send $'"'"'\e[B'"'"' 0.4 send $'"'"' '"'"' 0.4 send $'"'"'\r'"'"' 0.6 - send $'"'"'tg_token\r'"'"' 0.6 - send $'"'"'discord_token\r'"'"' 0.6 - send $'"'"'n\r'"'"' 0.6 - send $'"'"'+15551234567\r'"'"' 0.6 - send $'"'"'n\r'"'"' 0.6 + send $'"'"'tg_token\r'"'"' 0.8 + send $'"'"'discord_token\r'"'"' 0.8 + send "" 0.6 + send $'"'"'\r'"'"' 0.6 + send "" 0.6 + send $'"'"'slack_bot\r'"'"' 0.8 + send "" 0.6 + send $'"'"'slack_app\r'"'"' 0.8 } send_skills_flow() { @@ -393,11 +399,11 @@ if (cfg?.telegram?.botToken !== "tg_token") { if (cfg?.discord?.token !== "discord_token") { errors.push(`discord.token mismatch (got ${cfg?.discord?.token ?? "unset"})`); } -if (cfg?.signal?.account !== "+15551234567") { - errors.push(`signal.account mismatch (got ${cfg?.signal?.account ?? "unset"})`); +if (cfg?.slack?.botToken !== "slack_bot") { + errors.push(`slack.botToken mismatch (got ${cfg?.slack?.botToken ?? "unset"})`); } -if (cfg?.signal?.cliPath !== "signal-cli") { - errors.push(`signal.cliPath mismatch (got ${cfg?.signal?.cliPath ?? "unset"})`); +if (cfg?.slack?.appToken !== "slack_app") { + errors.push(`slack.appToken mismatch (got ${cfg?.slack?.appToken ?? "unset"})`); } if (cfg?.wizard?.lastRunMode !== "local") { errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`); diff --git a/scripts/e2e/qr-import-docker.sh b/scripts/e2e/qr-import-docker.sh new file mode 100755 index 000000000..036a996a1 --- /dev/null +++ b/scripts/e2e/qr-import-docker.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +IMAGE_NAME="${CLAWDBOT_QR_SMOKE_IMAGE:-clawdbot-qr-smoke}" + +echo "Building Docker image..." +docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile.qr-import" "$ROOT_DIR" + +echo "Running qrcode-terminal import smoke..." +docker run --rm -t "$IMAGE_NAME" node -e "import('qrcode-terminal').then((m)=>m.default.generate('qr-smoke',{small:true}))" diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 000000000..f849c02fd --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,110 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function isBunInstall() { + const ua = process.env.npm_config_user_agent ?? ""; + return ua.includes("bun/"); +} + +function getRepoRoot() { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, ".."); +} + +function run(cmd, args, opts = {}) { + const res = spawnSync(cmd, args, { stdio: "inherit", ...opts }); + if (typeof res.status === "number") return res.status; + return 1; +} + +function applyPatchIfNeeded(opts) { + const patchPath = path.resolve(opts.patchPath); + if (!fs.existsSync(patchPath)) { + throw new Error(`missing patch: ${patchPath}`); + } + + let targetDir = path.resolve(opts.targetDir); + if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) { + console.warn(`[postinstall] skip missing target: ${targetDir}`); + return; + } + + // Resolve symlinks to avoid "beyond a symbolic link" errors from git apply + // (bun/pnpm use symlinks in node_modules) + targetDir = fs.realpathSync(targetDir); + + const gitArgsBase = ["apply", "--unsafe-paths", "--whitespace=nowarn"]; + const reverseCheck = [ + ...gitArgsBase, + "--reverse", + "--check", + "--directory", + targetDir, + patchPath, + ]; + const forwardCheck = [ + ...gitArgsBase, + "--check", + "--directory", + targetDir, + patchPath, + ]; + const apply = [...gitArgsBase, "--directory", targetDir, patchPath]; + + // Already applied? + if (run("git", reverseCheck, { stdio: "ignore" }) === 0) { + return; + } + + if (run("git", forwardCheck, { stdio: "ignore" }) !== 0) { + throw new Error(`patch does not apply cleanly: ${path.basename(patchPath)}`); + } + + const status = run("git", apply); + if (status !== 0) { + throw new Error(`failed applying patch: ${path.basename(patchPath)}`); + } +} + +function extractPackageName(key) { + if (key.startsWith("@")) { + const idx = key.indexOf("@", 1); + if (idx === -1) return key; + return key.slice(0, idx); + } + const idx = key.lastIndexOf("@"); + if (idx <= 0) return key; + return key.slice(0, idx); +} + +function main() { + if (!isBunInstall()) return; + + const repoRoot = getRepoRoot(); + process.chdir(repoRoot); + + const pkgPath = path.join(repoRoot, "package.json"); + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + const patched = pkg?.pnpm?.patchedDependencies ?? {}; + + // Bun does not support pnpm.patchedDependencies. Apply these patch files to + // node_modules packages as a best-effort compatibility layer. + for (const [key, relPatchPath] of Object.entries(patched)) { + if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue; + const pkgName = extractPackageName(String(key)); + if (!pkgName) continue; + applyPatchIfNeeded({ + targetDir: path.join("node_modules", ...pkgName.split("/")), + patchPath: relPatchPath, + }); + } +} + +try { + main(); +} catch (err) { + console.error(String(err)); + process.exit(1); +} diff --git a/showcase.md b/showcase.md new file mode 100644 index 000000000..91ec938ce --- /dev/null +++ b/showcase.md @@ -0,0 +1,33 @@ +# Showcase: what your personal assistant can do + +Highlights from #showcase (Jan 2–5, 2026). Curated for “wow” factor + concrete links. + +## Automation & real-world outcomes +- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill +- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api +- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn (link check pending) +- **Accounting intake** — Collect PDFs from email, prep for tax consultant (monthly accounting batch). (No link shared.) + +## Knowledge & memory systems +- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.) +- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search +- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.) + +## Voice, docs, and assistants on the phone +- **Clawdia phone bridge** — Vapi voice assistant ↔ Clawdis HTTP bridge; near‑real‑time phone calls. https://github.com/alejandroOPI/clawdia-bridge +- **Google Docs edit skill** — Rich‑text editing skill built fast with Claude Code. (No link shared.) +- **OpenRouter transcription skill** — Multi‑lingual audio transcription via OpenRouter (Gemini etc). ClawdHub: https://clawdhub.com/obviyus/openrouter-transcribe (user/slug link) + +## Infrastructure & deployment +- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon +- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant +- **Nix packaging** — Batteries‑included nixified clawdis config. https://github.com/joshp123/nix-clawdis +- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar + +## Home + hardware +- **Roborock integration** — Plugin for robot vacuum control. https://github.com/joshp123/gohome/tree/main/plugins/roborock + +## Community builds (non‑Clawdis but made with/around it) +- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/ + +--- diff --git a/skills/1password/SKILL.md b/skills/1password/SKILL.md new file mode 100644 index 000000000..7aea6b8c1 --- /dev/null +++ b/skills/1password/SKILL.md @@ -0,0 +1,49 @@ +--- +name: 1password +description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op. +homepage: https://developer.1password.com/docs/cli/get-started/ +metadata: {"clawdbot":{"emoji":"🔐","requires":{"bins":["op"]},"install":[{"id":"brew","kind":"brew","formula":"1password-cli","bins":["op"],"label":"Install 1Password CLI (brew)"}]}} +--- + +# 1Password CLI + +Follow the official CLI get-started steps. Don't guess install commands. + +## References + +- `references/get-started.md` (install + app integration + sign-in flow) +- `references/cli-examples.md` (real `op` examples) + +## Workflow + +1. Check OS + shell. +2. Verify CLI present: `op --version`. +3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked. +4. Sign in / authorize this terminal: `op signin` (expect an app prompt). +5. If multiple accounts: use `--account` or `OP_ACCOUNT`. +6. Verify access: `op whoami` or `op account list`. + +## Avoid repeated auth prompts (tmux) + +The bash tool uses a fresh TTY per command, so app integration may prompt every time. To reuse authorization, run multiple `op` commands inside a single tmux session. + +Example (see `tmux` skill for socket conventions): + +```bash +SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}" +mkdir -p "$SOCKET_DIR" +SOCKET="$SOCKET_DIR/clawdbot.sock" +SESSION=op-auth + +tmux -S "$SOCKET" new -d -s "$SESSION" -n shell +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter +tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter +tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200 +``` + +## Guardrails + +- Never paste secrets into logs, chat, or code. +- Prefer `op run` / `op inject` over writing secrets to disk. +- If sign-in without app integration is needed, use `op account add`. +- If a command returns "account is not signed in", re-run `op signin` and authorize in the app. diff --git a/skills/1password/references/cli-examples.md b/skills/1password/references/cli-examples.md new file mode 100644 index 000000000..c8da0972b --- /dev/null +++ b/skills/1password/references/cli-examples.md @@ -0,0 +1,29 @@ +# op CLI examples (from op help) + +## Sign in + +- `op signin` +- `op signin --account ` + +## Read + +- `op read op://app-prod/db/password` +- `op read "op://app-prod/db/one-time password?attribute=otp"` +- `op read "op://app-prod/ssh key/private key?ssh-format=openssh"` +- `op read --out-file ./key.pem op://app-prod/server/ssh/key.pem` + +## Run + +- `export DB_PASSWORD="op://app-prod/db/password"` +- `op run --no-masking -- printenv DB_PASSWORD` +- `op run --env-file="./.env" -- printenv DB_PASSWORD` + +## Inject + +- `echo "db_password: {{ op://app-prod/db/password }}" | op inject` +- `op inject -i config.yml.tpl -o config.yml` + +## Whoami / accounts + +- `op whoami` +- `op account list` diff --git a/skills/1password/references/get-started.md b/skills/1password/references/get-started.md new file mode 100644 index 000000000..3c60f75ce --- /dev/null +++ b/skills/1password/references/get-started.md @@ -0,0 +1,17 @@ +# 1Password CLI get-started (summary) + +- Works on macOS, Windows, and Linux. + - macOS/Linux shells: bash, zsh, sh, fish. + - Windows shell: PowerShell. +- Requires a 1Password subscription and the desktop app to use app integration. +- macOS requirement: Big Sur 11.0.0 or later. +- Linux app integration requires PolKit + an auth agent. +- Install the CLI per the official doc for your OS. +- Enable desktop app integration in the 1Password app: + - Open and unlock the app, then select your account/collection. + - macOS: Settings > Developer > Integrate with 1Password CLI (Touch ID optional). + - Windows: turn on Windows Hello, then Settings > Developer > Integrate. + - Linux: Settings > Security > Unlock using system authentication, then Settings > Developer > Integrate. +- After integration, run any command to sign in (example in docs: `op vault list`). +- If multiple accounts: use `op signin` to pick one, or `--account` / `OP_ACCOUNT`. +- For non-integration auth, use `op account add`. diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md index 814ce326b..a36c21f64 100644 --- a/skills/nano-banana-pro/SKILL.md +++ b/skills/nano-banana-pro/SKILL.md @@ -26,4 +26,5 @@ API key Notes - Resolutions: `1K` (default), `2K`, `4K`. - Use timestamps in filenames: `yyyy-mm-dd-hh-mm-ss-name.png`. +- The script prints a `MEDIA:` line for Clawdbot to auto-attach on supported chat providers. - Do not read the image back; report the saved path only. diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index b3dbf30ba..48dd9e9e5 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -154,6 +154,8 @@ def main(): if image_saved: full_path = output_path.resolve() print(f"\nImage saved: {full_path}") + # Clawdbot parses MEDIA tokens and will attach the file on supported providers. + print(f"MEDIA: {full_path}") else: print("Error: No image was generated in the response.", file=sys.stderr) sys.exit(1) diff --git a/src/agents/agent-paths.ts b/src/agents/agent-paths.ts index 98a02d3b0..2fe019e75 100644 --- a/src/agents/agent-paths.ts +++ b/src/agents/agent-paths.ts @@ -1,14 +1,13 @@ import path from "node:path"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; - -const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent"); +import { resolveConfigDir, resolveUserPath } from "../utils.js"; export function resolveClawdbotAgentDir(): string { + const defaultAgentDir = path.join(resolveConfigDir(), "agent"); const override = process.env.CLAWDBOT_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim() || - DEFAULT_AGENT_DIR; + defaultAgentDir; return resolveUserPath(override); } diff --git a/src/agents/auth-profiles.test.ts b/src/agents/auth-profiles.test.ts new file mode 100644 index 000000000..493f2c09d --- /dev/null +++ b/src/agents/auth-profiles.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + type AuthProfileStore, + resolveAuthProfileOrder, +} from "./auth-profiles.js"; + +describe("resolveAuthProfileOrder", () => { + const store: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "sk-work", + }, + }, + }; + const cfg = { + auth: { + profiles: { + "anthropic:default": { provider: "anthropic", mode: "api_key" }, + "anthropic:work": { provider: "anthropic", mode: "api_key" }, + }, + }, + }; + + it("uses stored profiles when no config exists", () => { + const order = resolveAuthProfileOrder({ + store, + provider: "anthropic", + }); + expect(order).toEqual(["anthropic:default", "anthropic:work"]); + }); + + it("prioritizes preferred profiles", () => { + const order = resolveAuthProfileOrder({ + cfg, + store, + provider: "anthropic", + preferredProfile: "anthropic:work", + }); + expect(order[0]).toBe("anthropic:work"); + expect(order).toContain("anthropic:default"); + }); + + it("prioritizes last-good profile when no preferred override", () => { + const order = resolveAuthProfileOrder({ + cfg, + store: { ...store, lastGood: { anthropic: "anthropic:work" } }, + provider: "anthropic", + }); + expect(order[0]).toBe("anthropic:work"); + }); + + it("uses explicit profiles when order is missing", () => { + const order = resolveAuthProfileOrder({ + cfg, + store, + provider: "anthropic", + }); + expect(order).toEqual(["anthropic:default", "anthropic:work"]); + }); + + it("uses configured order when provided", () => { + const order = resolveAuthProfileOrder({ + cfg: { + auth: { + order: { anthropic: ["anthropic:work", "anthropic:default"] }, + profiles: cfg.auth.profiles, + }, + }, + store, + provider: "anthropic", + }); + expect(order).toEqual(["anthropic:work", "anthropic:default"]); + }); + + it("prioritizes oauth profiles when order missing", () => { + const mixedStore: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: "sk-default", + }, + "anthropic:oauth": { + type: "oauth", + provider: "anthropic", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + }, + }; + const order = resolveAuthProfileOrder({ + store: mixedStore, + provider: "anthropic", + }); + expect(order).toEqual(["anthropic:oauth", "anthropic:default"]); + }); +}); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts new file mode 100644 index 000000000..f390061dc --- /dev/null +++ b/src/agents/auth-profiles.ts @@ -0,0 +1,469 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + getOAuthApiKey, + type OAuthCredentials, + type OAuthProvider, +} from "@mariozechner/pi-ai"; +import lockfile from "proper-lockfile"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveOAuthPath } from "../config/paths.js"; +import { resolveUserPath } from "../utils.js"; +import { resolveClawdbotAgentDir } from "./agent-paths.js"; + +const AUTH_STORE_VERSION = 1; +const AUTH_PROFILE_FILENAME = "auth-profiles.json"; +const LEGACY_AUTH_FILENAME = "auth.json"; + +export type ApiKeyCredential = { + type: "api_key"; + provider: string; + key: string; + email?: string; +}; + +export type OAuthCredential = OAuthCredentials & { + type: "oauth"; + provider: OAuthProvider; + email?: string; +}; + +export type AuthProfileCredential = ApiKeyCredential | OAuthCredential; + +export type AuthProfileStore = { + version: number; + profiles: Record; + lastGood?: Record; +}; + +type LegacyAuthStore = Record; + +function resolveAuthStorePath(): string { + const agentDir = resolveClawdbotAgentDir(); + return path.join(agentDir, AUTH_PROFILE_FILENAME); +} + +function resolveLegacyAuthStorePath(): string { + const agentDir = resolveClawdbotAgentDir(); + return path.join(agentDir, LEGACY_AUTH_FILENAME); +} + +function loadJsonFile(pathname: string): unknown { + try { + if (!fs.existsSync(pathname)) return undefined; + const raw = fs.readFileSync(pathname, "utf8"); + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } +} + +function saveJsonFile(pathname: string, data: unknown) { + const dir = path.dirname(pathname); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(pathname, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + fs.chmodSync(pathname, 0o600); +} + +function ensureAuthStoreFile(pathname: string) { + if (fs.existsSync(pathname)) return; + const payload: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: {}, + }; + saveJsonFile(pathname, payload); +} + +function buildOAuthApiKey( + provider: OAuthProvider, + credentials: OAuthCredentials, +): string { + const needsProjectId = + provider === "google-gemini-cli" || provider === "google-antigravity"; + return needsProjectId + ? JSON.stringify({ + token: credentials.access, + projectId: credentials.projectId, + }) + : credentials.access; +} + +async function refreshOAuthTokenWithLock(params: { + profileId: string; + provider: OAuthProvider; +}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { + const authPath = resolveAuthStorePath(); + ensureAuthStoreFile(authPath); + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(authPath, { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10_000, + randomize: true, + }, + stale: 30_000, + }); + + const store = ensureAuthProfileStore(); + const cred = store.profiles[params.profileId]; + if (!cred || cred.type !== "oauth") return null; + + if (Date.now() < cred.expires) { + return { + apiKey: buildOAuthApiKey(cred.provider, cred), + newCredentials: cred, + }; + } + + const oauthCreds: Record = { + [cred.provider]: cred, + }; + const result = await getOAuthApiKey(cred.provider, oauthCreds); + if (!result) return null; + store.profiles[params.profileId] = { + ...cred, + ...result.newCredentials, + type: "oauth", + }; + saveAuthProfileStore(store); + return result; + } finally { + if (release) { + try { + await release(); + } catch { + // ignore unlock errors + } + } + } +} + +function coerceLegacyStore(raw: unknown): LegacyAuthStore | null { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + if ("profiles" in record) return null; + const entries: LegacyAuthStore = {}; + for (const [key, value] of Object.entries(record)) { + if (!value || typeof value !== "object") continue; + const typed = value as Partial; + if (typed.type !== "api_key" && typed.type !== "oauth") continue; + entries[key] = { + ...typed, + provider: typed.provider ?? (key as OAuthProvider), + } as AuthProfileCredential; + } + return Object.keys(entries).length > 0 ? entries : null; +} + +function coerceAuthStore(raw: unknown): AuthProfileStore | null { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + if (!record.profiles || typeof record.profiles !== "object") return null; + const profiles = record.profiles as Record; + const normalized: Record = {}; + for (const [key, value] of Object.entries(profiles)) { + if (!value || typeof value !== "object") continue; + const typed = value as Partial; + if (typed.type !== "api_key" && typed.type !== "oauth") continue; + if (!typed.provider) continue; + normalized[key] = typed as AuthProfileCredential; + } + return { + version: Number(record.version ?? AUTH_STORE_VERSION), + profiles: normalized, + lastGood: + record.lastGood && typeof record.lastGood === "object" + ? (record.lastGood as Record) + : undefined, + }; +} + +function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { + const oauthPath = resolveOAuthPath(); + const oauthRaw = loadJsonFile(oauthPath); + if (!oauthRaw || typeof oauthRaw !== "object") return false; + const oauthEntries = oauthRaw as Record; + let mutated = false; + for (const [provider, creds] of Object.entries(oauthEntries)) { + if (!creds || typeof creds !== "object") continue; + const profileId = `${provider}:default`; + if (store.profiles[profileId]) continue; + store.profiles[profileId] = { + type: "oauth", + provider: provider as OAuthProvider, + ...creds, + }; + mutated = true; + } + return mutated; +} + +export function loadAuthProfileStore(): AuthProfileStore { + const authPath = resolveAuthStorePath(); + const raw = loadJsonFile(authPath); + const asStore = coerceAuthStore(raw); + if (asStore) return asStore; + + const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath()); + const legacy = coerceLegacyStore(legacyRaw); + if (legacy) { + const store: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: {}, + }; + for (const [provider, cred] of Object.entries(legacy)) { + const profileId = `${provider}:default`; + if (cred.type === "api_key") { + store.profiles[profileId] = { + type: "api_key", + provider: cred.provider ?? (provider as OAuthProvider), + key: cred.key, + ...(cred.email ? { email: cred.email } : {}), + }; + } else { + store.profiles[profileId] = { + type: "oauth", + provider: cred.provider ?? (provider as OAuthProvider), + access: cred.access, + refresh: cred.refresh, + expires: cred.expires, + ...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}), + ...(cred.projectId ? { projectId: cred.projectId } : {}), + ...(cred.accountId ? { accountId: cred.accountId } : {}), + ...(cred.email ? { email: cred.email } : {}), + }; + } + } + return store; + } + + return { version: AUTH_STORE_VERSION, profiles: {} }; +} + +export function ensureAuthProfileStore(): AuthProfileStore { + const authPath = resolveAuthStorePath(); + const raw = loadJsonFile(authPath); + const asStore = coerceAuthStore(raw); + if (asStore) return asStore; + + const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath()); + const legacy = coerceLegacyStore(legacyRaw); + const store: AuthProfileStore = { + version: AUTH_STORE_VERSION, + profiles: {}, + }; + if (legacy) { + for (const [provider, cred] of Object.entries(legacy)) { + const profileId = `${provider}:default`; + if (cred.type === "api_key") { + store.profiles[profileId] = { + type: "api_key", + provider: cred.provider ?? (provider as OAuthProvider), + key: cred.key, + ...(cred.email ? { email: cred.email } : {}), + }; + } else { + store.profiles[profileId] = { + type: "oauth", + provider: cred.provider ?? (provider as OAuthProvider), + access: cred.access, + refresh: cred.refresh, + expires: cred.expires, + ...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}), + ...(cred.projectId ? { projectId: cred.projectId } : {}), + ...(cred.accountId ? { accountId: cred.accountId } : {}), + ...(cred.email ? { email: cred.email } : {}), + }; + } + } + } + + const mergedOAuth = mergeOAuthFileIntoStore(store); + const shouldWrite = legacy !== null || mergedOAuth; + if (shouldWrite) { + saveJsonFile(authPath, store); + } + return store; +} + +export function saveAuthProfileStore(store: AuthProfileStore): void { + const authPath = resolveAuthStorePath(); + const payload = { + version: AUTH_STORE_VERSION, + profiles: store.profiles, + lastGood: store.lastGood ?? undefined, + } satisfies AuthProfileStore; + saveJsonFile(authPath, payload); +} + +export function upsertAuthProfile(params: { + profileId: string; + credential: AuthProfileCredential; +}): void { + const store = ensureAuthProfileStore(); + store.profiles[params.profileId] = params.credential; + saveAuthProfileStore(store); +} + +export function listProfilesForProvider( + store: AuthProfileStore, + provider: string, +): string[] { + return Object.entries(store.profiles) + .filter(([, cred]) => cred.provider === provider) + .map(([id]) => id); +} + +export function resolveAuthProfileOrder(params: { + cfg?: ClawdbotConfig; + store: AuthProfileStore; + provider: string; + preferredProfile?: string; +}): string[] { + const { cfg, store, provider, preferredProfile } = params; + const configuredOrder = cfg?.auth?.order?.[provider]; + const explicitProfiles = cfg?.auth?.profiles + ? Object.entries(cfg.auth.profiles) + .filter(([, profile]) => profile.provider === provider) + .map(([profileId]) => profileId) + : []; + const lastGood = store.lastGood?.[provider]; + const baseOrder = + configuredOrder ?? + (explicitProfiles.length > 0 + ? explicitProfiles + : listProfilesForProvider(store, provider)); + if (baseOrder.length === 0) return []; + const order = + configuredOrder && configuredOrder.length > 0 + ? baseOrder + : orderProfilesByMode(baseOrder, store); + + const filtered = order.filter((profileId) => { + const cred = store.profiles[profileId]; + return cred ? cred.provider === provider : true; + }); + const deduped: string[] = []; + for (const entry of filtered) { + if (!deduped.includes(entry)) deduped.push(entry); + } + if (preferredProfile && deduped.includes(preferredProfile)) { + const rest = deduped.filter((entry) => entry !== preferredProfile); + if (lastGood && rest.includes(lastGood)) { + return [ + preferredProfile, + lastGood, + ...rest.filter((entry) => entry !== lastGood), + ]; + } + return [preferredProfile, ...rest]; + } + if (lastGood && deduped.includes(lastGood)) { + return [lastGood, ...deduped.filter((entry) => entry !== lastGood)]; + } + return deduped; +} + +function orderProfilesByMode( + order: string[], + store: AuthProfileStore, +): string[] { + const scored = order.map((profileId) => { + const type = store.profiles[profileId]?.type; + const score = type === "oauth" ? 0 : type === "api_key" ? 1 : 2; + return { profileId, score }; + }); + return scored + .sort((a, b) => a.score - b.score) + .map((entry) => entry.profileId); +} + +export async function resolveApiKeyForProfile(params: { + cfg?: ClawdbotConfig; + store: AuthProfileStore; + profileId: string; +}): Promise<{ apiKey: string; provider: string; email?: string } | null> { + const { cfg, store, profileId } = params; + const cred = store.profiles[profileId]; + if (!cred) return null; + const profileConfig = cfg?.auth?.profiles?.[profileId]; + if (profileConfig && profileConfig.provider !== cred.provider) return null; + if (profileConfig && profileConfig.mode !== cred.type) return null; + + if (cred.type === "api_key") { + return { apiKey: cred.key, provider: cred.provider, email: cred.email }; + } + if (Date.now() < cred.expires) { + return { + apiKey: buildOAuthApiKey(cred.provider, cred), + provider: cred.provider, + email: cred.email, + }; + } + + try { + const result = await refreshOAuthTokenWithLock({ + profileId, + provider: cred.provider, + }); + if (!result) return null; + return { + apiKey: result.apiKey, + provider: cred.provider, + email: cred.email, + }; + } catch (error) { + const refreshedStore = ensureAuthProfileStore(); + const refreshed = refreshedStore.profiles[profileId]; + if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { + return { + apiKey: buildOAuthApiKey(refreshed.provider, refreshed), + provider: refreshed.provider, + email: refreshed.email ?? cred.email, + }; + } + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `OAuth token refresh failed for ${cred.provider}: ${message}. ` + + "Please try again or re-authenticate.", + ); + } +} + +export function markAuthProfileGood(params: { + store: AuthProfileStore; + provider: string; + profileId: string; +}): void { + const { store, provider, profileId } = params; + const profile = store.profiles[profileId]; + if (!profile || profile.provider !== provider) return; + store.lastGood = { ...store.lastGood, [provider]: profileId }; + saveAuthProfileStore(store); +} + +export function resolveAuthStorePathForDisplay(): string { + const pathname = resolveAuthStorePath(); + return pathname.startsWith("~") ? pathname : resolveUserPath(pathname); +} + +export function resolveAuthProfileDisplayLabel(params: { + cfg?: ClawdbotConfig; + store: AuthProfileStore; + profileId: string; +}): string { + const { cfg, store, profileId } = params; + const profile = store.profiles[profileId]; + const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim(); + const email = configEmail || profile?.email?.trim(); + if (email) return `${profileId} (${email})`; + return profileId; +} diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts index f92057046..b8985756d 100644 --- a/src/agents/bash-tools.ts +++ b/src/agents/bash-tools.ts @@ -36,6 +36,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber( 150_000, ); const DEFAULT_PATH = + process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; const stringEnum = ( diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 5522a12ad..49900389f 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { Api, Model } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; const oauthFixture = { @@ -13,12 +12,16 @@ const oauthFixture = { }; describe("getApiKeyForModel", () => { - it("migrates legacy oauth.json into auth.json", async () => { + it("migrates legacy oauth.json into auth-profiles.json", async () => { const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-")); try { process.env.CLAWDBOT_STATE_DIR = tempDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; const oauthDir = path.join(tempDir, "credentials"); await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 }); @@ -28,10 +31,6 @@ describe("getApiKeyForModel", () => { "utf8", ); - const agentDir = path.join(tempDir, "agent"); - await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); - const authStorage = discoverAuthStorage(agentDir); - vi.resetModules(); const { getApiKeyForModel } = await import("./model-auth.js"); @@ -41,18 +40,33 @@ describe("getApiKeyForModel", () => { api: "openai-codex-responses", } as Model; - const apiKey = await getApiKeyForModel(model, authStorage); - expect(apiKey).toBe(oauthFixture.access); + const apiKey = await getApiKeyForModel({ + model, + cfg: { + auth: { + profiles: { + "openai-codex:default": { + provider: "openai-codex", + mode: "oauth", + }, + }, + }, + }, + }); + expect(apiKey.apiKey).toBe(oauthFixture.access); - const authJson = await fs.readFile( - path.join(agentDir, "auth.json"), + const authProfiles = await fs.readFile( + path.join(tempDir, "agent", "auth-profiles.json"), "utf8", ); - const authData = JSON.parse(authJson) as Record; - expect(authData["openai-codex"]).toMatchObject({ - type: "oauth", - access: oauthFixture.access, - refresh: oauthFixture.refresh, + const authData = JSON.parse(authProfiles) as Record; + expect(authData.profiles).toMatchObject({ + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: oauthFixture.access, + refresh: oauthFixture.refresh, + }, }); } finally { if (previousStateDir === undefined) { @@ -60,6 +74,92 @@ describe("getApiKeyForModel", () => { } else { process.env.CLAWDBOT_STATE_DIR = previousStateDir; } + if (previousAgentDir === undefined) { + delete process.env.CLAWDBOT_AGENT_DIR; + } else { + process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; + } + if (previousPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("suggests openai-codex when only Codex OAuth is configured", async () => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousOpenAiKey = process.env.OPENAI_API_KEY; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + + try { + delete process.env.OPENAI_API_KEY; + process.env.CLAWDBOT_STATE_DIR = tempDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const authProfilesPath = path.join( + tempDir, + "agent", + "auth-profiles.json", + ); + await fs.mkdir(path.dirname(authProfilesPath), { + recursive: true, + mode: 0o700, + }); + await fs.writeFile( + authProfilesPath, + `${JSON.stringify( + { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + ...oauthFixture, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + vi.resetModules(); + const { resolveApiKeyForProvider } = await import("./model-auth.js"); + + let error: unknown = null; + try { + await resolveApiKeyForProvider({ provider: "openai" }); + } catch (err) { + error = err; + } + expect(String(error)).toContain("openai-codex/gpt-5.2"); + } finally { + if (previousOpenAiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = previousOpenAiKey; + } + if (previousStateDir === undefined) { + delete process.env.CLAWDBOT_STATE_DIR; + } else { + process.env.CLAWDBOT_STATE_DIR = previousStateDir; + } + if (previousAgentDir === undefined) { + delete process.env.CLAWDBOT_AGENT_DIR; + } else { + process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; + } + if (previousPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + } await fs.rm(tempDir, { recursive: true, force: true }); } }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index a497f8a1a..0564381e4 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -1,179 +1,157 @@ -import fsSync from "node:fs"; -import os from "node:os"; -import path from "node:path"; - +import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { ModelProviderConfig } from "../config/types.js"; +import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; import { - type Api, - getEnvApiKey, - getOAuthApiKey, - type Model, - type OAuthCredentials, - type OAuthProvider, -} from "@mariozechner/pi-ai"; -import type { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; + type AuthProfileStore, + ensureAuthProfileStore, + listProfilesForProvider, + resolveApiKeyForProfile, + resolveAuthProfileOrder, +} from "./auth-profiles.js"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +export { + ensureAuthProfileStore, + resolveAuthProfileOrder, +} from "./auth-profiles.js"; -const OAUTH_FILENAME = "oauth.json"; -const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); -let oauthStorageConfigured = false; -let oauthStorageMigrated = false; - -type OAuthStorage = Record; - -function resolveClawdbotOAuthPath(): string { - const overrideDir = - process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR; - return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME); +export function getCustomProviderApiKey( + cfg: ClawdbotConfig | undefined, + provider: string, +): string | undefined { + const providers = cfg?.models?.providers ?? {}; + const entry = providers[provider] as ModelProviderConfig | undefined; + const key = entry?.apiKey?.trim(); + return key || undefined; } -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; +export async function resolveApiKeyForProvider(params: { + provider: string; + cfg?: ClawdbotConfig; + profileId?: string; + preferredProfile?: string; + store?: AuthProfileStore; +}): Promise<{ apiKey: string; profileId?: string; source: string }> { + const { provider, cfg, profileId, preferredProfile } = params; + const store = params.store ?? ensureAuthProfileStore(); + + if (profileId) { + const resolved = await resolveApiKeyForProfile({ + cfg, + store, + profileId, + }); + if (!resolved) { + throw new Error(`No credentials found for profile "${profileId}".`); + } + return { + apiKey: resolved.apiKey, + profileId, + source: `profile:${profileId}`, + }; } -} -function hasAnthropicOAuth(storage: OAuthStorage): boolean { - const entry = storage.anthropic as - | { - refresh?: string; - refresh_token?: string; - refreshToken?: string; - access?: string; - access_token?: string; - accessToken?: string; + const order = resolveAuthProfileOrder({ + cfg, + store, + provider, + preferredProfile, + }); + for (const candidate of order) { + try { + const resolved = await resolveApiKeyForProfile({ + cfg, + store, + profileId: candidate, + }); + if (resolved) { + return { + apiKey: resolved.apiKey, + profileId: candidate, + source: `profile:${candidate}`, + }; } - | 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)); + } catch {} } - 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; + const envResolved = resolveEnvApiKey(provider); + if (envResolved) { + return { apiKey: envResolved.apiKey, source: envResolved.source }; } -} -export function ensureOAuthStorage(): void { - if (oauthStorageConfigured) return; - oauthStorageConfigured = true; - const oauthPath = resolveClawdbotOAuthPath(); - importLegacyOAuthIfNeeded(oauthPath); -} - -function isValidOAuthCredential( - entry: OAuthCredentials | undefined, -): entry is OAuthCredentials { - if (!entry) return false; - return Boolean( - entry.access?.trim() && - entry.refresh?.trim() && - Number.isFinite(entry.expires), - ); -} - -function migrateOAuthStorageToAuthStorage( - authStorage: ReturnType, -): void { - if (oauthStorageMigrated) return; - oauthStorageMigrated = true; - const oauthPath = resolveClawdbotOAuthPath(); - const storage = loadOAuthStorageAt(oauthPath); - if (!storage) return; - for (const [provider, creds] of Object.entries(storage)) { - if (!isValidOAuthCredential(creds)) continue; - if (authStorage.get(provider)) continue; - authStorage.set(provider, { type: "oauth", ...creds }); + const customKey = getCustomProviderApiKey(cfg, provider); + if (customKey) { + return { apiKey: customKey, source: "models.json" }; } -} -export function hydrateAuthStorage( - authStorage: ReturnType, -): void { - ensureOAuthStorage(); - migrateOAuthStorageToAuthStorage(authStorage); -} - -function isOAuthProvider(provider: string): provider is OAuthProvider { - return ( - provider === "anthropic" || - provider === "anthropic-oauth" || - provider === "google" || - provider === "openai" || - provider === "openai-compatible" || - provider === "openai-codex" || - provider === "github-copilot" || - provider === "google-gemini-cli" || - provider === "google-antigravity" - ); -} - -export async function getApiKeyForModel( - model: Model, - authStorage: ReturnType, -): Promise { - ensureOAuthStorage(); - migrateOAuthStorageToAuthStorage(authStorage); - const storedKey = await authStorage.getApiKey(model.provider); - if (storedKey) return storedKey; - 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 - } + if (provider === "openai") { + const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; + if (hasCodex) { + throw new Error( + 'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.2 (ChatGPT OAuth) or set OPENAI_API_KEY for openai/gpt-5.2.', + ); } } - throw new Error(`No API key found for provider "${model.provider}"`); + + throw new Error(`No API key found for provider "${provider}".`); +} + +export type EnvApiKeyResult = { apiKey: string; source: string }; + +export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { + const applied = new Set(getShellEnvAppliedKeys()); + const pick = (envVar: string): EnvApiKeyResult | null => { + const value = process.env[envVar]?.trim(); + if (!value) return null; + const source = applied.has(envVar) + ? `shell env: ${envVar}` + : `env: ${envVar}`; + return { apiKey: value, source }; + }; + + if (provider === "github-copilot") { + return ( + pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN") + ); + } + + if (provider === "anthropic") { + return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY"); + } + + if (provider === "google-vertex") { + const envKey = getEnvApiKey(provider); + if (!envKey) return null; + return { apiKey: envKey, source: "gcloud adc" }; + } + + const envMap: Record = { + openai: "OPENAI_API_KEY", + google: "GEMINI_API_KEY", + groq: "GROQ_API_KEY", + cerebras: "CEREBRAS_API_KEY", + xai: "XAI_API_KEY", + openrouter: "OPENROUTER_API_KEY", + zai: "ZAI_API_KEY", + mistral: "MISTRAL_API_KEY", + }; + const envVar = envMap[provider]; + if (!envVar) return null; + return pick(envVar); +} + +export async function getApiKeyForModel(params: { + model: Model; + cfg?: ClawdbotConfig; + profileId?: string; + preferredProfile?: string; + store?: AuthProfileStore; +}): Promise<{ apiKey: string; profileId?: string; source: string }> { + return resolveApiKeyForProvider({ + provider: params.model.provider, + cfg: params.cfg, + profileId: params.profileId, + preferredProfile: params.preferredProfile, + store: params.store, + }); } diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index a8c63d870..96d9abeb5 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -33,7 +33,10 @@ function buildAllowedModelKeys( cfg: ClawdbotConfig | undefined, defaultProvider: string, ): Set | null { - const rawAllowlist = cfg?.agent?.allowedModels ?? []; + const rawAllowlist = (() => { + const modelMap = cfg?.agent?.models ?? {}; + return Object.keys(modelMap); + })(); if (rawAllowlist.length === 0) return null; const keys = new Set(); for (const raw of rawAllowlist) { @@ -81,11 +84,28 @@ function resolveImageFallbackCandidates(params: { if (params.modelOverride?.trim()) { addRaw(params.modelOverride, false); - } else if (params.cfg?.agent?.imageModel?.trim()) { - addRaw(params.cfg.agent.imageModel, false); + } else { + const imageModel = params.cfg?.agent?.imageModel as + | { primary?: string } + | string + | undefined; + const primary = + typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary; + if (primary?.trim()) addRaw(primary, false); } - for (const raw of params.cfg?.agent?.imageModelFallbacks ?? []) { + const imageFallbacks = (() => { + const imageModel = params.cfg?.agent?.imageModel as + | { fallbacks?: string[] } + | string + | undefined; + if (imageModel && typeof imageModel === "object") { + return imageModel.fallbacks ?? []; + } + return []; + })(); + + for (const raw of imageFallbacks) { addRaw(raw, true); } @@ -121,7 +141,16 @@ function resolveFallbackCandidates(params: { addCandidate({ provider, model }, false); - for (const raw of params.cfg?.agent?.modelFallbacks ?? []) { + const modelFallbacks = (() => { + const model = params.cfg?.agent?.model as + | { fallbacks?: string[] } + | string + | undefined; + if (model && typeof model === "object") return model.fallbacks ?? []; + return []; + })(); + + for (const raw of modelFallbacks) { const resolved = resolveModelRefFromString({ raw: String(raw ?? ""), defaultProvider: DEFAULT_PROVIDER, @@ -224,7 +253,7 @@ export async function runWithImageModelFallback(params: { }); if (candidates.length === 0) { throw new Error( - "No image model configured. Set agent.imageModel or agent.imageModelFallbacks.", + "No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.", ); } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index d791763b8..5b107916d 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -5,9 +5,9 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { resolveConfiguredModelRef } from "./model-selection.js"; describe("resolveConfiguredModelRef", () => { - it("parses provider/model from agent.model", () => { + it("parses provider/model from agent.model.primary", () => { const cfg = { - agent: { model: "openai/gpt-4.1-mini" }, + agent: { model: { primary: "openai/gpt-4.1-mini" } }, } satisfies ClawdbotConfig; const resolved = resolveConfiguredModelRef({ @@ -19,9 +19,9 @@ describe("resolveConfiguredModelRef", () => { expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" }); }); - it("falls back to anthropic when agent.model omits provider", () => { + it("falls back to anthropic when agent.model.primary omits provider", () => { const cfg = { - agent: { model: "claude-opus-4-5" }, + agent: { model: { primary: "claude-opus-4-5" } }, } satisfies ClawdbotConfig; const resolved = resolveConfiguredModelRef({ @@ -54,9 +54,9 @@ describe("resolveConfiguredModelRef", () => { it("resolves agent.model aliases when configured", () => { const cfg = { agent: { - model: "Opus", - modelAliases: { - Opus: "anthropic/claude-opus-4-5", + model: { primary: "Opus" }, + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, }, }, } satisfies ClawdbotConfig; @@ -72,4 +72,18 @@ describe("resolveConfiguredModelRef", () => { model: "claude-opus-4-5", }); }); + + it("still resolves legacy agent.model string", () => { + const cfg = { + agent: { model: "openai/gpt-4.1-mini" }, + } satisfies ClawdbotConfig; + + const resolved = resolveConfiguredModelRef({ + cfg, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + + expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" }); + }); }); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 2cda180d1..f342700dd 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -41,18 +41,17 @@ export function buildModelAliasIndex(params: { cfg: ClawdbotConfig; defaultProvider: string; }): ModelAliasIndex { - const rawAliases = params.cfg.agent?.modelAliases ?? {}; const byAlias = new Map(); const byKey = new Map(); - for (const [aliasRaw, targetRaw] of Object.entries(rawAliases)) { - const alias = aliasRaw.trim(); - if (!alias) continue; - const parsed = parseModelRef( - String(targetRaw ?? ""), - params.defaultProvider, - ); + const rawModels = params.cfg.agent?.models ?? {}; + for (const [keyRaw, entryRaw] of Object.entries(rawModels)) { + const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider); if (!parsed) continue; + const alias = String( + (entryRaw as { alias?: string } | undefined)?.alias ?? "", + ).trim(); + if (!alias) continue; const aliasKey = normalizeAliasKey(alias); byAlias.set(aliasKey, { alias, ref: parsed }); const key = modelKey(parsed.provider, parsed.model); @@ -88,7 +87,14 @@ export function resolveConfiguredModelRef(params: { defaultProvider: string; defaultModel: string; }): ModelRef { - const rawModel = params.cfg.agent?.model?.trim() || ""; + const rawModel = (() => { + const raw = params.cfg.agent?.model as + | { primary?: string } + | string + | undefined; + if (typeof raw === "string") return raw.trim(); + return raw?.primary?.trim() ?? ""; + })(); if (rawModel) { const trimmed = rawModel.trim(); const aliasIndex = buildModelAliasIndex({ @@ -116,7 +122,10 @@ export function buildAllowedModelSet(params: { allowedCatalog: ModelCatalogEntry[]; allowedKeys: Set; } { - const rawAllowlist = params.cfg.agent?.allowedModels ?? []; + const rawAllowlist = (() => { + const modelMap = params.cfg.agent?.models ?? {}; + return Object.keys(modelMap); + })(); const allowAny = rawAllowlist.length === 0; const catalogKeys = new Set( params.catalog.map((entry) => modelKey(entry.provider, entry.id)), diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts new file mode 100644 index 000000000..124965014 --- /dev/null +++ b/src/agents/pi-embedded-helpers.test.ts @@ -0,0 +1,77 @@ +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { describe, expect, it } from "vitest"; +import type { ThinkLevel } from "../auto-reply/thinking.js"; +import { + isRateLimitAssistantError, + pickFallbackThinkingLevel, +} from "./pi-embedded-helpers.js"; + +const asAssistant = (overrides: Partial) => + ({ + role: "assistant", + stopReason: "error", + ...overrides, + }) as AssistantMessage; + +describe("isRateLimitAssistantError", () => { + it("detects 429 rate limit payloads", () => { + const msg = asAssistant({ + errorMessage: + '429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\'s rate limit. Please try again later."}}', + }); + expect(isRateLimitAssistantError(msg)).toBe(true); + }); + + it("detects human-readable rate limit messages", () => { + const msg = asAssistant({ + errorMessage: "Too many requests. Rate limit exceeded.", + }); + expect(isRateLimitAssistantError(msg)).toBe(true); + }); + + it("detects quota exceeded messages", () => { + const msg = asAssistant({ + errorMessage: + "You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors.", + }); + expect(isRateLimitAssistantError(msg)).toBe(true); + }); + + it("returns false for non-error messages", () => { + const msg = asAssistant({ + stopReason: "end_turn", + errorMessage: "rate limit", + }); + expect(isRateLimitAssistantError(msg)).toBe(false); + }); +}); + +describe("pickFallbackThinkingLevel", () => { + it("selects the first supported thinking level", () => { + const attempted = new Set(["low"]); + const next = pickFallbackThinkingLevel({ + message: + "Unsupported value: 'low' is not supported with the 'gpt-5.2-pro' model. Supported values are: 'medium', 'high', and 'xhigh'.", + attempted, + }); + expect(next).toBe("medium"); + }); + + it("skips already attempted levels", () => { + const attempted = new Set(["low", "medium"]); + const next = pickFallbackThinkingLevel({ + message: "Supported values are: 'medium', 'high', and 'xhigh'.", + attempted, + }); + expect(next).toBe("high"); + }); + + it("returns undefined when no supported values are found", () => { + const attempted = new Set(["low"]); + const next = pickFallbackThinkingLevel({ + message: "Request failed.", + attempted, + }); + expect(next).toBeUndefined(); + }); +}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 8d2debc70..ef8049e3a 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -6,6 +6,10 @@ import type { AgentToolResult, } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { + normalizeThinkLevel, + type ThinkLevel, +} from "../auto-reply/thinking.js"; import { sanitizeContentBlocksImages } from "./tool-images.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; @@ -109,3 +113,78 @@ export function formatAssistantErrorText( // Keep it short for WhatsApp. return raw.length > 600 ? `${raw.slice(0, 600)}…` : raw; } + +export function isRateLimitAssistantError( + msg: AssistantMessage | undefined, +): boolean { + if (!msg || msg.stopReason !== "error") return false; + const raw = (msg.errorMessage ?? "").toLowerCase(); + if (!raw) return false; + return isRateLimitErrorMessage(raw); +} + +export function isRateLimitErrorMessage(raw: string): boolean { + const value = raw.toLowerCase(); + return ( + /rate[_ ]limit|too many requests|429/.test(value) || + value.includes("exceeded your current quota") + ); +} + +export function isAuthErrorMessage(raw: string): boolean { + const value = raw.toLowerCase(); + if (!value) return false; + return ( + /invalid[_ ]?api[_ ]?key/.test(value) || + value.includes("incorrect api key") || + value.includes("invalid token") || + value.includes("authentication") || + value.includes("unauthorized") || + value.includes("forbidden") || + value.includes("access denied") || + /\b401\b/.test(value) || + /\b403\b/.test(value) + ); +} + +export function isAuthAssistantError( + msg: AssistantMessage | undefined, +): boolean { + if (!msg || msg.stopReason !== "error") return false; + return isAuthErrorMessage(msg.errorMessage ?? ""); +} + +function extractSupportedValues(raw: string): string[] { + const match = + raw.match(/supported values are:\s*([^\n.]+)/i) ?? + raw.match(/supported values:\s*([^\n.]+)/i); + if (!match?.[1]) return []; + const fragment = match[1]; + const quoted = Array.from(fragment.matchAll(/['"]([^'"]+)['"]/g)).map( + (entry) => entry[1]?.trim(), + ); + if (quoted.length > 0) { + return quoted.filter((entry): entry is string => Boolean(entry)); + } + return fragment + .split(/,|\band\b/gi) + .map((entry) => entry.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "").trim()) + .filter(Boolean); +} + +export function pickFallbackThinkingLevel(params: { + message?: string; + attempted: Set; +}): ThinkLevel | undefined { + const raw = params.message?.trim(); + if (!raw) return undefined; + const supported = extractSupportedValues(raw); + if (supported.length === 0) return undefined; + for (const entry of supported) { + const normalized = normalizeThinkLevel(entry); + if (!normalized) continue; + if (params.attempted.has(normalized)) continue; + return normalized; + } + return undefined; +} diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 18c29d6e7..5666e85ea 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -24,14 +24,24 @@ import { } from "../process/command-queue.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; +import { markAuthProfileGood } from "./auth-profiles.js"; import type { BashElevatedDefaults } from "./bash-tools.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; -import { getApiKeyForModel } from "./model-auth.js"; +import { + ensureAuthProfileStore, + getApiKeyForModel, + resolveAuthProfileOrder, +} from "./model-auth.js"; import { ensureClawdbotModelsJson } from "./models-config.js"; import { buildBootstrapContextFiles, ensureSessionHeader, formatAssistantErrorText, + isAuthAssistantError, + isAuthErrorMessage, + isRateLimitAssistantError, + isRateLimitErrorMessage, + pickFallbackThinkingLevel, sanitizeSessionMessagesImages, } from "./pi-embedded-helpers.js"; import { @@ -72,6 +82,12 @@ export type EmbeddedPiRunMeta = { aborted?: boolean; }; +type ApiKeyInfo = { + apiKey: string; + profileId?: string; + source: string; +}; + export type EmbeddedPiRunResult = { payloads?: Array<{ text?: string; @@ -82,9 +98,22 @@ export type EmbeddedPiRunResult = { meta: EmbeddedPiRunMeta; }; +export type EmbeddedPiCompactResult = { + ok: boolean; + compacted: boolean; + reason?: string; + result?: { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; + }; +}; + type EmbeddedPiQueueHandle = { queueMessage: (text: string) => Promise; isStreaming: () => boolean; + isCompacting: () => boolean; abort: () => void; }; @@ -114,6 +143,57 @@ function resolveGlobalLane(lane?: string) { return cleaned ? cleaned : "main"; } +function resolveUserTimezone(configured?: string): string { + const trimmed = configured?.trim(); + if (trimmed) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format( + new Date(), + ); + return trimmed; + } catch { + // ignore invalid timezone + } + } + const host = Intl.DateTimeFormat().resolvedOptions().timeZone; + return host?.trim() || "UTC"; +} + +function formatUserTime(date: Date, timeZone: string): string | undefined { + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const map: Record = {}; + for (const part of parts) { + if (part.type !== "literal") map[part.type] = part.value; + } + if (!map.year || !map.month || !map.day || !map.hour || !map.minute) { + return undefined; + } + return `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`; + } catch { + return undefined; + } +} + +function describeUnknownError(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + try { + const serialized = JSON.stringify(error); + return serialized ?? "Unknown error"; + } catch { + return "Unknown error"; + } +} + export function buildEmbeddedSandboxInfo( sandbox?: Awaited>, ): EmbeddedSandboxInfo | undefined { @@ -133,6 +213,7 @@ export function queueEmbeddedPiMessage( const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId); if (!handle) return false; if (!handle.isStreaming()) return false; + if (handle.isCompacting()) return false; void handle.queueMessage(text); return true; } @@ -247,6 +328,215 @@ function resolvePromptSkills( .filter((skill): skill is Skill => Boolean(skill)); } +export async function compactEmbeddedPiSession(params: { + sessionId: string; + sessionKey?: string; + surface?: string; + sessionFile: string; + workspaceDir: string; + config?: ClawdbotConfig; + skillsSnapshot?: SkillSnapshot; + provider?: string; + model?: string; + thinkLevel?: ThinkLevel; + bashElevated?: BashElevatedDefaults; + customInstructions?: string; + lane?: string; + enqueue?: typeof enqueueCommand; + extraSystemPrompt?: string; + ownerNumbers?: string[]; +}): Promise { + const sessionLane = resolveSessionLane( + params.sessionKey?.trim() || params.sessionId, + ); + const globalLane = resolveGlobalLane(params.lane); + const enqueueGlobal = + params.enqueue ?? + ((task, opts) => enqueueCommandInLane(globalLane, task, opts)); + return enqueueCommandInLane(sessionLane, () => + enqueueGlobal(async () => { + const resolvedWorkspace = resolveUserPath(params.workspaceDir); + const prevCwd = process.cwd(); + + const provider = + (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; + const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; + await ensureClawdbotModelsJson(params.config); + const agentDir = resolveClawdbotAgentDir(); + const { model, error, authStorage, modelRegistry } = resolveModel( + provider, + modelId, + agentDir, + ); + if (!model) { + return { + ok: false, + compacted: false, + reason: error ?? `Unknown model: ${provider}/${modelId}`, + }; + } + try { + const apiKeyInfo = await getApiKeyForModel({ + model, + cfg: params.config, + }); + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + } catch (err) { + return { + ok: false, + compacted: false, + reason: describeUnknownError(err), + }; + } + + await fs.mkdir(resolvedWorkspace, { recursive: true }); + await ensureSessionHeader({ + sessionFile: params.sessionFile, + sessionId: params.sessionId, + cwd: resolvedWorkspace, + }); + + let restoreSkillEnv: (() => void) | undefined; + process.chdir(resolvedWorkspace); + try { + const shouldLoadSkillEntries = + !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; + const skillEntries = shouldLoadSkillEntries + ? loadWorkspaceSkillEntries(resolvedWorkspace) + : []; + const skillsSnapshot = + params.skillsSnapshot ?? + buildWorkspaceSkillSnapshot(resolvedWorkspace, { + config: params.config, + entries: skillEntries, + }); + const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; + const sandbox = await resolveSandboxContext({ + config: params.config, + sessionKey: sandboxSessionKey, + workspaceDir: resolvedWorkspace, + }); + restoreSkillEnv = params.skillsSnapshot + ? applySkillEnvOverridesFromSnapshot({ + snapshot: params.skillsSnapshot, + config: params.config, + }) + : applySkillEnvOverrides({ + skills: skillEntries ?? [], + config: params.config, + }); + + const bootstrapFiles = + await loadWorkspaceBootstrapFiles(resolvedWorkspace); + const contextFiles = buildBootstrapContextFiles(bootstrapFiles); + const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); + const tools = createClawdbotCodingTools({ + bash: { + ...params.config?.agent?.bash, + elevated: params.bashElevated, + }, + sandbox, + surface: params.surface, + sessionKey: params.sessionKey ?? params.sessionId, + config: params.config, + }); + const machineName = await getMachineDisplayName(); + const runtimeInfo = { + host: machineName, + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + node: process.version, + model: `${provider}/${modelId}`, + }; + const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); + const reasoningTagHint = provider === "ollama"; + const userTimezone = resolveUserTimezone( + params.config?.agent?.userTimezone, + ); + const userTime = formatUserTime(new Date(), userTimezone); + const systemPrompt = buildSystemPrompt({ + appendPrompt: buildAgentSystemPromptAppend({ + workspaceDir: resolvedWorkspace, + defaultThinkLevel: params.thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + reasoningTagHint, + runtimeInfo, + sandboxInfo, + toolNames: tools.map((tool) => tool.name), + userTimezone, + userTime, + }), + contextFiles, + skills: promptSkills, + cwd: resolvedWorkspace, + tools, + }); + + const sessionManager = SessionManager.open(params.sessionFile); + const settingsManager = SettingsManager.create( + resolvedWorkspace, + agentDir, + ); + + const builtInToolNames = new Set(["read", "bash", "edit", "write"]); + const builtInTools = tools.filter((t) => builtInToolNames.has(t.name)); + const customTools = toToolDefinitions( + tools.filter((t) => !builtInToolNames.has(t.name)), + ); + + const { session } = await createAgentSession({ + cwd: resolvedWorkspace, + agentDir, + authStorage, + modelRegistry, + model, + thinkingLevel: mapThinkingLevel(params.thinkLevel), + systemPrompt, + tools: builtInTools, + customTools, + sessionManager, + settingsManager, + skills: promptSkills, + contextFiles, + }); + + try { + const prior = await sanitizeSessionMessagesImages( + session.messages, + "session:history", + ); + if (prior.length > 0) { + session.agent.replaceMessages(prior); + } + const result = await session.compact(params.customInstructions); + return { + ok: true, + compacted: true, + result: { + summary: result.summary, + firstKeptEntryId: result.firstKeptEntryId, + tokensBefore: result.tokensBefore, + details: result.details, + }, + }; + } finally { + session.dispose(); + } + } catch (err) { + return { + ok: false, + compacted: false, + reason: describeUnknownError(err), + }; + } finally { + restoreSkillEnv?.(); + process.chdir(prevCwd); + } + }), + ); +} + export async function runEmbeddedPiAgent(params: { sessionId: string; sessionKey?: string; @@ -258,6 +548,7 @@ export async function runEmbeddedPiAgent(params: { prompt: string; provider?: string; model?: string; + authProfileId?: string; thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; bashElevated?: BashElevatedDefaults; @@ -315,314 +606,453 @@ export async function runEmbeddedPiAgent(params: { if (!model) { throw new Error(error ?? `Unknown model: ${provider}/${modelId}`); } - const apiKey = await getApiKeyForModel(model, authStorage); - authStorage.setRuntimeApiKey(model.provider, apiKey); - - const thinkingLevel = mapThinkingLevel(params.thinkLevel); - - log.debug( - `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} surface=${params.surface ?? "unknown"}`, - ); - - await fs.mkdir(resolvedWorkspace, { recursive: true }); - await ensureSessionHeader({ - sessionFile: params.sessionFile, - sessionId: params.sessionId, - cwd: resolvedWorkspace, + const authStore = ensureAuthProfileStore(); + const explicitProfileId = params.authProfileId?.trim(); + const profileOrder = resolveAuthProfileOrder({ + cfg: params.config, + store: authStore, + provider, + preferredProfile: explicitProfileId, }); - - let restoreSkillEnv: (() => void) | undefined; - process.chdir(resolvedWorkspace); - try { - const shouldLoadSkillEntries = - !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; - const skillEntries = shouldLoadSkillEntries - ? loadWorkspaceSkillEntries(resolvedWorkspace) - : []; - const skillsSnapshot = - params.skillsSnapshot ?? - buildWorkspaceSkillSnapshot(resolvedWorkspace, { - config: params.config, - entries: skillEntries, - }); - const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; - const sandbox = await resolveSandboxContext({ - config: params.config, - sessionKey: sandboxSessionKey, - workspaceDir: resolvedWorkspace, - }); - restoreSkillEnv = params.skillsSnapshot - ? applySkillEnvOverridesFromSnapshot({ - snapshot: params.skillsSnapshot, - config: params.config, - }) - : applySkillEnvOverrides({ - skills: skillEntries ?? [], - config: params.config, - }); - - const bootstrapFiles = - 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, - elevated: params.bashElevated, - }, - sandbox, - surface: params.surface, - sessionKey: params.sessionKey ?? params.sessionId, - config: params.config, - }); - const machineName = await getMachineDisplayName(); - const runtimeInfo = { - host: machineName, - os: `${os.type()} ${os.release()}`, - arch: os.arch(), - node: process.version, - model: `${provider}/${modelId}`, - }; - const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); - const reasoningTagHint = provider === "ollama"; - const systemPrompt = buildSystemPrompt({ - appendPrompt: buildAgentSystemPromptAppend({ - workspaceDir: resolvedWorkspace, - defaultThinkLevel: params.thinkLevel, - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, - reasoningTagHint, - runtimeInfo, - sandboxInfo, - toolNames: tools.map((tool) => tool.name), - }), - contextFiles, - skills: promptSkills, - cwd: resolvedWorkspace, - tools, - }); - - const sessionManager = SessionManager.open(params.sessionFile); - const settingsManager = SettingsManager.create( - resolvedWorkspace, - agentDir, + if (explicitProfileId && !profileOrder.includes(explicitProfileId)) { + throw new Error( + `Auth profile "${explicitProfileId}" is not configured for ${provider}.`, ); + } + const profileCandidates = + profileOrder.length > 0 ? profileOrder : [undefined]; + let profileIndex = 0; + const initialThinkLevel = params.thinkLevel ?? "off"; + let thinkLevel = initialThinkLevel; + const attemptedThinking = new Set(); + let apiKeyInfo: ApiKeyInfo | null = null; + let lastProfileId: string | undefined; - // Split tools into built-in (recognized by pi-coding-agent SDK) and custom (clawdbot-specific) - const builtInToolNames = new Set(["read", "bash", "edit", "write"]); - const builtInTools = tools.filter((t) => builtInToolNames.has(t.name)); - const customTools = toToolDefinitions( - tools.filter((t) => !builtInToolNames.has(t.name)), - ); - - const { session } = await createAgentSession({ - cwd: resolvedWorkspace, - agentDir, - authStorage, - modelRegistry, + const resolveApiKeyForCandidate = async (candidate?: string) => { + return getApiKeyForModel({ model, - thinkingLevel, - systemPrompt, - // Built-in tools recognized by pi-coding-agent SDK - tools: builtInTools, - // Custom clawdbot tools (browser, canvas, nodes, cron, etc.) - customTools, - sessionManager, - settingsManager, - skills: promptSkills, - contextFiles, + cfg: params.config, + profileId: candidate, + store: authStore, }); + }; - const prior = await sanitizeSessionMessagesImages( - session.messages, - "session:history", - ); - if (prior.length > 0) { - session.agent.replaceMessages(prior); - } - let aborted = Boolean(params.abortSignal?.aborted); - const abortRun = () => { - aborted = true; - void session.abort(); - }; - const queueHandle: EmbeddedPiQueueHandle = { - queueMessage: async (text: string) => { - await session.steer(text); - }, - isStreaming: () => session.isStreaming, - abort: abortRun, - }; - ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle); + const applyApiKeyInfo = async (candidate?: string): Promise => { + apiKeyInfo = await resolveApiKeyForCandidate(candidate); + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); + lastProfileId = apiKeyInfo.profileId; + }; - const { - assistantTexts, - toolMetas, - unsubscribe, - waitForCompactionRetry, - } = subscribeEmbeddedPiSession({ - session, - runId: params.runId, - verboseLevel: params.verboseLevel, - shouldEmitToolResult: params.shouldEmitToolResult, - onToolResult: params.onToolResult, - onBlockReply: params.onBlockReply, - blockReplyBreak: params.blockReplyBreak, - blockReplyChunking: params.blockReplyChunking, - onPartialReply: params.onPartialReply, - onAgentEvent: params.onAgentEvent, - enforceFinalTag: params.enforceFinalTag, - }); - - let abortWarnTimer: NodeJS.Timeout | undefined; - const abortTimer = setTimeout( - () => { - log.warn( - `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, - ); - abortRun(); - if (!abortWarnTimer) { - abortWarnTimer = setTimeout(() => { - if (!session.isStreaming) return; - log.warn( - `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, - ); - }, 10_000); - } - }, - Math.max(1, params.timeoutMs), - ); - - let messagesSnapshot: AgentMessage[] = []; - let sessionIdUsed = session.sessionId; - const onAbort = () => { - abortRun(); - }; - if (params.abortSignal) { - if (params.abortSignal.aborted) { - onAbort(); - } else { - params.abortSignal.addEventListener("abort", onAbort, { - once: true, - }); - } - } - let promptError: unknown = null; - try { - const promptStartedAt = Date.now(); - log.debug( - `embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`, - ); + const advanceAuthProfile = async (): Promise => { + let nextIndex = profileIndex + 1; + while (nextIndex < profileCandidates.length) { + const candidate = profileCandidates[nextIndex]; try { - await session.prompt(params.prompt); + await applyApiKeyInfo(candidate); + profileIndex = nextIndex; + thinkLevel = initialThinkLevel; + attemptedThinking.clear(); + return true; } catch (err) { - promptError = err; - } finally { - log.debug( - `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, - ); - } - await waitForCompactionRetry(); - messagesSnapshot = session.messages.slice(); - sessionIdUsed = session.sessionId; - } finally { - clearTimeout(abortTimer); - if (abortWarnTimer) { - clearTimeout(abortWarnTimer); - abortWarnTimer = undefined; - } - unsubscribe(); - if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) { - ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); - notifyEmbeddedRunEnded(params.sessionId); - } - session.dispose(); - params.abortSignal?.removeEventListener?.("abort", onAbort); - } - if (promptError && !aborted) { - throw promptError; - } - - const lastAssistant = messagesSnapshot - .slice() - .reverse() - .find((m) => (m as AgentMessage)?.role === "assistant") as - | AssistantMessage - | undefined; - - const usage = lastAssistant?.usage; - const agentMeta: EmbeddedPiAgentMeta = { - sessionId: sessionIdUsed, - provider: lastAssistant?.provider ?? provider, - model: lastAssistant?.model ?? model.id, - usage: usage - ? { - input: usage.input, - output: usage.output, - cacheRead: usage.cacheRead, - cacheWrite: usage.cacheWrite, - total: usage.totalTokens, - } - : undefined, - }; - - const replyItems: Array<{ text: string; media?: string[] }> = []; - - const errorText = lastAssistant - ? formatAssistantErrorText(lastAssistant) - : undefined; - if (errorText) replyItems.push({ text: errorText }); - - const inlineToolResults = - params.verboseLevel === "on" && - !params.onPartialReply && - !params.onToolResult && - toolMetas.length > 0; - if (inlineToolResults) { - for (const { toolName, meta } of toolMetas) { - const agg = formatToolAggregate(toolName, meta ? [meta] : []); - const { text: cleanedText, mediaUrls } = splitMediaFromOutput(agg); - if (cleanedText) - replyItems.push({ text: cleanedText, media: mediaUrls }); + if (candidate && candidate === explicitProfileId) throw err; + nextIndex += 1; } } + return false; + }; - for (const text of assistantTexts.length - ? assistantTexts - : lastAssistant - ? [extractAssistantText(lastAssistant)] - : []) { - const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text); - if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) continue; - replyItems.push({ text: cleanedText, media: mediaUrls }); - } + try { + await applyApiKeyInfo(profileCandidates[profileIndex]); + } catch (err) { + if (profileCandidates[profileIndex] === explicitProfileId) throw err; + const advanced = await advanceAuthProfile(); + if (!advanced) throw err; + } - const payloads = replyItems - .map((item) => ({ - text: item.text?.trim() ? item.text.trim() : undefined, - mediaUrls: item.media?.length ? item.media : undefined, - mediaUrl: item.media?.[0], - })) - .filter( - (p) => - p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0), - ); + while (true) { + const thinkingLevel = mapThinkingLevel(thinkLevel); + attemptedThinking.add(thinkLevel); log.debug( - `embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`, + `embedded run start: runId=${params.runId} sessionId=${params.sessionId} provider=${provider} model=${modelId} thinking=${thinkLevel} surface=${params.surface ?? "unknown"}`, ); - return { - payloads: payloads.length ? payloads : undefined, - meta: { - durationMs: Date.now() - started, - agentMeta, - aborted, - }, - }; - } finally { - restoreSkillEnv?.(); - process.chdir(prevCwd); + + await fs.mkdir(resolvedWorkspace, { recursive: true }); + await ensureSessionHeader({ + sessionFile: params.sessionFile, + sessionId: params.sessionId, + cwd: resolvedWorkspace, + }); + + let restoreSkillEnv: (() => void) | undefined; + process.chdir(resolvedWorkspace); + try { + const shouldLoadSkillEntries = + !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills; + const skillEntries = shouldLoadSkillEntries + ? loadWorkspaceSkillEntries(resolvedWorkspace) + : []; + const skillsSnapshot = + params.skillsSnapshot ?? + buildWorkspaceSkillSnapshot(resolvedWorkspace, { + config: params.config, + entries: skillEntries, + }); + const sandboxSessionKey = + params.sessionKey?.trim() || params.sessionId; + const sandbox = await resolveSandboxContext({ + config: params.config, + sessionKey: sandboxSessionKey, + workspaceDir: resolvedWorkspace, + }); + restoreSkillEnv = params.skillsSnapshot + ? applySkillEnvOverridesFromSnapshot({ + snapshot: params.skillsSnapshot, + config: params.config, + }) + : applySkillEnvOverrides({ + skills: skillEntries ?? [], + config: params.config, + }); + + const bootstrapFiles = + 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, + elevated: params.bashElevated, + }, + sandbox, + surface: params.surface, + sessionKey: params.sessionKey ?? params.sessionId, + config: params.config, + }); + const machineName = await getMachineDisplayName(); + const runtimeInfo = { + host: machineName, + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + node: process.version, + model: `${provider}/${modelId}`, + }; + const sandboxInfo = buildEmbeddedSandboxInfo(sandbox); + const reasoningTagHint = provider === "ollama"; + const userTimezone = resolveUserTimezone( + params.config?.agent?.userTimezone, + ); + const userTime = formatUserTime(new Date(), userTimezone); + const systemPrompt = buildSystemPrompt({ + appendPrompt: buildAgentSystemPromptAppend({ + workspaceDir: resolvedWorkspace, + defaultThinkLevel: thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + reasoningTagHint, + runtimeInfo, + sandboxInfo, + toolNames: tools.map((tool) => tool.name), + userTimezone, + userTime, + }), + contextFiles, + skills: promptSkills, + cwd: resolvedWorkspace, + tools, + }); + + const sessionManager = SessionManager.open(params.sessionFile); + const settingsManager = SettingsManager.create( + resolvedWorkspace, + agentDir, + ); + + // Split tools into built-in (recognized by pi-coding-agent SDK) and custom (clawdbot-specific) + const builtInToolNames = new Set(["read", "bash", "edit", "write"]); + const builtInTools = tools.filter((t) => + builtInToolNames.has(t.name), + ); + const customTools = toToolDefinitions( + tools.filter((t) => !builtInToolNames.has(t.name)), + ); + + const { session } = await createAgentSession({ + cwd: resolvedWorkspace, + agentDir, + authStorage, + modelRegistry, + model, + thinkingLevel, + systemPrompt, + // Built-in tools recognized by pi-coding-agent SDK + tools: builtInTools, + // Custom clawdbot tools (browser, canvas, nodes, cron, etc.) + customTools, + sessionManager, + settingsManager, + skills: promptSkills, + contextFiles, + }); + + const prior = await sanitizeSessionMessagesImages( + session.messages, + "session:history", + ); + if (prior.length > 0) { + session.agent.replaceMessages(prior); + } + let aborted = Boolean(params.abortSignal?.aborted); + const abortRun = () => { + aborted = true; + void session.abort(); + }; + const subscription = subscribeEmbeddedPiSession({ + session, + runId: params.runId, + verboseLevel: params.verboseLevel, + shouldEmitToolResult: params.shouldEmitToolResult, + onToolResult: params.onToolResult, + onBlockReply: params.onBlockReply, + blockReplyBreak: params.blockReplyBreak, + blockReplyChunking: params.blockReplyChunking, + onPartialReply: params.onPartialReply, + onAgentEvent: params.onAgentEvent, + enforceFinalTag: params.enforceFinalTag, + }); + const { + assistantTexts, + toolMetas, + unsubscribe, + waitForCompactionRetry, + } = subscription; + + const queueHandle: EmbeddedPiQueueHandle = { + queueMessage: async (text: string) => { + await session.steer(text); + }, + isStreaming: () => session.isStreaming, + isCompacting: () => subscription.isCompacting(), + abort: abortRun, + }; + ACTIVE_EMBEDDED_RUNS.set(params.sessionId, queueHandle); + + let abortWarnTimer: NodeJS.Timeout | undefined; + const abortTimer = setTimeout( + () => { + log.warn( + `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, + ); + abortRun(); + if (!abortWarnTimer) { + abortWarnTimer = setTimeout(() => { + if (!session.isStreaming) return; + log.warn( + `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, + ); + }, 10_000); + } + }, + Math.max(1, params.timeoutMs), + ); + + let messagesSnapshot: AgentMessage[] = []; + let sessionIdUsed = session.sessionId; + const onAbort = () => { + abortRun(); + }; + if (params.abortSignal) { + if (params.abortSignal.aborted) { + onAbort(); + } else { + params.abortSignal.addEventListener("abort", onAbort, { + once: true, + }); + } + } + let promptError: unknown = null; + try { + const promptStartedAt = Date.now(); + log.debug( + `embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`, + ); + try { + await session.prompt(params.prompt); + } catch (err) { + promptError = err; + } finally { + log.debug( + `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, + ); + } + await waitForCompactionRetry(); + messagesSnapshot = session.messages.slice(); + sessionIdUsed = session.sessionId; + } finally { + clearTimeout(abortTimer); + if (abortWarnTimer) { + clearTimeout(abortWarnTimer); + abortWarnTimer = undefined; + } + unsubscribe(); + if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) { + ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); + notifyEmbeddedRunEnded(params.sessionId); + } + session.dispose(); + params.abortSignal?.removeEventListener?.("abort", onAbort); + } + if (promptError && !aborted) { + const errorText = describeUnknownError(promptError); + if ( + (isAuthErrorMessage(errorText) || + isRateLimitErrorMessage(errorText)) && + (await advanceAuthProfile()) + ) { + continue; + } + const fallbackThinking = pickFallbackThinkingLevel({ + message: errorText, + attempted: attemptedThinking, + }); + if (fallbackThinking) { + log.warn( + `unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`, + ); + thinkLevel = fallbackThinking; + continue; + } + throw promptError; + } + + const lastAssistant = messagesSnapshot + .slice() + .reverse() + .find((m) => (m as AgentMessage)?.role === "assistant") as + | AssistantMessage + | undefined; + + const fallbackThinking = pickFallbackThinkingLevel({ + message: lastAssistant?.errorMessage, + attempted: attemptedThinking, + }); + if (fallbackThinking && !aborted) { + log.warn( + `unsupported thinking level for ${provider}/${modelId}; retrying with ${fallbackThinking}`, + ); + thinkLevel = fallbackThinking; + continue; + } + + const fallbackConfigured = + (params.config?.agent?.model?.fallbacks?.length ?? 0) > 0; + const authFailure = isAuthAssistantError(lastAssistant); + const rateLimitFailure = isRateLimitAssistantError(lastAssistant); + if (!aborted && (authFailure || rateLimitFailure)) { + const rotated = await advanceAuthProfile(); + if (rotated) { + continue; + } + if (fallbackConfigured) { + const message = + lastAssistant?.errorMessage?.trim() || + (lastAssistant + ? formatAssistantErrorText(lastAssistant) + : "") || + (rateLimitFailure + ? "LLM request rate limited." + : "LLM request unauthorized."); + throw new Error(message); + } + } + + const usage = lastAssistant?.usage; + const agentMeta: EmbeddedPiAgentMeta = { + sessionId: sessionIdUsed, + provider: lastAssistant?.provider ?? provider, + model: lastAssistant?.model ?? model.id, + usage: usage + ? { + input: usage.input, + output: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + total: usage.totalTokens, + } + : undefined, + }; + + const replyItems: Array<{ text: string; media?: string[] }> = []; + + const errorText = lastAssistant + ? formatAssistantErrorText(lastAssistant) + : undefined; + if (errorText) replyItems.push({ text: errorText }); + + const inlineToolResults = + params.verboseLevel === "on" && + !params.onPartialReply && + !params.onToolResult && + toolMetas.length > 0; + if (inlineToolResults) { + for (const { toolName, meta } of toolMetas) { + const agg = formatToolAggregate(toolName, meta ? [meta] : []); + const { text: cleanedText, mediaUrls } = + splitMediaFromOutput(agg); + if (cleanedText) + replyItems.push({ text: cleanedText, media: mediaUrls }); + } + } + + for (const text of assistantTexts.length + ? assistantTexts + : lastAssistant + ? [extractAssistantText(lastAssistant)] + : []) { + const { text: cleanedText, mediaUrls } = splitMediaFromOutput(text); + if (!cleanedText && (!mediaUrls || mediaUrls.length === 0)) + continue; + replyItems.push({ text: cleanedText, media: mediaUrls }); + } + + const payloads = replyItems + .map((item) => ({ + text: item.text?.trim() ? item.text.trim() : undefined, + mediaUrls: item.media?.length ? item.media : undefined, + mediaUrl: item.media?.[0], + })) + .filter( + (p) => + p.text || p.mediaUrl || (p.mediaUrls && p.mediaUrls.length > 0), + ); + + log.debug( + `embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`, + ); + if (lastProfileId) { + markAuthProfileGood({ + store: authStore, + provider, + profileId: lastProfileId, + }); + } + return { + payloads: payloads.length ? payloads : undefined, + meta: { + durationMs: Date.now() - started, + agentMeta, + aborted, + }, + }; + } finally { + restoreSkillEnv?.(); + process.chdir(prevCwd); + } } }), ); diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index 8c7751c51..c22316357 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -968,6 +968,7 @@ describe("subscribeEmbeddedPiSession", () => { }); } + expect(subscription.isCompacting()).toBe(true); expect(subscription.assistantTexts.length).toBe(0); let resolved = false; @@ -1004,6 +1005,8 @@ describe("subscribeEmbeddedPiSession", () => { listener({ type: "auto_compaction_start" }); } + expect(subscription.isCompacting()).toBe(true); + let resolved = false; const waitPromise = subscription.waitForCompactionRetry().then(() => { resolved = true; @@ -1018,6 +1021,7 @@ describe("subscribeEmbeddedPiSession", () => { await waitPromise; expect(resolved).toBe(true); + expect(subscription.isCompacting()).toBe(false); }); it("waits for multiple compaction retries before resolving", async () => { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 69bbecf65..e87ff74e8 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -555,6 +555,10 @@ export function subscribeEmbeddedPiSession(params: { compactionInFlight = true; ensureCompactionPromise(); log.debug(`embedded run compaction start: runId=${params.runId}`); + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "start" }, + }); } if (evt.type === "auto_compaction_end") { @@ -567,6 +571,10 @@ export function subscribeEmbeddedPiSession(params: { } else { maybeResolveCompactionWait(); } + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry }, + }); } if (evt.type === "agent_end") { @@ -596,6 +604,7 @@ export function subscribeEmbeddedPiSession(params: { assistantTexts, toolMetas, unsubscribe, + isCompacting: () => compactionInFlight || pendingCompactionRetry > 0, waitForCompactionRetry: () => { if (compactionInFlight || pendingCompactionRetry > 0) { ensureCompactionPromise(); diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index 022a4898f..81e99feec 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -1,10 +1,12 @@ export type { EmbeddedPiAgentMeta, + EmbeddedPiCompactResult, EmbeddedPiRunMeta, EmbeddedPiRunResult, } from "./pi-embedded-runner.js"; export { abortEmbeddedPiRun, + compactEmbeddedPiSession, isEmbeddedPiRunActive, isEmbeddedPiRunStreaming, queueEmbeddedPiMessage, diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 802feef77..e2bc2fe7b 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -46,4 +46,16 @@ describe("buildAgentSystemPromptAppend", () => { expect(prompt).toContain("sessions_send"); expect(prompt).toContain("Unavailable tools (do not call):"); }); + + it("includes user time when provided", () => { + const prompt = buildAgentSystemPromptAppend({ + workspaceDir: "/tmp/clawd", + userTimezone: "America/Chicago", + userTime: "2026-01-05 15:26", + }); + + expect(prompt).toContain("## Time"); + expect(prompt).toContain("User timezone: America/Chicago"); + expect(prompt).toContain("Current user time: 2026-01-05 15:26"); + }); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 7d97aff24..4528d372d 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -7,6 +7,8 @@ export function buildAgentSystemPromptAppend(params: { ownerNumbers?: string[]; reasoningTagHint?: boolean; toolNames?: string[]; + userTimezone?: string; + userTime?: string; runtimeInfo?: { host?: string; os?: string; @@ -109,6 +111,8 @@ export function buildAgentSystemPromptAppend(params: { "Hey there! What would you like to do next?", ].join(" ") : undefined; + const userTimezone = params.userTimezone?.trim(); + const userTime = params.userTime?.trim(); const runtimeInfo = params.runtimeInfo; const runtimeLines: string[] = []; if (runtimeInfo?.host) runtimeLines.push(`Host: ${runtimeInfo.host}`); @@ -182,6 +186,10 @@ export function buildAgentSystemPromptAppend(params: { "Never send streaming/partial replies to external messaging surfaces; only final replies should be delivered there.", "Clawdbot handles message transport automatically; respond normally and your reply will be delivered to the current chat.", "", + userTimezone || userTime ? "## Time" : "", + userTimezone ? `User timezone: ${userTimezone}` : "", + userTime ? `Current user time: ${userTime}` : "", + userTimezone || userTime ? "" : "", "## Reply Tags", "To request a native reply/quote on supported surfaces, include one tag in your reply:", "- [[reply_to_current]] replies to the triggering message.", diff --git a/src/agents/timeout.ts b/src/agents/timeout.ts new file mode 100644 index 000000000..65d0eeb9c --- /dev/null +++ b/src/agents/timeout.ts @@ -0,0 +1,35 @@ +import type { ClawdbotConfig } from "../config/config.js"; + +const DEFAULT_AGENT_TIMEOUT_SECONDS = 600; + +const normalizeNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) + ? Math.floor(value) + : undefined; + +export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number { + const raw = normalizeNumber(cfg?.agent?.timeoutSeconds); + const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS; + return Math.max(seconds, 1); +} + +export function resolveAgentTimeoutMs(opts: { + cfg?: ClawdbotConfig; + overrideMs?: number | null; + overrideSeconds?: number | null; + minMs?: number; +}): number { + const minMs = Math.max(normalizeNumber(opts.minMs) ?? 1, 1); + const defaultMs = resolveAgentTimeoutSeconds(opts.cfg) * 1000; + const overrideMs = normalizeNumber(opts.overrideMs); + if (overrideMs !== undefined) { + if (overrideMs <= 0) return defaultMs; + return Math.max(overrideMs, minMs); + } + const overrideSeconds = normalizeNumber(opts.overrideSeconds); + if (overrideSeconds !== undefined) { + if (overrideSeconds <= 0) return defaultMs; + return Math.max(overrideSeconds * 1000, minMs); + } + return Math.max(defaultMs, minMs); +} diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 403de1f2d..6de42b775 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -12,7 +12,7 @@ "element", "node", "nodeId", - "jobId", + "id", "requestId", "to", "channelId", @@ -136,10 +136,10 @@ "label": "add", "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] }, - "update": { "label": "update", "detailKeys": ["jobId"] }, - "remove": { "label": "remove", "detailKeys": ["jobId"] }, - "run": { "label": "run", "detailKeys": ["jobId"] }, - "runs": { "label": "runs", "detailKeys": ["jobId"] }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, "wake": { "label": "wake", "detailKeys": ["text", "mode"] } } }, @@ -229,4 +229,3 @@ } } } - diff --git a/src/agents/tool-display.ts b/src/agents/tool-display.ts index 6975675f5..1d531e8c5 100644 --- a/src/agents/tool-display.ts +++ b/src/agents/tool-display.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { redactToolDetail } from "../logging/redact.js"; import { shortenHomeInString } from "../utils.js"; type ToolDisplayActionSpec = { @@ -193,7 +194,7 @@ export function resolveToolDisplay(params: { export function formatToolDetail(display: ToolDisplay): string | undefined { const parts: string[] = []; if (display.verb) parts.push(display.verb); - if (display.detail) parts.push(display.detail); + if (display.detail) parts.push(redactToolDetail(display.detail)); if (parts.length === 0) return undefined; return parts.join(" · "); } diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 3a8c92d4a..0cc248d1c 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -35,14 +35,31 @@ describe("cron tool", () => { expect(call.params).toEqual(expectedParams); }); - it("rejects jobId params", async () => { + it("normalizes cron.add job payloads", async () => { const tool = createCronTool(); - await expect( - tool.execute("call2", { - action: "update", - jobId: "job-1", - patch: { foo: "bar" }, - }), - ).rejects.toThrow("id required"); + await tool.execute("call2", { + action: "add", + job: { + data: { + name: "wake-up", + schedule: { atMs: 123 }, + payload: { text: "hello" }, + }, + }, + }); + + expect(callGatewayMock).toHaveBeenCalledTimes(1); + const call = callGatewayMock.mock.calls[0]?.[0] as { + method?: string; + params?: unknown; + }; + expect(call.method).toBe("cron.add"); + expect(call.params).toEqual({ + name: "wake-up", + schedule: { kind: "at", atMs: 123 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); }); }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index ccc0fa33f..38e7ee9e9 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -1,8 +1,14 @@ import { Type } from "@sinclair/typebox"; - +import { + normalizeCronJobCreate, + normalizeCronJobPatch, +} from "../../cron/normalize.js"; +import { CronAddParamsSchema } from "../../gateway/protocol/schema.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; +const CronJobPatchSchema = Type.Partial(CronAddParamsSchema); + const CronToolSchema = Type.Union([ Type.Object({ action: Type.Literal("status"), @@ -22,7 +28,7 @@ const CronToolSchema = Type.Union([ gatewayUrl: Type.Optional(Type.String()), gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), - job: Type.Object({}, { additionalProperties: true }), + job: CronAddParamsSchema, }), Type.Object({ action: Type.Literal("update"), @@ -30,7 +36,7 @@ const CronToolSchema = Type.Union([ gatewayToken: Type.Optional(Type.String()), timeoutMs: Type.Optional(Type.Number()), id: Type.String(), - patch: Type.Object({}, { additionalProperties: true }), + patch: CronJobPatchSchema, }), Type.Object({ action: Type.Literal("remove"), @@ -97,8 +103,9 @@ export function createCronTool(): AnyAgentTool { if (!params.job || typeof params.job !== "object") { throw new Error("job required"); } + const job = normalizeCronJobCreate(params.job) ?? params.job; return jsonResult( - await callGatewayTool("cron.add", gatewayOpts, params.job), + await callGatewayTool("cron.add", gatewayOpts, job), ); } case "update": { @@ -106,10 +113,11 @@ export function createCronTool(): AnyAgentTool { if (!params.patch || typeof params.patch !== "object") { throw new Error("patch required"); } + const patch = normalizeCronJobPatch(params.patch) ?? params.patch; return jsonResult( await callGatewayTool("cron.update", gatewayOpts, { id, - patch: params.patch, + patch, }), ); } diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 63759cb35..855a72d8f 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -24,6 +24,32 @@ type ActionGate = ( defaultValue?: boolean, ) => boolean; +function formatDiscordTimestamp(ts?: string | null): string | undefined { + if (!ts) return undefined; + const date = new Date(ts); + if (Number.isNaN(date.getTime())) return undefined; + + const yyyy = String(date.getFullYear()).padStart(4, "0"); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const min = String(date.getMinutes()).padStart(2, "0"); + + // getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset. + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? "+" : "-"; + const absOffsetMinutes = Math.abs(offsetMinutes); + const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0"); + const offsetM = String(absOffsetMinutes % 60).padStart(2, "0"); + + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const tzSuffix = tz ? `{${tz}}` : ""; + + // Compact ISO-like *local* timestamp with minutes precision. + // Example: 2025-01-02T03:04-08:00{America/Los_Angeles} + return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`; +} + export async function handleDiscordMessagingAction( action: string, params: Record, @@ -100,9 +126,10 @@ export async function handleDiscordMessagingAction( typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined; + const maxSelections = allowMultiselect ? Math.max(2, answers.length) : 1; await sendPollDiscord( to, - { question, answers, allowMultiselect, durationHours }, + { question, options: answers, maxSelections, durationHours }, { content }, ); return jsonResult({ ok: true }); @@ -133,7 +160,12 @@ export async function handleDiscordMessagingAction( after: readStringParam(params, "after"), around: readStringParam(params, "around"), }); - return jsonResult({ ok: true, messages }); + const formattedMessages = messages.map((message) => ({ + ...message, + timestamp: + formatDiscordTimestamp(message.timestamp) ?? message.timestamp, + })); + return jsonResult({ ok: true, messages: formattedMessages }); } case "sendMessage": { if (!isActionEnabled("messages")) { diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 0f3ebd500..e39841972 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -24,9 +24,15 @@ 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); + const imageModel = cfg?.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | string + | undefined; + const primary = + typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary; + const fallbacks = + typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : []; + return Boolean(primary?.trim() || fallbacks.length > 0); } function pickMaxBytes( @@ -95,15 +101,18 @@ async function runImagePrompt(params: { `Model does not support images: ${provider}/${modelId}`, ); } - const apiKey = await getApiKeyForModel(model, authStorage); - authStorage.setRuntimeApiKey(model.provider, apiKey); + const apiKeyInfo = await getApiKeyForModel({ + model, + cfg: params.cfg, + }); + authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); const context = buildImageContext( params.prompt, params.base64, params.mimeType, ); const message = (await complete(model, context, { - apiKey, + apiKey: apiKeyInfo.apiKey, maxTokens: 512, temperature: 0, })) as AssistantMessage; diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts new file mode 100644 index 000000000..7599c7390 --- /dev/null +++ b/src/auto-reply/command-auth.ts @@ -0,0 +1,74 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { normalizeE164 } from "../utils.js"; +import type { MsgContext } from "./templating.js"; + +export type CommandAuthorization = { + isWhatsAppSurface: boolean; + ownerList: string[]; + senderE164?: string; + isAuthorizedSender: boolean; + from?: string; + to?: string; +}; + +export function resolveCommandAuthorization(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + commandAuthorized: boolean; +}): CommandAuthorization { + const { ctx, cfg, commandAuthorized } = params; + const surface = (ctx.Surface ?? "").trim().toLowerCase(); + const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); + const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); + const hasWhatsappPrefix = + (ctx.From ?? "").startsWith("whatsapp:") || + (ctx.To ?? "").startsWith("whatsapp:"); + const looksLikeE164 = (value: string) => + Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, ""))); + const inferWhatsApp = + !surface && + Boolean(cfg.whatsapp?.allowFrom?.length) && + (looksLikeE164(from) || looksLikeE164(to)); + const isWhatsAppSurface = + surface === "whatsapp" || hasWhatsappPrefix || inferWhatsApp; + + const configuredAllowFrom = isWhatsAppSurface + ? cfg.whatsapp?.allowFrom + : undefined; + const allowFromList = + configuredAllowFrom?.filter((entry) => entry?.trim()) ?? []; + const allowAll = + !isWhatsAppSurface || + allowFromList.length === 0 || + allowFromList.some((entry) => entry.trim() === "*"); + + const senderE164 = normalizeE164( + ctx.SenderE164 ?? (isWhatsAppSurface ? from : ""), + ); + 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 isOwner = + !isWhatsAppSurface || + allowAll || + ownerList.length === 0 || + (senderE164 ? ownerList.includes(senderE164) : false); + const isAuthorizedSender = commandAuthorized && isOwner; + + return { + isWhatsAppSurface, + ownerList, + senderE164: senderE164 || undefined, + isAuthorizedSender, + from: from || undefined, + to: to || undefined, + }; +} diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index 1148732af..1782f66f9 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -1,5 +1,5 @@ const CONTROL_COMMAND_RE = - /(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i; + /(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)(?=$|\s|:)\b/i; const CONTROL_COMMAND_EXACT = new Set([ "help", @@ -16,6 +16,8 @@ const CONTROL_COMMAND_EXACT = new Set([ "/reset", "new", "/new", + "compact", + "/compact", ]); export function hasControlCommand(text?: string): boolean { diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index 90f8b9ef0..d5ae06674 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -19,12 +19,12 @@ describe("formatAgentEnvelope", () => { process.env.TZ = originalTz; - expect(body).toMatch( - /^\[WebChat user1 mac-mini 10\.0\.0\.5 2025-01-02T03:04\+00:00\{.+\}\] hello$/, + expect(body).toBe( + "[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello", ); }); - it("formats timestamps in local time (not UTC)", () => { + it("formats timestamps in UTC regardless of local timezone", () => { const originalTz = process.env.TZ; process.env.TZ = "America/Los_Angeles"; @@ -37,9 +37,7 @@ describe("formatAgentEnvelope", () => { process.env.TZ = originalTz; - expect(body).toBe( - "[WebChat 2025-01-01T19:04-08:00{America/Los_Angeles}] hello", - ); + expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello"); }); it("handles missing optional fields", () => { diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index fc5ad21fe..6238e5c82 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -12,25 +12,15 @@ function formatTimestamp(ts?: number | Date): string | undefined { const date = ts instanceof Date ? ts : new Date(ts); if (Number.isNaN(date.getTime())) return undefined; - const yyyy = String(date.getFullYear()).padStart(4, "0"); - const mm = String(date.getMonth() + 1).padStart(2, "0"); - const dd = String(date.getDate()).padStart(2, "0"); - const hh = String(date.getHours()).padStart(2, "0"); - const min = String(date.getMinutes()).padStart(2, "0"); + const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(date.getUTCDate()).padStart(2, "0"); + const hh = String(date.getUTCHours()).padStart(2, "0"); + const min = String(date.getUTCMinutes()).padStart(2, "0"); - // getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset. - const offsetMinutes = -date.getTimezoneOffset(); - const sign = offsetMinutes >= 0 ? "+" : "-"; - const absOffsetMinutes = Math.abs(offsetMinutes); - const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0"); - const offsetM = String(absOffsetMinutes % 60).padStart(2, "0"); - - const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - const tzSuffix = tz ? `{${tz}}` : ""; - - // Compact ISO-like *local* timestamp with minutes precision. - // Example: 2025-01-02T03:04-08:00{America/Los_Angeles} - return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`; + // Compact ISO-like UTC timestamp with minutes precision. + // Example: 2025-01-02T03:04Z + return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; } export function formatAgentEnvelope(params: AgentEnvelopeParams): string { diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index ba0fa9868..d4b57bfe2 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -1,6 +1,7 @@ import { HEARTBEAT_TOKEN } from "./tokens.js"; export const HEARTBEAT_PROMPT = "HEARTBEAT"; +export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 30; export type StripHeartbeatMode = "heartbeat" | "message"; @@ -44,7 +45,10 @@ export function stripHeartbeatToken( if (!trimmed) return { shouldSkip: true, text: "", didStrip: false }; const mode: StripHeartbeatMode = opts.mode ?? "message"; - const maxAckChars = Math.max(0, opts.maxAckChars ?? 30); + const maxAckChars = Math.max( + 0, + opts.maxAckChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); if (!trimmed.includes(HEARTBEAT_TOKEN)) { return { shouldSkip: false, text: trimmed, didStrip: false }; diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts index 834e3daa8..56bb6e19e 100644 --- a/src/auto-reply/model.ts +++ b/src/auto-reply/model.ts @@ -1,19 +1,28 @@ export function extractModelDirective(body?: string): { cleaned: string; rawModel?: string; + rawProfile?: string; hasDirective: boolean; } { if (!body) return { cleaned: "", hasDirective: false }; const match = body.match( - /(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:-]+(?:\/[A-Za-z0-9_.:-]+)?)?/i, + /(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i, ); - const rawModel = match?.[1]?.trim(); + const raw = match?.[1]?.trim(); + let rawModel = raw; + let rawProfile: string | undefined; + if (raw?.includes("@")) { + const parts = raw.split("@"); + rawModel = parts[0]?.trim(); + rawProfile = parts.slice(1).join("@").trim() || undefined; + } const cleaned = match ? body.replace(match[0], "").replace(/\s+/g, " ").trim() : body.trim(); return { cleaned, rawModel, + rawProfile, hasDirective: !!match, }; } diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 082c05a71..062c2099c 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -37,11 +37,24 @@ vi.mock("../agents/model-catalog.js", () => ({ async function withTempHome(fn: (home: string) => Promise): Promise { const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-")); const previousHome = process.env.HOME; + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; process.env.HOME = base; + process.env.CLAWDBOT_STATE_DIR = path.join(base, ".clawdbot"); + process.env.CLAWDBOT_AGENT_DIR = path.join(base, ".clawdbot", "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; try { return await fn(base); } finally { process.env.HOME = previousHome; + if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previousStateDir; + if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR; + else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; + if (previousPiAgentDir === undefined) + delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; await fs.rm(base, { recursive: true, force: true }); } } @@ -566,9 +579,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, workspace: path.join(home, "clawd"), - allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"], + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, session: { store: storePath }, }, @@ -593,9 +609,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, workspace: path.join(home, "clawd"), - allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"], + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, session: { store: storePath }, }, @@ -620,9 +639,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, workspace: path.join(home, "clawd"), - allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"], + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, session: { store: storePath }, }, @@ -636,6 +658,33 @@ describe("directive parsing", () => { }); }); + it("does not repeat missing auth labels on /model list", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/model list", From: "+1222", To: "+1222" }, + {}, + { + agent: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: path.join(home, "clawd"), + models: { + "anthropic/claude-opus-4-5": {}, + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("auth:"); + expect(text).not.toContain("missing (missing)"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("sets model override on /model directive", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); @@ -646,9 +695,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, workspace: path.join(home, "clawd"), - allowedModels: ["openai/gpt-4.1-mini"], + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, session: { store: storePath }, }, @@ -674,11 +726,11 @@ describe("directive parsing", () => { {}, { agent: { - model: "openai/gpt-4.1-mini", + model: { primary: "openai/gpt-4.1-mini" }, workspace: path.join(home, "clawd"), - allowedModels: ["openai/gpt-4.1-mini", "anthropic/claude-opus-4-5"], - modelAliases: { - Opus: "anthropic/claude-opus-4-5", + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, }, }, session: { store: storePath }, @@ -696,6 +748,55 @@ describe("directive parsing", () => { }); }); + it("stores auth profile overrides on /model directive", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const storePath = path.join(home, "sessions.json"); + const authDir = path.join(home, ".clawdbot", "agent"); + await fs.mkdir(authDir, { recursive: true, mode: 0o700 }); + await fs.writeFile( + path.join(authDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "sk-test-1234567890", + }, + }, + }, + null, + 2, + ), + ); + + const res = await getReplyFromConfig( + { Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" }, + {}, + { + agent: { + model: { primary: "openai/gpt-4.1-mini" }, + workspace: path.join(home, "clawd"), + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Auth profile set to anthropic:work"); + const store = loadSessionStore(storePath); + const entry = store.main; + expect(entry.authProfileOverride).toBe("anthropic:work"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("queues a system event when switching models", async () => { await withTempHome(async (home) => { drainSystemEvents(); @@ -707,11 +808,11 @@ describe("directive parsing", () => { {}, { agent: { - model: "openai/gpt-4.1-mini", + model: { primary: "openai/gpt-4.1-mini" }, workspace: path.join(home, "clawd"), - allowedModels: ["openai/gpt-4.1-mini", "anthropic/claude-opus-4-5"], - modelAliases: { - Opus: "anthropic/claude-opus-4-5", + models: { + "openai/gpt-4.1-mini": {}, + "anthropic/claude-opus-4-5": { alias: "Opus" }, }, }, session: { store: storePath }, @@ -746,9 +847,12 @@ describe("directive parsing", () => { {}, { agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, workspace: path.join(home, "clawd"), - allowedModels: ["openai/gpt-4.1-mini"], + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-4.1-mini": {}, + }, }, whatsapp: { allowFrom: ["*"], diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 19d6f0ff7..006bfdaca 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + compactEmbeddedPiSession: vi.fn(), runEmbeddedPiAgent: vi.fn(), queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), resolveEmbeddedSessionLane: (key: string) => @@ -13,9 +14,12 @@ vi.mock("../agents/pi-embedded.js", () => ({ isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), })); -import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; +import { + compactEmbeddedPiSession, + runEmbeddedPiAgent, +} from "../agents/pi-embedded.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; -import { resolveSessionKey } from "../config/sessions.js"; +import { loadSessionStore, resolveSessionKey } from "../config/sessions.js"; import { getReplyFromConfig } from "./reply.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; @@ -670,6 +674,111 @@ describe("trigger handling", () => { }); }); + it("does not reset for unauthorized /reset", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/reset", + From: "+1003", + To: "+2000", + CommandAuthorized: false, + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["+1999"], + }, + session: { + store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`), + }, + }, + ); + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("blocks /reset for non-owner senders", async () => { + await withTempHome(async (home) => { + const res = await getReplyFromConfig( + { + Body: "/reset", + From: "+1003", + To: "+2000", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["+1999"], + }, + session: { + store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`), + }, + }, + ); + expect(res).toBeUndefined(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("runs /compact as a gated command", async () => { + await withTempHome(async (home) => { + const storePath = join( + tmpdir(), + `clawdbot-session-test-${Date.now()}.json`, + ); + vi.mocked(compactEmbeddedPiSession).mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "x", + tokensBefore: 12000, + }, + }); + + const res = await getReplyFromConfig( + { + Body: "/compact focus on decisions", + From: "+1003", + To: "+2000", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + }, + whatsapp: { + allowFrom: ["*"], + }, + session: { + store: storePath, + }, + }, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text?.startsWith("⚙️ Compacted")).toBe(true); + expect(compactEmbeddedPiSession).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + const store = loadSessionStore(storePath); + const sessionKey = resolveSessionKey("per-sender", { + Body: "/compact focus on decisions", + From: "+1003", + To: "+2000", + }); + expect(store[sessionKey]?.compactionCount).toBe(1); + }); + }); + it("ignores think directives that only appear in the context wrapper", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 703884bfb..cd91595d6 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -11,6 +11,7 @@ import { resolveEmbeddedSessionLane, } from "../agents/pi-embedded.js"; import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -24,6 +25,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 { resolveCommandAuthorization } from "./command-auth.js"; import { hasControlCommand } from "./command-detection.js"; import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; @@ -42,6 +44,7 @@ import { defaultGroupActivation, resolveGroupRequireMention, } from "./reply/groups.js"; +import { stripMentions } from "./reply/mentions.js"; import { createModelSelectionState, resolveContextTokens, @@ -76,6 +79,9 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js"; const BARE_SESSION_RESET_PROMPT = "A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning."; +const CONTROL_COMMAND_PREFIX_RE = + /^\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)\b/i; + function normalizeAllowToken(value?: string) { if (!value) return ""; return value.trim().toLowerCase(); @@ -216,8 +222,7 @@ export async function getReplyFromConfig( ensureBootstrapFiles: true, }); const workspaceDir = workspace.dir; - const timeoutSeconds = Math.max(agentCfg?.timeoutSeconds ?? 600, 1); - const timeoutMs = timeoutSeconds * 1000; + const timeoutMs = resolveAgentTimeoutMs({ cfg }); const configuredTypingSeconds = agentCfg?.typingIntervalSeconds ?? sessionCfg?.typingIntervalSeconds; const typingIntervalSeconds = @@ -228,6 +233,7 @@ export async function getReplyFromConfig( silentToken: SILENT_REPLY_TOKEN, log: defaultRuntime.log, }); + opts?.onTypingController?.(typing); let transcribedText: string | undefined; if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) { @@ -240,7 +246,17 @@ export async function getReplyFromConfig( } } - const sessionState = await initSessionState({ ctx, cfg }); + const commandAuthorized = ctx.CommandAuthorized ?? true; + const commandAuth = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized, + }); + const sessionState = await initSessionState({ + ctx, + cfg, + commandAuthorized, + }); let { sessionCtx, sessionEntry, @@ -258,7 +274,6 @@ export async function getReplyFromConfig( } = sessionState; const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; - const commandAuthorized = ctx.CommandAuthorized ?? true; const parsedDirectives = parseInlineDirectives(rawBody); const directives = commandAuthorized ? parsedDirectives @@ -361,7 +376,9 @@ export async function getReplyFromConfig( : `Model switched to ${label}.`; const isModelListAlias = directives.hasModelDirective && - directives.rawModelDirective?.trim().toLowerCase() === "status"; + ["status", "list"].includes( + directives.rawModelDirective?.trim().toLowerCase() ?? "", + ); const effectiveModelDirective = isModelListAlias ? undefined : directives.rawModelDirective; @@ -376,6 +393,7 @@ export async function getReplyFromConfig( }) ) { const directiveReply = await handleDirectiveOnly({ + cfg, directives, sessionEntry, sessionStore, @@ -401,6 +419,7 @@ export async function getReplyFromConfig( const persisted = await persistInlineDirectives({ directives, effectiveModelDirective, + cfg, sessionEntry, sessionStore, sessionKey, @@ -512,6 +531,16 @@ export async function getReplyFromConfig( const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? ""; const rawBodyTrimmed = (ctx.Body ?? "").trim(); const baseBodyTrimmedRaw = baseBody.trim(); + const strippedCommandBody = isGroup + ? stripMentions(triggerBodyNormalized, ctx, cfg) + : triggerBodyNormalized; + if ( + !commandAuth.isAuthorizedSender && + CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim()) + ) { + typing.cleanup(); + return undefined; + } if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) { typing.cleanup(); return undefined; @@ -634,6 +663,7 @@ export async function getReplyFromConfig( resolvedQueue.mode === "followup" || resolvedQueue.mode === "collect" || resolvedQueue.mode === "steer-backlog"; + const authProfileId = sessionEntry?.authProfileOverride; const followupRun = { prompt: queuedBody, summaryLine: baseBodyTrimmedRaw, @@ -648,6 +678,7 @@ export async function getReplyFromConfig( skillsSnapshot, provider, model, + authProfileId, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, elevatedLevel: resolvedElevatedLevel, diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts index e5cec45c8..b0abb516d 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.test.ts @@ -1,5 +1,9 @@ +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; @@ -46,6 +50,8 @@ function createTyping(): TypingController { startTypingLoop: vi.fn(async () => {}), startTypingOnText: vi.fn(async () => {}), refreshTypingTtl: vi.fn(), + markRunComplete: vi.fn(), + markDispatchIdle: vi.fn(), cleanup: vi.fn(), }; } @@ -54,7 +60,14 @@ type EmbeddedPiAgentParams = { onPartialReply?: (payload: { text?: string }) => Promise | void; }; -function createMinimalRun(params?: { opts?: GetReplyOptions }) { +function createMinimalRun(params?: { + opts?: GetReplyOptions; + resolvedVerboseLevel?: "off" | "on"; + sessionStore?: Record; + sessionEntry?: SessionEntry; + sessionKey?: string; + storePath?: string; +}) { const typing = createTyping(); const opts = params?.opts; const sessionCtx = { @@ -62,13 +75,14 @@ function createMinimalRun(params?: { opts?: GetReplyOptions }) { MessageSid: "msg", } as unknown as TemplateContext; const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const sessionKey = params?.sessionKey ?? "main"; const followupRun = { prompt: "hello", summaryLine: "hello", enqueuedAt: Date.now(), run: { sessionId: "session", - sessionKey: "main", + sessionKey, surface: "whatsapp", sessionFile: "/tmp/session.jsonl", workspaceDir: "/tmp", @@ -77,7 +91,7 @@ function createMinimalRun(params?: { opts?: GetReplyOptions }) { provider: "anthropic", model: "claude", thinkLevel: "low", - verboseLevel: "off", + verboseLevel: params?.resolvedVerboseLevel ?? "off", elevatedLevel: "off", bashElevated: { enabled: false, @@ -104,9 +118,13 @@ function createMinimalRun(params?: { opts?: GetReplyOptions }) { isStreaming: false, opts, typing, + sessionEntry: params?.sessionEntry, + sessionStore: params?.sessionStore, + sessionKey, + storePath: params?.storePath, sessionCtx, defaultModel: "anthropic/claude-opus-4-5", - resolvedVerboseLevel: "off", + resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off", isNewSession: false, blockStreamingEnabled: false, resolvedBlockStreamingBreak: "message_end", @@ -153,4 +171,42 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(typing.startTypingOnText).not.toHaveBeenCalled(); expect(typing.startTypingLoop).not.toHaveBeenCalled(); }); + + it("announces auto-compaction in verbose mode and tracks count", async () => { + const storePath = path.join( + await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")), + "sessions.json", + ); + const sessionEntry = { sessionId: "session", updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + runEmbeddedPiAgentMock.mockImplementationOnce( + async (params: { + onAgentEvent?: (evt: { + stream: string; + data: Record; + }) => void; + }) => { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }, + ); + + const { run } = createMinimalRun({ + resolvedVerboseLevel: "on", + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + expect(Array.isArray(res)).toBe(true); + const payloads = res as { text?: string }[]; + expect(payloads[0]?.text).toContain("Auto-compaction complete"); + expect(payloads[0]?.text).toContain("count 1"); + expect(sessionStore.main.compactionCount).toBe(1); + }); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 5ad34c387..21eadb12c 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -27,6 +27,7 @@ import { scheduleFollowupDrain, } from "./queue.js"; import { extractReplyToTag } from "./reply-tags.js"; +import { incrementCompactionCount } from "./session-updates.js"; import type { TypingController } from "./typing.js"; export async function runReplyAgent(params: { @@ -167,6 +168,7 @@ export async function runReplyAgent(params: { }; let didLogHeartbeatStrip = false; + let autoCompactionCompleted = false; try { const runId = crypto.randomUUID(); if (sessionKey) { @@ -195,6 +197,7 @@ export async function runReplyAgent(params: { enforceFinalTag: followupRun.run.enforceFinalTag, provider, model, + authProfileId: followupRun.run.authProfileId, thinkLevel: followupRun.run.thinkLevel, verboseLevel: followupRun.run.verboseLevel, bashElevated: followupRun.run.bashElevated, @@ -232,6 +235,15 @@ export async function runReplyAgent(params: { }); } : undefined, + onAgentEvent: (evt) => { + if (evt.stream !== "compaction") return; + const phase = + typeof evt.data.phase === "string" ? evt.data.phase : ""; + const willRetry = Boolean(evt.data.willRetry); + if (phase === "end" && !willRetry) { + autoCompactionCompleted = true; + } + }, onBlockReply: blockStreamingEnabled && opts?.onBlockReply ? async (payload) => { @@ -477,6 +489,21 @@ export async function runReplyAgent(params: { // If verbose is enabled and this is a new session, prepend a session hint. let finalPayloads = filteredPayloads; + if (autoCompactionCompleted) { + const count = await incrementCompactionCount({ + sessionEntry, + sessionStore, + sessionKey, + storePath, + }); + if (resolvedVerboseLevel === "on") { + const suffix = typeof count === "number" ? ` (count ${count})` : ""; + finalPayloads = [ + { text: `🧹 Auto-compaction complete${suffix}.` }, + ...finalPayloads, + ]; + } + } if (resolvedVerboseLevel === "on" && isNewSession) { finalPayloads = [ { text: `🧭 New session: ${followupRun.run.sessionId}` }, @@ -488,6 +515,6 @@ export async function runReplyAgent(params: { finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, ); } finally { - typing.cleanup(); + typing.markRunComplete(); } } diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index d225e28bf..22cc7f7c8 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,33 +1,50 @@ -import fs from "node:fs"; - -import { getEnvApiKey } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; -import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; -import type { ClawdbotConfig } from "../../config/config.js"; -import { resolveOAuthPath } from "../../config/paths.js"; import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "../../agents/auth-profiles.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; +import { + abortEmbeddedPiRun, + compactEmbeddedPiSession, + isEmbeddedPiRunActive, + waitForEmbeddedPiRunEnd, +} from "../../agents/pi-embedded.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { + resolveSessionTranscriptPath, type SessionEntry, type SessionScope, saveSessionStore, } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { triggerClawdbotRestart } from "../../infra/restart.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { normalizeE164 } from "../../utils.js"; import { resolveHeartbeatSeconds } from "../../web/reconnect.js"; import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; import { normalizeGroupActivation, parseActivationCommand, } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; -import { buildHelpMessage, buildStatusMessage } from "../status.js"; +import { + buildHelpMessage, + buildStatusMessage, + formatContextUsageShort, + formatTokenCount, +} from "../status.js"; import type { MsgContext } from "../templating.js"; import type { ElevatedLevel, 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"; +import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; +import { incrementCompactionCount } from "./session-updates.js"; export type CommandContext = { surface: string; @@ -42,59 +59,60 @@ export type CommandContext = { to?: string; }; -function hasOAuthCredentials(provider: string): boolean { - try { - const oauthPath = resolveOAuthPath(); - if (!fs.existsSync(oauthPath)) return false; - const raw = fs.readFileSync(oauthPath, "utf8"); - const parsed = JSON.parse(raw) as Record; - const entry = parsed?.[provider] 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()); - } catch { - return false; - } -} - -function resolveModelAuthLabel(provider?: string): string | undefined { +function resolveModelAuthLabel( + provider?: string, + cfg?: ClawdbotConfig, +): string | undefined { const resolved = provider?.trim(); if (!resolved) return undefined; - try { - const authStorage = discoverAuthStorage(resolveClawdbotAgentDir()); - const stored = authStorage.get(resolved); - if (stored?.type === "oauth") return "oauth"; - if (stored?.type === "api_key") return "api-key"; - } catch { - // ignore auth storage errors + const store = ensureAuthProfileStore(); + const profiles = listProfilesForProvider(store, resolved); + if (profiles.length > 0) { + const modes = new Set( + profiles + .map((id) => store.profiles[id]?.type) + .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)), + ); + if (modes.has("oauth") && modes.has("api_key")) return "mixed"; + if (modes.has("oauth")) return "oauth"; + if (modes.has("api_key")) return "api-key"; } - if (resolved === "anthropic") { - const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN; - if (oauthEnv?.trim()) return "oauth"; + const envKey = resolveEnvApiKey(resolved); + if (envKey?.apiKey) { + return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; } - if (hasOAuthCredentials(resolved)) return "oauth"; - - const envKey = getEnvApiKey(resolved); - if (envKey?.trim()) return "api-key"; + if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; return "unknown"; } +function extractCompactInstructions(params: { + rawBody?: string; + ctx: MsgContext; + cfg: ClawdbotConfig; + isGroup: boolean; +}): string | undefined { + const raw = stripStructuralPrefixes(params.rawBody ?? ""); + const stripped = params.isGroup + ? stripMentions(raw, params.ctx, params.cfg) + : raw; + const trimmed = stripped.trim(); + if (!trimmed) return undefined; + const lowered = trimmed.toLowerCase(); + const prefix = lowered.startsWith("/compact") + ? "/compact" + : lowered.startsWith("compact") + ? "compact" + : null; + if (!prefix) return undefined; + let rest = trimmed.slice(prefix.length).trimStart(); + if (rest.startsWith(":")) rest = rest.slice(1).trimStart(); + return rest.length ? rest : undefined; +} + export function buildCommandContext(params: { ctx: MsgContext; cfg: ClawdbotConfig; @@ -103,66 +121,31 @@ export function buildCommandContext(params: { triggerBodyNormalized: string; commandAuthorized: boolean; }): CommandContext { - const { + const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params; + const auth = resolveCommandAuthorization({ ctx, cfg, - sessionKey, - isGroup, - triggerBodyNormalized, - commandAuthorized, - } = params; + commandAuthorized: params.commandAuthorized, + }); const surface = (ctx.Surface ?? "").trim().toLowerCase(); - const isWhatsAppSurface = - surface === "whatsapp" || - (ctx.From ?? "").startsWith("whatsapp:") || - (ctx.To ?? "").startsWith("whatsapp:"); - - const configuredAllowFrom = isWhatsAppSurface - ? cfg.whatsapp?.allowFrom - : undefined; - const from = (ctx.From ?? "").replace(/^whatsapp:/, ""); - const to = (ctx.To ?? "").replace(/^whatsapp:/, ""); - 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 abortKey = + sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined); const rawBodyNormalized = triggerBodyNormalized; const commandBodyNormalized = isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized; - const senderE164 = normalizeE164(ctx.SenderE164 ?? ""); - 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 isOwner = - !isWhatsAppSurface || - allowAll || - ownerList.length === 0 || - (senderE164 ? ownerList.includes(senderE164) : false); - const isAuthorizedSender = commandAuthorized && isOwner; return { surface, - isWhatsAppSurface, - ownerList, - isAuthorizedSender, - senderE164: senderE164 || undefined, + isWhatsAppSurface: auth.isWhatsAppSurface, + ownerList: auth.ownerList, + isAuthorizedSender: auth.isAuthorizedSender, + senderE164: auth.senderE164, abortKey, rawBodyNormalized, commandBodyNormalized, - from: from || undefined, - to: to || undefined, + from: auth.from, + to: auth.to, }; } @@ -191,6 +174,7 @@ export async function handleCommands(params: { shouldContinue: boolean; }> { const { + ctx, cfg, command, directives, @@ -211,6 +195,18 @@ export async function handleCommands(params: { isGroup, } = params; + const resetRequested = + command.commandBodyNormalized === "/reset" || + command.commandBodyNormalized === "reset" || + command.commandBodyNormalized === "/new" || + command.commandBodyNormalized === "new"; + if (resetRequested && !command.isAuthorizedSender) { + logVerbose( + `Ignoring /reset from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + const activationCommand = parseActivationCommand( command.commandBodyNormalized, ); @@ -358,7 +354,10 @@ export async function handleCommands(params: { const statusText = buildStatusMessage({ agent: { ...cfg.agent, - model, + model: { + ...cfg.agent?.model, + primary: model, + }, contextTokens, thinkingDefault: cfg.agent?.thinkingDefault, verboseDefault: cfg.agent?.verboseDefault, @@ -374,7 +373,7 @@ export async function handleCommands(params: { resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), resolvedVerbose: resolvedVerboseLevel, resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider), + modelAuth: resolveModelAuthLabel(provider, cfg), webLinked, webAuthAgeMs, heartbeatSeconds, @@ -382,6 +381,86 @@ export async function handleCommands(params: { return { shouldContinue: false, reply: { text: statusText } }; } + const compactRequested = + command.commandBodyNormalized === "/compact" || + command.commandBodyNormalized === "compact" || + command.commandBodyNormalized.startsWith("/compact ") || + command.commandBodyNormalized.startsWith("compact "); + if (compactRequested) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /compact from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + if (!sessionEntry?.sessionId) { + return { + shouldContinue: false, + reply: { text: "⚙️ Compaction unavailable (missing session id)." }, + }; + } + const sessionId = sessionEntry.sessionId; + if (isEmbeddedPiRunActive(sessionId)) { + abortEmbeddedPiRun(sessionId); + await waitForEmbeddedPiRunEnd(sessionId, 15_000); + } + const customInstructions = extractCompactInstructions({ + rawBody: ctx.Body, + ctx, + cfg, + isGroup, + }); + const result = await compactEmbeddedPiSession({ + sessionId, + sessionKey, + surface: command.surface, + sessionFile: resolveSessionTranscriptPath(sessionId), + workspaceDir, + config: cfg, + skillsSnapshot: sessionEntry.skillsSnapshot, + provider, + model, + thinkLevel: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + customInstructions, + ownerNumbers: + command.ownerList.length > 0 ? command.ownerList : undefined, + }); + + const totalTokens = + sessionEntry.totalTokens ?? + (sessionEntry.inputTokens ?? 0) + (sessionEntry.outputTokens ?? 0); + const contextSummary = formatContextUsageShort( + totalTokens > 0 ? totalTokens : null, + contextTokens ?? sessionEntry.contextTokens ?? null, + ); + const compactLabel = result.ok + ? result.compacted + ? result.result?.tokensBefore + ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)` + : "Compacted" + : "Compaction skipped" + : "Compaction failed"; + if (result.ok && result.compacted) { + await incrementCompactionCount({ + sessionEntry, + sessionStore, + sessionKey, + storePath, + }); + } + const reason = result.reason?.trim(); + const line = reason + ? `${compactLabel}: ${reason} • ${contextSummary}` + : `${compactLabel} • ${contextSummary}`; + enqueueSystemEvent(line); + return { shouldContinue: false, reply: { text: `⚙️ ${line}` } }; + } + const abortRequested = isAbortTrigger(command.rawBodyNormalized); if (abortRequested) { if (sessionEntry && sessionStore && sessionKey) { diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 541345e6b..7b3c240ae 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -1,13 +1,20 @@ -import { getEnvApiKey } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay, +} from "../../agents/auth-profiles.js"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../../agents/defaults.js"; -import { hydrateAuthStorage } from "../../agents/model-auth.js"; +import { + ensureAuthProfileStore, + getCustomProviderApiKey, + resolveAuthProfileOrder, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { buildModelAliasIndex, type ModelAliasIndex, @@ -53,46 +60,93 @@ const maskApiKey = (value: string): string => { const resolveAuthLabel = async ( provider: string, - authStorage: ReturnType, - authPaths: { authPath: string; modelsPath: string }, + cfg: ClawdbotConfig, + modelsPath: string, ): Promise<{ label: string; source: string }> => { const formatPath = (value: string) => shortenHomePath(value); - const stored = authStorage.get(provider); - if (stored?.type === "oauth") { - const email = stored.email?.trim(); + const store = ensureAuthProfileStore(); + const order = resolveAuthProfileOrder({ cfg, store, provider }); + if (order.length > 0) { + const labels = order.map((profileId) => { + const profile = store.profiles[profileId]; + const configProfile = cfg.auth?.profiles?.[profileId]; + if ( + !profile || + (configProfile?.provider && + configProfile.provider !== profile.provider) || + (configProfile?.mode && configProfile.mode !== profile.type) + ) { + return `${profileId}=missing`; + } + if (profile.type === "api_key") { + return `${profileId}=${maskApiKey(profile.key)}`; + } + const display = resolveAuthProfileDisplayLabel({ + cfg, + store, + profileId, + }); + const suffix = + display === profileId + ? "" + : display.startsWith(profileId) + ? display.slice(profileId.length).trim() + : `(${display})`; + return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`; + }); return { - label: email ? `OAuth ${email}` : "OAuth (unknown)", - source: `auth.json: ${formatPath(authPaths.authPath)}`, + label: labels.join(", "), + source: `auth-profiles.json: ${formatPath( + resolveAuthStorePathForDisplay(), + )}`, }; } - if (stored?.type === "api_key") { + + const envKey = resolveEnvApiKey(provider); + if (envKey) { + const isOAuthEnv = + envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") || + envKey.source.toLowerCase().includes("oauth"); + const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey); + return { label, source: envKey.source }; + } + const customKey = getCustomProviderApiKey(cfg, provider); + if (customKey) { return { - label: maskApiKey(stored.key), - source: `auth.json: ${formatPath(authPaths.authPath)}`, + label: maskApiKey(customKey), + source: `models.json: ${formatPath(modelsPath)}`, }; } - const envKey = getEnvApiKey(provider); - if (envKey) return { label: maskApiKey(envKey), source: "env" }; - if (provider === "anthropic") { - const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN?.trim(); - if (oauthEnv) { - return { label: "OAuth (env)", source: "env: ANTHROPIC_OAUTH_TOKEN" }; - } - } - try { - const key = await authStorage.getApiKey(provider); - if (key) { - return { - label: maskApiKey(key), - source: `models.json: ${formatPath(authPaths.modelsPath)}`, - }; - } - } catch { - // ignore missing auth - } return { label: "missing", source: "missing" }; }; +const formatAuthLabel = (auth: { label: string; source: string }) => { + if (!auth.source || auth.source === auth.label || auth.source === "missing") { + return auth.label; + } + return `${auth.label} (${auth.source})`; +}; + +const resolveProfileOverride = (params: { + rawProfile?: string; + provider: string; + cfg: ClawdbotConfig; +}): { profileId?: string; error?: string } => { + const raw = params.rawProfile?.trim(); + if (!raw) return {}; + const store = ensureAuthProfileStore(); + const profile = store.profiles[raw]; + if (!profile) { + return { error: `Auth profile "${raw}" not found.` }; + } + if (profile.provider !== params.provider) { + return { + error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`, + }; + } + return { profileId: raw }; +}; + export type InlineDirectives = { cleaned: string; hasThinkDirective: boolean; @@ -107,6 +161,7 @@ export type InlineDirectives = { hasStatusDirective: boolean; hasModelDirective: boolean; rawModelDirective?: string; + rawModelProfile?: string; hasQueueDirective: boolean; queueMode?: QueueMode; queueReset: boolean; @@ -144,6 +199,7 @@ export function parseInlineDirectives(body: string): InlineDirectives { const { cleaned: modelCleaned, rawModel, + rawProfile, hasDirective: hasModelDirective, } = extractModelDirective(statusCleaned); const { @@ -175,6 +231,7 @@ export function parseInlineDirectives(body: string): InlineDirectives { hasStatusDirective, hasModelDirective, rawModelDirective: rawModel, + rawModelProfile: rawProfile, hasQueueDirective, queueMode, queueReset, @@ -211,6 +268,7 @@ export function isDirectiveOnly(params: { } export async function handleDirectiveOnly(params: { + cfg: ClawdbotConfig; directives: InlineDirectives; sessionEntry?: SessionEntry; sessionStore?: Record; @@ -258,21 +316,16 @@ export async function handleDirectiveOnly(params: { return { text: "No models available." }; } const agentDir = resolveClawdbotAgentDir(); - const authStorage = discoverAuthStorage(agentDir); - const authPaths = { - authPath: `${agentDir}/auth.json`, - modelsPath: `${agentDir}/models.json`, - }; - hydrateAuthStorage(authStorage); + const modelsPath = `${agentDir}/models.json`; const authByProvider = new Map(); for (const entry of allowedModelCatalog) { if (authByProvider.has(entry.provider)) continue; const auth = await resolveAuthLabel( entry.provider, - authStorage, - authPaths, + params.cfg, + modelsPath, ); - authByProvider.set(entry.provider, `${auth.label} (${auth.source})`); + authByProvider.set(entry.provider, formatAuthLabel(auth)); } const current = `${params.provider}/${params.model}`; const defaultLabel = `${defaultProvider}/${defaultModel}`; @@ -299,6 +352,9 @@ export async function handleDirectiveOnly(params: { } return { text: lines.join("\n") }; } + if (directives.rawModelProfile && !modelDirective) { + throw new Error("Auth profile override requires a model selection."); + } } if (directives.hasThinkDirective && !directives.thinkLevel) { @@ -371,6 +427,7 @@ export async function handleDirectiveOnly(params: { } let modelSelection: ModelDirectiveSelection | undefined; + let profileOverride: string | undefined; if (directives.hasModelDirective && directives.rawModelDirective) { const resolved = resolveModelDirectiveSelection({ raw: directives.rawModelDirective, @@ -384,6 +441,17 @@ export async function handleDirectiveOnly(params: { } modelSelection = resolved.selection; if (modelSelection) { + if (directives.rawModelProfile) { + const profileResolved = resolveProfileOverride({ + rawProfile: directives.rawModelProfile, + provider: modelSelection.provider, + cfg: params.cfg, + }); + if (profileResolved.error) { + return { text: profileResolved.error }; + } + profileOverride = profileResolved.profileId; + } const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; if (nextLabel !== initialModelLabel) { enqueueSystemEvent( @@ -395,6 +463,9 @@ export async function handleDirectiveOnly(params: { } } } + if (directives.rawModelProfile && !modelSelection) { + return { text: "Auth profile override requires a model selection." }; + } if (sessionEntry && sessionStore && sessionKey) { if (directives.hasThinkDirective && directives.thinkLevel) { @@ -417,6 +488,11 @@ export async function handleDirectiveOnly(params: { sessionEntry.providerOverride = modelSelection.provider; sessionEntry.modelOverride = modelSelection.model; } + if (profileOverride) { + sessionEntry.authProfileOverride = profileOverride; + } else if (directives.hasModelDirective) { + delete sessionEntry.authProfileOverride; + } } if (directives.hasQueueDirective && directives.queueReset) { delete sessionEntry.queueMode; @@ -474,6 +550,9 @@ export async function handleDirectiveOnly(params: { ? `Model reset to default (${labelWithAlias}).` : `Model set to ${labelWithAlias}.`, ); + if (profileOverride) { + parts.push(`Auth profile set to ${profileOverride}.`); + } } if (directives.hasQueueDirective && directives.queueMode) { parts.push(`${SYSTEM_MARK} Queue mode set to ${directives.queueMode}.`); @@ -501,6 +580,7 @@ export async function handleDirectiveOnly(params: { export async function persistInlineDirectives(params: { directives: InlineDirectives; effectiveModelDirective?: string; + cfg: ClawdbotConfig; sessionEntry?: SessionEntry; sessionStore?: Record; sessionKey?: string; @@ -519,6 +599,7 @@ export async function persistInlineDirectives(params: { }): Promise<{ provider: string; model: string; contextTokens: number }> { const { directives, + cfg, sessionEntry, sessionStore, sessionKey, @@ -579,6 +660,18 @@ export async function persistInlineDirectives(params: { if (resolved) { const key = modelKey(resolved.ref.provider, resolved.ref.model); if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { + let profileOverride: string | undefined; + if (directives.rawModelProfile) { + const profileResolved = resolveProfileOverride({ + rawProfile: directives.rawModelProfile, + provider: resolved.ref.provider, + cfg, + }); + if (profileResolved.error) { + throw new Error(profileResolved.error); + } + profileOverride = profileResolved.profileId; + } const isDefault = resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel; @@ -589,6 +682,11 @@ export async function persistInlineDirectives(params: { sessionEntry.providerOverride = resolved.ref.provider; sessionEntry.modelOverride = resolved.ref.model; } + if (profileOverride) { + sessionEntry.authProfileOverride = profileOverride; + } else if (directives.hasModelDirective) { + delete sessionEntry.authProfileOverride; + } provider = resolved.ref.provider; model = resolved.ref.model; const nextLabel = `${provider}/${model}`; diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts new file mode 100644 index 000000000..7eba4cf4b --- /dev/null +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -0,0 +1,46 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { getReplyFromConfig } from "../reply.js"; +import type { MsgContext } from "../templating.js"; +import type { GetReplyOptions, ReplyPayload } from "../types.js"; +import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; + +type DispatchFromConfigResult = { + queuedFinal: boolean; + counts: Record; +}; + +export async function dispatchReplyFromConfig(params: { + ctx: MsgContext; + cfg: ClawdbotConfig; + dispatcher: ReplyDispatcher; + replyOptions?: Omit; + replyResolver?: typeof getReplyFromConfig; +}): Promise { + const replyResult = await (params.replyResolver ?? getReplyFromConfig)( + params.ctx, + { + ...params.replyOptions, + onToolResult: (payload: ReplyPayload) => { + params.dispatcher.sendToolResult(payload); + }, + onBlockReply: (payload: ReplyPayload) => { + params.dispatcher.sendBlockReply(payload); + }, + }, + params.cfg, + ); + + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + + let queuedFinal = false; + for (const reply of replies) { + queuedFinal = params.dispatcher.sendFinalReply(reply) || queuedFinal; + } + await params.dispatcher.waitForIdle(); + + return { queuedFinal, counts: params.dispatcher.getQueuedCounts() }; +} diff --git a/src/auto-reply/reply/followup-runner.compaction.test.ts b/src/auto-reply/reply/followup-runner.compaction.test.ts new file mode 100644 index 000000000..481f42f8b --- /dev/null +++ b/src/auto-reply/reply/followup-runner.compaction.test.ts @@ -0,0 +1,121 @@ +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import type { SessionEntry } from "../../config/sessions.js"; +import type { FollowupRun } 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; + }) => ({ + result: await run(provider, model), + provider, + model, + }), +})); + +vi.mock("../../agents/pi-embedded.js", () => ({ + runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), +})); + +import { createFollowupRunner } from "./followup-runner.js"; + +function createTyping(): TypingController { + return { + onReplyStart: vi.fn(async () => {}), + startTypingLoop: vi.fn(async () => {}), + startTypingOnText: vi.fn(async () => {}), + refreshTypingTtl: vi.fn(), + markRunComplete: vi.fn(), + markDispatchIdle: vi.fn(), + cleanup: vi.fn(), + }; +} + +describe("createFollowupRunner compaction", () => { + it("adds verbose auto-compaction notice and tracks count", async () => { + const storePath = path.join( + await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")), + "sessions.json", + ); + const sessionEntry: SessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + }; + const sessionStore: Record = { + main: sessionEntry, + }; + const onBlockReply = vi.fn(async () => {}); + + runEmbeddedPiAgentMock.mockImplementationOnce( + async (params: { + onAgentEvent?: (evt: { + stream: string; + data: Record; + }) => void; + }) => { + params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: false }, + }); + return { payloads: [{ text: "final" }], meta: {} }; + }, + ); + + const runner = createFollowupRunner({ + opts: { onBlockReply }, + typing: createTyping(), + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + defaultModel: "anthropic/claude-opus-4-5", + }); + + const queued = { + 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: "on", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as FollowupRun; + + await runner(queued); + + expect(onBlockReply).toHaveBeenCalled(); + expect(onBlockReply.mock.calls[0][0].text).toContain( + "Auto-compaction complete", + ); + expect(sessionStore.main.compactionCount).toBe(1); + }); +}); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 86f978ec3..9a6be5bd8 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -12,6 +12,7 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { FollowupRun } from "./queue.js"; import { extractReplyToTag } from "./reply-tags.js"; +import { incrementCompactionCount } from "./session-updates.js"; import type { TypingController } from "./typing.js"; export function createFollowupRunner(params: { @@ -57,128 +58,160 @@ export function createFollowupRunner(params: { }; return async (queued: FollowupRun) => { - const runId = crypto.randomUUID(); - if (queued.run.sessionKey) { - registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey }); - } - let runResult: Awaited>; - let fallbackProvider = queued.run.provider; - let fallbackModel = queued.run.model; try { - const fallbackResult = await runWithModelFallback({ - cfg: queued.run.config, - provider: queued.run.provider, - model: queued.run.model, - 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, - }), + const runId = crypto.randomUUID(); + if (queued.run.sessionKey) { + registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey }); + } + let autoCompactionCompleted = false; + let runResult: Awaited>; + let fallbackProvider = queued.run.provider; + let fallbackModel = queued.run.model; + try { + const fallbackResult = await runWithModelFallback({ + cfg: queued.run.config, + provider: queued.run.provider, + model: queued.run.model, + 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, + authProfileId: queued.run.authProfileId, + thinkLevel: queued.run.thinkLevel, + verboseLevel: queued.run.verboseLevel, + bashElevated: queued.run.bashElevated, + timeoutMs: queued.run.timeoutMs, + runId, + blockReplyBreak: queued.run.blockReplyBreak, + onAgentEvent: (evt) => { + if (evt.stream !== "compaction") return; + const phase = + typeof evt.data.phase === "string" ? evt.data.phase : ""; + const willRetry = Boolean(evt.data.willRetry); + if (phase === "end" && !willRetry) { + autoCompactionCompleted = true; + } + }, + }), + }); + 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}`, + ); + return; + } + + const payloadArray = runResult.payloads ?? []; + if (payloadArray.length === 0) return; + const sanitizedPayloads = payloadArray.flatMap((payload) => { + const text = payload.text; + if (!text || !text.includes("HEARTBEAT_OK")) return [payload]; + const stripped = stripHeartbeatToken(text, { mode: "message" }); + const hasMedia = + Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + if (stripped.shouldSkip && !hasMedia) return []; + return [{ ...payload, text: stripped.text }]; }); - 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}`); - return; - } - const payloadArray = runResult.payloads ?? []; - if (payloadArray.length === 0) return; - const sanitizedPayloads = payloadArray.flatMap((payload) => { - const text = payload.text; - if (!text || !text.includes("HEARTBEAT_OK")) return [payload]; - const stripped = stripHeartbeatToken(text, { mode: "message" }); - const hasMedia = - Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - if (stripped.shouldSkip && !hasMedia) return []; - return [{ ...payload, text: stripped.text }]; - }); - - const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads - .map((payload) => { - const { cleaned, replyToId } = extractReplyToTag(payload.text); - return { - ...payload, - text: cleaned ? cleaned : undefined, - replyToId: replyToId ?? payload.replyToId, - }; - }) - .filter( - (payload) => - payload.text || - payload.mediaUrl || - (payload.mediaUrls && payload.mediaUrls.length > 0), - ); - - if (replyTaggedPayloads.length === 0) return; - - if (sessionStore && sessionKey) { - const usage = runResult.meta.agentMeta?.usage; - const modelUsed = - runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; - const contextTokensUsed = - agentCfgContextTokens ?? - lookupContextTokens(modelUsed) ?? - sessionEntry?.contextTokens ?? - DEFAULT_CONTEXT_TOKENS; - - if (usage) { - const entry = sessionStore[sessionKey]; - if (entry) { - const input = usage.input ?? 0; - const output = usage.output ?? 0; - const promptTokens = - input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); - sessionStore[sessionKey] = { - ...entry, - inputTokens: input, - outputTokens: output, - totalTokens: - promptTokens > 0 ? promptTokens : (usage.total ?? input), - modelProvider: fallbackProvider ?? entry.modelProvider, - model: modelUsed, - contextTokens: contextTokensUsed ?? entry.contextTokens, - updatedAt: Date.now(), + const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads + .map((payload) => { + const { cleaned, replyToId } = extractReplyToTag(payload.text); + return { + ...payload, + text: cleaned ? cleaned : undefined, + replyToId: replyToId ?? payload.replyToId, }; - if (storePath) { - await saveSessionStore(storePath, sessionStore); - } + }) + .filter( + (payload) => + payload.text || + payload.mediaUrl || + (payload.mediaUrls && payload.mediaUrls.length > 0), + ); + + if (replyTaggedPayloads.length === 0) return; + + if (autoCompactionCompleted) { + const count = await incrementCompactionCount({ + sessionEntry, + sessionStore, + sessionKey, + storePath, + }); + if (queued.run.verboseLevel === "on") { + const suffix = typeof count === "number" ? ` (count ${count})` : ""; + replyTaggedPayloads.unshift({ + text: `🧹 Auto-compaction complete${suffix}.`, + }); } - } else if (modelUsed || contextTokensUsed) { - const entry = sessionStore[sessionKey]; - if (entry) { - sessionStore[sessionKey] = { - ...entry, - modelProvider: fallbackProvider ?? entry.modelProvider, - model: modelUsed ?? entry.model, - contextTokens: contextTokensUsed ?? entry.contextTokens, - }; - if (storePath) { - await saveSessionStore(storePath, sessionStore); + } + + if (sessionStore && sessionKey) { + const usage = runResult.meta.agentMeta?.usage; + const modelUsed = + runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; + const contextTokensUsed = + agentCfgContextTokens ?? + lookupContextTokens(modelUsed) ?? + sessionEntry?.contextTokens ?? + DEFAULT_CONTEXT_TOKENS; + + if (usage) { + const entry = sessionStore[sessionKey]; + if (entry) { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const promptTokens = + input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + sessionStore[sessionKey] = { + ...entry, + inputTokens: input, + outputTokens: output, + totalTokens: + promptTokens > 0 ? promptTokens : (usage.total ?? input), + modelProvider: fallbackProvider ?? entry.modelProvider, + model: modelUsed, + contextTokens: contextTokensUsed ?? entry.contextTokens, + updatedAt: Date.now(), + }; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + } + } else if (modelUsed || contextTokensUsed) { + const entry = sessionStore[sessionKey]; + if (entry) { + sessionStore[sessionKey] = { + ...entry, + modelProvider: fallbackProvider ?? entry.modelProvider, + model: modelUsed ?? entry.model, + contextTokens: contextTokensUsed ?? entry.contextTokens, + }; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } } } } - } - await sendFollowupPayloads(replyTaggedPayloads); + await sendFollowupPayloads(replyTaggedPayloads); + } finally { + typing.markRunComplete(); + } }; } diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index c94f0ef73..fd9ccd40f 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,4 +1,5 @@ import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveProviderGroupRequireMention } from "../../config/group-policy.js"; import type { GroupKeyResolution, SessionEntry, @@ -53,38 +54,16 @@ export function resolveGroupRequireMention(params: { const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim(); const groupSpace = ctx.GroupSpace?.trim(); - if (surface === "telegram") { - if (groupId) { - const groupConfig = cfg.telegram?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; - } - if (surface === "whatsapp") { - if (groupId) { - const groupConfig = cfg.whatsapp?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; - } - if (surface === "imessage") { - if (groupId) { - const groupConfig = cfg.imessage?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; + if ( + surface === "telegram" || + surface === "whatsapp" || + surface === "imessage" + ) { + return resolveProviderGroupRequireMention({ + cfg, + surface, + groupId, + }); } if (surface === "discord") { const guildEntry = resolveDiscordGuildEntry( diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts new file mode 100644 index 000000000..34639c5aa --- /dev/null +++ b/src/auto-reply/reply/mentions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + buildMentionRegexes, + matchesMentionPatterns, + normalizeMentionText, +} from "./mentions.js"; + +describe("mention helpers", () => { + it("builds regexes and skips invalid patterns", () => { + const regexes = buildMentionRegexes({ + routing: { + groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] }, + }, + }); + expect(regexes).toHaveLength(1); + expect(regexes[0]?.test("clawd")).toBe(true); + }); + + it("normalizes zero-width characters", () => { + expect(normalizeMentionText("cl\u200bawd")).toBe("clawd"); + }); + + it("matches patterns case-insensitively", () => { + const regexes = buildMentionRegexes({ + routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } }, + }); + expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true); + }); +}); diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index c85914fd5..d9edcfa0f 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -1,6 +1,35 @@ import type { ClawdbotConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; +export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] { + const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? []; + return patterns + .map((pattern) => { + try { + return new RegExp(pattern, "i"); + } catch { + return null; + } + }) + .filter((value): value is RegExp => Boolean(value)); +} + +export function normalizeMentionText(text: string): string { + return (text ?? "") + .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") + .toLowerCase(); +} + +export function matchesMentionPatterns( + text: string, + mentionRegexes: RegExp[], +): boolean { + if (mentionRegexes.length === 0) return false; + const cleaned = normalizeMentionText(text ?? ""); + if (!cleaned) return false; + return mentionRegexes.some((re) => re.test(cleaned)); +} + export function stripStructuralPrefixes(text: string): string { // Ignore wrapper labels, timestamps, and sender prefixes so directive-only // detection still works in group batches that include history/context. diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 5ee553208..a4cb67359 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -57,7 +57,8 @@ export async function createModelSelectionState(params: { let provider = params.provider; let model = params.model; - const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0; + const hasAllowlist = + agentCfg?.models && Object.keys(agentCfg.models).length > 0; const hasStoredOverride = Boolean( sessionEntry?.modelOverride || sessionEntry?.providerOverride, ); @@ -110,6 +111,27 @@ export async function createModelSelectionState(params: { } } + if ( + sessionEntry && + sessionStore && + sessionKey && + sessionEntry.authProfileOverride + ) { + const { ensureAuthProfileStore } = await import( + "../../agents/auth-profiles.js" + ); + const store = ensureAuthProfileStore(); + const profile = store.profiles[sessionEntry.authProfileOverride]; + if (!profile || profile.provider !== provider) { + delete sessionEntry.authProfileOverride; + sessionEntry.updatedAt = Date.now(); + sessionStore[sessionKey] = sessionEntry; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + } + } + let defaultThinkingLevel: ThinkLevel | undefined; const resolveDefaultThinkingLevel = async () => { if (defaultThinkingLevel) return defaultThinkingLevel; diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index 074089a4e..47b0dc432 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -32,6 +32,7 @@ export type FollowupRun = { skillsSnapshot?: SkillSnapshot; provider: string; model: string; + authProfileId?: string; thinkLevel?: ThinkLevel; verboseLevel?: VerboseLevel; elevatedLevel?: ElevatedLevel; diff --git a/src/auto-reply/reply/reply-dispatcher.test.ts b/src/auto-reply/reply/reply-dispatcher.test.ts index d97822fe3..dee7795d2 100644 --- a/src/auto-reply/reply/reply-dispatcher.test.ts +++ b/src/auto-reply/reply/reply-dispatcher.test.ts @@ -79,4 +79,18 @@ describe("createReplyDispatcher", () => { await dispatcher.waitForIdle(); expect(delivered).toEqual(["tool", "block", "final"]); }); + + it("fires onIdle when the queue drains", async () => { + const deliver = vi.fn( + async () => await new Promise((resolve) => setTimeout(resolve, 5)), + ); + const onIdle = vi.fn(); + const dispatcher = createReplyDispatcher({ deliver, onIdle }); + + dispatcher.sendToolResult({ text: "one" }); + dispatcher.sendFinalReply({ text: "two" }); + + await dispatcher.waitForIdle(); + expect(onIdle).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 070cc7a65..9f4987530 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -18,10 +18,11 @@ export type ReplyDispatcherOptions = { deliver: ReplyDispatchDeliverer; responsePrefix?: string; onHeartbeatStrip?: () => void; + onIdle?: () => void; onError?: ReplyDispatchErrorHandler; }; -type ReplyDispatcher = { +export type ReplyDispatcher = { sendToolResult: (payload: ReplyPayload) => boolean; sendBlockReply: (payload: ReplyPayload) => boolean; sendFinalReply: (payload: ReplyPayload) => boolean; @@ -70,6 +71,8 @@ export function createReplyDispatcher( options: ReplyDispatcherOptions, ): ReplyDispatcher { let sendChain: Promise = Promise.resolve(); + // Track in-flight deliveries so we can emit a reliable "idle" signal. + let pending = 0; // Serialize outbound replies to preserve tool/block/final order. const queuedCounts: Record = { tool: 0, @@ -81,10 +84,17 @@ export function createReplyDispatcher( const normalized = normalizeReplyPayload(payload, options); if (!normalized) return false; queuedCounts[kind] += 1; + pending += 1; sendChain = sendChain .then(() => options.deliver(normalized, { kind })) .catch((err) => { options.onError?.(err, { kind }); + }) + .finally(() => { + pending -= 1; + if (pending === 0) { + options.onIdle?.(); + } }); return true; }; diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 23e1a8777..f780455e0 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -122,3 +122,32 @@ export async function ensureSkillSnapshot(params: { return { sessionEntry: nextEntry, skillsSnapshot, systemSent }; } + +export async function incrementCompactionCount(params: { + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + storePath?: string; + now?: number; +}): Promise { + const { + sessionEntry, + sessionStore, + sessionKey, + storePath, + now = Date.now(), + } = params; + if (!sessionStore || !sessionKey) return undefined; + const entry = sessionStore[sessionKey] ?? sessionEntry; + if (!entry) return undefined; + const nextCount = (entry.compactionCount ?? 0) + 1; + sessionStore[sessionKey] = { + ...entry, + compactionCount: nextCount, + updatedAt: now, + }; + if (storePath) { + await saveSessionStore(storePath, sessionStore); + } + return nextCount; +} diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index eeb2edc2f..a6d4f0357 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -14,6 +14,7 @@ import { type SessionScope, saveSessionStore, } from "../../config/sessions.js"; +import { resolveCommandAuthorization } from "../command-auth.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; @@ -37,8 +38,9 @@ export type SessionInitResult = { export async function initSessionState(params: { ctx: MsgContext; cfg: ClawdbotConfig; + commandAuthorized: boolean; }): Promise { - const { ctx, cfg } = params; + const { ctx, cfg, commandAuthorized } = params; const sessionCfg = cfg.session; const mainKey = sessionCfg?.mainKey ?? "main"; const resetTriggers = sessionCfg?.resetTriggers?.length @@ -76,6 +78,11 @@ export async function initSessionState(params: { const rawBody = ctx.Body ?? ""; const trimmedBody = rawBody.trim(); + const resetAuthorized = resolveCommandAuthorization({ + ctx, + cfg, + commandAuthorized, + }).isAuthorizedSender; // Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the // web inbox before we get here. They prevented reset triggers like "/new" // from matching, so strip structural wrappers when checking for resets. @@ -84,6 +91,7 @@ export async function initSessionState(params: { : triggerBodyNormalized; for (const trigger of resetTriggers) { if (!trigger) continue; + if (!resetAuthorized) break; if (trimmedBody === trigger || strippedForReset === trigger) { isNewSession = true; bodyStripped = ""; diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts new file mode 100644 index 000000000..4026eec13 --- /dev/null +++ b/src/auto-reply/reply/typing.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createTypingController } from "./typing.js"; + +describe("typing controller", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("stops after run completion and dispatcher idle", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(1_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(4); + }); + + it("keeps typing until both idle and run completion are set", async () => { + vi.useFakeTimers(); + const onReplyStart = vi.fn(async () => {}); + const typing = createTypingController({ + onReplyStart, + typingIntervalSeconds: 1, + typingTtlMs: 30_000, + }); + + await typing.startTypingLoop(); + expect(onReplyStart).toHaveBeenCalledTimes(1); + + typing.markDispatchIdle(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + + typing.markRunComplete(); + vi.advanceTimersByTime(2_000); + expect(onReplyStart).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index 6c2004e67..8a2077652 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -3,6 +3,8 @@ export type TypingController = { startTypingLoop: () => Promise; startTypingOnText: (text?: string) => Promise; refreshTypingTtl: () => void; + markRunComplete: () => void; + markDispatchIdle: () => void; cleanup: () => void; }; @@ -21,6 +23,9 @@ export function createTypingController(params: { log, } = params; let started = false; + let active = false; + let runComplete = false; + let dispatchIdle = false; let typingTimer: NodeJS.Timeout | undefined; let typingTtlTimer: NodeJS.Timeout | undefined; const typingIntervalMs = typingIntervalSeconds * 1000; @@ -30,6 +35,13 @@ export function createTypingController(params: { return `${Math.round(ms / 1000)}s`; }; + const resetCycle = () => { + started = false; + active = false; + runComplete = false; + dispatchIdle = false; + }; + const cleanup = () => { if (typingTtlTimer) { clearTimeout(typingTtlTimer); @@ -39,6 +51,7 @@ export function createTypingController(params: { clearInterval(typingTimer); typingTimer = undefined; } + resetCycle(); }; const refreshTypingTtl = () => { @@ -61,11 +74,20 @@ export function createTypingController(params: { }; const ensureStart = async () => { + if (!active) { + active = true; + } if (started) return; started = true; await triggerTyping(); }; + const maybeStopOnIdle = () => { + if (!active) return; + // Stop only when the model run is done and the dispatcher queue is empty. + if (runComplete && dispatchIdle) cleanup(); + }; + const startTypingLoop = async () => { if (!onReplyStart) return; if (typingIntervalMs <= 0) return; @@ -85,11 +107,23 @@ export function createTypingController(params: { await startTypingLoop(); }; + const markRunComplete = () => { + runComplete = true; + maybeStopOnIdle(); + }; + + const markDispatchIdle = () => { + dispatchIdle = true; + maybeStopOnIdle(); + }; + return { onReplyStart: ensureStart, startTypingLoop, startTypingOnText, refreshTypingTtl, + markRunComplete, + markDispatchIdle, cleanup, }; } diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 187f0ac97..3d4bc9587 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -22,6 +22,7 @@ describe("buildStatusMessage", () => { contextTokens: 32_000, thinkingLevel: "low", verboseLevel: "on", + compactionCount: 2, }, sessionKey: "main", sessionScope: "per-sender", @@ -39,6 +40,7 @@ describe("buildStatusMessage", () => { expect(text).toContain("Runtime: direct"); expect(text).toContain("Context: 16k/32k (50%)"); expect(text).toContain("Session: main"); + expect(text).toContain("compactions 2"); expect(text).toContain("Web: linked"); expect(text).toContain("heartbeat 45s"); expect(text).toContain("thinking=medium"); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 281f25824..bec2aa262 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -56,6 +56,8 @@ const formatAge = (ms?: number | null) => { const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; +export const formatTokenCount = (value: number) => formatKTokens(value); + const formatTokens = ( total: number | null | undefined, contextTokens: number | null, @@ -71,6 +73,11 @@ const formatTokens = ( return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`; }; +export const formatContextUsageShort = ( + total: number | null | undefined, + contextTokens: number | null | undefined, +) => `Context ${formatTokens(total, contextTokens ?? null)}`; + const readUsageFromSessionLog = ( sessionId?: string, ): @@ -210,6 +217,9 @@ export function buildStatusMessage(args: StatusArgs): string { entry?.updatedAt ? `updated ${formatAge(now - entry.updatedAt)}` : "no activity", + typeof entry?.compactionCount === "number" + ? `compactions ${entry.compactionCount}` + : undefined, args.storePath ? `store ${shortenHomePath(args.storePath)}` : undefined, ] .filter(Boolean) @@ -262,7 +272,7 @@ export function buildStatusMessage(args: StatusArgs): string { export function buildHelpMessage(): string { return [ "ℹ️ Help", - "Shortcuts: /new reset | /restart relink", + "Shortcuts: /new reset | /compact [instructions] | /restart relink", "Options: /think | /verbose on|off | /elevated on|off | /model ", ].join("\n"); } diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index be5df6196..d2987ec22 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -10,6 +10,9 @@ export type MsgContext = { MediaPath?: string; MediaUrl?: string; MediaType?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; Transcript?: string; ChatType?: string; GroupSubject?: string; diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 3ab927358..62b6d75bb 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -1,5 +1,8 @@ +import type { TypingController } from "./reply/typing.js"; + export type GetReplyOptions = { onReplyStart?: () => Promise | void; + onTypingController?: (typing: TypingController) => void; isHeartbeat?: boolean; onPartialReply?: (payload: ReplyPayload) => Promise | void; onBlockReply?: (payload: ReplyPayload) => Promise | void; diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index af844cce5..94076ea3d 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -155,7 +155,7 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|signal|imessage)", + "Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", "last", ) .option( @@ -414,7 +414,7 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--channel ", - "Delivery channel (last|whatsapp|telegram|discord|signal|imessage)", + "Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)", ) .option( "--to ", diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 4d46e70dd..c4a134a6d 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -13,6 +13,9 @@ const forceFreePortAndWait = vi.fn(async () => ({ waitedMs: 0, escalatedToSigkill: false, })); +const serviceStop = vi.fn().mockResolvedValue(undefined); +const serviceRestart = vi.fn().mockResolvedValue(undefined); +const serviceIsLoaded = vi.fn().mockResolvedValue(true); const runtimeLogs: string[] = []; const runtimeErrors: string[] = []; @@ -74,6 +77,20 @@ vi.mock("./ports.js", () => ({ forceFreePortAndWait: (port: number) => forceFreePortAndWait(port), })); +vi.mock("../daemon/service.js", () => ({ + resolveGatewayService: () => ({ + label: "LaunchAgent", + loadedText: "loaded", + notLoadedText: "not loaded", + install: vi.fn(), + uninstall: vi.fn(), + stop: serviceStop, + restart: serviceRestart, + isLoaded: serviceIsLoaded, + readCommand: vi.fn(), + }), +})); + describe("gateway-cli coverage", () => { it("registers call/health/status/send/agent commands and routes to callGateway", async () => { runtimeLogs.length = 0; @@ -228,6 +245,51 @@ describe("gateway-cli coverage", () => { } }); + it("supports gateway stop/restart via service helper", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + serviceStop.mockClear(); + serviceRestart.mockClear(); + serviceIsLoaded.mockResolvedValue(true); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await program.parseAsync(["gateway", "stop"], { from: "user" }); + await program.parseAsync(["gateway", "restart"], { from: "user" }); + + expect(serviceStop).toHaveBeenCalledTimes(1); + expect(serviceRestart).toHaveBeenCalledTimes(1); + }); + + it("prints stop hints on GatewayLockError when service is loaded", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + serviceIsLoaded.mockResolvedValue(true); + + const { GatewayLockError } = await import("../infra/gateway-lock.js"); + startGatewayServer.mockRejectedValueOnce( + new GatewayLockError("another gateway instance is already listening"), + ); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await expect( + program.parseAsync(["gateway", "--allow-unconfigured"], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + + expect(startGatewayServer).toHaveBeenCalled(); + expect(runtimeErrors.join("\n")).toContain("Gateway failed to start:"); + expect(runtimeErrors.join("\n")).toContain("clawdbot gateway stop"); + }); + it("uses env/config port when --port is omitted", async () => { await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "19001" }, async () => { runtimeLogs.length = 0; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 85dff78fd..6ac33db34 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -6,6 +6,12 @@ import { loadConfig, resolveGatewayPort, } from "../config/config.js"; +import { + GATEWAY_LAUNCH_AGENT_LABEL, + GATEWAY_SYSTEMD_SERVICE_NAME, + GATEWAY_WINDOWS_TASK_NAME, +} from "../daemon/constants.js"; +import { resolveGatewayService } from "../daemon/service.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { startGatewayServer } from "../gateway/server.js"; import { @@ -45,6 +51,80 @@ function parsePort(raw: unknown): number | null { return parsed; } +function describeUnknownError(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + if (typeof err === "number" || typeof err === "bigint") return err.toString(); + if (typeof err === "boolean") return err ? "true" : "false"; + if (err && typeof err === "object") { + if ("message" in err && typeof err.message === "string") { + return err.message; + } + try { + return JSON.stringify(err); + } catch { + return "Unknown error"; + } + } + return "Unknown error"; +} + +function renderGatewayServiceStopHints(): string[] { + switch (process.platform) { + case "darwin": + return [ + "Tip: clawdbot gateway stop", + `Or: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, + ]; + case "linux": + return [ + "Tip: clawdbot gateway stop", + `Or: systemctl --user stop ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, + ]; + case "win32": + return [ + "Tip: clawdbot gateway stop", + `Or: schtasks /End /TN "${GATEWAY_WINDOWS_TASK_NAME}"`, + ]; + default: + return ["Tip: clawdbot gateway stop"]; + } +} + +function renderGatewayServiceStartHints(): string[] { + switch (process.platform) { + case "darwin": + return [ + `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/${GATEWAY_LAUNCH_AGENT_LABEL}.plist`, + ]; + case "linux": + return [`systemctl --user start ${GATEWAY_SYSTEMD_SERVICE_NAME}.service`]; + case "win32": + return [`schtasks /Run /TN "${GATEWAY_WINDOWS_TASK_NAME}"`]; + default: + return []; + } +} + +async function maybeExplainGatewayServiceStop() { + const service = resolveGatewayService(); + let loaded: boolean | null = null; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch { + loaded = null; + } + if (loaded === false) return; + defaultRuntime.error( + loaded + ? `Gateway service appears ${service.loadedText}. Stop it first.` + : "Gateway service status unknown; if supervised, stop it first.", + ); + for (const hint of renderGatewayServiceStopHints()) { + defaultRuntime.error(hint); + } +} + async function runGatewayLoop(params: { start: () => Promise>>; runtime: typeof defaultRuntime; @@ -285,8 +365,17 @@ export function registerGatewayCli(program: Command) { }), }); } catch (err) { - if (err instanceof GatewayLockError) { - defaultRuntime.error(`Gateway failed to start: ${err.message}`); + if ( + err instanceof GatewayLockError || + (err && + typeof err === "object" && + (err as { name?: string }).name === "GatewayLockError") + ) { + const errMessage = describeUnknownError(err); + defaultRuntime.error( + `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`, + ); + await maybeExplainGatewayServiceStop(); defaultRuntime.exit(1); return; } @@ -486,8 +575,17 @@ export function registerGatewayCli(program: Command) { }), }); } catch (err) { - if (err instanceof GatewayLockError) { - defaultRuntime.error(`Gateway failed to start: ${err.message}`); + if ( + err instanceof GatewayLockError || + (err && + typeof err === "object" && + (err as { name?: string }).name === "GatewayLockError") + ) { + const errMessage = describeUnknownError(err); + defaultRuntime.error( + `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`, + ); + await maybeExplainGatewayServiceStop(); defaultRuntime.exit(1); return; } @@ -635,6 +733,59 @@ export function registerGatewayCli(program: Command) { }), ); + gateway + .command("stop") + .description("Stop the Gateway service (launchd/systemd/schtasks)") + .action(async () => { + const service = resolveGatewayService(); + let loaded = false; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch (err) { + defaultRuntime.error(`Gateway service check failed: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + if (!loaded) { + defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); + return; + } + try { + await service.stop({ stdout: process.stdout }); + } catch (err) { + defaultRuntime.error(`Gateway stop failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }); + + gateway + .command("restart") + .description("Restart the Gateway service (launchd/systemd/schtasks)") + .action(async () => { + const service = resolveGatewayService(); + let loaded = false; + try { + loaded = await service.isLoaded({ env: process.env }); + } catch (err) { + defaultRuntime.error(`Gateway service check failed: ${String(err)}`); + defaultRuntime.exit(1); + return; + } + if (!loaded) { + defaultRuntime.log(`Gateway service ${service.notLoadedText}.`); + for (const hint of renderGatewayServiceStartHints()) { + defaultRuntime.log(`Start with: ${hint}`); + } + return; + } + try { + await service.restart({ stdout: process.stdout }); + } catch (err) { + defaultRuntime.error(`Gateway restart failed: ${String(err)}`); + defaultRuntime.exit(1); + } + }); + // Build default deps (keeps parity with other commands; future-proofing). void createDefaultDeps(); } diff --git a/src/cli/program.ts b/src/cli/program.ts index 9732d7153..0b46a84ab 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,16 +1,22 @@ import chalk from "chalk"; import { Command } from "commander"; -import { agentCommand } from "../commands/agent.js"; +import { agentCliCommand } from "../commands/agent-via-gateway.js"; import { configureCommand } from "../commands/configure.js"; import { doctorCommand } from "../commands/doctor.js"; import { healthCommand } from "../commands/health.js"; import { onboardCommand } from "../commands/onboard.js"; +import { pollCommand } from "../commands/poll.js"; import { sendCommand } from "../commands/send.js"; import { sessionsCommand } from "../commands/sessions.js"; import { setupCommand } from "../commands/setup.js"; import { statusCommand } from "../commands/status.js"; import { updateCommand } from "../commands/update.js"; -import { readConfigFileSnapshot } from "../config/config.js"; +import { + isNixMode, + migrateLegacyConfig, + readConfigFileSnapshot, + writeConfigFile, +} from "../config/config.js"; import { danger, setVerbose } from "../globals.js"; import { loginWeb, logoutWeb } from "../provider-web.js"; import { defaultRuntime } from "../runtime.js"; @@ -87,6 +93,26 @@ export function buildProgram() { if (actionCommand.name() === "doctor") return; const snapshot = await readConfigFileSnapshot(); if (snapshot.legacyIssues.length === 0) return; + if (isNixMode) { + defaultRuntime.error( + danger( + "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.", + ), + ); + process.exit(1); + } + const migrated = migrateLegacyConfig(snapshot.parsed); + if (migrated.config) { + await writeConfigFile(migrated.config); + if (migrated.changes.length > 0) { + defaultRuntime.log( + `Migrated legacy config entries:\n${migrated.changes + .map((entry) => `- ${entry}`) + .join("\n")}`, + ); + } + return; + } const issues = snapshot.legacyIssues .map((issue) => `- ${issue.path}: ${issue.message}`) .join("\n"); @@ -361,10 +387,57 @@ Examples: }); program - .command("agent") - .description( - "Talk directly to the configured agent (no chat send; optional delivery)", + .command("poll") + .description("Create a poll via WhatsApp or Discord") + .requiredOption( + "-t, --to ", + "Recipient: WhatsApp JID/number or Discord channel/user", ) + .requiredOption("-q, --question ", "Poll question") + .requiredOption( + "-o, --option ", + "Poll option (use multiple times, 2-12 required)", + (value: string, previous: string[]) => previous.concat([value]), + [] as string[], + ) + .option( + "-s, --max-selections ", + "How many options can be selected (default: 1)", + ) + .option( + "--duration-hours ", + "Poll duration in hours (Discord only, default: 24)", + ) + .option( + "--provider ", + "Delivery provider: whatsapp|discord (default: whatsapp)", + ) + .option("--dry-run", "Print payload and skip sending", false) + .option("--json", "Output result as JSON", false) + .option("--verbose", "Verbose logging", false) + .addHelpText( + "after", + ` +Examples: + clawdbot poll --to +15555550123 -q "Lunch today?" -o "Yes" -o "No" -o "Maybe" + clawdbot poll --to 123456789@g.us -q "Meeting time?" -o "10am" -o "2pm" -o "4pm" -s 2 + clawdbot poll --to channel:123456789 -q "Snack?" -o "Pizza" -o "Sushi" --provider discord + clawdbot poll --to channel:123456789 -q "Plan?" -o "A" -o "B" --provider discord --duration-hours 48`, + ) + .action(async (opts) => { + setVerbose(Boolean(opts.verbose)); + const deps = createDefaultDeps(); + try { + await pollCommand(opts, deps, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); + + program + .command("agent") + .description("Run an agent turn via the Gateway (use --local for embedded)") .requiredOption("-m, --message ", "Message body for the agent") .option( "-t, --to ", @@ -380,6 +453,11 @@ Examples: "--provider ", "Delivery provider: whatsapp|telegram|discord|slack|signal|imessage (default: whatsapp)", ) + .option( + "--local", + "Run the embedded agent locally (requires provider API keys in your shell)", + false, + ) .option( "--deliver", "Send the agent's reply back to the selected provider (requires --to)", @@ -405,9 +483,9 @@ Examples: typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : ""; setVerbose(verboseLevel === "on"); // Build default deps (keeps parity with other commands; future-proofing). - void createDefaultDeps(); + const deps = createDefaultDeps(); try { - await agentCommand(opts, defaultRuntime); + await agentCliCommand(opts, defaultRuntime, deps); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts new file mode 100644 index 000000000..cd0867582 --- /dev/null +++ b/src/commands/agent-via-gateway.test.ts @@ -0,0 +1,126 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../gateway/call.js", () => ({ + callGateway: vi.fn(), + randomIdempotencyKey: () => "idem-1", +})); +vi.mock("./agent.js", () => ({ + agentCommand: vi.fn(), +})); + +import type { ClawdbotConfig } from "../config/config.js"; +import * as configModule from "../config/config.js"; +import { callGateway } from "../gateway/call.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { agentCommand } from "./agent.js"; +import { agentCliCommand } from "./agent-via-gateway.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +const configSpy = vi.spyOn(configModule, "loadConfig"); + +function mockConfig(storePath: string, overrides?: Partial) { + configSpy.mockReturnValue({ + agent: { + timeoutSeconds: 600, + ...overrides?.agent, + }, + session: { + store: storePath, + mainKey: "main", + ...overrides?.session, + }, + gateway: overrides?.gateway, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("agentCliCommand", () => { + it("uses gateway by default", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-")); + const store = path.join(dir, "sessions.json"); + mockConfig(store); + + vi.mocked(callGateway).mockResolvedValue({ + runId: "idem-1", + status: "ok", + result: { + payloads: [{ text: "hello" }], + meta: { stub: true }, + }, + }); + + try { + await agentCliCommand({ message: "hi", to: "+1555" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(agentCommand).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("hello"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("falls back to embedded agent when gateway fails", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-")); + const store = path.join(dir, "sessions.json"); + mockConfig(store); + + vi.mocked(callGateway).mockRejectedValue( + new Error("gateway not connected"), + ); + vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => { + rt.log?.("local"); + return { payloads: [{ text: "local" }], meta: { stub: true } }; + }); + + try { + await agentCliCommand({ message: "hi", to: "+1555" }, runtime); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(agentCommand).toHaveBeenCalledTimes(1); + expect(runtime.log).toHaveBeenCalledWith("local"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("skips gateway when --local is set", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-agent-cli-")); + const store = path.join(dir, "sessions.json"); + mockConfig(store); + + vi.mocked(agentCommand).mockImplementationOnce(async (_opts, rt) => { + rt.log?.("local"); + return { payloads: [{ text: "local" }], meta: { stub: true } }; + }); + + try { + await agentCliCommand( + { + message: "hi", + to: "+1555", + local: true, + }, + runtime, + ); + + expect(callGateway).not.toHaveBeenCalled(); + expect(agentCommand).toHaveBeenCalledTimes(1); + expect(runtime.log).toHaveBeenCalledWith("local"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts new file mode 100644 index 000000000..e0084de5e --- /dev/null +++ b/src/commands/agent-via-gateway.ts @@ -0,0 +1,194 @@ +import type { CliDeps } from "../cli/deps.js"; +import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveSessionKey, + resolveStorePath, +} from "../config/sessions.js"; +import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { agentCommand } from "./agent.js"; + +type AgentGatewayResult = { + payloads?: Array<{ + text?: string; + mediaUrl?: string | null; + mediaUrls?: string[]; + }>; + meta?: unknown; +}; + +type GatewayAgentResponse = { + runId?: string; + status?: string; + summary?: string; + result?: AgentGatewayResult; +}; + +export type AgentCliOpts = { + message: string; + to?: string; + sessionId?: string; + thinking?: string; + verbose?: string; + json?: boolean; + timeout?: string; + deliver?: boolean; + provider?: string; + bestEffortDeliver?: boolean; + lane?: string; + runId?: string; + extraSystemPrompt?: string; + local?: boolean; +}; + +function resolveGatewaySessionKey(opts: { + cfg: ReturnType; + to?: string; + sessionId?: string; +}): string | undefined { + const sessionCfg = opts.cfg.session; + const scope = sessionCfg?.scope ?? "per-sender"; + const mainKey = sessionCfg?.mainKey ?? "main"; + const storePath = resolveStorePath(sessionCfg?.store); + const store = loadSessionStore(storePath); + + const ctx = opts.to?.trim() ? ({ From: opts.to } as { From: string }) : null; + let sessionKey: string | undefined = ctx + ? resolveSessionKey(scope, ctx, mainKey) + : undefined; + + if ( + opts.sessionId && + (!sessionKey || store[sessionKey]?.sessionId !== opts.sessionId) + ) { + const foundKey = Object.keys(store).find( + (key) => store[key]?.sessionId === opts.sessionId, + ); + if (foundKey) sessionKey = foundKey; + } + + return sessionKey; +} + +function parseTimeoutSeconds(opts: { + cfg: ReturnType; + timeout?: string; +}) { + const raw = + opts.timeout !== undefined + ? Number.parseInt(String(opts.timeout), 10) + : (opts.cfg.agent?.timeoutSeconds ?? 600); + if (Number.isNaN(raw) || raw <= 0) { + throw new Error("--timeout must be a positive integer (seconds)"); + } + return raw; +} + +function normalizeProvider(raw?: string): string | undefined { + const normalized = raw?.trim().toLowerCase(); + if (!normalized) return undefined; + return normalized === "imsg" ? "imessage" : normalized; +} + +function formatPayloadForLog(payload: { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string | null; +}) { + const lines: string[] = []; + if (payload.text) lines.push(payload.text.trimEnd()); + const mediaUrl = + typeof payload.mediaUrl === "string" && payload.mediaUrl.trim() + ? payload.mediaUrl.trim() + : undefined; + const media = payload.mediaUrls ?? (mediaUrl ? [mediaUrl] : []); + for (const url of media) lines.push(`MEDIA:${url}`); + return lines.join("\n").trimEnd(); +} + +export async function agentViaGatewayCommand( + opts: AgentCliOpts, + runtime: RuntimeEnv, +) { + const body = (opts.message ?? "").trim(); + if (!body) throw new Error("Message (--message) is required"); + if (!opts.to && !opts.sessionId) { + throw new Error("Pass --to or --session-id to choose a session"); + } + + const cfg = loadConfig(); + const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout }); + const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000); + + const sessionKey = resolveGatewaySessionKey({ + cfg, + to: opts.to, + sessionId: opts.sessionId, + }); + + const channel = normalizeProvider(opts.provider) ?? "whatsapp"; + const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey(); + + const response = await callGateway({ + method: "agent", + params: { + message: body, + to: opts.to, + sessionId: opts.sessionId, + sessionKey, + thinking: opts.thinking, + deliver: Boolean(opts.deliver), + channel, + timeout: timeoutSeconds, + lane: opts.lane, + extraSystemPrompt: opts.extraSystemPrompt, + idempotencyKey, + }, + expectFinal: true, + timeoutMs: gatewayTimeoutMs, + clientName: "cli", + mode: "cli", + }); + + if (opts.json) { + runtime.log(JSON.stringify(response, null, 2)); + return response; + } + + const result = response?.result; + const payloads = result?.payloads ?? []; + + if (payloads.length === 0) { + runtime.log( + response?.summary ? String(response.summary) : "No reply from agent.", + ); + return response; + } + + for (const payload of payloads) { + const out = formatPayloadForLog(payload); + if (out) runtime.log(out); + } + + return response; +} + +export async function agentCliCommand( + opts: AgentCliOpts, + runtime: RuntimeEnv, + deps?: CliDeps, +) { + if (opts.local === true) { + return await agentCommand(opts, runtime, deps); + } + + try { + return await agentViaGatewayCommand(opts, runtime); + } catch (err) { + runtime.error?.( + `Gateway agent failed; falling back to embedded: ${String(err)}`, + ); + return await agentCommand(opts, runtime, deps); + } +} diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index c407d9666..b25a95304 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -59,7 +59,8 @@ function mockConfig( ) { configSpy.mockReturnValue({ agent: { - model: "anthropic/claude-opus-4-5", + model: { primary: "anthropic/claude-opus-4-5" }, + models: { "anthropic/claude-opus-4-5": {} }, workspace: path.join(home, "clawd"), ...agentOverrides, }, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index e6306ae66..fa5287534 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -15,6 +16,7 @@ import { } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, @@ -189,11 +191,17 @@ export async function agentCommand( const timeoutSecondsRaw = opts.timeout !== undefined ? Number.parseInt(String(opts.timeout), 10) - : (agentCfg?.timeoutSeconds ?? 600); - if (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) { + : undefined; + if ( + timeoutSecondsRaw !== undefined && + (Number.isNaN(timeoutSecondsRaw) || timeoutSecondsRaw <= 0) + ) { throw new Error("--timeout must be a positive integer (seconds)"); } - const timeoutMs = Math.max(timeoutSecondsRaw, 1) * 1000; + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideSeconds: timeoutSecondsRaw, + }); const sessionResolution = resolveSession({ cfg, @@ -289,7 +297,8 @@ export async function agentCommand( }); let provider = defaultProvider; let model = defaultModel; - const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0; + const hasAllowlist = + agentCfg?.models && Object.keys(agentCfg.models).length > 0; const hasStoredOverride = Boolean( sessionEntry?.modelOverride || sessionEntry?.providerOverride, ); @@ -335,6 +344,18 @@ export async function agentCommand( model = storedModelOverride; } } + if (sessionEntry?.authProfileOverride) { + const store = ensureAuthProfileStore(); + const profile = store.profiles[sessionEntry.authProfileOverride]; + if (!profile || profile.provider !== provider) { + delete sessionEntry.authProfileOverride; + sessionEntry.updatedAt = Date.now(); + if (sessionStore && sessionKey) { + sessionStore[sessionKey] = sessionEntry; + await saveSessionStore(storePath, sessionStore); + } + } + } if (!resolvedThinkLevel) { let catalogForThinking = modelCatalog ?? allowedModelCatalog; @@ -381,6 +402,7 @@ export async function agentCommand( prompt: body, provider: providerOverride, model: modelOverride, + authProfileId: sessionEntry?.authProfileOverride, thinkLevel: resolvedThinkLevel, verboseLevel: resolvedVerboseLevel, timeoutMs, @@ -583,12 +605,14 @@ export async function agentCommand( 2, ), ); - if (!deliver) return; + if (!deliver) { + return { payloads: normalizedPayloads, meta: result.meta }; + } } if (payloads.length === 0) { runtime.log("No reply from agent."); - return; + return { payloads: [], meta: result.meta }; } const deliveryTextLimit = @@ -772,4 +796,11 @@ export async function agentCommand( } } } + + const normalizedPayloads = payloads.map((p) => ({ + text: p.text ?? "", + mediaUrl: p.mediaUrl ?? null, + mediaUrls: p.mediaUrls ?? (p.mediaUrl ? [p.mediaUrl] : undefined), + })); + return { payloads: normalizedPayloads, meta: result.meta }; } diff --git a/src/commands/configure.ts b/src/commands/configure.ts index dda13dc83..e2b8b6451 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -32,6 +32,7 @@ import { } from "./antigravity-oauth.js"; import { healthCommand } from "./health.js"; import { + applyAuthProfileConfig, applyMinimaxConfig, setAnthropicApiKey, writeOAuthCredentials, @@ -275,6 +276,11 @@ async function promptAuthConfig( spin.stop("OAuth complete"); if (oauthCreds) { await writeOAuthCredentials("anthropic", oauthCreds); + next = applyAuthProfileConfig(next, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "oauth", + }); } } catch (err) { spin.stop("OAuth failed"); @@ -316,12 +322,33 @@ async function promptAuthConfig( spin.stop("Antigravity OAuth complete"); if (oauthCreds) { await writeOAuthCredentials("google-antigravity", oauthCreds); + next = applyAuthProfileConfig(next, { + profileId: "google-antigravity:default", + provider: "google-antigravity", + mode: "oauth", + }); // Set default model to Claude Opus 4.5 via Antigravity next = { ...next, agent: { ...next.agent, - model: "google-antigravity/claude-opus-4-5-thinking", + model: { + ...(next.agent?.model && + "fallbacks" in (next.agent.model as Record) + ? { + fallbacks: (next.agent.model as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: "google-antigravity/claude-opus-4-5-thinking", + }, + models: { + ...next.agent?.models, + "google-antigravity/claude-opus-4-5-thinking": + next.agent?.models?.[ + "google-antigravity/claude-opus-4-5-thinking" + ] ?? {}, + }, }, }; note( @@ -342,6 +369,11 @@ async function promptAuthConfig( runtime, ); await setAnthropicApiKey(String(key).trim()); + next = applyAuthProfileConfig(next, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "api_key", + }); } else if (authChoice === "minimax") { next = applyMinimaxConfig(next); } @@ -349,7 +381,10 @@ async function promptAuthConfig( const modelInput = guardCancel( await text({ message: "Default model (blank to keep)", - initialValue: next.agent?.model ?? "", + initialValue: + typeof next.agent?.model === "string" + ? next.agent?.model + : (next.agent?.model?.primary ?? ""), }), runtime, ); @@ -359,7 +394,20 @@ async function promptAuthConfig( ...next, agent: { ...next.agent, - model, + model: { + ...(next.agent?.model && + "fallbacks" in (next.agent.model as Record) + ? { + fallbacks: (next.agent.model as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: model, + }, + models: { + ...next.agent?.models, + [model]: next.agent?.models?.[model] ?? {}, + }, }, }; } diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index be4efaf6e..8902edfe0 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -38,6 +38,7 @@ const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({ }); const serviceInstall = vi.fn().mockResolvedValue(undefined); const serviceIsLoaded = vi.fn().mockResolvedValue(false); +const serviceStop = vi.fn().mockResolvedValue(undefined); const serviceRestart = vi.fn().mockResolvedValue(undefined); const serviceUninstall = vi.fn().mockResolvedValue(undefined); @@ -85,6 +86,7 @@ vi.mock("../daemon/service.js", () => ({ notLoadedText: "not loaded", install: serviceInstall, uninstall: serviceUninstall, + stop: serviceStop, restart: serviceRestart, isLoaded: serviceIsLoaded, readCommand: vi.fn(), diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index b3e886b30..9c960ce2a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -660,6 +660,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) { if (!loaded) { note("Gateway daemon not installed.", "Gateway"); } else { + if (process.platform === "darwin") { + note( + `LaunchAgent loaded; stopping requires "clawdbot gateway stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`, + "Gateway", + ); + } const restart = guardCancel( await confirm({ message: "Restart gateway daemon now?", diff --git a/src/commands/models/aliases.ts b/src/commands/models/aliases.ts index fe670e9f0..9600b7494 100644 --- a/src/commands/models/aliases.ts +++ b/src/commands/models/aliases.ts @@ -13,7 +13,15 @@ export async function modelsAliasesListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const aliases = cfg.agent?.modelAliases ?? {}; + const models = cfg.agent?.models ?? {}; + const aliases = Object.entries(models).reduce>( + (acc, [modelKey, entry]) => { + const alias = entry?.alias?.trim(); + if (alias) acc[alias] = modelKey; + return acc; + }, + {}, + ); if (opts.json) { runtime.log(JSON.stringify({ aliases }, null, 2)); @@ -42,21 +50,29 @@ export async function modelsAliasesAddCommand( 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}`; + const resolved = resolveModelTarget({ raw: modelRaw, cfg: loadConfig() }); + const _updated = await updateConfig((cfg) => { + const modelKey = `${resolved.provider}/${resolved.model}`; + const nextModels = { ...cfg.agent?.models }; + for (const [key, entry] of Object.entries(nextModels)) { + const existing = entry?.alias?.trim(); + if (existing && existing === alias && key !== modelKey) { + throw new Error(`Alias ${alias} already points to ${key}.`); + } + } + const existing = nextModels[modelKey] ?? {}; + nextModels[modelKey] = { ...existing, alias }; return { ...cfg, agent: { ...cfg.agent, - modelAliases: nextAliases, + models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - runtime.log(`Alias ${alias} -> ${updated.agent?.modelAliases?.[alias]}`); + runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`); } export async function modelsAliasesRemoveCommand( @@ -65,24 +81,31 @@ export async function modelsAliasesRemoveCommand( ) { const alias = normalizeAlias(aliasRaw); const updated = await updateConfig((cfg) => { - const nextAliases = { ...cfg.agent?.modelAliases }; - if (!nextAliases[alias]) { + const nextModels = { ...cfg.agent?.models }; + let found = false; + for (const [key, entry] of Object.entries(nextModels)) { + if (entry?.alias?.trim() === alias) { + nextModels[key] = { ...entry, alias: undefined }; + found = true; + break; + } + } + if (!found) { throw new Error(`Alias not found: ${alias}`); } - delete nextAliases[alias]; return { ...cfg, agent: { ...cfg.agent, - modelAliases: nextAliases, + models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); if ( - !updated.agent?.modelAliases || - Object.keys(updated.agent.modelAliases).length === 0 + !updated.agent?.models || + Object.values(updated.agent.models).every((entry) => !entry?.alias?.trim()) ) { runtime.log("No aliases configured."); } diff --git a/src/commands/models/fallbacks.ts b/src/commands/models/fallbacks.ts index 81f825abb..c5ac94f4d 100644 --- a/src/commands/models/fallbacks.ts +++ b/src/commands/models/fallbacks.ts @@ -18,7 +18,7 @@ export async function modelsFallbacksListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const fallbacks = cfg.agent?.modelFallbacks ?? []; + const fallbacks = cfg.agent?.model?.fallbacks ?? []; if (opts.json) { runtime.log(JSON.stringify({ fallbacks }, null, 2)); @@ -44,11 +44,13 @@ export async function modelsFallbacksAddCommand( const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const targetKey = modelKey(resolved.provider, resolved.model); + const nextModels = { ...cfg.agent?.models }; + if (!nextModels[targetKey]) nextModels[targetKey] = {}; const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.modelFallbacks ?? []; + const existing = cfg.agent?.model?.fallbacks ?? []; const existingKeys = existing .map((entry) => resolveModelRefFromString({ @@ -62,17 +64,29 @@ export async function modelsFallbacksAddCommand( if (existingKeys.includes(targetKey)) return cfg; + const existingModel = cfg.agent?.model as + | { primary?: string; fallbacks?: string[] } + | undefined; + return { ...cfg, agent: { ...cfg.agent, - modelFallbacks: [...existing, targetKey], + model: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: [...existing, targetKey], + }, + models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`); + runtime.log( + `Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`, + ); } export async function modelsFallbacksRemoveCommand( @@ -86,7 +100,7 @@ export async function modelsFallbacksRemoveCommand( cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.modelFallbacks ?? []; + const existing = cfg.agent?.model?.fallbacks ?? []; const filtered = existing.filter((entry) => { const resolvedEntry = resolveModelRefFromString({ raw: String(entry ?? ""), @@ -104,27 +118,48 @@ export async function modelsFallbacksRemoveCommand( throw new Error(`Fallback not found: ${targetKey}`); } + const existingModel = cfg.agent?.model as + | { primary?: string; fallbacks?: string[] } + | undefined; + return { ...cfg, agent: { ...cfg.agent, - modelFallbacks: filtered, + model: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: filtered, + }, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`); + runtime.log( + `Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`, + ); } export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) { - await updateConfig((cfg) => ({ - ...cfg, - agent: { - ...cfg.agent, - modelFallbacks: [], - }, - })); + await updateConfig((cfg) => { + const existingModel = cfg.agent?.model as + | { primary?: string; fallbacks?: string[] } + | undefined; + return { + ...cfg, + agent: { + ...cfg.agent, + model: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: [], + }, + }, + }; + }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log("Fallback list cleared."); diff --git a/src/commands/models/image-fallbacks.ts b/src/commands/models/image-fallbacks.ts index f4a941b8a..25ea316ec 100644 --- a/src/commands/models/image-fallbacks.ts +++ b/src/commands/models/image-fallbacks.ts @@ -18,7 +18,7 @@ export async function modelsImageFallbacksListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); - const fallbacks = cfg.agent?.imageModelFallbacks ?? []; + const fallbacks = cfg.agent?.imageModel?.fallbacks ?? []; if (opts.json) { runtime.log(JSON.stringify({ fallbacks }, null, 2)); @@ -44,11 +44,13 @@ export async function modelsImageFallbacksAddCommand( const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); const targetKey = modelKey(resolved.provider, resolved.model); + const nextModels = { ...cfg.agent?.models }; + if (!nextModels[targetKey]) nextModels[targetKey] = {}; const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.imageModelFallbacks ?? []; + const existing = cfg.agent?.imageModel?.fallbacks ?? []; const existingKeys = existing .map((entry) => resolveModelRefFromString({ @@ -62,18 +64,28 @@ export async function modelsImageFallbacksAddCommand( if (existingKeys.includes(targetKey)) return cfg; + const existingModel = cfg.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | undefined; + return { ...cfg, agent: { ...cfg.agent, - imageModelFallbacks: [...existing, targetKey], + imageModel: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: [...existing, targetKey], + }, + models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log( - `Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`, + `Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`, ); } @@ -88,7 +100,7 @@ export async function modelsImageFallbacksRemoveCommand( cfg, defaultProvider: DEFAULT_PROVIDER, }); - const existing = cfg.agent?.imageModelFallbacks ?? []; + const existing = cfg.agent?.imageModel?.fallbacks ?? []; const filtered = existing.filter((entry) => { const resolvedEntry = resolveModelRefFromString({ raw: String(entry ?? ""), @@ -106,29 +118,48 @@ export async function modelsImageFallbacksRemoveCommand( throw new Error(`Image fallback not found: ${targetKey}`); } + const existingModel = cfg.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | undefined; + return { ...cfg, agent: { ...cfg.agent, - imageModelFallbacks: filtered, + imageModel: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: filtered, + }, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log( - `Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`, + `Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`, ); } export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) { - await updateConfig((cfg) => ({ - ...cfg, - agent: { - ...cfg.agent, - imageModelFallbacks: [], - }, - })); + await updateConfig((cfg) => { + const existingModel = cfg.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | undefined; + return { + ...cfg, + agent: { + ...cfg.agent, + imageModel: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: [], + }, + }, + }; + }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log("Image fallback list cleared."); diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index e061cf5d3..7a8fb7858 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -1,4 +1,4 @@ -import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai"; +import type { Api, Model } from "@mariozechner/pi-ai"; import { discoverAuthStorage, discoverModels, @@ -6,6 +6,15 @@ import { import chalk from "chalk"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + type AuthProfileStore, + ensureAuthProfileStore, + listProfilesForProvider, +} from "../../agents/auth-profiles.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { buildModelAliasIndex, parseModelRef, @@ -81,6 +90,17 @@ const isLocalBaseUrl = (baseUrl: string) => { } }; +const hasAuthForProvider = ( + provider: string, + cfg: ClawdbotConfig, + authStore: AuthProfileStore, +): boolean => { + if (listProfilesForProvider(authStore, provider).length > 0) return true; + if (resolveEnvApiKey(provider)) return true; + if (getCustomProviderApiKey(cfg, provider)) return true; + return false; +}; + const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { const resolvedDefault = resolveConfiguredModelRef({ cfg, @@ -110,7 +130,21 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { addEntry(resolvedDefault, "default"); - (cfg.agent?.modelFallbacks ?? []).forEach((raw, idx) => { + const modelConfig = cfg.agent?.model as + | { primary?: string; fallbacks?: string[] } + | undefined; + const imageModelConfig = cfg.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | undefined; + const modelFallbacks = + typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : []; + const imageFallbacks = + typeof imageModelConfig === "object" + ? (imageModelConfig?.fallbacks ?? []) + : []; + const imagePrimary = imageModelConfig?.primary?.trim() ?? ""; + + modelFallbacks.forEach((raw, idx) => { const resolved = resolveModelRefFromString({ raw: String(raw ?? ""), defaultProvider: DEFAULT_PROVIDER, @@ -120,17 +154,16 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { addEntry(resolved.ref, `fallback#${idx + 1}`); }); - const imageModelRaw = cfg.agent?.imageModel?.trim(); - if (imageModelRaw) { + if (imagePrimary) { const resolved = resolveModelRefFromString({ - raw: imageModelRaw, + raw: imagePrimary, defaultProvider: DEFAULT_PROVIDER, aliasIndex, }); if (resolved) addEntry(resolved.ref, "image"); } - (cfg.agent?.imageModelFallbacks ?? []).forEach((raw, idx) => { + imageFallbacks.forEach((raw, idx) => { const resolved = resolveModelRefFromString({ raw: String(raw ?? ""), defaultProvider: DEFAULT_PROVIDER, @@ -140,20 +173,10 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => { addEntry(resolved.ref, `img-fallback#${idx + 1}`); }); - (cfg.agent?.allowedModels ?? []).forEach((raw) => { - const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); - if (!parsed) return; - addEntry(parsed, "allowed"); - }); - - for (const targetRaw of Object.values(cfg.agent?.modelAliases ?? {})) { - const resolved = resolveModelRefFromString({ - raw: String(targetRaw ?? ""), - defaultProvider: DEFAULT_PROVIDER, - aliasIndex, - }); - if (!resolved) continue; - addEntry(resolved.ref, "alias"); + for (const key of Object.keys(cfg.agent?.models ?? {})) { + const parsed = parseModelRef(String(key ?? ""), DEFAULT_PROVIDER); + if (!parsed) continue; + addEntry(parsed, "configured"); } const entries: ConfiguredEntry[] = order.map((key) => { @@ -190,8 +213,18 @@ function toModelRow(params: { tags: string[]; aliases?: string[]; availableKeys?: Set; + cfg?: ClawdbotConfig; + authStore?: AuthProfileStore; }): ModelRow { - const { model, key, tags, aliases = [], availableKeys } = params; + const { + model, + key, + tags, + aliases = [], + availableKeys, + cfg, + authStore, + } = params; if (!model) { return { key, @@ -207,9 +240,11 @@ function toModelRow(params: { const input = model.input.join("+") || "text"; const local = isLocalBaseUrl(model.baseUrl); - const envKey = getEnvApiKey(model.provider); const available = - availableKeys?.has(modelKey(model.provider, model.id)) || Boolean(envKey); + availableKeys?.has(modelKey(model.provider, model.id)) || + (cfg && authStore + ? hasAuthForProvider(model.provider, cfg, authStore) + : false); const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : []; const mergedTags = new Set(tags); if (aliasTags.length > 0) { @@ -304,6 +339,7 @@ export async function modelsListCommand( ) { ensureFlagCompatibility(opts); const cfg = loadConfig(); + const authStore = ensureAuthProfileStore(); const providerFilter = opts.provider?.trim().toLowerCase(); let models: Model[] = []; @@ -346,6 +382,8 @@ export async function modelsListCommand( tags: configured ? Array.from(configured.tags) : [], aliases: configured?.aliases ?? [], availableKeys, + cfg, + authStore, }), ); } @@ -367,6 +405,8 @@ export async function modelsListCommand( tags: Array.from(entry.tags), aliases: entry.aliases, availableKeys, + cfg, + authStore, }), ); } @@ -392,13 +432,35 @@ export async function modelsStatusCommand( defaultModel: DEFAULT_MODEL, }); - const rawModel = cfg.agent?.model?.trim() ?? ""; + const modelConfig = cfg.agent?.model as + | { primary?: string; fallbacks?: string[] } + | string + | undefined; + const imageConfig = cfg.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | string + | undefined; + const rawModel = + typeof modelConfig === "string" + ? modelConfig.trim() + : (modelConfig?.primary?.trim() ?? ""); const defaultLabel = rawModel || `${resolved.provider}/${resolved.model}`; - const fallbacks = cfg.agent?.modelFallbacks ?? []; - const imageModel = cfg.agent?.imageModel?.trim() ?? ""; - const imageFallbacks = cfg.agent?.imageModelFallbacks ?? []; - const aliases = cfg.agent?.modelAliases ?? {}; - const allowed = cfg.agent?.allowedModels ?? []; + const fallbacks = + typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : []; + const imageModel = + typeof imageConfig === "string" + ? imageConfig.trim() + : (imageConfig?.primary?.trim() ?? ""); + const imageFallbacks = + typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : []; + const aliases = Object.entries(cfg.agent?.models ?? {}).reduce< + Record + >((acc, [key, entry]) => { + const alias = entry?.alias?.trim(); + if (alias) acc[alias] = key; + return acc; + }, {}); + const allowed = Object.keys(cfg.agent?.models ?? {}); if (opts.json) { runtime.log( @@ -446,6 +508,8 @@ export async function modelsStatusCommand( }`, ); runtime.log( - `Allowed (${allowed.length || 0}): ${allowed.length ? allowed.join(", ") : "all"}`, + `Configured models (${allowed.length || 0}): ${ + allowed.length ? allowed.join(", ") : "all" + }`, ); } diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 4cb3b858f..6a2a058e2 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -1,20 +1,12 @@ import { cancel, isCancel, multiselect } from "@clack/prompts"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; - -import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; import { type ModelScanResult, scanOpenRouterModels, } from "../../agents/model-scan.js"; -import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; -import { warn } from "../../globals.js"; +import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { - buildAllowlistSet, - formatMs, - formatTokenK, - updateConfig, -} from "./shared.js"; +import { formatMs, formatTokenK, updateConfig } from "./shared.js"; const MODEL_PAD = 42; const CTX_PAD = 8; @@ -181,8 +173,17 @@ export async function modelsScanCommand( throw new Error("--concurrency must be > 0"); } - const authStorage = discoverAuthStorage(resolveClawdbotAgentDir()); - const storedKey = await authStorage.getApiKey("openrouter"); + const cfg = loadConfig(); + let storedKey: string | undefined; + try { + const resolved = await resolveApiKeyForProvider({ + provider: "openrouter", + cfg, + }); + storedKey = resolved.apiKey; + } catch { + storedKey = undefined; + } const results = await scanOpenRouterModels({ apiKey: storedKey ?? undefined, minParamB: minParams, @@ -266,32 +267,48 @@ export async function modelsScanCommand( throw new Error("No image-capable models selected for image model."); } - const updated = await updateConfig((cfg) => { + const _updated = await updateConfig((cfg) => { + const nextModels = { ...cfg.agent?.models }; + for (const entry of selected) { + if (!nextModels[entry]) nextModels[entry] = {}; + } + for (const entry of selectedImages) { + if (!nextModels[entry]) nextModels[entry] = {}; + } + const existingImageModel = cfg.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | undefined; + const nextImageModel = + selectedImages.length > 0 + ? { + ...(existingImageModel?.primary + ? { primary: existingImageModel.primary } + : undefined), + fallbacks: selectedImages, + ...(opts.setImage ? { primary: selectedImages[0] } : {}), + } + : cfg.agent?.imageModel; + const existingModel = cfg.agent?.model as + | { primary?: string; fallbacks?: string[] } + | undefined; const agent = { ...cfg.agent, - modelFallbacks: selected, - ...(opts.setDefault ? { model: selected[0] } : {}), - ...(opts.setImage && selectedImages.length > 0 - ? { imageModel: selectedImages[0] } - : {}), + model: { + ...(existingModel?.primary + ? { primary: existingModel.primary } + : undefined), + fallbacks: selected, + ...(opts.setDefault ? { primary: selected[0] } : {}), + }, + ...(nextImageModel ? { imageModel: nextImageModel } : {}), + models: nextModels, } satisfies NonNullable; - if (imageSorted.length > 0) { - agent.imageModelFallbacks = selectedImages; - } return { ...cfg, agent, }; }); - const allowlist = buildAllowlistSet(updated); - const allowlistMissing = - allowlist.size > 0 ? selected.filter((entry) => !allowlist.has(entry)) : []; - const allowlistMissingImages = - allowlist.size > 0 - ? selectedImages.filter((entry) => !allowlist.has(entry)) - : []; - if (opts.json) { runtime.log( JSON.stringify( @@ -301,21 +318,7 @@ export async function modelsScanCommand( setDefault: Boolean(opts.setDefault), setImage: Boolean(opts.setImage), results, - warnings: - allowlistMissing.length > 0 || allowlistMissingImages.length > 0 - ? [ - ...(allowlistMissing.length > 0 - ? [ - `Selected models not in agent.allowedModels: ${allowlistMissing.join(", ")}`, - ] - : []), - ...(allowlistMissingImages.length > 0 - ? [ - `Selected image models not in agent.allowedModels: ${allowlistMissingImages.join(", ")}`, - ] - : []), - ] - : [], + warnings: [], }, null, 2, @@ -324,21 +327,6 @@ export async function modelsScanCommand( return; } - if (allowlistMissing.length > 0) { - runtime.log( - warn( - `Warning: ${allowlistMissing.length} selected models are not in agent.allowedModels and will be ignored by fallback: ${allowlistMissing.join(", ")}`, - ), - ); - } - if (allowlistMissingImages.length > 0) { - runtime.log( - warn( - `Warning: ${allowlistMissingImages.length} selected image models are not in agent.allowedModels and will be ignored by fallback: ${allowlistMissingImages.join(", ")}`, - ), - ); - } - runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); runtime.log(`Fallbacks: ${selected.join(", ")}`); if (selectedImages.length > 0) { diff --git a/src/commands/models/set-image.ts b/src/commands/models/set-image.ts index 6613b2e98..ed7a3e0db 100644 --- a/src/commands/models/set-image.ts +++ b/src/commands/models/set-image.ts @@ -1,11 +1,6 @@ import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { - buildAllowlistSet, - modelKey, - resolveModelTarget, - updateConfig, -} from "./shared.js"; +import { resolveModelTarget, updateConfig } from "./shared.js"; export async function modelsSetImageCommand( modelRaw: string, @@ -13,22 +8,27 @@ export async function modelsSetImageCommand( ) { const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const allowlist = buildAllowlistSet(cfg); - if (allowlist.size > 0) { - const key = modelKey(resolved.provider, resolved.model); - if (!allowlist.has(key)) { - throw new Error(`Model ${key} is not in agent.allowedModels.`); - } - } + const key = `${resolved.provider}/${resolved.model}`; + const nextModels = { ...cfg.agent?.models }; + if (!nextModels[key]) nextModels[key] = {}; + const existingModel = cfg.agent?.imageModel as + | { primary?: string; fallbacks?: string[] } + | undefined; return { ...cfg, agent: { ...cfg.agent, - imageModel: `${resolved.provider}/${resolved.model}`, + imageModel: { + ...(existingModel?.fallbacks + ? { fallbacks: existingModel.fallbacks } + : undefined), + primary: key, + }, + models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - runtime.log(`Image model: ${updated.agent?.imageModel ?? modelRaw}`); + runtime.log(`Image model: ${updated.agent?.imageModel?.primary ?? modelRaw}`); } diff --git a/src/commands/models/set.ts b/src/commands/models/set.ts index 20e500519..0cfc9cdc3 100644 --- a/src/commands/models/set.ts +++ b/src/commands/models/set.ts @@ -1,31 +1,31 @@ import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { - buildAllowlistSet, - modelKey, - resolveModelTarget, - updateConfig, -} from "./shared.js"; +import { resolveModelTarget, updateConfig } from "./shared.js"; export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) { const updated = await updateConfig((cfg) => { const resolved = resolveModelTarget({ raw: modelRaw, cfg }); - const allowlist = buildAllowlistSet(cfg); - if (allowlist.size > 0) { - const key = modelKey(resolved.provider, resolved.model); - if (!allowlist.has(key)) { - throw new Error(`Model ${key} is not in agent.allowedModels.`); - } - } + const key = `${resolved.provider}/${resolved.model}`; + const nextModels = { ...cfg.agent?.models }; + if (!nextModels[key]) nextModels[key] = {}; + const existingModel = cfg.agent?.model as + | { primary?: string; fallbacks?: string[] } + | undefined; return { ...cfg, agent: { ...cfg.agent, - model: `${resolved.provider}/${resolved.model}`, + model: { + ...(existingModel?.fallbacks + ? { fallbacks: existingModel.fallbacks } + : undefined), + primary: key, + }, + models: nextModels, }, }; }); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - runtime.log(`Default model: ${updated.agent?.model ?? modelRaw}`); + runtime.log(`Default model: ${updated.agent?.model?.primary ?? modelRaw}`); } diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 347ae06d0..a8d305998 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -69,7 +69,8 @@ export function resolveModelTarget(params: { export function buildAllowlistSet(cfg: ClawdbotConfig): Set { const allowed = new Set(); - for (const raw of cfg.agent?.allowedModels ?? []) { + const models = cfg.agent?.models ?? {}; + for (const raw of Object.keys(models)) { const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); if (!parsed) continue; allowed.add(modelKey(parsed.provider, parsed.model)); diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 4187d1961..71526c61d 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -5,11 +5,12 @@ import path from "node:path"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import { resolveOAuthPath } from "../config/paths.js"; import { writeOAuthCredentials } from "./onboard-auth.js"; describe("writeOAuthCredentials", () => { const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; let tempStateDir: string | null = null; afterEach(async () => { @@ -22,12 +23,24 @@ describe("writeOAuthCredentials", () => { } else { process.env.CLAWDBOT_STATE_DIR = previousStateDir; } + if (previousAgentDir === undefined) { + delete process.env.CLAWDBOT_AGENT_DIR; + } else { + process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; + } + if (previousPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; + } delete process.env.CLAWDBOT_OAUTH_DIR; }); - it("writes oauth.json under CLAWDBOT_STATE_DIR/credentials", async () => { + it("writes auth-profiles.json under CLAWDBOT_STATE_DIR/agent", async () => { tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-")); process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; const creds = { refresh: "refresh-token", @@ -37,16 +50,19 @@ describe("writeOAuthCredentials", () => { await writeOAuthCredentials("anthropic", creds); - const oauthPath = resolveOAuthPath(); - expect(oauthPath).toBe( - path.join(tempStateDir, "credentials", "oauth.json"), + const authProfilePath = path.join( + tempStateDir, + "agent", + "auth-profiles.json", ); - - const raw = await fs.readFile(oauthPath, "utf8"); - const parsed = JSON.parse(raw) as Record; - expect(parsed.anthropic).toMatchObject({ + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["anthropic:default"]).toMatchObject({ refresh: "refresh-token", access: "access-token", + type: "oauth", }); }); }); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 16b076a3d..3da496b34 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,47 +1,73 @@ -import fs from "node:fs/promises"; -import path from "node:path"; - import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; - -import { resolveClawdbotAgentDir } from "../agents/agent-paths.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { resolveOAuthPath } from "../config/paths.js"; export async function writeOAuthCredentials( provider: OAuthProvider, creds: OAuthCredentials, ): Promise { - const filePath = resolveOAuthPath(); - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - let storage: Record = {}; - try { - const raw = await fs.readFile(filePath, "utf8"); - const parsed = JSON.parse(raw) as Record; - if (parsed && typeof parsed === "object") storage = parsed; - } catch { - // ignore - } - storage[provider] = creds; - await fs.writeFile(filePath, `${JSON.stringify(storage, null, 2)}\n`, "utf8"); - await fs.chmod(filePath, 0o600); + upsertAuthProfile({ + profileId: `${provider}:default`, + credential: { + type: "oauth", + provider, + ...creds, + }, + }); } export async function setAnthropicApiKey(key: string) { - const agentDir = resolveClawdbotAgentDir(); - const authStorage = discoverAuthStorage(agentDir); - authStorage.set("anthropic", { type: "api_key", key }); + upsertAuthProfile({ + profileId: "anthropic:default", + credential: { + type: "api_key", + provider: "anthropic", + key, + }, + }); +} + +export function applyAuthProfileConfig( + cfg: ClawdbotConfig, + params: { + profileId: string; + provider: string; + mode: "api_key" | "oauth"; + email?: string; + }, +): ClawdbotConfig { + const profiles = { + ...cfg.auth?.profiles, + [params.profileId]: { + provider: params.provider, + mode: params.mode, + ...(params.email ? { email: params.email } : {}), + }, + }; + const order = { ...cfg.auth?.order }; + const list = order[params.provider] ? [...order[params.provider]] : []; + if (!list.includes(params.profileId)) list.push(params.profileId); + order[params.provider] = list; + return { + ...cfg, + auth: { + ...cfg.auth, + profiles, + order, + }, + }; } export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { - const allowed = new Set(cfg.agent?.allowedModels ?? []); - allowed.add("anthropic/claude-opus-4-5"); - allowed.add("lmstudio/minimax-m2.1-gs32"); - - const aliases = { ...cfg.agent?.modelAliases }; - if (!aliases.Opus) aliases.Opus = "anthropic/claude-opus-4-5"; - if (!aliases.Minimax) aliases.Minimax = "lmstudio/minimax-m2.1-gs32"; + const models = { ...cfg.agent?.models }; + models["anthropic/claude-opus-4-5"] = { + ...models["anthropic/claude-opus-4-5"], + alias: models["anthropic/claude-opus-4-5"]?.alias ?? "Opus", + }; + models["lmstudio/minimax-m2.1-gs32"] = { + ...models["lmstudio/minimax-m2.1-gs32"], + alias: models["lmstudio/minimax-m2.1-gs32"]?.alias ?? "Minimax", + }; const providers = { ...cfg.models?.providers }; if (!providers.lmstudio) { @@ -67,9 +93,17 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig { ...cfg, agent: { ...cfg.agent, - model: "Minimax", - allowedModels: Array.from(allowed), - modelAliases: aliases, + model: { + ...(cfg.agent?.model && + "fallbacks" in (cfg.agent.model as Record) + ? { + fallbacks: (cfg.agent.model as { fallbacks?: string[] }) + .fallbacks, + } + : undefined), + primary: "lmstudio/minimax-m2.1-gs32", + }, + models, }, models: { mode: cfg.models?.mode ?? "merge", diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 3e6ff3a87..0e81bf768 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -33,7 +33,13 @@ export function summarizeExistingConfig(config: ClawdbotConfig): string { const rows: string[] = []; if (config.agent?.workspace) rows.push(`workspace: ${config.agent.workspace}`); - if (config.agent?.model) rows.push(`model: ${config.agent.model}`); + if (config.agent?.model) { + const model = + typeof config.agent.model === "string" + ? config.agent.model + : config.agent.model.primary; + if (model) rows.push(`model: ${model}`); + } if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`); if (typeof config.gateway?.port === "number") { rows.push(`gateway.port: ${config.gateway.port}`); diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 016c6fd3b..17d845cd7 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -14,7 +14,11 @@ import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; import { healthCommand } from "./health.js"; -import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js"; +import { + applyAuthProfileConfig, + applyMinimaxConfig, + setAnthropicApiKey, +} from "./onboard-auth.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -98,6 +102,11 @@ export async function runNonInteractiveOnboarding( return; } await setAnthropicApiKey(key); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "api_key", + }); } else if (authChoice === "minimax") { nextConfig = applyMinimaxConfig(nextConfig); } else if ( diff --git a/src/commands/poll.ts b/src/commands/poll.ts new file mode 100644 index 000000000..5fda34838 --- /dev/null +++ b/src/commands/poll.ts @@ -0,0 +1,101 @@ +import type { CliDeps } from "../cli/deps.js"; +import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; +import { success } from "../globals.js"; +import { normalizePollInput, type PollInput } from "../polls.js"; +import type { RuntimeEnv } from "../runtime.js"; + +function parseIntOption(value: unknown, label: string): number | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || value.trim().length === 0) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`${label} must be a number`); + } + return parsed; +} + +export async function pollCommand( + opts: { + to: string; + question: string; + option: string[]; + maxSelections?: string; + durationHours?: string; + provider?: string; + json?: boolean; + dryRun?: boolean; + }, + _deps: CliDeps, + runtime: RuntimeEnv, +) { + const provider = (opts.provider ?? "whatsapp").toLowerCase(); + if (provider !== "whatsapp" && provider !== "discord") { + throw new Error(`Unsupported poll provider: ${provider}`); + } + + const maxSelections = parseIntOption(opts.maxSelections, "max-selections"); + const durationHours = parseIntOption(opts.durationHours, "duration-hours"); + + const pollInput: PollInput = { + question: opts.question, + options: opts.option, + maxSelections, + durationHours, + }; + const maxOptions = provider === "discord" ? 10 : 12; + const normalized = normalizePollInput(pollInput, { maxOptions }); + + if (opts.dryRun) { + runtime.log( + `[dry-run] would send poll via ${provider} -> ${opts.to}:\n Question: ${normalized.question}\n Options: ${normalized.options.join(", ")}\n Max selections: ${normalized.maxSelections}`, + ); + return; + } + + const result = await callGateway<{ + messageId: string; + toJid?: string; + channelId?: string; + }>({ + url: "ws://127.0.0.1:18789", + method: "poll", + params: { + to: opts.to, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + durationHours: normalized.durationHours, + provider, + idempotencyKey: randomIdempotencyKey(), + }, + timeoutMs: 10_000, + clientName: "cli", + mode: "cli", + }); + + runtime.log( + success( + `✅ Poll sent via gateway (${provider}). Message ID: ${result.messageId ?? "unknown"}`, + ), + ); + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider, + via: "gateway", + to: opts.to, + toJid: result.toJid ?? null, + channelId: result.channelId ?? null, + messageId: result.messageId, + question: normalized.question, + options: normalized.options, + maxSelections: normalized.maxSelections, + durationHours: normalized.durationHours ?? null, + }, + null, + 2, + ), + ); + } +} diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index f6ef8c626..de68266e1 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -12,7 +12,11 @@ vi.mock("../config/config.js", async (importOriginal) => { return { ...actual, loadConfig: () => ({ - agent: { model: "pi:opus", contextTokens: 32000 }, + agent: { + model: { primary: "pi:opus" }, + models: { "pi:opus": {} }, + contextTokens: 32000, + }, }), }; }); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index cc36943cb..3bc176df9 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -8,7 +8,7 @@ import { ensureAgentWorkspace, } from "../agents/workspace.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js"; -import { applyModelAliasDefaults } from "../config/defaults.js"; +import { applyModelDefaults } from "../config/defaults.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -31,7 +31,7 @@ async function readConfigFileRaw(): Promise<{ async function writeConfigFile(cfg: ClawdbotConfig) { await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true }); - const json = JSON.stringify(applyModelAliasDefaults(cfg), null, 2) + const json = JSON.stringify(applyModelDefaults(cfg), null, 2) .trimEnd() .concat("\n"); await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8"); diff --git a/src/commands/systemd-linger.ts b/src/commands/systemd-linger.ts index c1043e73e..5f6af2abb 100644 --- a/src/commands/systemd-linger.ts +++ b/src/commands/systemd-linger.ts @@ -42,8 +42,8 @@ export async function ensureSystemdUserLingerInteractive(params: { params.reason ?? "Systemd user services stop when you log out or go idle, which kills the Gateway."; const actionNote = params.requireConfirm - ? "We can enable lingering now (needs sudo; writes /var/lib/systemd/linger)." - : "Enabling lingering now (needs sudo; writes /var/lib/systemd/linger)."; + ? "We can enable lingering now (may require sudo; writes /var/lib/systemd/linger)." + : "Enabling lingering now (may require sudo; writes /var/lib/systemd/linger)."; await prompter.note(`${reason}\n${actionNote}`, title); if (params.requireConfirm && prompter.confirm) { @@ -60,6 +60,15 @@ export async function ensureSystemdUserLingerInteractive(params: { } } + const resultNoSudo = await enableSystemdUserLinger({ + env, + user: status.user, + }); + if (resultNoSudo.ok) { + await prompter.note(`Enabled systemd lingering for ${status.user}.`, title); + return; + } + const result = await enableSystemdUserLinger({ env, user: status.user, diff --git a/src/config/config.test.ts b/src/config/config.test.ts index ece86c2af..cef464679 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -87,6 +87,57 @@ describe("config identity defaults", () => { }); }); + it("defaults ackReaction to identity emoji", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }, + messages: {}, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.messages?.ackReaction).toBe("🦥"); + expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); + }); + }); + + it("defaults ackReaction to 👀 when identity is missing", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + messages: {}, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.messages?.ackReaction).toBe("👀"); + expect(cfg.messages?.ackReactionScope).toBe("group-mentions"); + }); + }); + it("does not override explicit values", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); @@ -628,6 +679,18 @@ describe("legacy config detection", () => { } }); + it("rejects legacy agent.model string", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + agent: { model: "anthropic/claude-opus-4-5" }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("agent.model"); + } + }); + it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => { vi.resetModules(); const { migrateLegacyConfig } = await import("./config.js"); @@ -641,6 +704,38 @@ describe("legacy config detection", () => { expect(res.config?.telegram?.requireMention).toBeUndefined(); }); + it("migrates legacy model config to agent.models + model lists", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + agent: { + model: "anthropic/claude-opus-4-5", + modelFallbacks: ["openai/gpt-4.1-mini"], + imageModel: "openai/gpt-4.1-mini", + imageModelFallbacks: ["anthropic/claude-opus-4-5"], + allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"], + modelAliases: { Opus: "anthropic/claude-opus-4-5" }, + }, + }); + + expect(res.config?.agent?.model?.primary).toBe("anthropic/claude-opus-4-5"); + expect(res.config?.agent?.model?.fallbacks).toEqual([ + "openai/gpt-4.1-mini", + ]); + expect(res.config?.agent?.imageModel?.primary).toBe("openai/gpt-4.1-mini"); + expect(res.config?.agent?.imageModel?.fallbacks).toEqual([ + "anthropic/claude-opus-4-5", + ]); + expect( + res.config?.agent?.models?.["anthropic/claude-opus-4-5"], + ).toMatchObject({ alias: "Opus" }); + expect(res.config?.agent?.models?.["openai/gpt-4.1-mini"]).toBeTruthy(); + expect(res.config?.agent?.allowedModels).toBeUndefined(); + expect(res.config?.agent?.modelAliases).toBeUndefined(); + expect(res.config?.agent?.modelFallbacks).toBeUndefined(); + expect(res.config?.agent?.imageModelFallbacks).toBeUndefined(); + }); + it("surfaces legacy issues in snapshot", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 725d71695..68d35bef8 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -54,6 +54,32 @@ export function applyIdentityDefaults(cfg: ClawdbotConfig): ClawdbotConfig { return mutated ? next : cfg; } +export function applyMessageDefaults(cfg: ClawdbotConfig): ClawdbotConfig { + const messages = cfg.messages; + const hasAckReaction = messages?.ackReaction !== undefined; + const hasAckScope = messages?.ackReactionScope !== undefined; + if (hasAckReaction && hasAckScope) return cfg; + + const fallbackEmoji = cfg.identity?.emoji?.trim() || "👀"; + const nextMessages = messages ? { ...messages } : {}; + let mutated = false; + + if (!hasAckReaction) { + nextMessages.ackReaction = fallbackEmoji; + mutated = true; + } + if (!hasAckScope) { + nextMessages.ackReactionScope = "group-mentions"; + mutated = true; + } + + if (!mutated) return cfg; + return { + ...cfg, + messages: nextMessages, + }; +} + export function applySessionDefaults( cfg: ClawdbotConfig, options: SessionDefaultsOptions = {}, @@ -92,43 +118,23 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig { }; } -function normalizeAliasKey(value: string): string { - return value.trim().toLowerCase(); -} - -export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig { +export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig { const existingAgent = cfg.agent; if (!existingAgent) return cfg; - const existingAliases = existingAgent?.modelAliases ?? {}; - - const byNormalized = new Map(); - for (const key of Object.keys(existingAliases)) { - const norm = normalizeAliasKey(key); - if (!norm) continue; - if (!byNormalized.has(norm)) byNormalized.set(norm, key); - } + const existingModels = existingAgent.models ?? {}; + if (Object.keys(existingModels).length === 0) return cfg; let mutated = false; - const nextAliases: Record = { ...existingAliases }; + const nextModels: Record = { + ...existingModels, + }; - for (const [canonicalKey, target] of Object.entries(DEFAULT_MODEL_ALIASES)) { - const norm = normalizeAliasKey(canonicalKey); - const existingKey = byNormalized.get(norm); - - if (!existingKey) { - nextAliases[canonicalKey] = target; - byNormalized.set(norm, canonicalKey); - mutated = true; - continue; - } - - const existingValue = String(existingAliases[existingKey] ?? ""); - if (existingKey !== canonicalKey && existingValue === target) { - delete nextAliases[existingKey]; - nextAliases[canonicalKey] = target; - byNormalized.set(norm, canonicalKey); - mutated = true; - } + for (const [alias, target] of Object.entries(DEFAULT_MODEL_ALIASES)) { + const entry = nextModels[target]; + if (!entry) continue; + if (entry.alias !== undefined) continue; + nextModels[target] = { ...entry, alias }; + mutated = true; } if (!mutated) return cfg; @@ -137,7 +143,20 @@ export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig { ...cfg, agent: { ...existingAgent, - modelAliases: nextAliases, + models: nextModels, + }, + }; +} + +export function applyLoggingDefaults(cfg: ClawdbotConfig): ClawdbotConfig { + const logging = cfg.logging; + if (!logging) return cfg; + if (logging.redactSensitive) return cfg; + return { + ...cfg, + logging: { + ...logging, + redactSensitive: "tools", }, }; } diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts new file mode 100644 index 000000000..4f0337a1a --- /dev/null +++ b/src/config/group-policy.ts @@ -0,0 +1,85 @@ +import type { ClawdbotConfig } from "./config.js"; + +export type GroupPolicySurface = "whatsapp" | "telegram" | "imessage"; + +export type ProviderGroupConfig = { + requireMention?: boolean; +}; + +export type ProviderGroupPolicy = { + allowlistEnabled: boolean; + allowed: boolean; + groupConfig?: ProviderGroupConfig; + defaultConfig?: ProviderGroupConfig; +}; + +type ProviderGroups = Record; + +function resolveProviderGroups( + cfg: ClawdbotConfig, + surface: GroupPolicySurface, +): ProviderGroups | undefined { + if (surface === "whatsapp") return cfg.whatsapp?.groups; + if (surface === "telegram") return cfg.telegram?.groups; + if (surface === "imessage") return cfg.imessage?.groups; + return undefined; +} + +export function resolveProviderGroupPolicy(params: { + cfg: ClawdbotConfig; + surface: GroupPolicySurface; + groupId?: string | null; +}): ProviderGroupPolicy { + const { cfg, surface } = params; + const groups = resolveProviderGroups(cfg, surface); + const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); + const normalizedId = params.groupId?.trim(); + const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined; + const defaultConfig = groups?.["*"]; + const allowAll = + allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")); + const allowed = + !allowlistEnabled || + allowAll || + (normalizedId + ? Boolean(groups && Object.hasOwn(groups, normalizedId)) + : false); + return { + allowlistEnabled, + allowed, + groupConfig, + defaultConfig, + }; +} + +export function resolveProviderGroupRequireMention(params: { + cfg: ClawdbotConfig; + surface: GroupPolicySurface; + groupId?: string | null; + requireMentionOverride?: boolean; + overrideOrder?: "before-config" | "after-config"; +}): boolean { + const { requireMentionOverride, overrideOrder = "after-config" } = params; + const { groupConfig, defaultConfig } = resolveProviderGroupPolicy(params); + const configMention = + typeof groupConfig?.requireMention === "boolean" + ? groupConfig.requireMention + : typeof defaultConfig?.requireMention === "boolean" + ? defaultConfig.requireMention + : undefined; + + if ( + overrideOrder === "before-config" && + typeof requireMentionOverride === "boolean" + ) { + return requireMentionOverride; + } + if (typeof configMention === "boolean") return configMention; + if ( + overrideOrder !== "before-config" && + typeof requireMentionOverride === "boolean" + ) { + return requireMentionOverride; + } + return true; +} diff --git a/src/config/io.ts b/src/config/io.ts index ee4fb4cf5..9ce2f72e5 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -10,7 +10,9 @@ import { } from "../infra/shell-env.js"; import { applyIdentityDefaults, - applyModelAliasDefaults, + applyLoggingDefaults, + applyMessageDefaults, + applyModelDefaults, applySessionDefaults, applyTalkApiKey, } from "./defaults.js"; @@ -113,9 +115,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } return {}; } - const cfg = applyModelAliasDefaults( + const cfg = applyModelDefaults( applySessionDefaults( - applyIdentityDefaults(validated.data as ClawdbotConfig), + applyLoggingDefaults( + applyMessageDefaults( + applyIdentityDefaults(validated.data as ClawdbotConfig), + ), + ), ), ); @@ -145,7 +151,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const exists = deps.fs.existsSync(configPath); if (!exists) { const config = applyTalkApiKey( - applyModelAliasDefaults(applySessionDefaults({})), + applyModelDefaults(applySessionDefaults(applyMessageDefaults({}))), ); const legacyIssues: LegacyConfigIssue[] = []; return { @@ -201,7 +207,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { parsed: parsedRes.parsed, valid: true, config: applyTalkApiKey( - applyModelAliasDefaults(applySessionDefaults(validated.config)), + applyModelDefaults( + applySessionDefaults( + applyLoggingDefaults(applyMessageDefaults(validated.config)), + ), + ), ), issues: [], legacyIssues, @@ -224,7 +234,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { await deps.fs.promises.mkdir(path.dirname(configPath), { recursive: true, }); - const json = JSON.stringify(applyModelAliasDefaults(cfg), null, 2) + const json = JSON.stringify(applyModelDefaults(cfg), null, 2) .trimEnd() .concat("\n"); await deps.fs.promises.writeFile(configPath, json, "utf-8"); diff --git a/src/config/legacy.ts b/src/config/legacy.ts index 9c633724b..1955955c3 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -3,6 +3,7 @@ import type { LegacyConfigIssue } from "./types.js"; type LegacyConfigRule = { path: string[]; message: string; + match?: (value: unknown, root: Record) => boolean; }; type LegacyConfigMigration = { @@ -27,6 +28,38 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ message: 'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).', }, + { + path: ["agent", "model"], + message: + "agent.model string was replaced by agent.model.primary/fallbacks and agent.models (run `clawdbot doctor` to migrate).", + match: (value) => typeof value === "string", + }, + { + path: ["agent", "imageModel"], + message: + "agent.imageModel string was replaced by agent.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).", + match: (value) => typeof value === "string", + }, + { + path: ["agent", "allowedModels"], + message: + "agent.allowedModels was replaced by agent.models (run `clawdbot doctor` to migrate).", + }, + { + path: ["agent", "modelAliases"], + message: + "agent.modelAliases was replaced by agent.models.*.alias (run `clawdbot doctor` to migrate).", + }, + { + path: ["agent", "modelFallbacks"], + message: + "agent.modelFallbacks was replaced by agent.model.fallbacks (run `clawdbot doctor` to migrate).", + }, + { + path: ["agent", "imageModelFallbacks"], + message: + "agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).", + }, ]; const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ @@ -165,6 +198,161 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ } }, }, + { + id: "agent.model-config-v2", + describe: + "Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists", + apply: (raw, changes) => { + const agent = + raw.agent && typeof raw.agent === "object" + ? (raw.agent as Record) + : null; + if (!agent) return; + + const legacyModel = + typeof agent.model === "string" ? String(agent.model) : undefined; + const legacyImageModel = + typeof agent.imageModel === "string" + ? String(agent.imageModel) + : undefined; + const legacyAllowed = Array.isArray(agent.allowedModels) + ? (agent.allowedModels as unknown[]).map(String) + : []; + const legacyModelFallbacks = Array.isArray(agent.modelFallbacks) + ? (agent.modelFallbacks as unknown[]).map(String) + : []; + const legacyImageModelFallbacks = Array.isArray(agent.imageModelFallbacks) + ? (agent.imageModelFallbacks as unknown[]).map(String) + : []; + const legacyAliases = + agent.modelAliases && typeof agent.modelAliases === "object" + ? (agent.modelAliases as Record) + : {}; + + const hasLegacy = + legacyModel || + legacyImageModel || + legacyAllowed.length > 0 || + legacyModelFallbacks.length > 0 || + legacyImageModelFallbacks.length > 0 || + Object.keys(legacyAliases).length > 0; + if (!hasLegacy) return; + + const models = + agent.models && typeof agent.models === "object" + ? (agent.models as Record) + : {}; + + const ensureModel = (rawKey?: string) => { + if (typeof rawKey !== "string") return; + const key = rawKey.trim(); + if (!key) return; + if (!models[key]) models[key] = {}; + }; + + ensureModel(legacyModel); + ensureModel(legacyImageModel); + for (const key of legacyAllowed) ensureModel(key); + for (const key of legacyModelFallbacks) ensureModel(key); + for (const key of legacyImageModelFallbacks) ensureModel(key); + for (const target of Object.values(legacyAliases)) { + if (typeof target !== "string") continue; + ensureModel(target); + } + + for (const [alias, targetRaw] of Object.entries(legacyAliases)) { + if (typeof targetRaw !== "string") continue; + const target = targetRaw.trim(); + if (!target) continue; + const entry = + models[target] && typeof models[target] === "object" + ? (models[target] as Record) + : {}; + if (!("alias" in entry)) { + entry.alias = alias; + models[target] = entry; + } + } + + const currentModel = + agent.model && typeof agent.model === "object" + ? (agent.model as Record) + : null; + if (currentModel) { + if (!currentModel.primary && legacyModel) { + currentModel.primary = legacyModel; + } + if ( + legacyModelFallbacks.length > 0 && + (!Array.isArray(currentModel.fallbacks) || + currentModel.fallbacks.length === 0) + ) { + currentModel.fallbacks = legacyModelFallbacks; + } + agent.model = currentModel; + } else if (legacyModel || legacyModelFallbacks.length > 0) { + agent.model = { + primary: legacyModel, + fallbacks: legacyModelFallbacks.length ? legacyModelFallbacks : [], + }; + } + + const currentImageModel = + agent.imageModel && typeof agent.imageModel === "object" + ? (agent.imageModel as Record) + : null; + if (currentImageModel) { + if (!currentImageModel.primary && legacyImageModel) { + currentImageModel.primary = legacyImageModel; + } + if ( + legacyImageModelFallbacks.length > 0 && + (!Array.isArray(currentImageModel.fallbacks) || + currentImageModel.fallbacks.length === 0) + ) { + currentImageModel.fallbacks = legacyImageModelFallbacks; + } + agent.imageModel = currentImageModel; + } else if (legacyImageModel || legacyImageModelFallbacks.length > 0) { + agent.imageModel = { + primary: legacyImageModel, + fallbacks: legacyImageModelFallbacks.length + ? legacyImageModelFallbacks + : [], + }; + } + + agent.models = models; + + if (legacyModel !== undefined) { + changes.push("Migrated agent.model string → agent.model.primary."); + } + if (legacyModelFallbacks.length > 0) { + changes.push("Migrated agent.modelFallbacks → agent.model.fallbacks."); + } + if (legacyImageModel !== undefined) { + changes.push( + "Migrated agent.imageModel string → agent.imageModel.primary.", + ); + } + if (legacyImageModelFallbacks.length > 0) { + changes.push( + "Migrated agent.imageModelFallbacks → agent.imageModel.fallbacks.", + ); + } + if (legacyAllowed.length > 0) { + changes.push("Migrated agent.allowedModels → agent.models."); + } + if (Object.keys(legacyAliases).length > 0) { + changes.push("Migrated agent.modelAliases → agent.models.*.alias."); + } + + delete agent.allowedModels; + delete agent.modelAliases; + delete agent.modelFallbacks; + delete agent.imageModelFallbacks; + }, + }, ]; export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { @@ -180,7 +368,7 @@ export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { } cursor = (cursor as Record)[key]; } - if (cursor !== undefined) { + if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) { issues.push({ path: rule.path.join("."), message: rule.message }); } } diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index b2599f94f..cf11f6c0e 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -1,76 +1,56 @@ import { describe, expect, it } from "vitest"; -import { applyModelAliasDefaults } from "./defaults.js"; +import { applyModelDefaults } from "./defaults.js"; import type { ClawdbotConfig } from "./types.js"; -describe("applyModelAliasDefaults", () => { - it("adds default shorthands", () => { - const cfg = { agent: {} } satisfies ClawdbotConfig; - const next = applyModelAliasDefaults(cfg); +describe("applyModelDefaults", () => { + it("adds default aliases when models are present", () => { + const cfg = { + agent: { + models: { + "anthropic/claude-opus-4-5": {}, + "openai/gpt-5.2": {}, + }, + }, + } satisfies ClawdbotConfig; + const next = applyModelDefaults(cfg); - expect(next.agent?.modelAliases).toEqual({ - 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", - }); + expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe( + "opus", + ); + expect(next.agent?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt"); }); - it("normalizes casing when alias matches the default target", () => { + it("does not override existing aliases", () => { const cfg = { - agent: { modelAliases: { Opus: "anthropic/claude-opus-4-5" } }, + agent: { + models: { + "anthropic/claude-opus-4-5": { alias: "Opus" }, + }, + }, } satisfies ClawdbotConfig; - const next = applyModelAliasDefaults(cfg); + const next = applyModelDefaults(cfg); - expect(next.agent?.modelAliases).toMatchObject({ - opus: "anthropic/claude-opus-4-5", - }); - expect(next.agent?.modelAliases).not.toHaveProperty("Opus"); + expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe( + "Opus", + ); }); - it("does not override existing alias values", () => { + it("respects explicit empty alias disables", () => { const cfg = { - agent: { modelAliases: { gpt: "openai/gpt-4.1" } }, + agent: { + models: { + "google/gemini-3-pro-preview": { alias: "" }, + "google/gemini-3-flash-preview": {}, + }, + }, } satisfies ClawdbotConfig; - const next = applyModelAliasDefaults(cfg); + const next = applyModelDefaults(cfg); - expect(next.agent?.modelAliases?.gpt).toBe("openai/gpt-4.1"); - expect(next.agent?.modelAliases).toMatchObject({ - "gpt-mini": "openai/gpt-5-mini", - opus: "anthropic/claude-opus-4-5", - sonnet: "anthropic/claude-sonnet-4-5", - gemini: "google/gemini-3-pro-preview", - "gemini-flash": "google/gemini-3-flash-preview", - }); - }); - - it("does not rename when casing differs and value differs", () => { - const cfg = { - agent: { modelAliases: { GPT: "openai/gpt-4.1-mini" } }, - } satisfies ClawdbotConfig; - - const next = applyModelAliasDefaults(cfg); - - expect(next.agent?.modelAliases).toMatchObject({ - GPT: "openai/gpt-4.1-mini", - }); - expect(next.agent?.modelAliases).not.toHaveProperty("gpt"); - }); - - it("respects explicit empty-string disables", () => { - const cfg = { - agent: { modelAliases: { gemini: "" } }, - } satisfies ClawdbotConfig; - - const next = applyModelAliasDefaults(cfg); - - expect(next.agent?.modelAliases?.gemini).toBe(""); - expect(next.agent?.modelAliases).toHaveProperty( + expect(next.agent?.models?.["google/gemini-3-pro-preview"]?.alias).toBe(""); + expect(next.agent?.models?.["google/gemini-3-flash-preview"]?.alias).toBe( "gemini-flash", - "google/gemini-3-flash-preview", ); }); }); diff --git a/src/config/paths.ts b/src/config/paths.ts index 1f6558625..134062561 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -1,6 +1,5 @@ import os from "node:os"; import path from "node:path"; -import { resolveUserPath } from "../utils.js"; import type { ClawdbotConfig } from "./types.js"; /** @@ -33,6 +32,15 @@ export function resolveStateDir( return path.join(homedir(), ".clawdbot"); } +function resolveUserPath(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + if (trimmed.startsWith("~")) { + return path.resolve(trimmed.replace("~", os.homedir())); + } + return path.resolve(trimmed); +} + export const STATE_DIR_CLAWDBOT = resolveStateDir(); /** diff --git a/src/config/schema.ts b/src/config/schema.ts index b09b26a4a..9b4ff37e2 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -87,13 +87,18 @@ const FIELD_LABELS: Record = { "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", "agent.workspace": "Workspace", - "agent.model": "Default Model", - "agent.imageModel": "Image Model", - "agent.modelFallbacks": "Model Fallbacks", - "agent.imageModelFallbacks": "Image Model Fallbacks", + "auth.profiles": "Auth Profiles", + "auth.order": "Auth Profile Order", + "agent.models": "Models", + "agent.model.primary": "Primary Model", + "agent.model.fallbacks": "Model Fallbacks", + "agent.imageModel.primary": "Image Model", + "agent.imageModel.fallbacks": "Image Model Fallbacks", "ui.seamColor": "Accent Color", "browser.controlUrl": "Browser Control URL", "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", + "messages.ackReaction": "Ack Reaction Emoji", + "messages.ackReactionScope": "Ack Reaction Scope", "talk.apiKey": "Talk API Key", "telegram.botToken": "Telegram Bot Token", "discord.token": "Discord Bot Token", @@ -114,14 +119,24 @@ const FIELD_HELP: Record = { 'Hot reload strategy for config changes ("hybrid" recommended).', "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", - "agent.modelFallbacks": + "auth.profiles": "Named auth profiles (provider + mode + optional email).", + "auth.order": + "Ordered auth profile IDs per provider (used for automatic failover).", + "agent.models": + "Configured model catalog (keys are full provider/model IDs).", + "agent.model.primary": "Primary model (provider/model).", + "agent.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", - "agent.imageModel": - "Optional image-capable model (provider/model) used by the image tool.", - "agent.imageModelFallbacks": - "Ordered fallback image models (provider/model) used by the image tool.", + "agent.imageModel.primary": + "Optional image model (provider/model) used when the primary model lacks image input.", + "agent.imageModel.fallbacks": + "Ordered fallback image models (provider/model).", "session.agentToAgent.maxPingPongTurns": "Max reply-back turns between requester and target (0–5).", + "messages.ackReaction": + "Emoji reaction used to acknowledge inbound messages (empty disables).", + "messages.ackReactionScope": + 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', }; const FIELD_PLACEHOLDERS: Record = { diff --git a/src/config/sessions.ts b/src/config/sessions.ts index e7ff6a2f5..ff440eab8 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -34,6 +34,7 @@ export type SessionEntry = { elevatedLevel?: string; providerOverride?: string; modelOverride?: string; + authProfileOverride?: string; groupActivation?: "mention" | "always"; groupActivationNeedsSystemIntro?: boolean; sendPolicy?: "allow" | "deny"; @@ -54,6 +55,7 @@ export type SessionEntry = { modelProvider?: string; model?: string; contextTokens?: number; + compactionCount?: number; displayName?: string; surface?: string; subject?: string; diff --git a/src/config/types.ts b/src/config/types.ts index 0eec44357..514f108f3 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -44,6 +44,10 @@ export type LoggingConfig = { | "debug" | "trace"; consoleStyle?: "pretty" | "compact" | "json"; + /** Redact sensitive tokens in tool summaries. Default: "tools". */ + redactSensitive?: "off" | "tools"; + /** Regex patterns used to redact sensitive tokens (defaults apply when unset). */ + redactPatterns?: string[]; }; export type WebReconnectConfig = { @@ -74,6 +78,13 @@ export type AgentElevatedAllowFromConfig = { export type WhatsAppConfig = { /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; + /** + * Controls how group messages are handled: + * - "open" (default): groups bypass allowFrom, only mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in allowFrom + */ + groupPolicy?: "open" | "disabled" | "allowlist"; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; groups?: Record< @@ -203,6 +214,13 @@ export type TelegramConfig = { } >; allowFrom?: Array; + /** + * Controls how group messages are handled: + * - "open" (default): groups bypass allowFrom, only mention-gating applies + * - "disabled": block all group messages entirely + * - "allowlist": only allow group messages from senders in allowFrom + */ + groupPolicy?: "open" | "disabled" | "allowlist"; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; mediaMaxMb?: number; @@ -445,7 +463,10 @@ export type RoutingConfig = { export type MessagesConfig = { messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdbot]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") - timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) + /** Emoji reaction used to acknowledge inbound messages (empty disables). */ + ackReaction?: string; + /** When to send ack reactions. Default: "group-mentions". */ + ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; }; export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; @@ -636,7 +657,28 @@ export type ModelsConfig = { providers?: Record; }; +export type AuthProfileConfig = { + provider: string; + mode: "api_key" | "oauth"; + email?: string; +}; + +export type AuthConfig = { + profiles?: Record; + order?: Record; +}; + +export type AgentModelEntryConfig = { + alias?: string; +}; + +export type AgentModelListConfig = { + primary?: string; + fallbacks?: string[]; +}; + export type ClawdbotConfig = { + auth?: AuthConfig; env?: { /** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */ shellEnv?: { @@ -666,20 +708,16 @@ export type ClawdbotConfig = { skills?: SkillsConfig; models?: ModelsConfig; agent?: { - /** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */ - model?: string; - /** Optional image-capable model (provider/model) used by the image tool. */ - imageModel?: string; + /** Primary model and fallbacks (provider/model). */ + model?: AgentModelListConfig; + /** Optional image-capable model and fallbacks (provider/model). */ + imageModel?: AgentModelListConfig; + /** Model catalog with optional aliases (full provider/model keys). */ + models?: Record; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; - /** Optional allowlist for /model (provider/model or model-only). */ - allowedModels?: string[]; - /** Optional model aliases for /model (alias -> provider/model). */ - modelAliases?: Record; - /** Ordered fallback models (provider/model). */ - modelFallbacks?: string[]; - /** Ordered fallback image models (provider/model) for the image tool. */ - imageModelFallbacks?: string[]; + /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ + userTimezone?: string; /** Optional display-only context window override (used for % in status UIs). */ contextTokens?: number; /** Default thinking level when no /think directive is present. */ @@ -726,6 +764,8 @@ export type ClawdbotConfig = { to?: string; /** Override the heartbeat prompt body (default: "HEARTBEAT"). */ prompt?: string; + /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ + ackMaxChars?: number; }; /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ maxConcurrent?: number; diff --git a/src/config/validation.ts b/src/config/validation.ts index ecf57d8ab..9c6378753 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,6 +1,6 @@ import { applyIdentityDefaults, - applyModelAliasDefaults, + applyModelDefaults, applySessionDefaults, } from "./defaults.js"; import { findLegacyConfigIssues } from "./legacy.js"; @@ -34,7 +34,7 @@ export function validateConfigObject( } return { ok: true, - config: applyModelAliasDefaults( + config: applyModelDefaults( applySessionDefaults( applyIdentityDefaults(validated.data as ClawdbotConfig), ), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8a598ff5c..4d50c041e 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -81,6 +81,12 @@ const ReplyToModeSchema = z.union([ z.literal("all"), ]); +// GroupPolicySchema: controls how group messages are handled +// Used with .default("open").optional() pattern: +// - .optional() allows field omission in input config +// - .default("open") ensures runtime always resolves to "open" if not provided +const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]); + const QueueModeBySurfaceSchema = z .object({ whatsapp: QueueModeSchema.optional(), @@ -150,7 +156,10 @@ const MessagesSchema = z .object({ messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), - timestampPrefix: z.union([z.boolean(), z.string()]).optional(), + ackReaction: z.string().optional(), + ackReactionScope: z + .enum(["group-mentions", "group-all", "direct", "all"]) + .optional(), }) .optional(); @@ -172,6 +181,7 @@ const HeartbeatSchema = z .optional(), to: z.string().optional(), prompt: z.string().optional(), + ackMaxChars: z.number().int().nonnegative().optional(), }) .superRefine((val, ctx) => { if (!val.every) return; @@ -330,6 +340,10 @@ export const ClawdbotSchema = z.object({ consoleStyle: z .union([z.literal("pretty"), z.literal("compact"), z.literal("json")]) .optional(), + redactSensitive: z + .union([z.literal("off"), z.literal("tools")]) + .optional(), + redactPatterns: z.array(z.string()).optional(), }) .optional(), browser: z @@ -369,16 +383,46 @@ export const ClawdbotSchema = z.object({ seamColor: HexColorSchema.optional(), }) .optional(), + auth: z + .object({ + profiles: z + .record( + z.string(), + z.object({ + provider: z.string(), + mode: z.union([z.literal("api_key"), z.literal("oauth")]), + email: z.string().optional(), + }), + ) + .optional(), + order: z.record(z.string(), z.array(z.string())).optional(), + }) + .optional(), models: ModelsConfigSchema, agent: z .object({ - model: z.string().optional(), - imageModel: z.string().optional(), + model: z + .object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }) + .optional(), + imageModel: z + .object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }) + .optional(), + models: z + .record( + z.string(), + z.object({ + alias: z.string().optional(), + }), + ) + .optional(), workspace: z.string().optional(), - allowedModels: z.array(z.string()).optional(), - modelAliases: z.record(z.string(), z.string()).optional(), - modelFallbacks: z.array(z.string()).optional(), - imageModelFallbacks: z.array(z.string()).optional(), + userTimezone: z.string().optional(), contextTokens: z.number().int().positive().optional(), tools: z .object({ @@ -554,6 +598,7 @@ export const ClawdbotSchema = z.object({ whatsapp: z .object({ allowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.default("open").optional(), textChunkLimit: z.number().int().positive().optional(), groups: z .record( @@ -584,6 +629,7 @@ export const ClawdbotSchema = z.object({ ) .optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.default("open").optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), proxy: z.string().optional(), diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts new file mode 100644 index 000000000..f7b57c551 --- /dev/null +++ b/src/cron/cron-protocol-conformance.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { CronPayloadSchema } from "../gateway/protocol/schema.js"; + +type SchemaLike = { + anyOf?: Array<{ properties?: Record }>; + properties?: Record; + const?: unknown; +}; + +type ChannelSchema = { + anyOf?: Array<{ const?: unknown }>; +}; + +function extractCronChannels(schema: SchemaLike): string[] { + const union = schema.anyOf ?? []; + const payloadWithChannel = union.find((entry) => + Boolean(entry?.properties && "channel" in entry.properties), + ); + const channelSchema = payloadWithChannel?.properties + ? (payloadWithChannel.properties.channel as ChannelSchema) + : undefined; + const channels = (channelSchema?.anyOf ?? []) + .map((entry) => entry?.const) + .filter((value): value is string => typeof value === "string"); + return channels; +} + +const UI_FILES = [ + "ui/src/ui/types.ts", + "ui/src/ui/ui-types.ts", + "ui/src/ui/views/cron.ts", +]; + +const SWIFT_FILES = ["apps/macos/Sources/Clawdbot/GatewayConnection.swift"]; + +describe("cron protocol conformance", () => { + it("ui + swift include all cron channels from gateway schema", async () => { + const channels = extractCronChannels(CronPayloadSchema as SchemaLike); + expect(channels.length).toBeGreaterThan(0); + + const cwd = process.cwd(); + for (const relPath of UI_FILES) { + const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); + for (const channel of channels) { + expect( + content.includes(`"${channel}"`), + `${relPath} missing ${channel}`, + ).toBe(true); + } + } + + for (const relPath of SWIFT_FILES) { + const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); + for (const channel of channels) { + const pattern = new RegExp(`\\bcase\\s+${channel}\\b`); + expect( + pattern.test(content), + `${relPath} missing case ${channel}`, + ).toBe(true); + } + } + }); + + it("cron status shape matches gateway fields in UI + Swift", async () => { + const cwd = process.cwd(); + const uiTypes = await fs.readFile( + path.join(cwd, "ui/src/ui/types.ts"), + "utf-8", + ); + expect(uiTypes.includes("export type CronStatus")).toBe(true); + expect(uiTypes.includes("jobs:")).toBe(true); + expect(uiTypes.includes("jobCount")).toBe(false); + + const swift = await fs.readFile( + path.join(cwd, "apps/macos/Sources/Clawdbot/GatewayConnection.swift"), + "utf-8", + ); + expect(swift.includes("struct CronSchedulerStatus")).toBe(true); + expect(swift.includes("let jobs:")).toBe(true); + }); +}); diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index 916385441..23b29e6b2 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -377,4 +377,224 @@ describe("runCronIsolatedAgentTurn", () => { ); }); }); + + it("skips delivery when response is exactly HEARTBEAT_OK", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "HEARTBEAT_OK" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + // Job still succeeds, but no delivery happens. + expect(res.status).toBe("ok"); + expect(res.summary).toBe("HEARTBEAT_OK"); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + }); + }); + + it("skips delivery when response has HEARTBEAT_OK with short padding", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn().mockResolvedValue({ + messageId: "w1", + chatId: "+1234", + }), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + // Short junk around HEARTBEAT_OK (<=30 chars) should still skip delivery. + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "HEARTBEAT_OK 🦞" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath, { whatsapp: { allowFrom: ["+1234"] } }), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "whatsapp", + to: "+1234", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); + }); + }); + + it("delivers when response has HEARTBEAT_OK but also substantial content", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + // Long content after HEARTBEAT_OK should still be delivered. + const longContent = `Important alert: ${"a".repeat(50)}`; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: `HEARTBEAT_OK ${longContent}` }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageTelegram).toHaveBeenCalled(); + }); + }); + + it("delivers when response has HEARTBEAT_OK but includes media", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + // Media should still be delivered even if text is just HEARTBEAT_OK. + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [ + { text: "HEARTBEAT_OK", mediaUrl: "https://example.com/img.png" }, + ], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageTelegram).toHaveBeenCalledWith( + "123", + "HEARTBEAT_OK", + expect.objectContaining({ mediaUrl: "https://example.com/img.png" }), + ); + }); + }); + + it("delivers when heartbeat ack padding exceeds configured limit", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "HEARTBEAT_OK 🦞" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const cfg = makeCfg(home, storePath); + cfg.agent = { ...cfg.agent, heartbeat: { ackMaxChars: 0 } }; + + const res = await runCronIsolatedAgentTurn({ + cfg, + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageTelegram).toHaveBeenCalled(); + }); + }); }); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index 66b26b390..a121636a5 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -13,11 +13,16 @@ import { } from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace, } from "../agents/workspace.js"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + stripHeartbeatToken, +} from "../auto-reply/heartbeat.js"; import { normalizeThinkLevel } from "../auto-reply/thinking.js"; import type { CliDeps } from "../cli/deps.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -57,6 +62,28 @@ function pickSummaryFromPayloads( return undefined; } +/** + * Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK). + * Returns true if delivery should be skipped because there's no real content. + */ +function isHeartbeatOnlyResponse( + payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>, + ackMaxChars: number, +) { + if (payloads.length === 0) return true; + return payloads.every((payload) => { + // If there's media, we should deliver regardless of text content. + const hasMedia = + (payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl); + if (hasMedia) return false; + // Use heartbeat mode to check if text is just HEARTBEAT_OK or short ack. + const result = stripHeartbeatToken(payload.text, { + mode: "heartbeat", + maxAckChars: ackMaxChars, + }); + return result.shouldSkip; + }); +} function resolveDeliveryTarget( cfg: ClawdbotConfig, jobPayload: { @@ -208,12 +235,13 @@ export async function runCronIsolatedAgentTurn(params: { }); } - const timeoutSecondsRaw = - params.job.payload.kind === "agentTurn" && params.job.payload.timeoutSeconds - ? params.job.payload.timeoutSeconds - : (agentCfg?.timeoutSeconds ?? 600); - const timeoutSeconds = Math.max(Math.floor(timeoutSecondsRaw), 1); - const timeoutMs = timeoutSeconds * 1000; + const timeoutMs = resolveAgentTimeoutMs({ + cfg: params.cfg, + overrideSeconds: + params.job.payload.kind === "agentTurn" + ? params.job.payload.timeoutSeconds + : undefined, + }); const delivery = params.job.payload.kind === "agentTurn" && @@ -343,7 +371,15 @@ export async function runCronIsolatedAgentTurn(params: { const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText); - if (delivery) { + // Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content). + // This allows cron jobs to silently ack when nothing to report but still deliver + // actual content when there is something to say. + const ackMaxChars = + params.cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS; + const skipHeartbeatDelivery = + delivery && isHeartbeatOnlyResponse(payloads, Math.max(0, ackMaxChars)); + + if (delivery && !skipHeartbeatDelivery) { if (resolvedDelivery.channel === "whatsapp") { if (!resolvedDelivery.to) { if (!bestEffortDeliver) diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts new file mode 100644 index 000000000..8586d56f8 --- /dev/null +++ b/src/cron/normalize.ts @@ -0,0 +1,91 @@ +import type { CronJobCreate, CronJobPatch } from "./types.js"; + +type UnknownRecord = Record; + +type NormalizeOptions = { + applyDefaults?: boolean; +}; + +const DEFAULT_OPTIONS: NormalizeOptions = { + applyDefaults: false, +}; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function coerceSchedule(schedule: UnknownRecord) { + const next: UnknownRecord = { ...schedule }; + const kind = typeof schedule.kind === "string" ? schedule.kind : undefined; + if (!kind) { + if (typeof schedule.atMs === "number") next.kind = "at"; + else if (typeof schedule.everyMs === "number") next.kind = "every"; + else if (typeof schedule.expr === "string") next.kind = "cron"; + } + return next; +} + +function coercePayload(payload: UnknownRecord) { + const next: UnknownRecord = { ...payload }; + const kind = typeof payload.kind === "string" ? payload.kind : undefined; + if (!kind) { + if (typeof payload.text === "string") next.kind = "systemEvent"; + else if (typeof payload.message === "string") next.kind = "agentTurn"; + } + return next; +} + +function unwrapJob(raw: UnknownRecord) { + if (isRecord(raw.data)) return raw.data; + if (isRecord(raw.job)) return raw.job; + return raw; +} + +export function normalizeCronJobInput( + raw: unknown, + options: NormalizeOptions = DEFAULT_OPTIONS, +): UnknownRecord | null { + if (!isRecord(raw)) return null; + const base = unwrapJob(raw); + const next: UnknownRecord = { ...base }; + + if (isRecord(base.schedule)) { + next.schedule = coerceSchedule(base.schedule); + } + + if (isRecord(base.payload)) { + next.payload = coercePayload(base.payload); + } + + if (options.applyDefaults) { + if (!next.wakeMode) next.wakeMode = "next-heartbeat"; + if (!next.sessionTarget && isRecord(next.payload)) { + const kind = + typeof next.payload.kind === "string" ? next.payload.kind : ""; + if (kind === "systemEvent") next.sessionTarget = "main"; + if (kind === "agentTurn") next.sessionTarget = "isolated"; + } + } + + return next; +} + +export function normalizeCronJobCreate( + raw: unknown, + options?: NormalizeOptions, +): CronJobCreate | null { + return normalizeCronJobInput(raw, { + applyDefaults: true, + ...options, + }) as CronJobCreate | null; +} + +export function normalizeCronJobPatch( + raw: unknown, + options?: NormalizeOptions, +): CronJobPatch | null { + return normalizeCronJobInput(raw, { + applyDefaults: false, + ...options, + }) as CronJobPatch | null; +} diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 6fb6a0391..3a6fc3ced 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -307,6 +307,35 @@ export async function uninstallLaunchAgent({ } } +function isLaunchctlNotLoaded(res: { + stdout: string; + stderr: string; + code: number; +}): boolean { + const detail = `${res.stderr || res.stdout}`.toLowerCase(); + return ( + detail.includes("no such process") || + detail.includes("could not find service") || + detail.includes("not found") + ); +} + +export async function stopLaunchAgent({ + stdout, +}: { + stdout: NodeJS.WritableStream; +}): Promise { + const domain = resolveGuiDomain(); + const label = GATEWAY_LAUNCH_AGENT_LABEL; + const res = await execLaunchctl(["bootout", `${domain}/${label}`]); + if (res.code !== 0 && !isLaunchctlNotLoaded(res)) { + throw new Error( + `launchctl bootout failed: ${res.stderr || res.stdout}`.trim(), + ); + } + stdout.write(`Stopped LaunchAgent: ${domain}/${label}\n`); +} + export async function installLaunchAgent({ env, stdout, diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 3d8da28f7..94ba8cb7a 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -233,6 +233,28 @@ export async function uninstallScheduledTask({ } } +function isTaskNotRunning(res: { + stdout: string; + stderr: string; + code: number; +}): boolean { + const detail = `${res.stderr || res.stdout}`.toLowerCase(); + return detail.includes("not running"); +} + +export async function stopScheduledTask({ + stdout, +}: { + stdout: NodeJS.WritableStream; +}): Promise { + await assertSchtasksAvailable(); + const res = await execSchtasks(["/End", "/TN", GATEWAY_WINDOWS_TASK_NAME]); + if (res.code !== 0 && !isTaskNotRunning(res)) { + throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim()); + } + stdout.write(`Stopped Scheduled Task: ${GATEWAY_WINDOWS_TASK_NAME}\n`); +} + export async function restartScheduledTask({ stdout, }: { diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 0ce53469e..c2799cc71 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -3,6 +3,7 @@ import { isLaunchAgentLoaded, readLaunchAgentProgramArguments, restartLaunchAgent, + stopLaunchAgent, uninstallLaunchAgent, } from "./launchd.js"; import { @@ -10,6 +11,7 @@ import { isScheduledTaskInstalled, readScheduledTaskCommand, restartScheduledTask, + stopScheduledTask, uninstallScheduledTask, } from "./schtasks.js"; import { @@ -17,6 +19,7 @@ import { isSystemdServiceEnabled, readSystemdServiceExecStart, restartSystemdService, + stopSystemdService, uninstallSystemdService, } from "./systemd.js"; @@ -37,6 +40,7 @@ export type GatewayService = { env: Record; stdout: NodeJS.WritableStream; }) => Promise; + stop: (args: { stdout: NodeJS.WritableStream }) => Promise; restart: (args: { stdout: NodeJS.WritableStream }) => Promise; isLoaded: (args: { env: Record; @@ -59,6 +63,9 @@ export function resolveGatewayService(): GatewayService { uninstall: async (args) => { await uninstallLaunchAgent(args); }, + stop: async (args) => { + await stopLaunchAgent(args); + }, restart: async (args) => { await restartLaunchAgent(args); }, @@ -78,6 +85,9 @@ export function resolveGatewayService(): GatewayService { uninstall: async (args) => { await uninstallSystemdService(args); }, + stop: async (args) => { + await stopSystemdService(args); + }, restart: async (args) => { await restartSystemdService(args); }, @@ -97,6 +107,9 @@ export function resolveGatewayService(): GatewayService { uninstall: async (args) => { await uninstallScheduledTask(args); }, + stop: async (args) => { + await stopScheduledTask(args); + }, restart: async (args) => { await restartScheduledTask(args); }, diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 0906b2294..5d8875c5f 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -331,6 +331,22 @@ export async function uninstallSystemdService({ } } +export async function stopSystemdService({ + stdout, +}: { + stdout: NodeJS.WritableStream; +}): Promise { + await assertSystemdAvailable(); + const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`; + const res = await execSystemctl(["--user", "stop", unitName]); + if (res.code !== 0) { + throw new Error( + `systemctl stop failed: ${res.stderr || res.stdout}`.trim(), + ); + } + stdout.write(`Stopped systemd service: ${unitName}\n`); +} + export async function restartSystemdService({ stdout, }: { diff --git a/src/discord/index.ts b/src/discord/index.ts index 4bd4018e3..c9e1b3c83 100644 --- a/src/discord/index.ts +++ b/src/discord/index.ts @@ -1,2 +1,2 @@ export { monitorDiscordProvider } from "./monitor.js"; -export { sendMessageDiscord } from "./send.js"; +export { sendMessageDiscord, sendPollDiscord } from "./send.js"; diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts index 3b95db93b..2d000c61d 100644 --- a/src/discord/monitor.tool-result.test.ts +++ b/src/discord/monitor.tool-result.test.ts @@ -31,12 +31,11 @@ vi.mock("../config/sessions.js", () => ({ vi.mock("discord.js", () => { const handlers = new Map void>>(); - let lastClient: Client | null = null; - class Client { + static lastClient: Client | null = null; user = { id: "bot-id", tag: "bot#1" }; constructor() { - lastClient = this; + Client.lastClient = this; } on(event: string, handler: (...args: unknown[]) => void) { if (!handlers.has(event)) handlers.set(event, new Set()); @@ -50,7 +49,7 @@ vi.mock("discord.js", () => { } emit(event: string, ...args: unknown[]) { for (const handler of handlers.get(event) ?? []) { - void handler(...args); + Promise.resolve(handler(...args)).catch(() => {}); } } login = vi.fn().mockResolvedValue(undefined); @@ -59,7 +58,7 @@ vi.mock("discord.js", () => { return { Client, - __getLastClient: () => lastClient, + __getLastClient: () => Client.lastClient, Events: { ClientReady: "ready", Error: "error", @@ -148,4 +147,59 @@ describe("monitorDiscordProvider tool results", () => { expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + + it("accepts guild messages when mentionPatterns match", async () => { + config = { + messages: { responsePrefix: "PFX" }, + discord: { + dm: { enabled: true }, + guilds: { "*": { requireMention: true } }, + }, + routing: { + allowFrom: [], + groupChat: { mentionPatterns: ["\\bclawd\\b"] }, + }, + }; + replyMock.mockResolvedValue({ text: "hi" }); + + const controller = new AbortController(); + const run = monitorDiscordProvider({ + token: "token", + abortSignal: controller.signal, + }); + + const discord = await import("discord.js"); + const client = await waitForClient(); + if (!client) throw new Error("Discord client not created"); + + client.emit(discord.Events.MessageCreate, { + id: "m2", + content: "clawd: hello", + author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, + member: { displayName: "Ada" }, + channelId: "c1", + channel: { + type: discord.ChannelType.GuildText, + name: "general", + isSendable: () => false, + }, + guild: { id: "g1", name: "Guild" }, + mentions: { + has: () => false, + everyone: false, + users: { size: 0 }, + roles: { size: 0 }, + }, + attachments: { first: () => undefined }, + type: discord.MessageType.Default, + createdTimestamp: Date.now(), + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); + }); }); diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 35bebbab5..dc30b0a1b 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -18,8 +18,13 @@ import { import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; +import type { TypingController } from "../auto-reply/reply/typing.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { DiscordSlashCommandConfig, @@ -140,6 +145,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const textLimit = resolveTextChunkLimit(cfg, "discord"); + const mentionRegexes = buildMentionRegexes(cfg); + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const historyLimit = Math.max( 0, opts.historyLimit ?? cfg.discord?.historyLimit ?? 20, @@ -202,13 +210,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { return; } const botId = client.user?.id; - const wasMentioned = - !isDirectMessage && Boolean(botId && message.mentions.has(botId)); const forwardedSnapshot = resolveForwardedSnapshot(message); const forwardedText = forwardedSnapshot ? resolveDiscordSnapshotText(forwardedSnapshot.snapshot) : ""; const baseText = resolveDiscordMessageText(message, forwardedText); + const wasMentioned = + !isDirectMessage && + (Boolean(botId && message.mentions.has(botId)) || + matchesMentionPatterns(baseText, mentionRegexes)); if (shouldLogVerbose()) { logVerbose( `discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${baseText ? "yes" : "no"}`, @@ -309,8 +319,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { !hasAnyMention && commandAuthorized && hasControlCommand(baseText); - if (isGuildMessage && resolvedRequireMention) { - if (botId && !wasMentioned && !shouldBypassMention) { + const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; + if (isGuildMessage && resolvedRequireMention && canDetectMention) { + if (!wasMentioned && !shouldBypassMention) { logVerbose( `discord: drop guild message (mention required, botId=${botId})`, ); @@ -401,6 +412,27 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { logVerbose(`discord: drop message ${message.id} (empty content)`); return; } + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return isDirectMessage; + const isGroupChat = isGuildMessage || isGroupDm; + if (ackReactionScope === "group-all") return isGroupChat; + if (ackReactionScope === "group-mentions") { + if (!isGuildMessage) return false; + if (!resolvedRequireMention) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction()) { + message.react(ackReaction).catch((err) => { + logVerbose( + `discord react failed for channel ${message.channelId}: ${String(err)}`, + ); + }); + } const fromLabel = isDirectMessage ? buildDirectLabel(message) @@ -485,9 +517,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { From: isDirectMessage ? `discord:${message.author.id}` : `group:${message.channelId}`, - To: isDirectMessage - ? `user:${message.author.id}` - : `channel:${message.channelId}`, + To: `channel:${message.channelId}`, ChatType: isDirectMessage ? "direct" : "group", SenderName: message.member?.displayName ?? message.author.tag, SenderId: message.author.id, @@ -533,6 +563,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } let didSendReply = false; + let typingController: TypingController | undefined; const dispatcher = createReplyDispatcher({ responsePrefix: cfg.messages?.responsePrefix, deliver: async (payload) => { @@ -546,6 +577,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }); didSendReply = true; }, + onIdle: () => { + typingController?.markDispatchIdle(); + }, onError: (err, info) => { runtime.error?.( danger(`discord ${info.kind} reply failed: ${String(err)}`), @@ -553,29 +587,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, }); - const replyResult = await getReplyFromConfig( - ctxPayload, - { + const { queuedFinal, counts } = await dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { onReplyStart: () => sendTyping(message), - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); + onTypingController: (typing) => { + typingController = typing; }, }, - cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; - } - await dispatcher.waitForIdle(); + }); + typingController?.markDispatchIdle(); if (!queuedFinal) { if ( isGuildMessage && @@ -589,7 +612,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } didSendReply = true; if (shouldLogVerbose()) { - const finalCount = dispatcher.getQueuedCounts().final; + const finalCount = counts.final; logVerbose( `discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); diff --git a/src/discord/send.test.ts b/src/discord/send.test.ts index dac4426ed..f0b291698 100644 --- a/src/discord/send.test.ts +++ b/src/discord/send.test.ts @@ -106,6 +106,40 @@ describe("sendMessageDiscord", () => { expect(res.channelId).toBe("chan1"); }); + it("adds missing permission hints on 50013", async () => { + const { rest, postMock, getMock } = makeRest(); + const perms = new PermissionsBitField([ + PermissionsBitField.Flags.ViewChannel, + ]); + const apiError = Object.assign(new Error("Missing Permissions"), { + code: 50013, + status: 403, + }); + postMock.mockRejectedValueOnce(apiError); + getMock + .mockResolvedValueOnce({ + id: "789", + guild_id: "guild1", + type: 0, + permission_overwrites: [], + }) + .mockResolvedValueOnce({ id: "bot1" }) + .mockResolvedValueOnce({ + id: "guild1", + roles: [{ id: "guild1", permissions: perms.bitfield.toString() }], + }) + .mockResolvedValueOnce({ roles: [] }); + + let error: unknown; + try { + await sendMessageDiscord("channel:789", "hello", { rest, token: "t" }); + } catch (err) { + error = err; + } + expect(String(error)).toMatch(/missing permissions/i); + expect(String(error)).toMatch(/SendMessages/); + }); + it("uploads media attachments", async () => { const { rest, postMock } = makeRest(); postMock.mockResolvedValue({ id: "msg", channel_id: "789" }); @@ -562,7 +596,7 @@ describe("sendPollDiscord", () => { "channel:789", { question: "Lunch?", - answers: ["Pizza", "Sushi"], + options: ["Pizza", "Sushi"], }, { rest, diff --git a/src/discord/send.ts b/src/discord/send.ts index 9aebd7f2b..7c58158fa 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -1,4 +1,4 @@ -import { PermissionsBitField, REST, Routes } from "discord.js"; +import { ChannelType, PermissionsBitField, REST, Routes } from "discord.js"; import { PollLayoutType } from "discord-api-types/payloads/v10"; import type { RESTAPIPoll } from "discord-api-types/rest/v10"; import type { @@ -14,6 +14,11 @@ import type { import { chunkText } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; +import { + normalizePollDurationHours, + normalizePollInput, + type PollInput, +} from "../polls.js"; import { loadWebMedia, loadWebMediaRaw } from "../web/media.js"; import { normalizeDiscordToken } from "./token.js"; @@ -21,9 +26,26 @@ const DISCORD_TEXT_LIMIT = 2000; const DISCORD_MAX_STICKERS = 3; const DISCORD_MAX_EMOJI_BYTES = 256 * 1024; const DISCORD_MAX_STICKER_BYTES = 512 * 1024; -const DISCORD_POLL_MIN_ANSWERS = 2; const DISCORD_POLL_MAX_ANSWERS = 10; const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24; +const DISCORD_MISSING_PERMISSIONS = 50013; +const DISCORD_CANNOT_DM = 50007; + +export class DiscordSendError extends Error { + kind?: "missing-permissions" | "dm-blocked"; + channelId?: string; + missingPermissions?: string[]; + + constructor(message: string, opts?: Partial) { + super(message); + this.name = "DiscordSendError"; + if (opts) Object.assign(this, opts); + } + + override toString() { + return this.message; + } +} type DiscordRecipient = | { @@ -48,13 +70,6 @@ export type DiscordSendResult = { channelId: string; }; -export type DiscordPollInput = { - question: string; - answers: string[]; - allowMultiselect?: boolean; - durationHours?: number; -}; - export type DiscordReactOpts = { token?: string; rest?: REST; @@ -78,6 +93,7 @@ export type DiscordPermissionsSummary = { permissions: string[]; raw: string; isDm: boolean; + channelType?: number; }; export type DiscordMessageQuery = { @@ -219,38 +235,97 @@ function normalizeEmojiName(raw: string, label: string) { return name; } -function normalizePollInput(input: DiscordPollInput): RESTAPIPoll { - const question = input.question.trim(); - if (!question) { - throw new Error("Poll question is required"); - } - const answers = (input.answers ?? []) - .map((answer) => answer.trim()) - .filter(Boolean); - if (answers.length < DISCORD_POLL_MIN_ANSWERS) { - throw new Error("Polls require at least 2 answers"); - } - if (answers.length > DISCORD_POLL_MAX_ANSWERS) { - throw new Error("Polls support up to 10 answers"); - } - const durationRaw = - typeof input.durationHours === "number" && - Number.isFinite(input.durationHours) - ? Math.floor(input.durationHours) - : 24; - const duration = Math.min( - Math.max(durationRaw, 1), - DISCORD_POLL_MAX_DURATION_HOURS, - ); +function normalizeDiscordPollInput(input: PollInput): RESTAPIPoll { + const poll = normalizePollInput(input, { + maxOptions: DISCORD_POLL_MAX_ANSWERS, + }); + const duration = normalizePollDurationHours(poll.durationHours, { + defaultHours: 24, + maxHours: DISCORD_POLL_MAX_DURATION_HOURS, + }); return { - question: { text: question }, - answers: answers.map((answer) => ({ poll_media: { text: answer } })), + question: { text: poll.question }, + answers: poll.options.map((answer) => ({ poll_media: { text: answer } })), duration, - allow_multiselect: input.allowMultiselect ?? false, + allow_multiselect: poll.maxSelections > 1, layout_type: PollLayoutType.Default, }; } +function getDiscordErrorCode(err: unknown) { + if (!err || typeof err !== "object") return undefined; + const candidate = + "code" in err && err.code !== undefined + ? err.code + : "rawError" in err && err.rawError && typeof err.rawError === "object" + ? (err.rawError as { code?: unknown }).code + : undefined; + if (typeof candidate === "number") return candidate; + if (typeof candidate === "string" && /^\d+$/.test(candidate)) { + return Number(candidate); + } + return undefined; +} + +function isThreadChannelType(channelType?: number) { + return ( + channelType === ChannelType.GuildNewsThread || + channelType === ChannelType.GuildPublicThread || + channelType === ChannelType.GuildPrivateThread + ); +} + +async function buildDiscordSendError( + err: unknown, + ctx: { + channelId: string; + rest: REST; + token: string; + hasMedia: boolean; + }, +) { + if (err instanceof DiscordSendError) return err; + const code = getDiscordErrorCode(err); + if (code === DISCORD_CANNOT_DM) { + return new DiscordSendError( + "discord dm failed: user blocks dms or privacy settings disallow it", + { kind: "dm-blocked" }, + ); + } + if (code !== DISCORD_MISSING_PERMISSIONS) return err; + + let missing: string[] = []; + try { + const permissions = await fetchChannelPermissionsDiscord(ctx.channelId, { + rest: ctx.rest, + token: ctx.token, + }); + const current = new Set(permissions.permissions); + const required = ["ViewChannel", "SendMessages"]; + if (isThreadChannelType(permissions.channelType)) { + required.push("SendMessagesInThreads"); + } + if (ctx.hasMedia) { + required.push("AttachFiles"); + } + missing = required.filter((permission) => !current.has(permission)); + } catch { + /* ignore permission probe errors */ + } + + const missingLabel = missing.length + ? `missing permissions in channel ${ctx.channelId}: ${missing.join(", ")}` + : `missing permissions in channel ${ctx.channelId}`; + return new DiscordSendError( + `${missingLabel}. bot might be muted or blocked by role/channel overrides`, + { + kind: "missing-permissions", + channelId: ctx.channelId, + missingPermissions: missing, + }, + ); +} + async function resolveChannelId( rest: REST, recipient: DiscordRecipient, @@ -374,17 +449,25 @@ export async function sendMessageDiscord( let result: | { id: string; channel_id: string } | { id: string | null; channel_id: string }; - - if (opts.mediaUrl) { - result = await sendDiscordMedia( - rest, + try { + if (opts.mediaUrl) { + result = await sendDiscordMedia( + rest, + channelId, + text, + opts.mediaUrl, + opts.replyTo, + ); + } else { + result = await sendDiscordText(rest, channelId, text, opts.replyTo); + } + } catch (err) { + throw await buildDiscordSendError(err, { channelId, - text, - opts.mediaUrl, - opts.replyTo, - ); - } else { - result = await sendDiscordText(rest, channelId, text, opts.replyTo); + rest, + token, + hasMedia: Boolean(opts.mediaUrl), + }); } return { @@ -418,7 +501,7 @@ export async function sendStickerDiscord( export async function sendPollDiscord( to: string, - poll: DiscordPollInput, + poll: PollInput, opts: DiscordSendOpts & { content?: string } = {}, ): Promise { const token = resolveToken(opts.token); @@ -426,7 +509,7 @@ export async function sendPollDiscord( const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient); const content = opts.content?.trim(); - const payload = normalizePollInput(poll); + const payload = normalizeDiscordPollInput(poll); const res = (await rest.post(Routes.channelMessages(channelId), { body: { content: content || undefined, @@ -512,6 +595,7 @@ export async function fetchChannelPermissionsDiscord( const token = resolveToken(opts.token); const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); const channel = (await rest.get(Routes.channel(channelId))) as APIChannel; + const channelType = "type" in channel ? channel.type : undefined; const guildId = "guild_id" in channel ? channel.guild_id : undefined; if (!guildId) { return { @@ -519,6 +603,7 @@ export async function fetchChannelPermissionsDiscord( permissions: [], raw: "0", isDm: true, + channelType, }; } @@ -573,6 +658,7 @@ export async function fetchChannelPermissionsDiscord( permissions: permissions.toArray(), raw: permissions.bitfield.toString(), isDm: false, + channelType, }; } diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 3e39b0628..16b0b2176 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -68,6 +68,8 @@ import { NodePairVerifyParamsSchema, type NodeRenameParams, NodeRenameParamsSchema, + type PollParams, + PollParamsSchema, PROTOCOL_VERSION, type PresenceEntry, PresenceEntrySchema, @@ -147,6 +149,7 @@ export const validateResponseFrame = ajv.compile(ResponseFrameSchema); export const validateEventFrame = ajv.compile(EventFrameSchema); export const validateSendParams = ajv.compile(SendParamsSchema); +export const validatePollParams = ajv.compile(PollParamsSchema); export const validateAgentParams = ajv.compile(AgentParamsSchema); export const validateAgentWaitParams = ajv.compile( AgentWaitParamsSchema, @@ -282,6 +285,7 @@ export { AgentEventSchema, ChatEventSchema, SendParamsSchema, + PollParamsSchema, AgentParamsSchema, WakeParamsSchema, NodePairRequestParamsSchema, @@ -390,4 +394,5 @@ export type { CronRunParams, CronRunsParams, CronRunLogEntry, + PollParams, }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index d5dfed536..c93645366 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -198,6 +198,18 @@ export const SendParamsSchema = Type.Object( { additionalProperties: false }, ); +export const PollParamsSchema = Type.Object( + { + to: NonEmptyString, + question: NonEmptyString, + options: Type.Array(NonEmptyString, { minItems: 2, maxItems: 12 }), + maxSelections: Type.Optional(Type.Integer({ minimum: 1, maximum: 12 })), + durationHours: Type.Optional(Type.Integer({ minimum: 1 })), + provider: Type.Optional(Type.String()), + idempotencyKey: NonEmptyString, + }, + { additionalProperties: false }, +); export const AgentParamsSchema = Type.Object( { message: NonEmptyString, @@ -635,6 +647,8 @@ export const CronPayloadSchema = Type.Union([ Type.Literal("telegram"), Type.Literal("discord"), Type.Literal("slack"), + Type.Literal("signal"), + Type.Literal("imessage"), ]), ), to: Type.Optional(Type.String()), @@ -829,6 +843,7 @@ export const ProtocolSchemas: Record = { ErrorShape: ErrorShapeSchema, AgentEvent: AgentEventSchema, SendParams: SendParamsSchema, + PollParams: PollParamsSchema, AgentParams: AgentParamsSchema, AgentWaitParams: AgentWaitParamsSchema, WakeParams: WakeParamsSchema, @@ -898,6 +913,7 @@ export type PresenceEntry = Static; export type ErrorShape = Static; export type StateVersion = Static; export type AgentEvent = Static; +export type PollParams = Static; export type AgentWaitParams = Static; export type WakeParams = Static; export type NodePairRequestParams = Static; diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 71eec2329..610c74946 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -16,6 +16,7 @@ import { resolveEmbeddedSessionLane, waitForEmbeddedPiRunEnd, } from "../agents/pi-embedded.js"; +import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; import { normalizeElevatedLevel, @@ -886,10 +887,6 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { timeoutMs?: number; idempotencyKey: string; }; - const timeoutMs = Math.min( - Math.max(p.timeoutMs ?? 30_000, 0), - 30_000, - ); const normalizedAttachments = p.attachments?.map((a) => ({ type: typeof a?.type === "string" ? a.type : undefined, @@ -928,7 +925,13 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { } } - const { storePath, store, entry } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, store, entry } = loadSessionEntry( + p.sessionKey, + ); + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideMs: p.timeoutMs, + }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const sessionEntry: SessionEntry = { diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 6bc1d6df4..f9497f025 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -240,11 +240,12 @@ export const agentHandlers: GatewayRequestHandlers = { defaultRuntime, context.deps, ) - .then(() => { + .then((result) => { const payload = { runId, status: "ok" as const, summary: "completed", + result, }; context.dedupe.set(`agent:${idem}`, { ts: Date.now(), diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 8d21cccd4..9d687de53 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; +import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { agentCommand } from "../../commands/agent.js"; import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -154,7 +155,6 @@ export const chatHandlers: GatewayRequestHandlers = { timeoutMs?: number; idempotencyKey: string; }; - const timeoutMs = Math.min(Math.max(p.timeoutMs ?? 30_000, 0), 30_000); const normalizedAttachments = p.attachments?.map((a) => ({ type: typeof a?.type === "string" ? a.type : undefined, @@ -189,6 +189,10 @@ export const chatHandlers: GatewayRequestHandlers = { } } const { cfg, storePath, store, entry } = loadSessionEntry(p.sessionKey); + const timeoutMs = resolveAgentTimeoutMs({ + cfg, + overrideMs: p.timeoutMs, + }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); const sessionEntry: SessionEntry = { diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index d173fe26f..705338d35 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -1,3 +1,7 @@ +import { + normalizeCronJobCreate, + normalizeCronJobPatch, +} from "../../cron/normalize.js"; import { readCronRunLogEntries, resolveCronRunLogPath, @@ -72,7 +76,8 @@ export const cronHandlers: GatewayRequestHandlers = { respond(true, status, undefined); }, "cron.add": async ({ params, respond, context }) => { - if (!validateCronAddParams(params)) { + const normalized = normalizeCronJobCreate(params) ?? params; + if (!validateCronAddParams(normalized)) { respond( false, undefined, @@ -83,11 +88,18 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const job = await context.cron.add(params as unknown as CronJobCreate); + const job = await context.cron.add(normalized as unknown as CronJobCreate); respond(true, job, undefined); }, "cron.update": async ({ params, respond, context }) => { - if (!validateCronUpdateParams(params)) { + const normalizedPatch = normalizeCronJobPatch( + (params as { patch?: unknown } | null)?.patch, + ); + const candidate = + normalizedPatch && typeof params === "object" && params !== null + ? { ...(params as Record), patch: normalizedPatch } + : params; + if (!validateCronUpdateParams(candidate)) { respond( false, undefined, @@ -98,7 +110,7 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const p = params as { + const p = candidate as { id: string; patch: Record; }; diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 07ebf4cdb..65461385a 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -1,16 +1,17 @@ import { loadConfig } from "../../config/config.js"; -import { sendMessageDiscord } from "../../discord/index.js"; +import { sendMessageDiscord, sendPollDiscord } from "../../discord/index.js"; import { shouldLogVerbose } from "../../globals.js"; import { sendMessageIMessage } from "../../imessage/index.js"; import { sendMessageSignal } from "../../signal/index.js"; import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageTelegram } from "../../telegram/send.js"; import { resolveTelegramToken } from "../../telegram/token.js"; -import { sendMessageWhatsApp } from "../../web/outbound.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { ErrorCodes, errorShape, formatValidationErrors, + validatePollParams, validateSendParams, } from "../protocol/index.js"; import { formatForLog } from "../ws-log.js"; @@ -178,4 +179,99 @@ export const sendHandlers: GatewayRequestHandlers = { }); } }, + poll: async ({ params, respond, context }) => { + const p = params as Record; + if (!validatePollParams(p)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid poll params: ${formatValidationErrors(validatePollParams.errors)}`, + ), + ); + return; + } + const request = p as { + to: string; + question: string; + options: string[]; + maxSelections?: number; + durationHours?: number; + provider?: string; + idempotencyKey: string; + }; + const idem = request.idempotencyKey; + const cached = context.dedupe.get(`poll:${idem}`); + if (cached) { + respond(cached.ok, cached.payload, cached.error, { + cached: true, + }); + return; + } + const to = request.to.trim(); + const providerRaw = (request.provider ?? "whatsapp").toLowerCase(); + const provider = providerRaw === "imsg" ? "imessage" : providerRaw; + if (provider !== "whatsapp" && provider !== "discord") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `unsupported poll provider: ${provider}`, + ), + ); + return; + } + const poll = { + question: request.question, + options: request.options, + maxSelections: request.maxSelections, + durationHours: request.durationHours, + }; + try { + if (provider === "discord") { + const result = await sendPollDiscord(to, poll); + const payload = { + runId: idem, + messageId: result.messageId, + channelId: result.channelId, + provider, + }; + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } else { + const result = await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + }); + const payload = { + runId: idem, + messageId: result.messageId, + toJid: result.toJid ?? `${to}@s.whatsapp.net`, + provider, + }; + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); + } + } catch (err) { + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + context.dedupe.set(`poll:${idem}`, { + ts: Date.now(), + ok: false, + error, + }); + respond(false, undefined, error, { + provider, + error: formatForLog(err), + }); + } + }, }; diff --git a/src/gateway/server.chat.test.ts b/src/gateway/server.chat.test.ts index ffb8e09a8..18c078a79 100644 --- a/src/gateway/server.chat.test.ts +++ b/src/gateway/server.chat.test.ts @@ -40,6 +40,27 @@ describe("gateway server chat", () => { await server.close(); }); + test("chat.send defaults to agent timeout config", async () => { + testState.agentConfig = { timeoutSeconds: 123 }; + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const res = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-timeout-1", + }); + expect(res.ok).toBe(true); + + const call = vi.mocked(agentCommand).mock.calls.at(-1)?.[0] as + | { timeout?: string } + | undefined; + expect(call?.timeout).toBe("123"); + + ws.close(); + await server.close(); + }); + test("chat.send blocked by send policy", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index e9519aa88..6b387a4be 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -68,6 +68,88 @@ describe("gateway server cron", () => { testState.cronStorePath = undefined; }); + test("normalizes wrapped cron.add payloads", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); + testState.cronStorePath = path.join(dir, "cron", "jobs.json"); + await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); + await fs.writeFile( + testState.cronStorePath, + JSON.stringify({ version: 1, jobs: [] }), + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const atMs = Date.now() + 1000; + const addRes = await rpcReq(ws, "cron.add", { + data: { + name: "wrapped", + schedule: { atMs }, + payload: { text: "hello" }, + }, + }); + expect(addRes.ok).toBe(true); + const payload = addRes.payload as + | { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown } + | undefined; + expect(payload?.sessionTarget).toBe("main"); + expect(payload?.wakeMode).toBe("next-heartbeat"); + expect((payload?.schedule as { kind?: unknown } | undefined)?.kind).toBe( + "at", + ); + + ws.close(); + await server.close(); + await fs.rm(dir, { recursive: true, force: true }); + testState.cronStorePath = undefined; + }); + + test("normalizes cron.update patch payloads", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-")); + testState.cronStorePath = path.join(dir, "cron", "jobs.json"); + await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true }); + await fs.writeFile( + testState.cronStorePath, + JSON.stringify({ version: 1, jobs: [] }), + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + const addRes = await rpcReq(ws, "cron.add", { + name: "patch test", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "hello" }, + }); + expect(addRes.ok).toBe(true); + const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id; + const jobId = typeof jobIdValue === "string" ? jobIdValue : ""; + expect(jobId.length > 0).toBe(true); + + const atMs = Date.now() + 1_000; + const updateRes = await rpcReq(ws, "cron.update", { + id: jobId, + patch: { + schedule: { atMs }, + payload: { text: "updated" }, + }, + }); + expect(updateRes.ok).toBe(true); + const updated = updateRes.payload as + | { schedule?: { kind?: unknown }; payload?: { kind?: unknown } } + | undefined; + expect(updated?.schedule?.kind).toBe("at"); + expect(updated?.payload?.kind).toBe("systemEvent"); + + ws.close(); + await server.close(); + await fs.rm(dir, { recursive: true, force: true }); + testState.cronStorePath = undefined; + }); + test("writes cron run history to runs/.jsonl", async () => { const dir = await fs.mkdtemp( path.join(os.tmpdir(), "clawdbot-gw-cron-log-"), diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index fe2d99db8..98caaa89a 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -84,6 +84,7 @@ export const cronIsolatedRun = hoisted.cronIsolatedRun; export const agentCommand = hoisted.agentCommand; export const testState = { + agentConfig: undefined as Record | undefined, sessionStorePath: undefined as string | undefined, sessionConfig: undefined as Record | undefined, allowFrom: undefined as string[] | undefined, @@ -243,6 +244,7 @@ vi.mock("../config/config.js", async () => { agent: { model: "anthropic/claude-opus-4-5", workspace: path.join(os.tmpdir(), "clawd-gateway-test"), + ...testState.agentConfig, }, whatsapp: { allowFrom: testState.allowFrom, @@ -351,6 +353,7 @@ export function installGatewayTestHooks() { testState.cronStorePath = undefined; testState.sessionConfig = undefined; testState.sessionStorePath = undefined; + testState.agentConfig = undefined; testState.allowFrom = undefined; testIsNixMode.value = false; cronIsolatedRun.mockClear(); diff --git a/src/hooks/gmail-watcher.test.ts b/src/hooks/gmail-watcher.test.ts new file mode 100644 index 000000000..039a5977e --- /dev/null +++ b/src/hooks/gmail-watcher.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { isAddressInUseError } from "./gmail-watcher.js"; + +describe("gmail watcher", () => { + it("detects address already in use errors", () => { + expect( + isAddressInUseError( + "listen tcp 127.0.0.1:8788: bind: address already in use", + ), + ).toBe(true); + expect(isAddressInUseError("EADDRINUSE: address already in use")).toBe( + true, + ); + expect(isAddressInUseError("some other error")).toBe(false); + }); +}); diff --git a/src/hooks/gmail-watcher.ts b/src/hooks/gmail-watcher.ts index bd4e07969..0b3133066 100644 --- a/src/hooks/gmail-watcher.ts +++ b/src/hooks/gmail-watcher.ts @@ -20,6 +20,12 @@ import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js"; const log = createSubsystemLogger("gmail-watcher"); +const ADDRESS_IN_USE_RE = /address already in use|EADDRINUSE/i; + +export function isAddressInUseError(line: string): boolean { + return ADDRESS_IN_USE_RE.test(line); +} + let watcherProcess: ChildProcess | null = null; let renewInterval: ReturnType | null = null; let shuttingDown = false; @@ -61,6 +67,7 @@ async function startGmailWatch( function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess { const args = buildGogWatchServeArgs(cfg); log.info(`starting gog ${args.join(" ")}`); + let addressInUse = false; const child = spawn("gog", args, { stdio: ["ignore", "pipe", "pipe"], @@ -74,7 +81,11 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess { child.stderr?.on("data", (data: Buffer) => { const line = data.toString().trim(); - if (line) log.warn(`[gog] ${line}`); + if (!line) return; + if (isAddressInUseError(line)) { + addressInUse = true; + } + log.warn(`[gog] ${line}`); }); child.on("error", (err) => { @@ -83,6 +94,14 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess { child.on("exit", (code, signal) => { if (shuttingDown) return; + if (addressInUse) { + log.warn( + "gog serve failed to bind (address already in use); stopping restarts. " + + "Another watcher is likely running. Set CLAWDBOT_SKIP_GMAIL_WATCHER=1 or stop the other process.", + ); + watcherProcess = null; + return; + } log.warn(`gog exited (code=${code}, signal=${signal}); restarting in 5s`); watcherProcess = null; setTimeout(() => { diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts index 6b9dac986..e50765150 100644 --- a/src/imessage/monitor.test.ts +++ b/src/imessage/monitor.test.ts @@ -139,6 +139,66 @@ describe("monitorIMessageProvider", () => { expect(replyMock).toHaveBeenCalled(); }); + it("allows group messages when requireMention is true but no mentionPatterns exist", async () => { + config = { + ...config, + routing: { groupChat: { mentionPatterns: [] }, allowFrom: [] }, + imessage: { groups: { "*": { requireMention: true } } }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 12, + chat_id: 777, + sender: "+15550001111", + is_from_me: false, + text: "hello group", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).toHaveBeenCalled(); + }); + + it("blocks group messages when imessage.groups is set without a wildcard", async () => { + config = { + ...config, + imessage: { groups: { "99": { requireMention: false } } }, + }; + const run = monitorIMessageProvider(); + await waitForSubscribe(); + + notificationHandler?.({ + method: "message", + params: { + message: { + id: 13, + chat_id: 123, + sender: "+15550001111", + is_from_me: false, + text: "@clawd hello", + is_group: true, + }, + }, + }); + + await flush(); + closeResolve?.(); + await run; + + expect(replyMock).not.toHaveBeenCalled(); + expect(sendMock).not.toHaveBeenCalled(); + }); + it("prefixes tool and final replies with responsePrefix", async () => { config = { ...config, diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index 300d27ed2..5b289ec8b 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -1,10 +1,18 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { mediaKindFromMime } from "../media/constants.js"; @@ -67,46 +75,6 @@ function resolveAllowFrom(opts: MonitorIMessageOpts): string[] { return raw.map((entry) => String(entry).trim()).filter(Boolean); } -function resolveMentionRegexes(cfg: ReturnType): RegExp[] { - return ( - cfg.routing?.groupChat?.mentionPatterns - ?.map((pattern) => { - try { - return new RegExp(pattern, "i"); - } catch { - return null; - } - }) - .filter((val): val is RegExp => Boolean(val)) ?? [] - ); -} - -function resolveGroupRequireMention( - cfg: ReturnType, - opts: MonitorIMessageOpts, - chatId?: number | null, -): boolean { - if (typeof opts.requireMention === "boolean") return opts.requireMention; - const groupId = chatId != null ? String(chatId) : undefined; - if (groupId) { - const groupConfig = cfg.imessage?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - } - const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; -} - -function isMentioned(text: string, regexes: RegExp[]): boolean { - if (!text) return false; - const cleaned = text - .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") - .toLowerCase(); - return regexes.some((re) => re.test(cleaned)); -} - async function deliverReplies(params: { replies: ReplyPayload[]; target: string; @@ -148,7 +116,7 @@ export async function monitorIMessageProvider( const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "imessage"); const allowFrom = resolveAllowFrom(opts); - const mentionRegexes = resolveMentionRegexes(cfg); + const mentionRegexes = buildMentionRegexes(cfg); const includeAttachments = opts.includeAttachments ?? cfg.imessage?.includeAttachments ?? false; const mediaMaxBytes = @@ -170,6 +138,21 @@ export async function monitorIMessageProvider( const isGroup = Boolean(message.is_group); if (isGroup && !chatId) return; + const groupId = isGroup ? String(chatId) : undefined; + if (isGroup) { + const groupPolicy = resolveProviderGroupPolicy({ + cfg, + surface: "imessage", + groupId, + }); + if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + logVerbose( + `imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`, + ); + return; + } + } + const commandAuthorized = isAllowedIMessageSender({ allowFrom, sender, @@ -183,15 +166,30 @@ export async function monitorIMessageProvider( } const messageText = (message.text ?? "").trim(); - const mentioned = isGroup ? isMentioned(messageText, mentionRegexes) : true; - const requireMention = resolveGroupRequireMention(cfg, opts, chatId); + const mentioned = isGroup + ? matchesMentionPatterns(messageText, mentionRegexes) + : true; + const requireMention = resolveProviderGroupRequireMention({ + cfg, + surface: "imessage", + groupId, + requireMentionOverride: opts.requireMention, + overrideOrder: "before-config", + }); + const canDetectMention = mentionRegexes.length > 0; const shouldBypassMention = isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommand(messageText); - if (isGroup && requireMention && !mentioned && !shouldBypassMention) { + if ( + isGroup && + requireMention && + canDetectMention && + !mentioned && + !shouldBypassMention + ) { logVerbose(`imessage: skipping group message (no mention)`); return; } @@ -287,28 +285,11 @@ export async function monitorIMessageProvider( }, }); - const replyResult = await getReplyFromConfig( - ctxPayload, - { - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); - }, - }, + const { queuedFinal } = await dispatchReplyFromConfig({ + ctx: ctxPayload, cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; - } - await dispatcher.waitForIdle(); + dispatcher, + }); if (!queuedFinal) return; }; diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index c555e237b..107c66b9e 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -181,6 +181,64 @@ describe("runHeartbeatOnce", () => { } }); + it("respects ackMaxChars for heartbeat acks", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdbotConfig = { + agent: { + heartbeat: { + every: "5m", + target: "whatsapp", + to: "+1555", + ackMaxChars: 0, + }, + }, + whatsapp: { allowFrom: ["*"] }, + session: { store: storePath }, + }; + + replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(sendWhatsApp).toHaveBeenCalled(); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("skips WhatsApp delivery when not linked or running", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index c78630ceb..9f136e3df 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,5 +1,6 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, HEARTBEAT_PROMPT, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; @@ -102,6 +103,13 @@ export function resolveHeartbeatPrompt(cfg: ClawdbotConfig) { return trimmed || HEARTBEAT_PROMPT; } +function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig) { + return Math.max( + 0, + cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); +} + function resolveHeartbeatSession(cfg: ClawdbotConfig) { const sessionCfg = cfg.session; const scope = sessionCfg?.scope ?? "per-sender"; @@ -277,11 +285,12 @@ async function restoreHeartbeatUpdatedAt(params: { function normalizeHeartbeatReply( payload: ReplyPayload, - responsePrefix?: string, + responsePrefix: string | undefined, + ackMaxChars: number, ) { const stripped = stripHeartbeatToken(payload.text, { mode: "heartbeat", - maxAckChars: 30, + maxAckChars: ackMaxChars, }); const hasMedia = Boolean( payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0, @@ -478,9 +487,11 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } + const ackMaxChars = resolveHeartbeatAckMaxChars(cfg); const normalized = normalizeHeartbeatReply( replyPayload, cfg.messages?.responsePrefix, + ackMaxChars, ); if (normalized.shouldSkip && !normalized.hasMedia) { await restoreHeartbeatUpdatedAt({ diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 157cc1a4c..9010edcd9 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024; +let lastAppliedKeys: string[] = []; function isTruthy(raw: string | undefined): boolean { if (!raw) return false; @@ -34,13 +35,16 @@ export function loadShellEnvFallback( const logger = opts.logger ?? console; const exec = opts.exec ?? execFileSync; - if (!opts.enabled) + if (!opts.enabled) { + lastAppliedKeys = []; return { ok: true, applied: [], skippedReason: "disabled" }; + } const hasAnyKey = opts.expectedKeys.some((key) => Boolean(opts.env[key]?.trim()), ); if (hasAnyKey) { + lastAppliedKeys = []; return { ok: true, applied: [], skippedReason: "already-has-keys" }; } @@ -63,6 +67,7 @@ export function loadShellEnvFallback( } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.warn(`[clawdbot] shell env fallback failed: ${msg}`); + lastAppliedKeys = []; return { ok: false, error: msg, applied: [] }; } @@ -87,6 +92,7 @@ export function loadShellEnvFallback( applied.push(key); } + lastAppliedKeys = applied; return { ok: true, applied }; } @@ -103,3 +109,7 @@ export function resolveShellEnvFallbackTimeoutMs( if (!Number.isFinite(parsed)) return DEFAULT_TIMEOUT_MS; return Math.max(0, parsed); } + +export function getShellEnvAppliedKeys(): string[] { + return [...lastAppliedKeys]; +} diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts new file mode 100644 index 000000000..7783751d1 --- /dev/null +++ b/src/logging/redact.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { getDefaultRedactPatterns, redactSensitiveText } from "./redact.js"; + +const defaults = getDefaultRedactPatterns(); + +describe("redactSensitiveText", () => { + it("masks env assignments while keeping the key", () => { + const input = "OPENAI_API_KEY=sk-1234567890abcdef"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("OPENAI_API_KEY=sk-123…cdef"); + }); + + it("masks CLI flags", () => { + const input = "curl --token abcdef1234567890ghij https://api.test"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("curl --token abcdef…ghij https://api.test"); + }); + + it("masks JSON fields", () => { + const input = '{"token":"abcdef1234567890ghij"}'; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe('{"token":"abcdef…ghij"}'); + }); + + it("masks bearer tokens", () => { + const input = "Authorization: Bearer abcdef1234567890ghij"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("Authorization: Bearer abcdef…ghij"); + }); + + it("masks Telegram-style tokens", () => { + const input = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("123456…cdef"); + }); + + it("redacts short tokens fully", () => { + const input = "TOKEN=shortvalue"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("TOKEN=***"); + }); + + it("redacts private key blocks", () => { + const input = [ + "-----BEGIN PRIVATE KEY-----", + "ABCDEF1234567890", + "ZYXWVUT987654321", + "-----END PRIVATE KEY-----", + ].join("\n"); + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe( + [ + "-----BEGIN PRIVATE KEY-----", + "…redacted…", + "-----END PRIVATE KEY-----", + ].join("\n"), + ); + }); + + it("honors custom patterns with flags", () => { + const input = "token=abcdef1234567890ghij"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: ["/token=([A-Za-z0-9]+)/i"], + }); + expect(output).toBe("token=abcdef…ghij"); + }); + + it("skips redaction when mode is off", () => { + const input = "OPENAI_API_KEY=sk-1234567890abcdef"; + const output = redactSensitiveText(input, { + mode: "off", + patterns: defaults, + }); + expect(output).toBe(input); + }); +}); diff --git a/src/logging/redact.ts b/src/logging/redact.ts new file mode 100644 index 000000000..065b4e334 --- /dev/null +++ b/src/logging/redact.ts @@ -0,0 +1,125 @@ +import { loadConfig } from "../config/config.js"; + +export type RedactSensitiveMode = "off" | "tools"; + +const DEFAULT_REDACT_MODE: RedactSensitiveMode = "tools"; +const DEFAULT_REDACT_MIN_LENGTH = 18; +const DEFAULT_REDACT_KEEP_START = 6; +const DEFAULT_REDACT_KEEP_END = 4; + +const DEFAULT_REDACT_PATTERNS: string[] = [ + // ENV-style assignments. + String.raw`\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1`, + // JSON fields. + String.raw`"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken)"\s*:\s*"([^"]+)"`, + // CLI flags. + String.raw`--(?:api[-_]?key|token|secret|password|passwd)\s+(["']?)([^\s"']+)\1`, + // Authorization headers. + String.raw`Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)`, + String.raw`\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b`, + // PEM blocks. + String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----`, + // Common token prefixes. + String.raw`\b(sk-[A-Za-z0-9_-]{8,})\b`, + String.raw`\b(ghp_[A-Za-z0-9]{20,})\b`, + String.raw`\b(github_pat_[A-Za-z0-9_]{20,})\b`, + String.raw`\b(xox[baprs]-[A-Za-z0-9-]{10,})\b`, + String.raw`\b(xapp-[A-Za-z0-9-]{10,})\b`, + String.raw`\b(gsk_[A-Za-z0-9_-]{10,})\b`, + String.raw`\b(AIza[0-9A-Za-z\-_]{20,})\b`, + String.raw`\b(pplx-[A-Za-z0-9_-]{10,})\b`, + String.raw`\b(npm_[A-Za-z0-9]{10,})\b`, + String.raw`\b(\d{6,}:[A-Za-z0-9_-]{20,})\b`, +]; + +type RedactOptions = { + mode?: RedactSensitiveMode; + patterns?: string[]; +}; + +function normalizeMode(value?: string): RedactSensitiveMode { + return value === "off" ? "off" : DEFAULT_REDACT_MODE; +} + +function parsePattern(raw: string): RegExp | null { + if (!raw.trim()) return null; + const match = raw.match(/^\/(.+)\/([gimsuy]*)$/); + try { + if (match) { + const flags = match[2].includes("g") ? match[2] : `${match[2]}g`; + return new RegExp(match[1], flags); + } + return new RegExp(raw, "gi"); + } catch { + return null; + } +} + +function resolvePatterns(value?: string[]): RegExp[] { + const source = value?.length ? value : DEFAULT_REDACT_PATTERNS; + return source.map(parsePattern).filter((re): re is RegExp => Boolean(re)); +} + +function maskToken(token: string): string { + if (token.length < DEFAULT_REDACT_MIN_LENGTH) return "***"; + const start = token.slice(0, DEFAULT_REDACT_KEEP_START); + const end = token.slice(-DEFAULT_REDACT_KEEP_END); + return `${start}…${end}`; +} + +function redactPemBlock(block: string): string { + const lines = block.split(/\r?\n/).filter(Boolean); + if (lines.length < 2) return "***"; + return `${lines[0]}\n…redacted…\n${lines[lines.length - 1]}`; +} + +function redactMatch(match: string, groups: string[]): string { + if (match.includes("PRIVATE KEY-----")) return redactPemBlock(match); + const token = + groups + .filter((value) => typeof value === "string" && value.length > 0) + .at(-1) ?? match; + const masked = maskToken(token); + if (token === match) return masked; + return match.replace(token, masked); +} + +function redactText(text: string, patterns: RegExp[]): string { + let next = text; + for (const pattern of patterns) { + next = next.replace(pattern, (...args: string[]) => + redactMatch(args[0], args.slice(1, args.length - 2)), + ); + } + return next; +} + +function resolveConfigRedaction(): RedactOptions { + const cfg = loadConfig().logging; + return { + mode: normalizeMode(cfg?.redactSensitive), + patterns: cfg?.redactPatterns, + }; +} + +export function redactSensitiveText( + text: string, + options?: RedactOptions, +): string { + if (!text) return text; + const resolved = options ?? resolveConfigRedaction(); + if (normalizeMode(resolved.mode) === "off") return text; + const patterns = resolvePatterns(resolved.patterns); + if (!patterns.length) return text; + return redactText(text, patterns); +} + +export function redactToolDetail(detail: string): string { + const resolved = resolveConfigRedaction(); + if (normalizeMode(resolved.mode) !== "tools") return detail; + return redactSensitiveText(detail, resolved); +} + +export function getDefaultRedactPatterns(): string[] { + return [...DEFAULT_REDACT_PATTERNS]; +} diff --git a/src/media/mime.ts b/src/media/mime.ts index d26cfb969..a53abdb23 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -107,6 +107,17 @@ export function extensionForMime(mime?: string | null): string | undefined { return EXT_BY_MIME[mime.toLowerCase()]; } +export function isGifMedia(opts: { + contentType?: string | null; + fileName?: string | null; +}): boolean { + if (opts.contentType?.toLowerCase() === "image/gif") return true; + const ext = opts.fileName + ? path.extname(opts.fileName).toLowerCase() + : undefined; + return ext === ".gif"; +} + export function imageMimeFromFormat( format?: string | null, ): string | undefined { diff --git a/src/polls.test.ts b/src/polls.test.ts new file mode 100644 index 000000000..e2f351b9a --- /dev/null +++ b/src/polls.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { normalizePollDurationHours, normalizePollInput } from "./polls.js"; + +describe("polls", () => { + it("normalizes question/options and validates maxSelections", () => { + expect( + normalizePollInput({ + question: " Lunch? ", + options: [" Pizza ", " ", "Sushi"], + maxSelections: 2, + }), + ).toEqual({ + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 2, + durationHours: undefined, + }); + }); + + it("enforces max option count when configured", () => { + expect(() => + normalizePollInput( + { question: "Q", options: ["A", "B", "C"] }, + { maxOptions: 2 }, + ), + ).toThrow(/at most 2/); + }); + + it("clamps poll duration with defaults", () => { + expect( + normalizePollDurationHours(undefined, { defaultHours: 24, maxHours: 48 }), + ).toBe(24); + expect( + normalizePollDurationHours(999, { defaultHours: 24, maxHours: 48 }), + ).toBe(48); + expect( + normalizePollDurationHours(1, { defaultHours: 24, maxHours: 48 }), + ).toBe(1); + }); +}); diff --git a/src/polls.ts b/src/polls.ts new file mode 100644 index 000000000..784412fd4 --- /dev/null +++ b/src/polls.ts @@ -0,0 +1,71 @@ +export type PollInput = { + question: string; + options: string[]; + maxSelections?: number; + durationHours?: number; +}; + +export type NormalizedPollInput = { + question: string; + options: string[]; + maxSelections: number; + durationHours?: number; +}; + +type NormalizePollOptions = { + maxOptions?: number; +}; + +export function normalizePollInput( + input: PollInput, + options: NormalizePollOptions = {}, +): NormalizedPollInput { + const question = input.question.trim(); + if (!question) { + throw new Error("Poll question is required"); + } + const pollOptions = (input.options ?? []).map((option) => option.trim()); + const cleaned = pollOptions.filter(Boolean); + if (cleaned.length < 2) { + throw new Error("Poll requires at least 2 options"); + } + if (options.maxOptions !== undefined && cleaned.length > options.maxOptions) { + throw new Error(`Poll supports at most ${options.maxOptions} options`); + } + const maxSelectionsRaw = input.maxSelections; + const maxSelections = + typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw) + ? Math.floor(maxSelectionsRaw) + : 1; + if (maxSelections < 1) { + throw new Error("maxSelections must be at least 1"); + } + if (maxSelections > cleaned.length) { + throw new Error("maxSelections cannot exceed option count"); + } + const durationRaw = input.durationHours; + const durationHours = + typeof durationRaw === "number" && Number.isFinite(durationRaw) + ? Math.floor(durationRaw) + : undefined; + if (durationHours !== undefined && durationHours < 1) { + throw new Error("durationHours must be at least 1"); + } + return { + question, + options: cleaned, + maxSelections, + durationHours, + }; +} + +export function normalizePollDurationHours( + value: number | undefined, + options: { defaultHours: number; maxHours: number }, +): number { + const base = + typeof value === "number" && Number.isFinite(value) + ? Math.floor(value) + : options.defaultHours; + return Math.min(Math.max(base, 1), options.maxHours); +} diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index 9b35bc060..f9bbffbc2 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -231,4 +231,252 @@ describe("google-shared convertMessages", () => { thoughtSignature: "sig", }); }); + + it("merges consecutive user messages to satisfy Gemini role alternation", () => { + const model = makeModel("gemini-1.5-pro"); + const context = { + messages: [ + { + role: "user", + content: "Hello", + }, + { + role: "user", + content: "How are you?", + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + // Should merge into a single user message + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); + }); + + it("merges consecutive user messages for non-Gemini Google models", () => { + const model = makeModel("claude-3-opus"); + const context = { + messages: [ + { + role: "user", + content: "First", + }, + { + role: "user", + content: "Second", + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); + }); + + it("merges consecutive model messages to satisfy Gemini role alternation", () => { + const model = makeModel("gemini-1.5-pro"); + const context = { + messages: [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: [{ type: "text", text: "Hi there!" }], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + { + role: "assistant", + content: [{ type: "text", text: "How can I help?" }], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + // Should have 1 user + 1 merged model message + expect(contents).toHaveLength(2); + expect(contents[0].role).toBe("user"); + expect(contents[1].role).toBe("model"); + expect(contents[1].parts).toHaveLength(2); + }); + + it("handles user message after tool result without model response in between", () => { + const model = makeModel("gemini-1.5-pro"); + const context = { + messages: [ + { + role: "user", + content: "Use a tool", + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "myTool", + arguments: { arg: "value" }, + }, + ], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "myTool", + content: [{ type: "text", text: "Tool result" }], + isError: false, + timestamp: 0, + }, + { + role: "user", + content: "Now do something else", + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + // Tool result creates a user turn with functionResponse + // The next user message should be merged into it or there should be proper alternation + // Check that we don't have consecutive user messages + for (let i = 1; i < contents.length; i++) { + if (contents[i].role === "user" && contents[i - 1].role === "user") { + // If consecutive, they should have been merged + expect.fail("Consecutive user messages should be merged"); + } + } + // The conversation should be valid for Gemini + expect(contents.length).toBeGreaterThan(0); + }); + + it("ensures function call comes after user turn, not after model turn", () => { + const model = makeModel("gemini-1.5-pro"); + const context = { + messages: [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: [{ type: "text", text: "Hi!" }], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "myTool", + arguments: {}, + }, + ], + api: "google-generative-ai", + provider: "google", + model: "gemini-1.5-pro", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 0, + }, + ], + } as unknown as Context; + + const contents = convertMessages(model, context); + // Consecutive model messages should be merged so function call is in same turn as text + expect(contents).toHaveLength(2); + expect(contents[0].role).toBe("user"); + expect(contents[1].role).toBe("model"); + // The model message should have both text and function call + expect(contents[1].parts?.length).toBe(2); + }); }); diff --git a/src/providers/location.test.ts b/src/providers/location.test.ts new file mode 100644 index 000000000..1db7e2115 --- /dev/null +++ b/src/providers/location.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { formatLocationText, toLocationContext } from "./location.js"; + +describe("provider location helpers", () => { + it("formats pin locations with accuracy", () => { + const text = formatLocationText({ + latitude: 48.858844, + longitude: 2.294351, + accuracy: 12, + }); + expect(text).toBe("📍 48.858844, 2.294351 ±12m"); + }); + + it("formats named places with address and caption", () => { + const text = formatLocationText({ + latitude: 40.689247, + longitude: -74.044502, + name: "Statue of Liberty", + address: "Liberty Island, NY", + accuracy: 8, + caption: "Bring snacks", + }); + expect(text).toBe( + "📍 Statue of Liberty — Liberty Island, NY (40.689247, -74.044502 ±8m)\nBring snacks", + ); + }); + + it("formats live locations with live label", () => { + const text = formatLocationText({ + latitude: 37.819929, + longitude: -122.478255, + accuracy: 20, + caption: "On the move", + isLive: true, + source: "live", + }); + expect(text).toBe( + "🛰 Live location: 37.819929, -122.478255 ±20m\nOn the move", + ); + }); + + it("builds ctx fields with normalized source", () => { + const ctx = toLocationContext({ + latitude: 1, + longitude: 2, + name: "Cafe", + address: "Main St", + }); + expect(ctx).toEqual({ + LocationLat: 1, + LocationLon: 2, + LocationAccuracy: undefined, + LocationName: "Cafe", + LocationAddress: "Main St", + LocationSource: "place", + LocationIsLive: false, + }); + }); +}); diff --git a/src/providers/location.ts b/src/providers/location.ts new file mode 100644 index 000000000..6cc4997ef --- /dev/null +++ b/src/providers/location.ts @@ -0,0 +1,78 @@ +export type LocationSource = "pin" | "place" | "live"; + +export type NormalizedLocation = { + latitude: number; + longitude: number; + accuracy?: number; + name?: string; + address?: string; + isLive?: boolean; + source?: LocationSource; + caption?: string; +}; + +type ResolvedLocation = NormalizedLocation & { + source: LocationSource; + isLive: boolean; +}; + +function resolveLocation(location: NormalizedLocation): ResolvedLocation { + const source = + location.source ?? + (location.isLive + ? "live" + : location.name || location.address + ? "place" + : "pin"); + const isLive = Boolean(location.isLive ?? source === "live"); + return { ...location, source, isLive }; +} + +function formatAccuracy(accuracy?: number): string { + if (!Number.isFinite(accuracy)) return ""; + return ` ±${Math.round(accuracy ?? 0)}m`; +} + +function formatCoords(latitude: number, longitude: number): string { + return `${latitude.toFixed(6)}, ${longitude.toFixed(6)}`; +} + +export function formatLocationText(location: NormalizedLocation): string { + const resolved = resolveLocation(location); + const coords = formatCoords(resolved.latitude, resolved.longitude); + const accuracy = formatAccuracy(resolved.accuracy); + const caption = resolved.caption?.trim(); + let header = ""; + + if (resolved.source === "live" || resolved.isLive) { + header = `🛰 Live location: ${coords}${accuracy}`; + } else if (resolved.name || resolved.address) { + const label = [resolved.name, resolved.address].filter(Boolean).join(" — "); + header = `📍 ${label} (${coords}${accuracy})`; + } else { + header = `📍 ${coords}${accuracy}`; + } + + return caption ? `${header}\n${caption}` : header; +} + +export function toLocationContext(location: NormalizedLocation): { + LocationLat: number; + LocationLon: number; + LocationAccuracy?: number; + LocationName?: string; + LocationAddress?: string; + LocationSource: LocationSource; + LocationIsLive: boolean; +} { + const resolved = resolveLocation(location); + return { + LocationLat: resolved.latitude, + LocationLon: resolved.longitude, + LocationAccuracy: resolved.accuracy, + LocationName: resolved.name, + LocationAddress: resolved.address, + LocationSource: resolved.source, + LocationIsLive: resolved.isLive, + }; +} diff --git a/src/signal/monitor.ts b/src/signal/monitor.ts index 3cf92a6bd..3bbe0afbd 100644 --- a/src/signal/monitor.ts +++ b/src/signal/monitor.ts @@ -1,7 +1,7 @@ import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; @@ -400,28 +400,11 @@ export async function monitorSignalProvider( }, }); - const replyResult = await getReplyFromConfig( - ctxPayload, - { - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); - }, - }, + const { queuedFinal } = await dispatchReplyFromConfig({ + ctx: ctxPayload, cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; - } - await dispatcher.waitForIdle(); + dispatcher, + }); if (!queuedFinal) return; }; diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index ade080f2b..90dc30343 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -5,6 +5,7 @@ import { monitorSlackProvider } from "./monitor.js"; const sendMock = vi.fn(); const replyMock = vi.fn(); const updateLastRouteMock = vi.fn(); +const reactMock = vi.fn(); let config: Record = {}; const getSlackHandlers = () => ( @@ -12,6 +13,8 @@ const getSlackHandlers = () => __slackHandlers?: Map Promise>; } ).__slackHandlers; +const getSlackClient = () => + (globalThis as { __slackClient?: Record }).__slackClient; vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -39,20 +42,25 @@ vi.mock("@slack/bolt", () => { const handlers = new Map Promise>(); (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; + const client = { + auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, + conversations: { + info: vi.fn().mockResolvedValue({ + channel: { name: "dm", is_im: true }, + }), + }, + users: { + info: vi.fn().mockResolvedValue({ + user: { profile: { display_name: "Ada" } }, + }), + }, + reactions: { + add: (...args: unknown[]) => reactMock(...args), + }, + }; + (globalThis as { __slackClient?: typeof client }).__slackClient = client; class App { - client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "dm", is_im: true }, - }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - }; + client = client; event(name: string, handler: (args: unknown) => Promise) { handlers.set(name, handler); } @@ -76,13 +84,18 @@ async function waitForEvent(name: string) { beforeEach(() => { config = { - messages: { responsePrefix: "PFX" }, + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, slack: { dm: { enabled: true }, groupDm: { enabled: false } }, routing: { allowFrom: [] }, }; sendMock.mockReset().mockResolvedValue(undefined); replyMock.mockReset(); updateLastRouteMock.mockReset(); + reactMock.mockReset(); }); describe("monitorSlackProvider tool results", () => { @@ -122,4 +135,160 @@ describe("monitorSlackProvider tool results", () => { expect(sendMock.mock.calls[0][1]).toBe("PFX tool update"); expect(sendMock.mock.calls[1][1]).toBe("PFX final reply"); }); + + it("accepts channel messages when mentionPatterns match", async () => { + config = { + messages: { responsePrefix: "PFX" }, + slack: { + dm: { enabled: true }, + groupDm: { enabled: false }, + channels: { C1: { allow: true, requireMention: true } }, + }, + routing: { + allowFrom: [], + groupChat: { mentionPatterns: ["\\bclawd\\b"] }, + }, + }; + replyMock.mockResolvedValue({ text: "hi" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "clawd: hello", + ts: "123", + channel: "C1", + channel_type: "channel", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); + }); + + it("threads replies when incoming message is in a thread", async () => { + replyMock.mockResolvedValue({ text: "thread reply" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "123", + thread_ts: "456", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "456" }); + }); + + it("keeps replies in channel root when message is not threaded", async () => { + replyMock.mockResolvedValue({ text: "root reply" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "789", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined }); + }); + + it("reacts to mention-gated room messages when ackReaction is enabled", async () => { + replyMock.mockResolvedValue(undefined); + const client = getSlackClient(); + if (!client) throw new Error("Slack client not registered"); + const conversations = client.conversations as { + info: ReturnType; + }; + conversations.info.mockResolvedValueOnce({ + channel: { name: "general", is_channel: true }, + }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "<@bot-user> hello", + ts: "456", + channel: "C1", + channel_type: "channel", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(reactMock).toHaveBeenCalledWith({ + channel: "C1", + timestamp: "456", + name: "👀", + }); + }); }); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 4e26961d8..fb8f11cbd 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -6,6 +6,11 @@ import bolt from "@slack/bolt"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; @@ -26,6 +31,7 @@ import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; import type { RuntimeEnv } from "../runtime.js"; +import { reactSlackMessage } from "./actions.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; @@ -379,6 +385,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { opts.slashCommand ?? cfg.slack?.slashCommand, ); const textLimit = resolveTextChunkLimit(cfg, "slack"); + const mentionRegexes = buildMentionRegexes(cfg); + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024; @@ -581,7 +590,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const wasMentioned = opts.wasMentioned ?? (!isDirectMessage && - Boolean(botUserId && message.text?.includes(`<@${botUserId}>`))); + (Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)) || + matchesMentionPatterns(message.text ?? "", mentionRegexes))); const sender = await resolveUserName(message.user); const senderName = sender?.name ?? message.user; const allowList = normalizeAllowListLower(allowFrom); @@ -600,9 +610,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { !hasAnyMention && commandAuthorized && hasControlCommand(message.text ?? ""); + const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0; if ( isRoom && channelConfig?.requireMention && + canDetectMention && !wasMentioned && !shouldBypassMention ) { @@ -620,6 +632,30 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; if (!rawBody) return; + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return isDirectMessage; + const isGroupChat = isRoom || isGroupDm; + if (ackReactionScope === "group-all") return isGroupChat; + if (ackReactionScope === "group-mentions") { + if (!isRoom) return false; + if (!channelConfig?.requireMention) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction() && message.ts) { + reactSlackMessage(message.channel, message.ts, ackReaction, { + token: botToken, + client: app.client, + }).catch((err) => { + logVerbose( + `slack react failed for channel ${message.channel}: ${String(err)}`, + ); + }); + } const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; @@ -700,6 +736,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ); } + // Only thread replies if the incoming message was in a thread. + const incomingThreadTs = message.thread_ts; const dispatcher = createReplyDispatcher({ responsePrefix: cfg.messages?.responsePrefix, deliver: async (payload) => { @@ -709,6 +747,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { token: botToken, runtime, textLimit, + threadTs: incomingThreadTs, }); }, onError: (err, info) => { @@ -718,31 +757,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }, }); - const replyResult = await getReplyFromConfig( - ctxPayload, - { - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); - }, - }, + const { queuedFinal, counts } = await dispatchReplyFromConfig({ + ctx: ctxPayload, cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; - } - await dispatcher.waitForIdle(); + dispatcher, + }); if (!queuedFinal) return; if (shouldLogVerbose()) { - const finalCount = dispatcher.getQueuedCounts().final; + const finalCount = counts.final; logVerbose( `slack: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); @@ -1379,6 +1401,7 @@ async function deliverReplies(params: { token: string; runtime: RuntimeEnv; textLimit: number; + threadTs?: string; }) { const chunkLimit = Math.min(params.textLimit, 4000); for (const payload of params.replies) { @@ -1389,12 +1412,11 @@ async function deliverReplies(params: { if (mediaList.length === 0) { for (const chunk of chunkText(text, chunkLimit)) { - const threadTs = undefined; const trimmed = chunk.trim(); if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue; await sendMessageSlack(params.target, trimmed, { token: params.token, - threadTs, + threadTs: params.threadTs, }); } } else { @@ -1402,11 +1424,10 @@ async function deliverReplies(params: { for (const mediaUrl of mediaList) { const caption = first ? text : ""; first = false; - const threadTs = undefined; await sendMessageSlack(params.target, caption, { token: params.token, mediaUrl, - threadTs, + threadTs: params.threadTs, }); } } diff --git a/src/telegram/bot.media.test.ts b/src/telegram/bot.media.test.ts index 0157d0831..09ca06c20 100644 --- a/src/telegram/bot.media.test.ts +++ b/src/telegram/bot.media.test.ts @@ -209,3 +209,216 @@ describe("telegram inbound media", () => { fetchSpy.mockRestore(); }); }); + +describe("telegram media groups", () => { + const waitForMediaGroupProcessing = () => + new Promise((resolve) => setTimeout(resolve, 600)); + + it("buffers messages with same media_group_id and processes them together", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + const runtimeError = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/png" }, + arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + } as Response); + + createTelegramBot({ + token: "tok", + runtime: { + log: vi.fn(), + error: runtimeError, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 1, + caption: "Here are my photos", + date: 1736380800, + media_group_id: "album123", + photo: [{ file_id: "photo1" }], + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "photos/photo1.jpg" }), + }); + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 2, + date: 1736380801, + media_group_id: "album123", + photo: [{ file_id: "photo2" }], + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "photos/photo2.jpg" }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + await waitForMediaGroupProcessing(); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("Here are my photos"); + expect(payload.MediaPaths).toHaveLength(2); + + fetchSpy.mockRestore(); + }, 2000); + + it("processes separate media groups independently", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + const fetchSpy = vi.spyOn(globalThis, "fetch" as never).mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/png" }, + arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer, + } as Response); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 1, + caption: "Album A", + date: 1736380800, + media_group_id: "albumA", + photo: [{ file_id: "photoA1" }], + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "photos/photoA1.jpg" }), + }); + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 2, + caption: "Album B", + date: 1736380801, + media_group_id: "albumB", + photo: [{ file_id: "photoB1" }], + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "photos/photoB1.jpg" }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + await waitForMediaGroupProcessing(); + + expect(replySpy).toHaveBeenCalledTimes(2); + + fetchSpy.mockRestore(); + }, 2000); +}); + +describe("telegram location parsing", () => { + it("includes location text and ctx fields for pins", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0]?.[1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 5, + caption: "Meet here", + date: 1736380800, + location: { + latitude: 48.858844, + longitude: 2.294351, + horizontal_accuracy: 12, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "unused" }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("Meet here"); + expect(payload.Body).toContain("48.858844"); + expect(payload.LocationLat).toBe(48.858844); + expect(payload.LocationLon).toBe(2.294351); + expect(payload.LocationSource).toBe("pin"); + expect(payload.LocationIsLive).toBe(false); + }); + + it("captures venue fields for named places", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0]?.[1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 42, type: "private" }, + message_id: 6, + date: 1736380800, + venue: { + title: "Eiffel Tower", + address: "Champ de Mars, Paris", + location: { latitude: 48.858844, longitude: 2.294351 }, + }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ file_path: "unused" }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("Eiffel Tower"); + expect(payload.LocationName).toBe("Eiffel Tower"); + expect(payload.LocationAddress).toBe("Champ de Mars, Paris"); + expect(payload.LocationSource).toBe("place"); + }); +}); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 26f82f659..f698d6caa 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -2,6 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as replyModule from "../auto-reply/reply.js"; import { createTelegramBot } from "./bot.js"; +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia, +})); + const { loadConfig } = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({})), })); @@ -17,16 +25,25 @@ const useSpy = vi.fn(); const onSpy = vi.fn(); const stopSpy = vi.fn(); const sendChatActionSpy = vi.fn(); +const setMessageReactionSpy = vi.fn(async () => undefined); const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); +const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); +const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); type ApiStub = { config: { use: (arg: unknown) => void }; sendChatAction: typeof sendChatActionSpy; + setMessageReaction: typeof setMessageReactionSpy; sendMessage: typeof sendMessageSpy; + sendAnimation: typeof sendAnimationSpy; + sendPhoto: typeof sendPhotoSpy; }; const apiStub: ApiStub = { config: { use: useSpy }, sendChatAction: sendChatActionSpy, + setMessageReaction: setMessageReactionSpy, sendMessage: sendMessageSpy, + sendAnimation: sendAnimationSpy, + sendPhoto: sendPhotoSpy, }; vi.mock("grammy", () => ({ @@ -57,6 +74,10 @@ vi.mock("../auto-reply/reply.js", () => { describe("createTelegramBot", () => { beforeEach(() => { loadConfig.mockReturnValue({}); + loadWebMedia.mockReset(); + sendAnimationSpy.mockReset(); + sendPhotoSpy.mockReset(); + setMessageReactionSpy.mockReset(); }); it("installs grammY throttler", () => { @@ -101,7 +122,7 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T01:00\+01:00\{Europe\/Vienna\}\]/, + /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/, ); expect(payload.Body).toContain("hello world"); } finally { @@ -126,6 +147,143 @@ describe("createTelegramBot", () => { expect(sendChatActionSpy).toHaveBeenCalledWith(42, "typing"); }); + it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + identity: { name: "Bert" }, + routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: introduce yourself", + date: 1736380800, + message_id: 1, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(true); + }); + + it("reacts to mention-gated group messages when ackReaction is enabled", async () => { + onSpy.mockReset(); + setMessageReactionSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert hello", + date: 1736380800, + message_id: 123, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(setMessageReactionSpy).toHaveBeenCalledWith(7, 123, [ + { type: "emoji", emoji: "👀" }, + ]); + }); + + it("skips group messages when requireMention is enabled and no mention matches", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + routing: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 2, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows group messages when requireMention is enabled but mentions cannot be detected", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + routing: { groupChat: { mentionPatterns: [] } }, + telegram: { groups: { "*": { requireMention: true } } }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "hello everyone", + date: 1736380800, + message_id: 3, + from: { id: 9, first_name: "Ada" }, + }, + me: {}, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.WasMentioned).toBe(false); + }); + it("includes reply-to context when a Telegram reply is received", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); @@ -293,6 +451,38 @@ describe("createTelegramBot", () => { } }); + it("blocks group messages when telegram.groups is set without a wildcard", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groups: { + "123": { requireMention: false }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 456, type: "group", title: "Ops" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + it("skips group messages without mention when requireMention is enabled", async () => { onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType< @@ -410,4 +600,537 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); }); + + it("sends GIF replies as animations", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + replySpy.mockResolvedValueOnce({ + text: "caption", + mediaUrl: "https://example.com/fun", + }); + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("GIF89a"), + contentType: "image/gif", + fileName: "fun.gif", + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, + message_id: 5, + from: { first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendAnimationSpy).toHaveBeenCalledTimes(1); + expect(sendAnimationSpy).toHaveBeenCalledWith("1234", expect.anything(), { + caption: "caption", + reply_to_message_id: undefined, + }); + expect(sendPhotoSpy).not.toHaveBeenCalled(); + }); + + // groupPolicy tests + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "disabled", + allowFrom: ["123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should NOT call getReplyFromConfig because groupPolicy is disabled + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], // Does not include sender 999999 + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "notallowed" }, // Not in allowFrom + text: "@clawdbot_bot hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("allows group messages from senders in allowFrom (by ID) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + groups: { "*": { requireMention: false } }, // Skip mention check + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // In allowFrom + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from senders in allowFrom (by username) when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@testuser"], // By username + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, // Username matches @testuser + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from telegram:-prefixed allowFrom entries when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:77112533"], + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages from tg:-prefixed allowFrom entries case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:77112533"], + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 77112533, username: "mneves" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows all group messages when groupPolicy is 'open' (default)", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + // groupPolicy not set, should default to "open" + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, // Random sender + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("matches usernames case-insensitively when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["@TestUser"], // Uppercase in config + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 12345, username: "testuser" }, // Lowercase in message + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages regardless of groupPolicy", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "disabled", // Even with disabled, DMs should work + allowFrom: ["123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with tg/Telegram-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + allowFrom: [" TG:123456789 "], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, // Direct message + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows direct messages with telegram:-prefixed allowFrom entries", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + allowFrom: ["telegram:123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 123456789, type: "private" }, + from: { id: 123456789, username: "testuser" }, + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("allows group messages with wildcard in allowFrom when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["*"], // Wildcard allows everyone + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 999999, username: "random" }, // Random sender, but wildcard allows + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + }); + + it("blocks group messages with no sender ID when groupPolicy is 'allowlist'", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["123456789"], + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + // No `from` field (e.g., channel post or anonymous admin) + text: "hello", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).not.toHaveBeenCalled(); + }); + + it("matches telegram:-prefixed allowFrom entries in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["telegram:123456789"], // Prefixed format + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // Matches after stripping prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping telegram: prefix + expect(replySpy).toHaveBeenCalled(); + }); + + it("matches tg:-prefixed allowFrom entries case-insensitively in group allowlist", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + loadConfig.mockReturnValue({ + telegram: { + groupPolicy: "allowlist", + allowFrom: ["TG:123456789"], // Prefixed format (case-insensitive) + groups: { "*": { requireMention: false } }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: -100123456789, type: "group", title: "Test Group" }, + from: { id: 123456789, username: "testuser" }, // Matches after stripping tg: prefix + text: "hello from prefixed user", + date: 1736380800, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + // Should call reply because sender ID matches after stripping tg: prefix + expect(replySpy).toHaveBeenCalled(); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index ff7722c17..6fe351080 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -7,26 +7,72 @@ import { Bot, InputFile, webhookCallback } from "grammy"; import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; -import { getReplyFromConfig } from "../auto-reply/reply.js"; +import type { TypingController } from "../auto-reply/reply/typing.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ReplyToMode } from "../config/config.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; import { getChildLogger } from "../logging.js"; import { mediaKindFromMime } from "../media/constants.js"; -import { detectMime } from "../media/mime.js"; +import { detectMime, isGifMedia } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + formatLocationText, + type NormalizedLocation, + toLocationContext, +} from "../providers/location.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +// Media group aggregation - Telegram sends multi-image messages as separate updates +// with a shared media_group_id. We buffer them and process as a single message after a short delay. +const MEDIA_GROUP_TIMEOUT_MS = 500; + type TelegramMessage = Message.CommonMessage; +type MediaGroupEntry = { + messages: Array<{ + msg: TelegramMessage; + ctx: TelegramContext; + }>; + timer: ReturnType; +}; + +/** Telegram Location object */ +interface TelegramLocation { + latitude: number; + longitude: number; + horizontal_accuracy?: number; + live_period?: number; + heading?: number; +} + +/** Telegram Venue object */ +interface TelegramVenue { + location: TelegramLocation; + title: string; + address: string; + foursquare_id?: string; + foursquare_type?: string; + google_place_id?: string; + google_place_type?: string; +} + type TelegramContext = { message: TelegramMessage; me?: { username?: string }; @@ -60,97 +106,355 @@ export function createTelegramBot(opts: TelegramBotOptions) { const bot = new Bot(opts.token, { client }); bot.api.config.use(apiThrottler()); + const mediaGroupBuffer = new Map(); + const cfg = loadConfig(); const textLimit = resolveTextChunkLimit(cfg, "telegram"); const allowFrom = opts.allowFrom ?? cfg.telegram?.allowFrom; + const normalizedAllowFrom = (allowFrom ?? []) + .map((value) => String(value).trim()) + .filter(Boolean) + .map((value) => value.replace(/^(telegram|tg):/i, "")); + const normalizedAllowFromLower = normalizedAllowFrom.map((value) => + value.toLowerCase(), + ); + const hasAllowFromWildcard = normalizedAllowFrom.includes("*"); const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off"; + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.telegram?.mediaMaxMb ?? 5) * 1024 * 1024; const logger = getChildLogger({ module: "telegram-auto-reply" }); - const resolveGroupRequireMention = (chatId: string | number) => { - const groupId = String(chatId); - const groupConfig = cfg.telegram?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; + const mentionRegexes = buildMentionRegexes(cfg); + const resolveGroupPolicy = (chatId: string | number) => + resolveProviderGroupPolicy({ + cfg, + surface: "telegram", + groupId: String(chatId), + }); + const resolveGroupRequireMention = (chatId: string | number) => + resolveProviderGroupRequireMention({ + cfg, + surface: "telegram", + groupId: String(chatId), + requireMentionOverride: opts.requireMention, + overrideOrder: "after-config", + }); + + const processMessage = async ( + primaryCtx: TelegramContext, + allMedia: Array<{ path: string; contentType?: string }>, + ) => { + const msg = primaryCtx.message; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + + const sendTyping = async () => { + try { + await bot.api.sendChatAction(chatId, "typing"); + } catch (err) { + logVerbose( + `telegram typing cue failed for chat ${chatId}: ${String(err)}`, + ); + } + }; + + // allowFrom for direct chats + if (!isGroup && normalizedAllowFrom.length > 0) { + const candidate = String(chatId); + const permitted = + hasAllowFromWildcard || normalizedAllowFrom.includes(candidate); + if (!permitted) { + logVerbose( + `Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`, + ); + return; + } } - const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - if (typeof opts.requireMention === "boolean") return opts.requireMention; - return true; + + const botUsername = primaryCtx.me?.username?.toLowerCase(); + const allowFromList = normalizedAllowFrom; + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const senderUsernameLower = senderUsername.toLowerCase(); + const commandAuthorized = + allowFromList.length === 0 || + hasAllowFromWildcard || + (senderId && allowFromList.includes(senderId)) || + (senderUsername && + normalizedAllowFromLower.some( + (entry) => + entry === senderUsernameLower || + entry === `@${senderUsernameLower}`, + )); + const wasMentioned = + (Boolean(botUsername) && hasBotMention(msg, botUsername)) || + matchesMentionPatterns(msg.text ?? msg.caption ?? "", mentionRegexes); + const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( + (ent) => ent.type === "mention", + ); + const requireMention = resolveGroupRequireMention(chatId); + const shouldBypassMention = + isGroup && + requireMention && + !wasMentioned && + !hasAnyMention && + commandAuthorized && + hasControlCommand(msg.text ?? msg.caption ?? ""); + const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; + if (isGroup && requireMention && canDetectMention) { + if (!wasMentioned && !shouldBypassMention) { + logger.info({ chatId, reason: "no-mention" }, "skipping group message"); + return; + } + } + + // ACK reactions + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return !isGroup; + if (ackReactionScope === "group-all") return isGroup; + if (ackReactionScope === "group-mentions") { + if (!isGroup) return false; + if (!requireMention) return false; + if (!canDetectMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction() && msg.message_id) { + const api = bot.api as unknown as { + setMessageReaction?: ( + chatId: number | string, + messageId: number, + reactions: Array<{ type: "emoji"; emoji: string }>, + ) => Promise; + }; + if (typeof api.setMessageReaction === "function") { + api + .setMessageReaction(chatId, msg.message_id, [ + { type: "emoji", emoji: ackReaction }, + ]) + .catch((err) => { + logVerbose( + `telegram react failed for chat ${chatId}: ${String(err)}`, + ); + }); + } + } + + let placeholder = ""; + if (msg.photo) placeholder = ""; + else if (msg.video) placeholder = ""; + else if (msg.audio || msg.voice) placeholder = ""; + else if (msg.document) placeholder = ""; + + const replyTarget = describeReplyTarget(msg); + const locationData = extractTelegramLocation(msg); + const locationText = locationData + ? formatLocationText(locationData) + : undefined; + const rawText = (msg.text ?? msg.caption ?? "").trim(); + let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim(); + if (!rawBody) rawBody = placeholder; + if (!rawBody && allMedia.length === 0) return; + + let bodyText = rawBody; + if (!bodyText && allMedia.length > 0) { + bodyText = `${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`; + } + + const replySuffix = replyTarget + ? `\n\n[Replying to ${replyTarget.sender}${ + replyTarget.id ? ` id:${replyTarget.id}` : "" + }]\n${replyTarget.body}\n[/Replying]` + : ""; + const body = formatAgentEnvelope({ + surface: "Telegram", + from: isGroup + ? buildGroupLabel(msg, chatId) + : buildSenderLabel(msg, chatId), + timestamp: msg.date ? msg.date * 1000 : undefined, + body: `${bodyText}${replySuffix}`, + }); + + const ctxPayload = { + Body: body, + From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, + To: `telegram:${chatId}`, + ChatType: isGroup ? "group" : "direct", + GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, + SenderName: buildSenderName(msg), + SenderId: senderId || undefined, + SenderUsername: senderUsername || undefined, + Surface: "telegram", + MessageSid: String(msg.message_id), + ReplyToId: replyTarget?.id, + ReplyToBody: replyTarget?.body, + ReplyToSender: replyTarget?.sender, + Timestamp: msg.date ? msg.date * 1000 : undefined, + WasMentioned: isGroup ? wasMentioned : undefined, + MediaPath: allMedia[0]?.path, + MediaType: allMedia[0]?.contentType, + MediaUrl: allMedia[0]?.path, + MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, + MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, + MediaTypes: + allMedia.length > 0 + ? (allMedia.map((m) => m.contentType).filter(Boolean) as string[]) + : undefined, + ...(locationData ? toLocationContext(locationData) : undefined), + CommandAuthorized: commandAuthorized, + }; + + if (replyTarget && shouldLogVerbose()) { + const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120); + logVerbose( + `telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`, + ); + } + + if (!isGroup) { + const sessionCfg = cfg.session; + const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; + const storePath = resolveStorePath(sessionCfg?.store); + await updateLastRoute({ + storePath, + sessionKey: mainKey, + channel: "telegram", + to: String(chatId), + }); + } + + if (shouldLogVerbose()) { + const preview = body.slice(0, 200).replace(/\n/g, "\\n"); + const mediaInfo = + allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : ""; + logVerbose( + `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`, + ); + } + + let typingController: TypingController | undefined; + const dispatcher = createReplyDispatcher({ + responsePrefix: cfg.messages?.responsePrefix, + deliver: async (payload) => { + await deliverReplies({ + replies: [payload], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + }); + }, + onIdle: () => { + typingController?.markDispatchIdle(); + }, + onError: (err, info) => { + runtime.error?.( + danger(`telegram ${info.kind} reply failed: ${String(err)}`), + ); + }, + }); + + const { queuedFinal } = await dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + onReplyStart: sendTyping, + onTypingController: (typing) => { + typingController = typing; + }, + }, + }); + typingController?.markDispatchIdle(); + if (!queuedFinal) return; }; bot.on("message", async (ctx) => { try { const msg = ctx.message; if (!msg) return; + const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; - const sendTyping = async () => { - try { - await bot.api.sendChatAction(chatId, "typing"); - } catch (err) { - logVerbose( - `telegram typing cue failed for chat ${chatId}: ${String(err)}`, - ); + if (isGroup) { + // Group policy filtering: controls how group messages are handled + // - "open" (default): groups bypass allowFrom, only mention-gating applies + // - "disabled": block all group messages entirely + // - "allowlist": only allow group messages from senders in allowFrom + const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; + if (groupPolicy === "disabled") { + logVerbose(`Blocked telegram group message (groupPolicy: disabled)`); + return; + } + if (groupPolicy === "allowlist") { + // For allowlist mode, the sender (msg.from.id) must be in allowFrom + const senderId = msg.from?.id; + if (senderId == null) { + logVerbose( + `Blocked telegram group message (no sender ID, groupPolicy: allowlist)`, + ); + return; + } + const senderIdAllowed = normalizedAllowFrom.includes( + String(senderId), + ); + // Also check username if available (with or without @ prefix) + const senderUsername = msg.from?.username?.toLowerCase(); + const usernameAllowed = + senderUsername != null && + normalizedAllowFromLower.some( + (value) => + value === senderUsername || value === `@${senderUsername}`, + ); + if (!hasAllowFromWildcard && !senderIdAllowed && !usernameAllowed) { + logVerbose( + `Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`, + ); + return; + } } - }; - // allowFrom for direct chats - if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) { - const candidate = String(chatId); - const allowed = allowFrom.map(String); - const allowedWithPrefix = allowFrom.map((v) => `telegram:${String(v)}`); - const permitted = - allowed.includes(candidate) || - allowedWithPrefix.includes(`telegram:${candidate}`) || - allowed.includes("*"); - if (!permitted) { - logVerbose( - `Blocked unauthorized telegram sender ${candidate} (not in allowFrom)`, + // Group allowlist based on configured group IDs. + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { + logger.info( + { chatId, title: msg.chat.title, reason: "not-allowed" }, + "skipping group message", ); return; } } - const botUsername = ctx.me?.username?.toLowerCase(); - const allowFromList = Array.isArray(allowFrom) - ? allowFrom.map((entry) => String(entry).trim()).filter(Boolean) - : []; - const senderId = msg.from?.id ? String(msg.from.id) : ""; - const senderUsername = msg.from?.username ?? ""; - const commandAuthorized = - allowFromList.length === 0 || - allowFromList.includes("*") || - (senderId && allowFromList.includes(senderId)) || - (senderId && allowFromList.includes(`telegram:${senderId}`)) || - (senderUsername && - allowFromList.some( - (entry) => - entry.toLowerCase() === senderUsername.toLowerCase() || - entry.toLowerCase() === `@${senderUsername.toLowerCase()}`, - )); - const wasMentioned = - Boolean(botUsername) && hasBotMention(msg, botUsername); - const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( - (ent) => ent.type === "mention", - ); - const shouldBypassMention = - isGroup && - resolveGroupRequireMention(chatId) && - !wasMentioned && - !hasAnyMention && - commandAuthorized && - hasControlCommand(msg.text ?? msg.caption ?? ""); - if (isGroup && resolveGroupRequireMention(chatId) && botUsername) { - if (!wasMentioned && !shouldBypassMention) { - logger.info( - { chatId, reason: "no-mention" }, - "skipping group message", - ); - return; + // Media group handling - buffer multi-image messages + const mediaGroupId = (msg as { media_group_id?: string }).media_group_id; + if (mediaGroupId) { + const existing = mediaGroupBuffer.get(mediaGroupId); + if (existing) { + clearTimeout(existing.timer); + existing.messages.push({ msg, ctx }); + existing.timer = setTimeout(async () => { + mediaGroupBuffer.delete(mediaGroupId); + await processMediaGroup(existing); + }, MEDIA_GROUP_TIMEOUT_MS); + } else { + const entry: MediaGroupEntry = { + messages: [{ msg, ctx }], + timer: setTimeout(async () => { + mediaGroupBuffer.delete(mediaGroupId); + await processMediaGroup(entry); + }, MEDIA_GROUP_TIMEOUT_MS), + }; + mediaGroupBuffer.set(mediaGroupId, entry); } + return; } const media = await resolveMedia( @@ -159,121 +463,43 @@ export function createTelegramBot(opts: TelegramBotOptions) { opts.token, opts.proxyFetch, ); - const replyTarget = describeReplyTarget(msg); - const rawBody = ( - msg.text ?? - msg.caption ?? - media?.placeholder ?? - "" - ).trim(); - if (!rawBody) return; - const replySuffix = replyTarget - ? `\n\n[Replying to ${replyTarget.sender}${ - replyTarget.id ? ` id:${replyTarget.id}` : "" - }]\n${replyTarget.body}\n[/Replying]` - : ""; - const body = formatAgentEnvelope({ - surface: "Telegram", - from: isGroup - ? buildGroupLabel(msg, chatId) - : buildSenderLabel(msg, chatId), - timestamp: msg.date ? msg.date * 1000 : undefined, - body: `${rawBody}${replySuffix}`, - }); - - const ctxPayload = { - Body: body, - From: isGroup ? `group:${chatId}` : `telegram:${chatId}`, - To: `telegram:${chatId}`, - ChatType: isGroup ? "group" : "direct", - GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, - SenderName: buildSenderName(msg), - SenderId: senderId || undefined, - SenderUsername: senderUsername || undefined, - Surface: "telegram", - MessageSid: String(msg.message_id), - ReplyToId: replyTarget?.id, - ReplyToBody: replyTarget?.body, - ReplyToSender: replyTarget?.sender, - Timestamp: msg.date ? msg.date * 1000 : undefined, - WasMentioned: isGroup && botUsername ? wasMentioned : undefined, - MediaPath: media?.path, - MediaType: media?.contentType, - MediaUrl: media?.path, - CommandAuthorized: commandAuthorized, - }; - - if (replyTarget && shouldLogVerbose()) { - const preview = replyTarget.body.replace(/\s+/g, " ").slice(0, 120); - logVerbose( - `telegram reply-context: replyToId=${replyTarget.id} replyToSender=${replyTarget.sender} replyToBody="${preview}"`, - ); - } - - if (!isGroup) { - const sessionCfg = cfg.session; - const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; - const storePath = resolveStorePath(sessionCfg?.store); - await updateLastRoute({ - storePath, - sessionKey: mainKey, - channel: "telegram", - to: String(chatId), - }); - } - - if (shouldLogVerbose()) { - const preview = body.slice(0, 200).replace(/\n/g, "\\n"); - logVerbose( - `telegram inbound: chatId=${chatId} from=${ctxPayload.From} len=${body.length} preview="${preview}"`, - ); - } - - const dispatcher = createReplyDispatcher({ - responsePrefix: cfg.messages?.responsePrefix, - deliver: async (payload) => { - await deliverReplies({ - replies: [payload], - chatId: String(chatId), - token: opts.token, - runtime, - bot, - replyToMode, - textLimit, - }); - }, - onError: (err, info) => { - runtime.error?.( - danger(`telegram ${info.kind} reply failed: ${String(err)}`), - ); - }, - }); - - const replyResult = await getReplyFromConfig( - ctxPayload, - { - onReplyStart: sendTyping, - onToolResult: dispatcher.sendToolResult, - onBlockReply: dispatcher.sendBlockReply, - }, - cfg, - ); - const replies = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] + const allMedia = media + ? [{ path: media.path, contentType: media.contentType }] : []; - let queuedFinal = false; - for (const reply of replies) { - queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal; - } - await dispatcher.waitForIdle(); - if (!queuedFinal) return; + await processMessage(ctx, allMedia); } catch (err) { runtime.error?.(danger(`handler failed: ${String(err)}`)); } }); + const processMediaGroup = async (entry: MediaGroupEntry) => { + try { + entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id); + + const captionMsg = entry.messages.find( + (m) => m.msg.caption || m.msg.text, + ); + const primaryEntry = captionMsg ?? entry.messages[0]; + + const allMedia: Array<{ path: string; contentType?: string }> = []; + for (const { ctx } of entry.messages) { + const media = await resolveMedia( + ctx, + mediaMaxBytes, + opts.token, + opts.proxyFetch, + ); + if (media) { + allMedia.push({ path: media.path, contentType: media.contentType }); + } + } + + await processMessage(primaryEntry.ctx, allMedia); + } catch (err) { + runtime.error?.(danger(`media group handler failed: ${String(err)}`)); + } + }; + return bot; } @@ -328,14 +554,24 @@ async function deliverReplies(params: { for (const mediaUrl of mediaList) { const media = await loadWebMedia(mediaUrl); const kind = mediaKindFromMime(media.contentType ?? undefined); - const file = new InputFile(media.buffer, media.fileName ?? "file"); + const isGif = isGifMedia({ + contentType: media.contentType, + fileName: media.fileName, + }); + const fileName = media.fileName ?? (isGif ? "animation.gif" : "file"); + const file = new InputFile(media.buffer, fileName); const caption = first ? (reply.text ?? undefined) : undefined; first = false; const replyToMessageId = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; - if (kind === "image") { + if (isGif) { + await bot.api.sendAnimation(chatId, file, { + caption, + reply_to_message_id: replyToMessageId, + }); + } else if (kind === "image") { await bot.api.sendPhoto(chatId, file, { caption, reply_to_message_id: replyToMessageId, @@ -494,6 +730,10 @@ function describeReplyTarget(msg: TelegramMessage) { else if (reply.video) body = ""; else if (reply.audio || reply.voice) body = ""; else if (reply.document) body = ""; + else { + const locationData = extractTelegramLocation(reply); + if (locationData) body = formatLocationText(locationData); + } } if (!body) return null; const sender = buildSenderName(reply); @@ -504,3 +744,39 @@ function describeReplyTarget(msg: TelegramMessage) { body, }; } + +function extractTelegramLocation( + msg: TelegramMessage, +): NormalizedLocation | null { + const msgWithLocation = msg as { + location?: TelegramLocation; + venue?: TelegramVenue; + }; + const { venue, location } = msgWithLocation; + + if (venue) { + return { + latitude: venue.location.latitude, + longitude: venue.location.longitude, + accuracy: venue.location.horizontal_accuracy, + name: venue.title, + address: venue.address, + source: "place", + isLive: false, + }; + } + + if (location) { + const isLive = + typeof location.live_period === "number" && location.live_period > 0; + return { + latitude: location.latitude, + longitude: location.longitude, + accuracy: location.horizontal_accuracy, + source: isLive ? "live" : "pin", + isLive, + }; + } + + return null; +} diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 04a068cd7..a302c9f5a 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1,8 +1,20 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia, +})); import { sendMessageTelegram } from "./send.js"; describe("sendMessageTelegram", () => { + beforeEach(() => { + loadWebMedia.mockReset(); + }); + it("falls back to plain text when Telegram rejects Markdown", async () => { const chatId = "123"; const parseErr = new Error( @@ -67,4 +79,32 @@ describe("sendMessageTelegram", () => { sendMessageTelegram(chatId, "hi", { token: "tok", api }), ).rejects.toThrow(/chat_id=123/); }); + + it("sends GIF media as animation", async () => { + const chatId = "123"; + const sendAnimation = vi.fn().mockResolvedValue({ + message_id: 9, + chat: { id: chatId }, + }); + const api = { sendAnimation } as unknown as { + sendAnimation: typeof sendAnimation; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("GIF89a"), + fileName: "fun.gif", + }); + + const res = await sendMessageTelegram(chatId, "caption", { + token: "tok", + api, + mediaUrl: "https://example.com/fun", + }); + + expect(sendAnimation).toHaveBeenCalledTimes(1); + expect(sendAnimation).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "caption", + }); + expect(res.messageId).toBe("9"); + }); }); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 06b50da5b..39c063cc7 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -2,6 +2,7 @@ import { Bot, InputFile } from "grammy"; import { formatErrorMessage } from "../infra/errors.js"; import { mediaKindFromMime } from "../media/constants.js"; +import { isGifMedia } from "../media/mime.js"; import { loadWebMedia } from "../web/media.js"; type TelegramSendOpts = { @@ -110,17 +111,30 @@ export async function sendMessageTelegram( if (mediaUrl) { const media = await loadWebMedia(mediaUrl, opts.maxBytes); const kind = mediaKindFromMime(media.contentType ?? undefined); - const file = new InputFile( - media.buffer, - media.fileName ?? inferFilename(kind) ?? "file", - ); + const isGif = isGifMedia({ + contentType: media.contentType, + fileName: media.fileName, + }); + const fileName = + media.fileName ?? + (isGif ? "animation.gif" : inferFilename(kind)) ?? + "file"; + const file = new InputFile(media.buffer, fileName); const caption = text?.trim() || undefined; let result: | Awaited> | Awaited> | Awaited> + | Awaited> | Awaited>; - if (kind === "image") { + if (isGif) { + result = await sendWithRetry( + () => api.sendAnimation(chatId, file, { caption }), + "animation", + ).catch((err) => { + throw wrapChatNotFound(err); + }); + } else if (kind === "image") { result = await sendWithRetry( () => api.sendPhoto(chatId, file, { caption }), "photo", diff --git a/src/web/active-listener.ts b/src/web/active-listener.ts index bdcac6b85..6c9fc41a6 100644 --- a/src/web/active-listener.ts +++ b/src/web/active-listener.ts @@ -1,3 +1,5 @@ +import type { PollInput } from "../polls.js"; + export type ActiveWebSendOptions = { gifPlayback?: boolean; }; @@ -10,6 +12,7 @@ export type ActiveWebListener = { mediaType?: string, options?: ActiveWebSendOptions, ) => Promise<{ messageId: string }>; + sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; sendComposingTo: (to: string) => Promise; close?: () => Promise; }; diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 05fd023a9..3b3525cbd 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -244,6 +244,63 @@ describe("partial reply gating", () => { }); }); +describe("typing controller idle", () => { + it("marks dispatch idle after replies flush", async () => { + const markDispatchIdle = vi.fn(); + const typingMock = { + onReplyStart: vi.fn(async () => {}), + startTypingLoop: vi.fn(async () => {}), + startTypingOnText: vi.fn(async () => {}), + refreshTypingTtl: vi.fn(), + markRunComplete: vi.fn(), + markDispatchIdle, + cleanup: vi.fn(), + }; + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn().mockResolvedValue(undefined); + const sendMedia = vi.fn().mockResolvedValue(undefined); + + const replyResolver = vi.fn().mockImplementation(async (_ctx, opts) => { + opts?.onTypingController?.(typingMock); + return { text: "final reply" }; + }); + + const mockConfig: ClawdbotConfig = { + whatsapp: { + allowFrom: ["*"], + }, + }; + + setLoadConfigMock(mockConfig); + + await monitorWebProvider( + false, + async ({ onMessage }) => { + await onMessage({ + id: "m1", + from: "+1000", + conversationId: "+1000", + to: "+2000", + body: "hello", + timestamp: Date.now(), + chatType: "direct", + chatId: "direct:+1000", + sendComposing, + reply, + sendMedia, + }); + return { close: vi.fn().mockResolvedValue(undefined) }; + }, + false, + replyResolver, + ); + + resetLoadConfigMock(); + + expect(markDispatchIdle).toHaveBeenCalled(); + }); +}); + describe("web auto-reply", () => { beforeEach(() => { vi.clearAllMocks(); @@ -465,9 +522,6 @@ describe("web auto-reply", () => { }; setLoadConfigMock(() => ({ - messages: { - timestampPrefix: "UTC", - }, session: { store: store.storePath }, })); @@ -500,11 +554,11 @@ describe("web auto-reply", () => { const firstArgs = resolver.mock.calls[0][0]; const secondArgs = resolver.mock.calls[1][0]; expect(firstArgs.Body).toContain( - "[WhatsApp +1 2025-01-01T01:00+01:00{Europe/Vienna}] [clawdbot] first", + "[WhatsApp +1 2025-01-01T00:00Z] [clawdbot] first", ); expect(firstArgs.Body).not.toContain("second"); expect(secondArgs.Body).toContain( - "[WhatsApp +1 2025-01-01T02:00+01:00{Europe/Vienna}] [clawdbot] second", + "[WhatsApp +1 2025-01-01T01:00Z] [clawdbot] second", ); expect(secondArgs.Body).not.toContain("first"); @@ -1048,6 +1102,57 @@ describe("web auto-reply", () => { resetLoadConfigMock(); }); + it("blocks group messages when whatsapp groups is set without a wildcard", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + + setLoadConfigMock(() => ({ + whatsapp: { + allowFrom: ["*"], + groups: { "999@g.us": { requireMention: false } }, + }, + routing: { groupChat: { mentionPatterns: ["@clawd"] } }, + })); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "@clawd hello", + from: "123@g.us", + conversationId: "123@g.us", + chatId: "123@g.us", + chatType: "group", + to: "+2", + id: "g-allowlist-block", + senderE164: "+111", + senderName: "Alice", + mentionedJids: ["999@s.whatsapp.net"], + selfE164: "+999", + selfJid: "999@s.whatsapp.net", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).not.toHaveBeenCalled(); + resetLoadConfigMock(); + }); + it("honors per-group mention overrides when conversationId uses session key", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); @@ -1350,7 +1455,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: "[same-phone]", responsePrefix: undefined, - timestampPrefix: false, }, })); @@ -1475,7 +1579,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: undefined, responsePrefix: "🦞", - timestampPrefix: false, }, })); @@ -1520,7 +1623,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: undefined, responsePrefix: "🦞", - timestampPrefix: false, }, })); @@ -1565,7 +1667,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: undefined, responsePrefix: "🦞", - timestampPrefix: false, }, })); @@ -1611,7 +1712,6 @@ describe("web auto-reply", () => { messages: { messagePrefix: undefined, responsePrefix: "🦞", - timestampPrefix: false, }, })); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 4600d6682..86d65cfce 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -5,15 +5,26 @@ import { parseActivationCommand, } from "../auto-reply/group-activation.js"; import { + DEFAULT_HEARTBEAT_ACK_MAX_CHARS, HEARTBEAT_PROMPT, stripHeartbeatToken, } from "../auto-reply/heartbeat.js"; +import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js"; +import { + buildMentionRegexes, + normalizeMentionText, +} from "../auto-reply/reply/mentions.js"; import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js"; +import type { TypingController } from "../auto-reply/reply/typing.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import { waitForever } from "../cli/wait.js"; import { loadConfig } from "../config/config.js"; +import { + resolveProviderGroupPolicy, + resolveProviderGroupRequireMention, +} from "../config/group-policy.js"; import { DEFAULT_IDLE_MINUTES, loadSessionStore, @@ -28,6 +39,7 @@ import { emitHeartbeatEvent } from "../infra/heartbeat-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; +import { toLocationContext } from "../providers/location.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { isSelfChatMode, jidToE164, normalizeE164 } from "../utils.js"; import { setActiveWebListener } from "./active-listener.js"; @@ -146,17 +158,7 @@ type MentionConfig = { }; function buildMentionConfig(cfg: ReturnType): MentionConfig { - const gc = cfg.routing?.groupChat; - const mentionRegexes = - gc?.mentionPatterns - ?.map((p) => { - try { - return new RegExp(p, "i"); - } catch { - return null; - } - }) - .filter((r): r is RegExp => Boolean(r)) ?? []; + const mentionRegexes = buildMentionRegexes(cfg); return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom }; } @@ -165,10 +167,8 @@ function isBotMentioned( mentionCfg: MentionConfig, ): boolean { const clean = (text: string) => - text - // Remove zero-width and directionality markers WhatsApp injects around display names - .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") - .toLowerCase(); + // Remove zero-width and directionality markers WhatsApp injects around display names + normalizeMentionText(text); const isSelfChat = isSelfChatMode(msg.selfE164, mentionCfg.allowFrom); @@ -211,9 +211,7 @@ function debugMention( const details = { from: msg.from, body: msg.body, - bodyClean: msg.body - .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") - .toLowerCase(), + bodyClean: normalizeMentionText(msg.body), mentionedJids: msg.mentionedJids ?? null, selfJid: msg.selfJid ?? null, selfE164: msg.selfE164 ?? null, @@ -369,9 +367,13 @@ export async function runWebHeartbeatOnce(opts: { const hasMedia = Boolean( replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0, ); + const ackMaxChars = Math.max( + 0, + cfg.agent?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, + ); const stripped = stripHeartbeatToken(replyPayload.text, { mode: "heartbeat", - maxAckChars: 30, + maxAckChars: ackMaxChars, }); if (stripped.shouldSkip && !hasMedia) { // Don't let heartbeats keep sessions alive: restore previous updatedAt so idle expiry still works. @@ -854,16 +856,24 @@ export async function monitorWebProvider( Surface: "whatsapp", }); + const resolveGroupPolicyFor = (conversationId: string) => { + const groupId = + resolveGroupResolution(conversationId)?.id ?? conversationId; + return resolveProviderGroupPolicy({ + cfg, + surface: "whatsapp", + groupId, + }); + }; + const resolveGroupRequireMentionFor = (conversationId: string) => { const groupId = resolveGroupResolution(conversationId)?.id ?? conversationId; - const groupConfig = cfg.whatsapp?.groups?.[groupId]; - if (typeof groupConfig?.requireMention === "boolean") { - return groupConfig.requireMention; - } - const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention; - if (typeof groupDefault === "boolean") return groupDefault; - return true; + return resolveProviderGroupRequireMention({ + cfg, + surface: "whatsapp", + groupId, + }); }; const resolveGroupActivationFor = (conversationId: string) => { @@ -1118,6 +1128,7 @@ export async function monitorWebProvider( const textLimit = resolveTextChunkLimit(cfg, "whatsapp"); let didLogHeartbeatStrip = false; let didSendReply = false; + let typingController: TypingController | undefined; const dispatcher = createReplyDispatcher({ responsePrefix: cfg.messages?.responsePrefix, onHeartbeatStrip: () => { @@ -1168,6 +1179,9 @@ export async function monitorWebProvider( } } }, + onIdle: () => { + typingController?.markDispatchIdle(); + }, onError: (err, info) => { const label = info.kind === "tool" @@ -1181,8 +1195,8 @@ export async function monitorWebProvider( }, }); - const replyResult = await (replyResolver ?? getReplyFromConfig)( - { + const { queuedFinal } = await dispatchReplyFromConfig({ + ctx: { Body: combinedBody, From: msg.from, To: msg.to, @@ -1203,30 +1217,20 @@ export async function monitorWebProvider( SenderName: msg.senderName, SenderE164: msg.senderE164, WasMentioned: msg.wasMentioned, + ...(msg.location ? toLocationContext(msg.location) : {}), Surface: "whatsapp", }, - { + cfg, + dispatcher, + replyResolver, + replyOptions: { onReplyStart: msg.sendComposing, - onToolResult: (payload) => { - dispatcher.sendToolResult(payload); - }, - onBlockReply: (payload) => { - dispatcher.sendBlockReply(payload); + onTypingController: (typing) => { + typingController = typing; }, }, - ); - - const replyList = replyResult - ? Array.isArray(replyResult) - ? replyResult - : [replyResult] - : []; - - let queuedFinal = false; - for (const replyPayload of replyList) { - queuedFinal = dispatcher.sendFinalReply(replyPayload) || queuedFinal; - } - await dispatcher.waitForIdle(); + }); + typingController?.markDispatchIdle(); if (!queuedFinal) { if (shouldClearGroupHistory && didSendReply) { groupHistories.set(conversationId, []); @@ -1271,6 +1275,13 @@ export async function monitorWebProvider( } if (msg.chatType === "group") { + const groupPolicy = resolveGroupPolicyFor(conversationId); + if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) { + logVerbose( + `Skipping group message ${conversationId} (not in allowlist)`, + ); + return; + } noteGroupMember(conversationId, msg.senderE164, msg.senderName); const commandBody = stripMentionsForCommand(msg.body, msg.selfE164); const activationCommand = parseActivationCommand(commandBody); diff --git a/src/web/inbound.media.test.ts b/src/web/inbound.media.test.ts index a1cfb9a6b..fb4fd78ec 100644 --- a/src/web/inbound.media.test.ts +++ b/src/web/inbound.media.test.ts @@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }), }; diff --git a/src/web/inbound.test.ts b/src/web/inbound.test.ts index 161b0d62b..6efcfa9e0 100644 --- a/src/web/inbound.test.ts +++ b/src/web/inbound.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { extractMediaPlaceholder, extractText } from "./inbound.js"; +import { + extractLocationData, + extractMediaPlaceholder, + extractText, +} from "./inbound.js"; describe("web inbound helpers", () => { it("prefers the main conversation body", () => { @@ -45,4 +49,46 @@ describe("web inbound helpers", () => { } as unknown as import("@whiskeysockets/baileys").proto.IMessage), ).toBe(""); }); + + it("extracts WhatsApp location messages", () => { + const location = extractLocationData({ + locationMessage: { + degreesLatitude: 48.858844, + degreesLongitude: 2.294351, + name: "Eiffel Tower", + address: "Champ de Mars, Paris", + accuracyInMeters: 12, + comment: "Meet here", + }, + } as unknown as import("@whiskeysockets/baileys").proto.IMessage); + expect(location).toEqual({ + latitude: 48.858844, + longitude: 2.294351, + accuracy: 12, + name: "Eiffel Tower", + address: "Champ de Mars, Paris", + caption: "Meet here", + source: "place", + isLive: false, + }); + }); + + it("extracts WhatsApp live location messages", () => { + const location = extractLocationData({ + liveLocationMessage: { + degreesLatitude: 37.819929, + degreesLongitude: -122.478255, + accuracyInMeters: 20, + caption: "On the move", + }, + } as unknown as import("@whiskeysockets/baileys").proto.IMessage); + expect(location).toEqual({ + latitude: 37.819929, + longitude: -122.478255, + accuracy: 20, + caption: "On the move", + source: "live", + isLive: true, + }); + }); }); diff --git a/src/web/inbound.ts b/src/web/inbound.ts index fe147e2fe..d6aff8390 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -16,6 +16,10 @@ import { loadConfig } from "../config/config.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; import { saveMediaBuffer } from "../media/store.js"; +import { + formatLocationText, + type NormalizedLocation, +} from "../providers/location.js"; import { isSelfChatMode, jidToE164, @@ -56,6 +60,7 @@ export type WebInboundMessage = { mentionedJids?: string[]; selfJid?: string | null; selfE164?: string | null; + location?: NormalizedLocation; sendComposing: () => Promise; reply: (text: string) => Promise; sendMedia: (payload: AnyMessageContent) => Promise; @@ -144,10 +149,14 @@ export async function monitorWebInbox(options: { continue; const group = isJidGroup(remoteJid); const participantJid = msg.key?.participant ?? undefined; - const senderE164 = participantJid ? jidToE164(participantJid) : null; const from = group ? remoteJid : jidToE164(remoteJid); // Skip if we still can't resolve an id to key conversation if (!from) continue; + const senderE164 = group + ? participantJid + ? jidToE164(participantJid) + : null + : from; let groupSubject: string | undefined; let groupParticipants: string[] | undefined; if (group) { @@ -172,16 +181,44 @@ export async function monitorWebInbox(options: { const isSamePhone = from === selfE164; const isSelfChat = isSelfChatMode(selfE164, configuredAllowFrom); + // Pre-compute normalized allowlist for filtering (used by both group and DM checks) + const hasWildcard = allowFrom?.includes("*") ?? false; + const normalizedAllowFrom = + allowFrom && allowFrom.length > 0 ? allowFrom.map(normalizeE164) : []; + + // Group policy filtering: controls how group messages are handled + // - "open" (default): groups bypass allowFrom, only mention-gating applies + // - "disabled": block all group messages entirely + // - "allowlist": only allow group messages from senders in allowFrom + const groupPolicy = cfg.whatsapp?.groupPolicy ?? "open"; + if (group && groupPolicy === "disabled") { + logVerbose(`Blocked group message (groupPolicy: disabled)`); + continue; + } + if (group && groupPolicy === "allowlist") { + // For allowlist mode, the sender (participant) must be in allowFrom + // If we can't resolve the sender E164, block the message for safety + const senderAllowed = + hasWildcard || + (senderE164 != null && normalizedAllowFrom.includes(senderE164)); + if (!senderAllowed) { + logVerbose( + `Blocked group message from ${senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`, + ); + continue; + } + } + + // DM allowlist filtering (unchanged behavior) const allowlistEnabled = !group && Array.isArray(allowFrom) && allowFrom.length > 0; if (!isSamePhone && allowlistEnabled) { const candidate = from; - const allowedList = allowFrom.map(normalizeE164); - if (!allowFrom.includes("*") && !allowedList.includes(candidate)) { + if (!hasWildcard && !normalizedAllowFrom.includes(candidate)) { logVerbose( `Blocked unauthorized sender ${candidate} (not in allowFrom list)`, ); - continue; // Skip processing entirely + continue; } } @@ -209,7 +246,12 @@ export async function monitorWebInbox(options: { // but we skip triggering the auto-reply logic to avoid spamming old context. if (upsert.type === "append") continue; + const location = extractLocationData(msg.message ?? undefined); + const locationText = location ? formatLocationText(location) : undefined; let body = extractText(msg.message ?? undefined); + if (locationText) { + body = [body, locationText].filter(Boolean).join("\n").trim(); + } if (!body) { body = extractMediaPlaceholder(msg.message ?? undefined); if (!body) continue; @@ -287,6 +329,7 @@ export async function monitorWebInbox(options: { mentionedJids: mentionedJids ?? undefined, selfJid, selfE164, + location: location ?? undefined, sendComposing, reply, sendMedia, @@ -424,6 +467,24 @@ export async function monitorWebInbox(options: { const result = await sock.sendMessage(jid, payload); return { messageId: result?.key?.id ?? "unknown" }; }, + /** + * Send a poll message through this connection's socket. + * Used by IPC to create WhatsApp polls in groups or chats. + */ + sendPoll: async ( + to: string, + poll: { question: string; options: string[]; maxSelections?: number }, + ): Promise<{ messageId: string }> => { + const jid = toWhatsappJid(to); + const result = await sock.sendMessage(jid, { + poll: { + name: poll.question, + values: poll.options, + selectableCount: poll.maxSelections ?? 1, + }, + }); + return { messageId: result?.key?.id ?? "unknown" }; + }, /** * Send typing indicator ("composing") to a chat. * Used after IPC send to show more messages are coming. @@ -548,6 +609,62 @@ export function extractMediaPlaceholder( return undefined; } +export function extractLocationData( + rawMessage: proto.IMessage | undefined, +): NormalizedLocation | null { + const message = unwrapMessage(rawMessage); + if (!message) return null; + + const live = message.liveLocationMessage ?? undefined; + if (live) { + const latitudeRaw = live.degreesLatitude; + const longitudeRaw = live.degreesLongitude; + if (latitudeRaw != null && longitudeRaw != null) { + const latitude = Number(latitudeRaw); + const longitude = Number(longitudeRaw); + if (Number.isFinite(latitude) && Number.isFinite(longitude)) { + return { + latitude, + longitude, + accuracy: live.accuracyInMeters ?? undefined, + caption: live.caption ?? undefined, + source: "live", + isLive: true, + }; + } + } + } + + const location = message.locationMessage ?? undefined; + if (location) { + const latitudeRaw = location.degreesLatitude; + const longitudeRaw = location.degreesLongitude; + if (latitudeRaw != null && longitudeRaw != null) { + const latitude = Number(latitudeRaw); + const longitude = Number(longitudeRaw); + if (Number.isFinite(latitude) && Number.isFinite(longitude)) { + const isLive = Boolean(location.isLive); + return { + latitude, + longitude, + accuracy: location.accuracyInMeters ?? undefined, + name: location.name ?? undefined, + address: location.address ?? undefined, + caption: location.comment ?? undefined, + source: isLive + ? "live" + : location.name || location.address + ? "place" + : "pin", + isLive, + }; + } + } + } + + return null; +} + function describeReplyContext(rawMessage: proto.IMessage | undefined): { id?: string; body: string; @@ -560,7 +677,14 @@ function describeReplyContext(rawMessage: proto.IMessage | undefined): { contextInfo?.quotedMessage as proto.IMessage | undefined, ) as proto.IMessage | undefined; if (!quoted) return null; - const body = extractText(quoted) ?? extractMediaPlaceholder(quoted); + const location = extractLocationData(quoted); + const locationText = location ? formatLocationText(location) : undefined; + const text = extractText(quoted); + let body: string | undefined = [text, locationText] + .filter(Boolean) + .join("\n") + .trim(); + if (!body) body = extractMediaPlaceholder(quoted); if (!body) { const quotedType = quoted ? getContentType(quoted) : undefined; logVerbose( diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 8cf90da8b..02377a46b 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -76,6 +76,34 @@ describe("web media loading", () => { fetchMock.mockRestore(); }); + it("uses content-disposition filename when available", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + body: true, + arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, + headers: { + get: (name: string) => { + if (name === "content-disposition") { + return 'attachment; filename="report.pdf"'; + } + if (name === "content-type") return "application/pdf"; + return null; + }, + }, + status: 200, + } as Response); + + const result = await loadWebMedia( + "https://example.com/download?id=1", + 1024 * 1024, + ); + + expect(result.kind).toBe("document"); + expect(result.fileName).toBe("report.pdf"); + + fetchMock.mockRestore(); + }); + it("preserves GIF animation by skipping JPEG optimization", async () => { // Create a minimal valid GIF (1x1 pixel) // GIF89a header + minimal image data diff --git a/src/web/media.ts b/src/web/media.ts index 97743f3d0..e1ba089e6 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -22,6 +22,29 @@ type WebMediaOptions = { optimizeImages?: boolean; }; +function stripQuotes(value: string): string { + return value.replace(/^["']|["']$/g, ""); +} + +function parseContentDispositionFileName( + header?: string | null, +): string | undefined { + if (!header) return undefined; + const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header); + if (starMatch?.[1]) { + const cleaned = stripQuotes(starMatch[1].trim()); + const encoded = cleaned.split("''").slice(1).join("''") || cleaned; + try { + return path.basename(decodeURIComponent(encoded)); + } catch { + return path.basename(encoded); + } + } + const match = /filename\s*=\s*([^;]+)/i.exec(header); + if (match?.[1]) return path.basename(stripQuotes(match[1].trim())); + return undefined; +} + async function loadWebMediaInternal( mediaUrl: string, options: WebMediaOptions = {}, @@ -54,11 +77,11 @@ async function loadWebMediaInternal( }; if (/^https?:\/\//i.test(mediaUrl)) { - let fileName: string | undefined; + let fileNameFromUrl: string | undefined; try { const url = new URL(mediaUrl); const base = path.basename(url.pathname); - fileName = base || undefined; + fileNameFromUrl = base || undefined; } catch { // ignore parse errors; leave undefined } @@ -67,10 +90,18 @@ async function loadWebMediaInternal( throw new Error(`Failed to fetch media: HTTP ${res.status}`); } const array = Buffer.from(await res.arrayBuffer()); + const headerFileName = parseContentDispositionFileName( + res.headers.get("content-disposition"), + ); + let fileName = headerFileName || fileNameFromUrl || undefined; + const filePathForMime = + headerFileName && path.extname(headerFileName) + ? headerFileName + : mediaUrl; const contentType = await detectMime({ buffer: array, headerMime: res.headers.get("content-type"), - filePath: mediaUrl, + filePath: filePathForMime, }); if (fileName && !path.extname(fileName) && contentType) { const ext = extensionForMime(contentType); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index ab8aa4525..02af9d057 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -16,7 +16,6 @@ const mockLoadConfig = vi.fn().mockReturnValue({ messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -480,7 +479,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -536,7 +534,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -576,7 +573,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -592,7 +588,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -628,7 +623,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -643,7 +637,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -677,6 +670,175 @@ describe("web monitor inbox", () => { await listener.close(); }); + it("blocks all group messages when groupPolicy is 'disabled'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["+1234"], + groupPolicy: "disabled", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-disabled", + fromMe: false, + remoteJid: "11111@g.us", + participant: "999@s.whatsapp.net", + }, + message: { conversation: "group message should be blocked" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should NOT call onMessage because groupPolicy is disabled + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("blocks group messages from senders not in allowFrom when groupPolicy is 'allowlist'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["+1234"], // Does not include +999 + groupPolicy: "allowlist", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-allowlist-blocked", + fromMe: false, + remoteJid: "11111@g.us", + participant: "999@s.whatsapp.net", + }, + message: { conversation: "unauthorized group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should NOT call onMessage because sender +999 not in allowFrom + expect(onMessage).not.toHaveBeenCalled(); + + await listener.close(); + }); + + it("allows group messages from senders in allowFrom when groupPolicy is 'allowlist'", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["+15551234567"], // Includes the sender + groupPolicy: "allowlist", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-allowlist-allowed", + fromMe: false, + remoteJid: "11111@g.us", + participant: "15551234567@s.whatsapp.net", + }, + message: { conversation: "authorized group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should call onMessage because sender is in allowFrom + expect(onMessage).toHaveBeenCalledTimes(1); + const payload = onMessage.mock.calls[0][0]; + expect(payload.chatType).toBe("group"); + expect(payload.senderE164).toBe("+15551234567"); + + await listener.close(); + }); + + it("allows all group senders with wildcard in groupPolicy allowlist", async () => { + mockLoadConfig.mockReturnValue({ + whatsapp: { + allowFrom: ["*"], // Wildcard allows everyone + groupPolicy: "allowlist", + }, + messages: { + messagePrefix: undefined, + responsePrefix: undefined, + timestampPrefix: false, + }, + }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = await createWaSocket(); + + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp-wildcard-test", + fromMe: false, + remoteJid: "22222@g.us", + participant: "9999999999@s.whatsapp.net", // Random sender + }, + message: { conversation: "wildcard group sender" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + // Should call onMessage because wildcard allows all senders + expect(onMessage).toHaveBeenCalledTimes(1); + const payload = onMessage.mock.calls[0][0]; + expect(payload.chatType).toBe("group"); + + await listener.close(); + }); + it("allows messages from senders in allowFrom list", async () => { mockLoadConfig.mockReturnValue({ whatsapp: { @@ -685,7 +847,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -709,7 +870,11 @@ describe("web monitor inbox", () => { // Should call onMessage for authorized senders expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ body: "authorized message", from: "+999" }), + expect.objectContaining({ + body: "authorized message", + from: "+999", + senderE164: "+999", + }), ); // Reset mock for other tests @@ -720,7 +885,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -737,7 +901,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -773,7 +936,6 @@ describe("web monitor inbox", () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); @@ -840,7 +1002,6 @@ it("defaults to self-only when no config is present", async () => { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }); diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index d36a51f66..e7c3a2ba1 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -8,15 +8,16 @@ vi.mock("./media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), })); -import { sendMessageWhatsApp } from "./outbound.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "./outbound.js"; describe("web outbound", () => { const sendComposingTo = vi.fn(async () => {}); const sendMessage = vi.fn(async () => ({ messageId: "msg123" })); + const sendPoll = vi.fn(async () => ({ messageId: "poll123" })); beforeEach(() => { vi.clearAllMocks(); - setActiveWebListener({ sendComposingTo, sendMessage }); + setActiveWebListener({ sendComposingTo, sendMessage, sendPoll }); }); afterEach(() => { @@ -137,4 +138,22 @@ describe("web outbound", () => { "application/pdf", ); }); + + it("sends polls via active listener", async () => { + const result = await sendPollWhatsApp( + "+1555", + { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 }, + { verbose: false }, + ); + expect(result).toEqual({ + messageId: "poll123", + toJid: "1555@s.whatsapp.net", + }); + expect(sendPoll).toHaveBeenCalledWith("+1555", { + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 2, + durationHours: undefined, + }); + }); }); diff --git a/src/web/outbound.ts b/src/web/outbound.ts index a8c3c076b..5d4b3dfdc 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { createSubsystemLogger, getChildLogger } from "../logging.js"; +import { normalizePollInput, type PollInput } from "../polls.js"; import { toWhatsappJid } from "../utils.js"; import { type ActiveWebSendOptions, @@ -85,3 +86,49 @@ export async function sendMessageWhatsApp( throw err; } } +export async function sendPollWhatsApp( + to: string, + poll: PollInput, + _options: { verbose: boolean }, +): Promise<{ messageId: string; toJid: string }> { + const correlationId = randomUUID(); + const startedAt = Date.now(); + const active = getActiveWebListener(); + if (!active) { + throw new Error( + "No active gateway listener. Start the gateway before sending WhatsApp polls.", + ); + } + const logger = getChildLogger({ + module: "web-outbound", + correlationId, + to, + }); + try { + const jid = toWhatsappJid(to); + const normalized = normalizePollInput(poll, { maxOptions: 12 }); + outboundLog.info(`Sending poll -> ${jid}: "${normalized.question}"`); + logger.info( + { + jid, + question: normalized.question, + optionCount: normalized.options.length, + maxSelections: normalized.maxSelections, + }, + "sending poll", + ); + const result = await active.sendPoll(to, normalized); + const messageId = + (result as { messageId?: string })?.messageId ?? "unknown"; + const durationMs = Date.now() - startedAt; + outboundLog.info(`Sent poll ${messageId} -> ${jid} (${durationMs}ms)`); + logger.info({ jid, messageId }, "sent poll"); + return { messageId, toJid: jid }; + } catch (err) { + logger.error( + { err: String(err), to, question: poll.question }, + "failed to send poll via web session", + ); + throw err; + } +} diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts index 968d7649c..e1c1597aa 100644 --- a/src/web/test-helpers.ts +++ b/src/web/test-helpers.ts @@ -13,7 +13,6 @@ const DEFAULT_CONFIG = { messages: { messagePrefix: undefined, responsePrefix: undefined, - timestampPrefix: false, }, }; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 576a5a466..724dfec01 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -2,17 +2,28 @@ import path from "node:path"; import { loginAnthropic, + loginOpenAICodex, type OAuthCredentials, type OAuthProvider, } from "@mariozechner/pi-ai"; -import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; -import { resolveClawdbotAgentDir } from "../agents/agent-paths.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, +} from "../agents/auth-profiles.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../agents/model-auth.js"; +import { loadModelCatalog } from "../agents/model-catalog.js"; +import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { isRemoteEnvironment, loginAntigravityVpsAware, } from "../commands/antigravity-oauth.js"; import { healthCommand } from "../commands/health.js"; import { + applyAuthProfileConfig, applyMinimaxConfig, setAnthropicApiKey, writeOAuthCredentials, @@ -49,6 +60,7 @@ import { resolveGatewayPort, writeConfigFile, } from "../config/config.js"; +import type { AgentModelListConfig } from "../config/types.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; @@ -58,6 +70,96 @@ import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; import type { WizardPrompter } from "./prompts.js"; +const OPENAI_CODEX_DEFAULT_MODEL = "openai-codex/gpt-5.2"; + +function shouldSetOpenAICodexModel(model?: string): boolean { + const trimmed = model?.trim(); + if (!trimmed) return true; + const normalized = trimmed.toLowerCase(); + if (normalized.startsWith("openai-codex/")) return false; + if (normalized.startsWith("openai/")) return true; + return normalized === "gpt" || normalized === "gpt-mini"; +} + +function resolvePrimaryModel( + model?: AgentModelListConfig | string, +): string | undefined { + if (typeof model === "string") return model; + if (model && typeof model === "object" && typeof model.primary === "string") { + return model.primary; + } + return undefined; +} + +function applyOpenAICodexModelDefault(cfg: ClawdbotConfig): { + next: ClawdbotConfig; + changed: boolean; +} { + const current = resolvePrimaryModel(cfg.agent?.model); + if (!shouldSetOpenAICodexModel(current)) { + return { next: cfg, changed: false }; + } + return { + next: { + ...cfg, + agent: { + ...cfg.agent, + model: + cfg.agent?.model && typeof cfg.agent.model === "object" + ? { ...cfg.agent.model, primary: OPENAI_CODEX_DEFAULT_MODEL } + : { primary: OPENAI_CODEX_DEFAULT_MODEL }, + }, + }, + changed: true, + }; +} + +async function warnIfModelConfigLooksOff( + config: ClawdbotConfig, + prompter: WizardPrompter, +) { + const ref = resolveConfiguredModelRef({ + cfg: config, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const warnings: string[] = []; + const catalog = await loadModelCatalog({ config, useCache: false }); + if (catalog.length > 0) { + const known = catalog.some( + (entry) => entry.provider === ref.provider && entry.id === ref.model, + ); + if (!known) { + warnings.push( + `Model not found: ${ref.provider}/${ref.model}. Update agent.model or run /models list.`, + ); + } + } + + const store = ensureAuthProfileStore(); + const hasProfile = listProfilesForProvider(store, ref.provider).length > 0; + const envKey = resolveEnvApiKey(ref.provider); + const customKey = getCustomProviderApiKey(config, ref.provider); + if (!hasProfile && !envKey && !customKey) { + warnings.push( + `No auth configured for provider "${ref.provider}". The agent may fail until credentials are added.`, + ); + } + + if (ref.provider === "openai") { + const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0; + if (hasCodex) { + warnings.push( + `Detected OpenAI Codex OAuth. Consider setting agent.model to ${OPENAI_CODEX_DEFAULT_MODEL}.`, + ); + } + } + + if (warnings.length > 0) { + await prompter.note(warnings.join("\n"), "Model check"); + } +} + export async function runOnboardingWizard( opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime, @@ -227,6 +329,11 @@ export async function runOnboardingWizard( spin.stop("OAuth complete"); if (oauthCreds) { await writeOAuthCredentials("anthropic", oauthCreds); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "oauth", + }); } } catch (err) { spin.stop("OAuth failed"); @@ -249,15 +356,19 @@ export async function runOnboardingWizard( "OpenAI Codex OAuth", ); const spin = prompter.progress("Starting OAuth flow…"); + let manualCodePromise: Promise | undefined; try { - const agentDir = resolveClawdbotAgentDir(); - const authStorage = discoverAuthStorage(agentDir); - const provider = "openai-codex" as unknown as OAuthProvider; - await authStorage.login(provider, { + const creds = await loginOpenAICodex({ onAuth: async ({ url }) => { if (isRemote) { spin.stop("OAuth URL ready"); runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + manualCodePromise = prompter + .text({ + message: "Paste the redirect URL (or authorization code)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }) + .then((value) => String(value)); } else { spin.update("Complete sign-in in browser…"); await openUrl(url); @@ -265,6 +376,9 @@ export async function runOnboardingWizard( } }, onPrompt: async (prompt) => { + if (manualCodePromise) { + return manualCodePromise; + } const code = await prompter.text({ message: prompt.message, placeholder: prompt.placeholder, @@ -272,9 +386,42 @@ export async function runOnboardingWizard( }); return String(code); }, + onManualCodeInput: isRemote + ? () => { + if (!manualCodePromise) { + manualCodePromise = prompter + .text({ + message: "Paste the redirect URL (or authorization code)", + validate: (value) => + value?.trim() ? undefined : "Required", + }) + .then((value) => String(value)); + } + return manualCodePromise; + } + : undefined, onProgress: (msg) => spin.update(msg), }); spin.stop("OpenAI OAuth complete"); + if (creds) { + await writeOAuthCredentials( + "openai-codex" as unknown as OAuthProvider, + creds, + ); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "openai-codex:default", + provider: "openai-codex", + mode: "oauth", + }); + const applied = applyOpenAICodexModelDefault(nextConfig); + nextConfig = applied.next; + if (applied.changed) { + await prompter.note( + `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, + "Model configured", + ); + } + } } catch (err) { spin.stop("OpenAI OAuth failed"); runtime.error(String(err)); @@ -314,11 +461,33 @@ export async function runOnboardingWizard( spin.stop("Antigravity OAuth complete"); if (oauthCreds) { await writeOAuthCredentials("google-antigravity", oauthCreds); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "google-antigravity:default", + provider: "google-antigravity", + mode: "oauth", + }); nextConfig = { ...nextConfig, agent: { ...nextConfig.agent, - model: "google-antigravity/claude-opus-4-5-thinking", + model: { + ...(nextConfig.agent?.model && + "fallbacks" in (nextConfig.agent.model as Record) + ? { + fallbacks: ( + nextConfig.agent.model as { fallbacks?: string[] } + ).fallbacks, + } + : undefined), + primary: "google-antigravity/claude-opus-4-5-thinking", + }, + models: { + ...nextConfig.agent?.models, + "google-antigravity/claude-opus-4-5-thinking": + nextConfig.agent?.models?.[ + "google-antigravity/claude-opus-4-5-thinking" + ] ?? {}, + }, }, }; await prompter.note( @@ -336,10 +505,17 @@ export async function runOnboardingWizard( validate: (value) => (value?.trim() ? undefined : "Required"), }); await setAnthropicApiKey(String(key).trim()); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "anthropic:default", + provider: "anthropic", + mode: "api_key", + }); } else if (authChoice === "minimax") { nextConfig = applyMinimaxConfig(nextConfig); } + await warnIfModelConfigLooksOff(nextConfig, prompter); + const portRaw = await prompter.text({ message: "Gateway port", initialValue: String(localPort), @@ -489,6 +665,17 @@ export async function runOnboardingWizard( nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); + await ensureSystemdUserLingerInteractive({ + runtime, + prompter: { + confirm: prompter.confirm, + note: prompter.note, + }, + reason: + "Linux installs use a systemd user service by default. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.", + requireConfirm: false, + }); + const installDaemon = await prompter.confirm({ message: "Install Gateway daemon (recommended)", initialValue: true, @@ -538,17 +725,6 @@ export async function runOnboardingWizard( environment, }); } - - await ensureSystemdUserLingerInteractive({ - runtime, - prompter: { - confirm: prompter.confirm, - note: prompter.note, - }, - reason: - "Linux installs use a systemd user service. Without lingering, systemd stops the user session on logout/idle and kills the Gateway.", - requireConfirm: true, - }); } await sleep(1500); diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 9ade22641..2236a21b7 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -1,7 +1,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { renderConfigForm } from "./views/config-form"; +import { analyzeConfigSchema, renderConfigForm } from "./views/config-form"; const rootSchema = { type: "object", @@ -28,6 +28,14 @@ const rootSchema = { enabled: { type: "boolean", }, + bind: { + anyOf: [ + { const: "auto" }, + { const: "lan" }, + { const: "tailnet" }, + { const: "loopback" }, + ], + }, }, }; @@ -35,12 +43,14 @@ describe("config form renderer", () => { it("renders inputs and patches values", () => { const onPatch = vi.fn(); const container = document.createElement("div"); + const analysis = analyzeConfigSchema(rootSchema); render( renderConfigForm({ - schema: rootSchema, + schema: analysis.schema, uiHints: { "gateway.auth.token": { label: "Gateway Token", sensitive: true }, }, + unsupportedPaths: analysis.unsupportedPaths, value: {}, onPatch, }), @@ -62,7 +72,7 @@ describe("config form renderer", () => { const select = container.querySelector("select") as HTMLSelectElement | null; expect(select).not.toBeNull(); if (!select) return; - select.value = "token"; + select.value = "1"; select.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["mode"], "token"); @@ -79,10 +89,12 @@ describe("config form renderer", () => { it("adds and removes array entries", () => { const onPatch = vi.fn(); const container = document.createElement("div"); + const analysis = analyzeConfigSchema(rootSchema); render( renderConfigForm({ - schema: rootSchema, + schema: analysis.schema, uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, value: { allowFrom: ["+1"] }, onPatch, }), @@ -103,4 +115,102 @@ describe("config form renderer", () => { removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []); }); + + it("renders union literals as select options", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const analysis = analyzeConfigSchema(rootSchema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { bind: "auto" }, + onPatch, + }), + container, + ); + + const selects = Array.from(container.querySelectorAll("select")); + const bindSelect = selects.find((el) => + Array.from(el.options).some((opt) => opt.value === "tailnet"), + ) as HTMLSelectElement | undefined; + expect(bindSelect).not.toBeUndefined(); + if (!bindSelect) return; + bindSelect.value = "tailnet"; + bindSelect.dispatchEvent(new Event("change", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet"); + }); + + it("renders map fields from additionalProperties", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + slack: { + type: "object", + additionalProperties: { + type: "string", + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: {}, + unsupportedPaths: analysis.unsupportedPaths, + value: { slack: { channelA: "ok" } }, + onPatch, + }), + container, + ); + + const removeButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Remove", + ); + expect(removeButton).not.toBeUndefined(); + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPatch).toHaveBeenCalledWith(["slack"], {}); + }); + + it("flags unsupported unions", () => { + const schema = { + type: "object", + properties: { + mixed: { + anyOf: [{ type: "string" }, { type: "number" }], + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + expect(analysis.unsupportedPaths).toContain("mixed"); + }); + + it("supports nullable types", () => { + const schema = { + type: "object", + properties: { + note: { type: ["string", "null"] }, + }, + }; + const analysis = analyzeConfigSchema(schema); + expect(analysis.unsupportedPaths).not.toContain("note"); + }); + + it("flags additionalProperties true", () => { + const schema = { + type: "object", + properties: { + extra: { + type: "object", + additionalProperties: true, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + expect(analysis.unsupportedPaths).toContain("extra"); + }); }); diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts new file mode 100644 index 000000000..87fda4ce1 --- /dev/null +++ b/ui/src/ui/controllers/config.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; + +import { + applyConfigSnapshot, + updateConfigFormValue, + type ConfigState, +} from "./config"; +import { + defaultDiscordActions, + defaultSlackActions, + type DiscordForm, + type IMessageForm, + type SignalForm, + type SlackForm, + type TelegramForm, +} from "../ui-types"; + +const baseTelegramForm: TelegramForm = { + token: "", + requireMention: true, + allowFrom: "", + proxy: "", + webhookUrl: "", + webhookSecret: "", + webhookPath: "", +}; + +const baseDiscordForm: DiscordForm = { + enabled: true, + token: "", + dmEnabled: true, + allowFrom: "", + groupEnabled: false, + groupChannels: "", + mediaMaxMb: "", + historyLimit: "", + textChunkLimit: "", + replyToMode: "off", + guilds: [], + actions: { ...defaultDiscordActions }, + slashEnabled: false, + slashName: "", + slashSessionPrefix: "", + slashEphemeral: true, +}; + +const baseSlackForm: SlackForm = { + enabled: true, + botToken: "", + appToken: "", + dmEnabled: true, + allowFrom: "", + groupEnabled: false, + groupChannels: "", + mediaMaxMb: "", + textChunkLimit: "", + reactionNotifications: "own", + reactionAllowlist: "", + slashEnabled: false, + slashName: "", + slashSessionPrefix: "", + slashEphemeral: true, + actions: { ...defaultSlackActions }, + channels: [], +}; + +const baseSignalForm: SignalForm = { + enabled: true, + account: "", + httpUrl: "", + httpHost: "", + httpPort: "", + cliPath: "", + autoStart: true, + receiveMode: "", + ignoreAttachments: false, + ignoreStories: false, + sendReadReceipts: false, + allowFrom: "", + mediaMaxMb: "", +}; + +const baseIMessageForm: IMessageForm = { + enabled: true, + cliPath: "", + dbPath: "", + service: "auto", + region: "", + allowFrom: "", + includeAttachments: false, + mediaMaxMb: "", +}; + +function createState(): ConfigState { + return { + client: null, + connected: false, + configLoading: false, + configRaw: "", + configValid: null, + configIssues: [], + configSaving: false, + configSnapshot: null, + configSchema: null, + configSchemaVersion: null, + configSchemaLoading: false, + configUiHints: {}, + configForm: null, + configFormDirty: false, + configFormMode: "form", + lastError: null, + telegramForm: { ...baseTelegramForm }, + discordForm: { ...baseDiscordForm }, + slackForm: { ...baseSlackForm }, + signalForm: { ...baseSignalForm }, + imessageForm: { ...baseIMessageForm }, + telegramConfigStatus: null, + discordConfigStatus: null, + slackConfigStatus: null, + signalConfigStatus: null, + imessageConfigStatus: null, + }; +} + +describe("applyConfigSnapshot", () => { + it("handles missing slack config without throwing", () => { + const state = createState(); + applyConfigSnapshot(state, { + config: { + telegram: {}, + discord: {}, + signal: {}, + imessage: {}, + }, + valid: true, + issues: [], + raw: "{}", + }); + + expect(state.slackForm.botToken).toBe(""); + expect(state.slackForm.actions).toEqual(defaultSlackActions); + }); +}); + +describe("updateConfigFormValue", () => { + it("seeds from snapshot when form is null", () => { + const state = createState(); + state.configSnapshot = { + config: { telegram: { botToken: "t" }, gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: "{}", + }; + + updateConfigFormValue(state, ["gateway", "port"], 18789); + + expect(state.configFormDirty).toBe(true); + expect(state.configForm).toEqual({ + telegram: { botToken: "t" }, + gateway: { mode: "local", port: 18789 }, + }); + }); +}); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index afe4b88b5..5844baad5 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -100,6 +100,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot const config = snapshot.config ?? {}; const telegram = (config.telegram ?? {}) as Record; const discord = (config.discord ?? {}) as Record; + const slack = (config.slack ?? {}) as Record; const signal = (config.signal ?? {}) as Record; const imessage = (config.imessage ?? {}) as Record; const toList = (value: unknown) => @@ -401,7 +402,9 @@ export function updateConfigFormValue( path: Array, value: unknown, ) { - const base = cloneConfigObject(state.configForm ?? {}); + const base = cloneConfigObject( + state.configForm ?? state.configSnapshot?.config ?? {}, + ); setPathValue(base, path, value); state.configForm = base; state.configFormDirty = true; @@ -411,7 +414,9 @@ export function removeConfigFormValue( state: ConfigState, path: Array, ) { - const base = cloneConfigObject(state.configForm ?? {}); + const base = cloneConfigObject( + state.configForm ?? state.configSnapshot?.config ?? {}, + ); removePathValue(base, path); state.configForm = base; state.configFormDirty = true; diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index fa90455d3..f0d24d472 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -73,7 +73,14 @@ export function buildCronPayload(form: CronFormState) { kind: "agentTurn"; message: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; to?: string; timeoutSeconds?: number; } = { kind: "agentTurn", message }; @@ -188,4 +195,3 @@ export async function loadCronRuns(state: CronState, jobId: string) { state.cronError = String(err); } } - diff --git a/ui/src/ui/tool-display.json b/ui/src/ui/tool-display.json index db86e2267..ce83d1520 100644 --- a/ui/src/ui/tool-display.json +++ b/ui/src/ui/tool-display.json @@ -12,7 +12,7 @@ "element", "node", "nodeId", - "jobId", + "id", "requestId", "to", "channelId", @@ -136,10 +136,10 @@ "label": "add", "detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"] }, - "update": { "label": "update", "detailKeys": ["jobId"] }, - "remove": { "label": "remove", "detailKeys": ["jobId"] }, - "run": { "label": "run", "detailKeys": ["jobId"] }, - "runs": { "label": "runs", "detailKeys": ["jobId"] }, + "update": { "label": "update", "detailKeys": ["id"] }, + "remove": { "label": "remove", "detailKeys": ["id"] }, + "run": { "label": "run", "detailKeys": ["id"] }, + "runs": { "label": "runs", "detailKeys": ["id"] }, "wake": { "label": "wake", "detailKeys": ["text", "mode"] } } }, diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index eb1e2ce6f..bd3a002f1 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -271,7 +271,14 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram"; + channel?: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; to?: string; bestEffortDeliver?: boolean; }; @@ -306,7 +313,7 @@ export type CronJob = { export type CronStatus = { enabled: boolean; - jobCount: number; + jobs: number; nextWakeAtMs?: number | null; }; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index dd1a6a84c..90a1372d8 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -162,7 +162,14 @@ export type CronFormState = { payloadKind: "systemEvent" | "agentTurn"; payloadText: string; deliver: boolean; - channel: "last" | "whatsapp" | "telegram"; + channel: + | "last" + | "whatsapp" + | "telegram" + | "discord" + | "slack" + | "signal" + | "imessage"; to: string; timeoutSeconds: string; postToMainPrefix: string; diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts index 29bb0af99..5dbd2aa0c 100644 --- a/ui/src/ui/views/config-form.ts +++ b/ui/src/ui/views/config-form.ts @@ -1,10 +1,12 @@ -import { html, nothing } from "lit"; +import { html, nothing, type TemplateResult } from "lit"; import type { ConfigUiHint, ConfigUiHints } from "../types"; export type ConfigFormProps = { - schema: unknown | null; + schema: JsonSchema | null; uiHints: ConfigUiHints; value: Record | null; + disabled?: boolean; + unsupportedPaths?: string[]; onPatch: (path: Array, value: unknown) => void; }; @@ -14,22 +16,26 @@ type JsonSchema = { description?: string; properties?: Record; items?: JsonSchema | JsonSchema[]; + additionalProperties?: JsonSchema | boolean; enum?: unknown[]; + const?: unknown; default?: unknown; anyOf?: JsonSchema[]; oneOf?: JsonSchema[]; allOf?: JsonSchema[]; + nullable?: boolean; }; export function renderConfigForm(props: ConfigFormProps) { if (!props.schema) { return html`
Schema unavailable.
`; } - const schema = props.schema as JsonSchema; + const schema = props.schema; const value = props.value ?? {}; if (schemaType(schema) !== "object" || !schema.properties) { return html`
Unsupported schema. Use Raw.
`; } + const unsupported = new Set(props.unsupportedPaths ?? []); const entries = Object.entries(schema.properties); const sorted = entries.sort((a, b) => { const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0; @@ -46,6 +52,8 @@ export function renderConfigForm(props: ConfigFormProps) { value: (value as Record)[key], path: [key], hints: props.uiHints, + unsupported, + disabled: props.disabled ?? false, onPatch: props.onPatch, }), )} @@ -58,15 +66,114 @@ function renderNode(params: { value: unknown; path: Array; hints: ConfigUiHints; + unsupported: Set; + disabled: boolean; + showLabel?: boolean; onPatch: (path: Array, value: unknown) => void; -}) { - const { schema, value, path, hints, onPatch } = params; +}): TemplateResult | typeof nothing { + const { schema, value, path, hints, unsupported, disabled, onPatch } = params; + const showLabel = params.showLabel ?? true; const type = schemaType(schema); const hint = hintForPath(path, hints); const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1))); const help = hint?.help ?? schema.description; + const key = pathKey(path); - if (schema.anyOf || schema.oneOf || schema.allOf) { + if (unsupported.has(key)) { + return html`
+ ${label}: unsupported schema node. Use Raw. +
`; + } + + if (schema.anyOf || schema.oneOf) { + const variants = schema.anyOf ?? schema.oneOf ?? []; + const nonNull = variants.filter( + (v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))), + ); + + if (nonNull.length === 1) { + return renderNode({ ...params, schema: nonNull[0] }); + } + + const extractLiteral = (v: JsonSchema): unknown | undefined => { + if (v.const !== undefined) return v.const; + if (v.enum && v.enum.length === 1) return v.enum[0]; + return undefined; + }; + const literals = nonNull.map(extractLiteral); + const allLiterals = literals.every((v) => v !== undefined); + + if (allLiterals && literals.length > 0) { + const currentIndex = literals.findIndex( + (lit) => lit === value || String(lit) === String(value), + ); + return html` + + `; + } + + const primitiveTypes = ["string", "number", "integer", "boolean"]; + const allPrimitive = nonNull.every((v) => v.type && primitiveTypes.includes(String(v.type))); + if (allPrimitive) { + const typeHint = nonNull.map((v) => v.type).join(" | "); + const hasBoolean = nonNull.some((v) => v.type === "boolean"); + const hasNumber = nonNull.some((v) => v.type === "number" || v.type === "integer"); + const isInteger = nonNull.every((v) => v.type !== "number"); + return html` + + `; + } + + return html`
+ ${label}: unsupported schema node. Use Raw. +
`; + } + + if (schema.allOf) { return html`
${label}: unsupported schema node. Use Raw.
`; @@ -75,7 +182,11 @@ function renderNode(params: { if (type === "object") { const props = schema.properties ?? {}; const entries = Object.entries(props); - if (entries.length === 0) return nothing; + const hasMap = + schema.additionalProperties && + typeof schema.additionalProperties === "object"; + if (entries.length === 0 && !hasMap) return nothing; + const reservedKeys = new Set(entries.map(([key]) => key)); return html`
${label} @@ -86,9 +197,23 @@ function renderNode(params: { value: value && typeof value === "object" ? (value as any)[key] : undefined, path: [...path, key], hints, + unsupported, onPatch, + disabled, }), )} + ${hasMap + ? renderMapField({ + schema: schema.additionalProperties as JsonSchema, + value: value && typeof value === "object" ? (value as any) : {}, + path, + hints, + unsupported, + disabled, + reservedKeys, + onPatch, + }) + : nothing}
`; } @@ -101,14 +226,15 @@ function renderNode(params: { return html`
- ${label} -
@@ -121,11 +247,14 @@ function renderNode(params: { value: entry, path: [...path, index], hints, + unsupported, + disabled, onPatch, }) : nothing} +
+ ${entries.length === 0 + ? html`
No entries yet.
` + : entries.map(([key, entryValue]) => { + const valuePath = [...path, key]; + return html`
+ { + const nextKey = (e.target as HTMLInputElement).value.trim(); + if (!nextKey || nextKey === key) return; + const next = { ...(value ?? {}) }; + if (nextKey in next) return; + next[nextKey] = next[key]; + delete next[key]; + onPatch(path, next); + }} + /> +
+ ${renderNode({ + schema, + value: entryValue, + path: valuePath, + hints, + unsupported, + disabled, + showLabel: false, + onPatch, + })} +
+ +
`; + })} + + `; +} + +export type ConfigSchemaAnalysis = { + schema: JsonSchema | null; + unsupportedPaths: string[]; +}; + +export function analyzeConfigSchema(raw: unknown): ConfigSchemaAnalysis { + if (!raw || typeof raw !== "object") { + return { schema: null, unsupportedPaths: [""] }; + } + const result = normalizeSchemaNode(raw as JsonSchema, []); + return result; +} + +function normalizeSchemaNode( + schema: JsonSchema, + path: Array, +): ConfigSchemaAnalysis { + const unsupportedPaths: string[] = []; + const normalized = { ...schema }; + const pathLabel = pathKey(path) || ""; + + if (schema.anyOf || schema.oneOf || schema.allOf) { + const union = normalizeUnion(schema, path); + if (union) return union; + unsupportedPaths.push(pathLabel); + return { schema, unsupportedPaths }; + } + + const nullable = + Array.isArray(schema.type) && schema.type.includes("null"); + const type = + schemaType(schema) ?? + (schema.properties || schema.additionalProperties ? "object" : undefined); + normalized.type = type ?? schema.type; + normalized.nullable = nullable || schema.nullable; + + if (normalized.enum) { + const { enumValues, nullable: enumNullable } = normalizeEnumValues( + normalized.enum, + ); + normalized.enum = enumValues; + if (enumNullable) normalized.nullable = true; + if (enumValues.length === 0) { + unsupportedPaths.push(pathLabel); + } + } + + if (type === "object") { + const props = schema.properties ?? {}; + const normalizedProps: Record = {}; + for (const [key, child] of Object.entries(props)) { + const result = normalizeSchemaNode(child, [...path, key]); + if (result.schema) normalizedProps[key] = result.schema; + unsupportedPaths.push(...result.unsupportedPaths); + } + normalized.properties = normalizedProps; + + if (schema.additionalProperties === true) { + unsupportedPaths.push(pathLabel); + } else if (schema.additionalProperties === false) { + normalized.additionalProperties = false; + } else if (schema.additionalProperties) { + const result = normalizeSchemaNode( + schema.additionalProperties, + [...path, "*"], + ); + normalized.additionalProperties = result.schema ?? schema.additionalProperties; + if (result.unsupportedPaths.length > 0) { + unsupportedPaths.push(pathLabel); + } + } + } else if (type === "array") { + const itemSchema = Array.isArray(schema.items) + ? schema.items[0] + : schema.items; + if (!itemSchema) { + unsupportedPaths.push(pathLabel); + } else { + const result = normalizeSchemaNode(itemSchema, [...path, "*"]); + normalized.items = result.schema ?? itemSchema; + if (result.unsupportedPaths.length > 0) { + unsupportedPaths.push(pathLabel); + } + } + } else if ( + type === "string" || + type === "number" || + type === "integer" || + type === "boolean" + ) { + // ok + } else if (!normalized.enum) { + unsupportedPaths.push(pathLabel); + } + + return { + schema: normalized, + unsupportedPaths: Array.from(new Set(unsupportedPaths)), + }; +} + +function normalizeUnion( + schema: JsonSchema, + path: Array, +): ConfigSchemaAnalysis | null { + if (schema.allOf) return null; + const variants = schema.anyOf ?? schema.oneOf; + if (!variants) return null; + const values: unknown[] = []; + const nonLiteral: JsonSchema[] = []; + let nullable = false; + for (const variant of variants) { + if (!variant || typeof variant !== "object") return null; + if (Array.isArray(variant.enum)) { + const { enumValues, nullable: enumNullable } = normalizeEnumValues( + variant.enum, + ); + values.push(...enumValues); + if (enumNullable) nullable = true; + continue; + } + if ("const" in variant) { + if (variant.const === null || variant.const === undefined) { + nullable = true; + continue; + } + values.push(variant.const); + continue; + } + if (schemaType(variant) === "null") { + nullable = true; + continue; + } + nonLiteral.push(variant); + } + + if (values.length > 0 && nonLiteral.length === 0) { + const unique: unknown[] = []; + for (const value of values) { + if (!unique.some((entry) => Object.is(entry, value))) unique.push(value); + } + return { + schema: { + ...schema, + enum: unique, + nullable, + anyOf: undefined, + oneOf: undefined, + allOf: undefined, + }, + unsupportedPaths: [], + }; + } + + if (nonLiteral.length === 1) { + const result = normalizeSchemaNode(nonLiteral[0], path); + if (result.schema) { + result.schema.nullable = nullable || result.schema.nullable; + } + return result; + } + + const primitiveTypes = ["string", "number", "integer", "boolean"]; + const allPrimitive = nonLiteral.every( + (v) => v.type && primitiveTypes.includes(String(v.type)), + ); + if (allPrimitive && nonLiteral.length > 0 && values.length === 0) { + return { + schema: { ...schema, nullable }, + unsupportedPaths: [], + }; + } + + return null; +} + +function normalizeEnumValues(values: unknown[]) { + const filtered = values.filter((value) => value !== null && value !== undefined); + const nullable = filtered.length !== values.length; + const unique: unknown[] = []; + for (const value of filtered) { + if (!unique.some((entry) => Object.is(entry, value))) unique.push(value); + } + return { enumValues: unique, nullable }; +} diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts new file mode 100644 index 000000000..c975c4631 --- /dev/null +++ b/ui/src/ui/views/config.browser.test.ts @@ -0,0 +1,44 @@ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; + +import { renderConfig } from "./config"; + +describe("config view", () => { + it("disables save when form is unsafe", () => { + const container = document.createElement("div"); + render( + renderConfig({ + raw: "{\n}\n", + valid: true, + issues: [], + loading: false, + saving: false, + connected: true, + schema: { + type: "object", + properties: { + mixed: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + }, + schemaLoading: false, + uiHints: {}, + formMode: "form", + formValue: { mixed: "x" }, + onRawChange: vi.fn(), + onFormModeChange: vi.fn(), + onFormPatch: vi.fn(), + onReload: vi.fn(), + onSave: vi.fn(), + }), + container, + ); + + const saveButton = Array.from( + container.querySelectorAll("button"), + ).find((btn) => btn.textContent?.trim() === "Save") as + | HTMLButtonElement + | undefined; + expect(saveButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(true); + }); +}); diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index c11ee4204..27571d1ab 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,6 +1,6 @@ import { html, nothing } from "lit"; import type { ConfigUiHints } from "../types"; -import { renderConfigForm } from "./config-form"; +import { analyzeConfigSchema, renderConfigForm } from "./config-form"; export type ConfigProps = { raw: string; @@ -24,6 +24,16 @@ export type ConfigProps = { export function renderConfig(props: ConfigProps) { const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; + const analysis = analyzeConfigSchema(props.schema); + const formUnsafe = analysis.schema + ? analysis.unsupportedPaths.length > 0 + : false; + const canSaveForm = + Boolean(props.formValue) && !props.loading && !formUnsafe; + const canSave = + props.connected && + !props.saving && + (props.formMode === "raw" ? true : canSaveForm); return html`
@@ -52,7 +62,7 @@ export function renderConfig(props: ConfigProps) {
` : html`