Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dcedca323 | ||
|
|
25cc12e590 | ||
|
|
d826d96e2e |
No files matched your search
@@ -30,7 +30,6 @@ vendor/
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
.bundle.hash
|
||||
|
||||
# fastlane (iOS)
|
||||
apps/ios/fastlane/README.md
|
||||
|
||||
@@ -1 +1 @@
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext
|
||||
+2
-22
@@ -1,21 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
||||
|
||||
### Docs
|
||||
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
|
||||
|
||||
## 2.0.0-beta5 — 2026-01-03
|
||||
## 2.0.0-beta5 — Unreleased
|
||||
|
||||
### Fixed
|
||||
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
|
||||
- Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).
|
||||
- Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).
|
||||
- Tests: cover read tool image metadata + text output.
|
||||
- Tests: add queue mode coverage (collect/followup + directive parsing).
|
||||
- Hooks: treat null transforms as handled skips (204) and let explicit mappings override presets (#117) — thanks @jverdi.
|
||||
|
||||
### Breaking
|
||||
- Skills config schema moved under `skills.*`:
|
||||
@@ -30,13 +22,10 @@
|
||||
- Config: remove `routing.groupChat.requireMention` + `telegram.requireMention`; use `whatsapp.groups`, `imessage.groups`, and `telegram.groups` defaults instead (run `clawdis doctor` to migrate).
|
||||
|
||||
### Features
|
||||
- Discord: expand `discord` tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.
|
||||
- Discord/Telegram: add reply tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`) with per-provider `replyToMode` (off|first|all) for native threaded replies.
|
||||
- Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.
|
||||
- Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.
|
||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||
- Nix mode: opt-in declarative config + read-only settings UI when `CLAWDIS_NIX_MODE=1` (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).
|
||||
- CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.
|
||||
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
||||
- Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via `*.groups` with `"*"` defaults; Discord now supports `discord.guilds."*"` as a default.
|
||||
- Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.
|
||||
@@ -57,11 +46,8 @@
|
||||
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
|
||||
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
|
||||
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
|
||||
- CLI: enhance `clawdis tui` with model/session pickers, tool cards, and slash commands (local or remote).
|
||||
- Gateway: allow `sessions.patch` to set per-session model overrides (used by the TUI `/model` flow).
|
||||
- Skills: allow `bun` as a node manager for skill installs.
|
||||
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
|
||||
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
|
||||
- Tests: add a Docker-based onboarding E2E harness.
|
||||
- Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.
|
||||
- Browser tools: add remote CDP URL support, Linux launcher options (`executablePath`, `noSandbox`), and surface `cdpUrl` in status.
|
||||
@@ -69,11 +55,8 @@
|
||||
|
||||
### Fixes
|
||||
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
|
||||
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
|
||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||
- Chat UI: show rich session display names in Web Chat + SwiftUI + Android.
|
||||
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
|
||||
- Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).
|
||||
- Providers: make outbound text chunk limits configurable via `*.textChunkLimit` (defaults remain 4000/Discord 2000).
|
||||
- CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.
|
||||
- Control UI: accept a `?token=` URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.
|
||||
@@ -110,7 +93,6 @@
|
||||
- macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.
|
||||
- macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b
|
||||
- macOS codesign: include camera entitlement so permission prompts work in the menu bar app.
|
||||
- Agent tools: bash tool supports real TTY via `stdinMode: "pty"` with node-pty, warning + fallback on load/start failure.
|
||||
- Agent tools: map `camera.snap` JPEG payloads to `image/jpeg` to avoid MIME mismatch errors.
|
||||
- Tests: cover `camera.snap` MIME mapping to prevent image/png vs image/jpeg mismatches.
|
||||
- macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.
|
||||
@@ -121,14 +103,12 @@
|
||||
- macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b
|
||||
- macOS remote: route settings through gateway config and avoid local config reads in remote mode.
|
||||
- Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).
|
||||
- Telegram: honor per-group mention gating defaults/overrides via `telegram.groups` and `"*"` defaults (thanks @joshp123).
|
||||
- Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl
|
||||
- Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.
|
||||
- Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.
|
||||
- Cron: prevent `every` schedules without an anchor from firing in a tight loop (thanks @jamesgroat).
|
||||
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
|
||||
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
|
||||
- Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.
|
||||
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
|
||||
- Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.
|
||||
- Browser CLI: add `clawdis browser reset-profile` to move the clawd profile to Trash when it gets wedged.
|
||||
|
||||
+17
-198
@@ -3,210 +3,29 @@
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<item>
|
||||
<title>2.0.0-beta5</title>
|
||||
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
|
||||
<title>2.0.0-beta3</title>
|
||||
<pubDate>Sat, 27 Dec 2025 19:02:02 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2765</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
||||
<sparkle:version>2.0.0-beta3</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2>
|
||||
<h3>Fixed</h3>
|
||||
<ul>
|
||||
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
|
||||
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
|
||||
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
|
||||
<li>Tests: cover read tool image metadata + text output.</li>
|
||||
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Skills config schema moved under <code>skills.*</code>:</li>
|
||||
</ul>
|
||||
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
|
||||
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
|
||||
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
|
||||
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
|
||||
<ul>
|
||||
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
|
||||
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
|
||||
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
|
||||
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdis doctor</code> to migrate).</li>
|
||||
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdis doctor</code> to migrate).</li>
|
||||
</ul>
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</li>
|
||||
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
|
||||
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
|
||||
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
|
||||
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDIS_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
|
||||
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
|
||||
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
|
||||
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
|
||||
<li>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
|
||||
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</li>
|
||||
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
|
||||
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
|
||||
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: allow agent-triggered reactions via <code>clawdis_discord</code> when enabled, and surface message ids in context.</li>
|
||||
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
|
||||
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
|
||||
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
|
||||
<li>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
|
||||
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
|
||||
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
|
||||
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
|
||||
<li>CLI: add ASCII banner header to wizard entry points.</li>
|
||||
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
|
||||
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
|
||||
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
|
||||
<li>CLI: enhance <code>clawdis tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
|
||||
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
|
||||
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
|
||||
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
||||
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
|
||||
<li>Tests: add a Docker-based onboarding E2E harness.</li>
|
||||
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
|
||||
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
|
||||
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
|
||||
</ul>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta3/Clawdis-2.0.0-beta3.zip" length="70407960" type="application/octet-stream" sparkle:edSignature="A8ySMmbLRrpIkqkrmc9QrC+6om8Iyqray/6x/YNiJxDoJeXjp2T5t8XT0CKJeNBUlDkzIj/fwiK53v0qQ59cDQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2.0.0-beta4</title>
|
||||
<pubDate>Sat, 27 Dec 2025 19:43:22 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2.0.0-beta4</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta4</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta4</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
|
||||
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
|
||||
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
|
||||
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
|
||||
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
|
||||
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
|
||||
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
|
||||
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
|
||||
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
|
||||
<li>Agent prompt: remove hardcoded user name in system prompt example.</li>
|
||||
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
|
||||
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
|
||||
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
|
||||
<li>Gateway CLI: read <code>CLAWDIS_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
|
||||
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
|
||||
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
|
||||
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
|
||||
<li>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
|
||||
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
|
||||
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
|
||||
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
|
||||
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
|
||||
<li>Skills: switch imsg installer to brew tap formula.</li>
|
||||
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
|
||||
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
|
||||
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
|
||||
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
|
||||
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
|
||||
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
|
||||
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
|
||||
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
|
||||
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
|
||||
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
|
||||
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
|
||||
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
|
||||
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
|
||||
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
|
||||
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
|
||||
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
|
||||
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
|
||||
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
|
||||
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
|
||||
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
|
||||
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
|
||||
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
|
||||
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
|
||||
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
|
||||
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
|
||||
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
|
||||
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
|
||||
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
|
||||
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
|
||||
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
|
||||
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
|
||||
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
|
||||
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
|
||||
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
|
||||
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</li>
|
||||
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
|
||||
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
|
||||
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
|
||||
<li>Browser CLI: add <code>clawdis browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
|
||||
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
|
||||
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
|
||||
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
|
||||
<li>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
|
||||
<li>macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.</li>
|
||||
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
|
||||
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
|
||||
<li>macOS Debug: add an icon for the App Logging submenu.</li>
|
||||
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
|
||||
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
|
||||
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
|
||||
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
|
||||
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
|
||||
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
|
||||
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
|
||||
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
|
||||
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
|
||||
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
|
||||
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
|
||||
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
|
||||
<li>Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).</li>
|
||||
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
|
||||
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
|
||||
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
|
||||
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
|
||||
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
|
||||
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
|
||||
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
|
||||
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
|
||||
<li>macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.</li>
|
||||
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
|
||||
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
|
||||
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
|
||||
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
|
||||
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
|
||||
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
|
||||
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
|
||||
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
|
||||
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
|
||||
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
|
||||
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
|
||||
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
|
||||
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
|
||||
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
|
||||
<li>macOS menu: device list now shows connected nodes only.</li>
|
||||
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
|
||||
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
|
||||
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
|
||||
<li>Canvas A2UI: polish the debug status HUD styling.</li>
|
||||
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
|
||||
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
|
||||
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
|
||||
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
|
||||
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
|
||||
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
|
||||
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
|
||||
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
|
||||
<li>iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.</li>
|
||||
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
|
||||
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
|
||||
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
|
||||
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.</li>
|
||||
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
|
||||
<li>Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@@ -148,7 +148,7 @@ fun ChatComposer(
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
|
||||
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
@@ -186,7 +186,7 @@ fun ChatComposer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
||||
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -201,7 +201,7 @@ private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
||||
) {}
|
||||
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
|
||||
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
|
||||
Text(
|
||||
if (healthOk) "Connected" else "Connecting…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -289,12 +289,6 @@ struct ConnectionsSettings: View {
|
||||
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("DMs enabled")
|
||||
Toggle("", isOn: self.$store.discordDmEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Group DMs")
|
||||
Toggle("", isOn: self.$store.discordGroupEnabled)
|
||||
@@ -317,18 +311,10 @@ struct ConnectionsSettings: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Text chunk limit")
|
||||
TextField("2000", text: self.$store.discordTextChunkLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reply to mode")
|
||||
Picker("", selection: self.$store.discordReplyToMode) {
|
||||
Text("off").tag("off")
|
||||
Text("first").tag("first")
|
||||
Text("all").tag("all")
|
||||
}
|
||||
.labelsHidden()
|
||||
self.gridLabel("Reactions")
|
||||
Toggle("", isOn: self.$store.discordEnableReactions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash command")
|
||||
@@ -354,178 +340,6 @@ struct ConnectionsSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Text("Guilds")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach($store.discordGuilds) { $guild in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
TextField("guild id or slug", text: $guild.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Remove") {
|
||||
self.store.discordGuilds.removeAll { $0.id == guild.id }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Slug")
|
||||
TextField("optional slug", text: $guild.slug)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Require mention")
|
||||
Toggle("", isOn: $guild.requireMention)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Users allowlist")
|
||||
TextField("123456789, username#1234", text: $guild.users)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Channels")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach($guild.channels) { $channel in
|
||||
HStack(spacing: 10) {
|
||||
TextField("channel id or slug", text: $channel.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Allow", isOn: $channel.allow)
|
||||
.toggleStyle(.checkbox)
|
||||
Toggle("Require mention", isOn: $channel.requireMention)
|
||||
.toggleStyle(.checkbox)
|
||||
Button("Remove") {
|
||||
guild.channels.removeAll { $0.id == channel.id }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
Button("Add channel") {
|
||||
guild.channels.append(DiscordGuildChannelForm())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
Button("Add guild") {
|
||||
self.store.discordGuilds.append(DiscordGuildForm())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Text("Tool actions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Reactions")
|
||||
Toggle("", isOn: self.$store.discordActionReactions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Stickers")
|
||||
Toggle("", isOn: self.$store.discordActionStickers)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Polls")
|
||||
Toggle("", isOn: self.$store.discordActionPolls)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Permissions")
|
||||
Toggle("", isOn: self.$store.discordActionPermissions)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Messages")
|
||||
Toggle("", isOn: self.$store.discordActionMessages)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Threads")
|
||||
Toggle("", isOn: self.$store.discordActionThreads)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Pins")
|
||||
Toggle("", isOn: self.$store.discordActionPins)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Search")
|
||||
Toggle("", isOn: self.$store.discordActionSearch)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Member info")
|
||||
Toggle("", isOn: self.$store.discordActionMemberInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Role info")
|
||||
Toggle("", isOn: self.$store.discordActionRoleInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Channel info")
|
||||
Toggle("", isOn: self.$store.discordActionChannelInfo)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Voice status")
|
||||
Toggle("", isOn: self.$store.discordActionVoiceStatus)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Events")
|
||||
Toggle("", isOn: self.$store.discordActionEvents)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Role changes")
|
||||
Toggle("", isOn: self.$store.discordActionRoles)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Moderation")
|
||||
Toggle("", isOn: self.$store.discordActionModeration)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
}
|
||||
|
||||
if self.isDiscordTokenLocked {
|
||||
Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.")
|
||||
.font(.caption)
|
||||
|
||||
@@ -144,42 +144,6 @@ struct ConfigSnapshot: Codable {
|
||||
let issues: [Issue]?
|
||||
}
|
||||
|
||||
struct DiscordGuildChannelForm: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var allow: Bool
|
||||
var requireMention: Bool
|
||||
|
||||
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
|
||||
self.key = key
|
||||
self.allow = allow
|
||||
self.requireMention = requireMention
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscordGuildForm: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var slug: String
|
||||
var requireMention: Bool
|
||||
var users: String
|
||||
var channels: [DiscordGuildChannelForm]
|
||||
|
||||
init(
|
||||
key: String = "",
|
||||
slug: String = "",
|
||||
requireMention: Bool = false,
|
||||
users: String = "",
|
||||
channels: [DiscordGuildChannelForm] = []
|
||||
) {
|
||||
self.key = key
|
||||
self.slug = slug
|
||||
self.requireMention = requireMention
|
||||
self.users = users
|
||||
self.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ConnectionsStore {
|
||||
@@ -205,30 +169,12 @@ final class ConnectionsStore {
|
||||
var telegramBusy = false
|
||||
var discordEnabled = true
|
||||
var discordToken: String = ""
|
||||
var discordDmEnabled = true
|
||||
var discordAllowFrom: String = ""
|
||||
var discordGroupEnabled = false
|
||||
var discordGroupChannels: String = ""
|
||||
var discordMediaMaxMb: String = ""
|
||||
var discordHistoryLimit: String = ""
|
||||
var discordTextChunkLimit: String = ""
|
||||
var discordReplyToMode: String = "off"
|
||||
var discordGuilds: [DiscordGuildForm] = []
|
||||
var discordActionReactions = true
|
||||
var discordActionStickers = true
|
||||
var discordActionPolls = true
|
||||
var discordActionPermissions = true
|
||||
var discordActionMessages = true
|
||||
var discordActionThreads = true
|
||||
var discordActionPins = true
|
||||
var discordActionSearch = true
|
||||
var discordActionMemberInfo = true
|
||||
var discordActionRoleInfo = true
|
||||
var discordActionChannelInfo = true
|
||||
var discordActionVoiceStatus = true
|
||||
var discordActionEvents = true
|
||||
var discordActionRoles = false
|
||||
var discordActionModeration = false
|
||||
var discordEnableReactions = true
|
||||
var discordSlashEnabled = false
|
||||
var discordSlashName: String = ""
|
||||
var discordSlashSessionPrefix: String = ""
|
||||
@@ -441,7 +387,6 @@ final class ConnectionsStore {
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
let discordDm = discord?["dm"]?.dictionaryValue
|
||||
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
|
||||
if let allow = discordDm?["allowFrom"]?.arrayValue {
|
||||
let strings = allow.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
@@ -475,72 +420,7 @@ final class ConnectionsStore {
|
||||
} else {
|
||||
self.discordHistoryLimit = ""
|
||||
}
|
||||
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) {
|
||||
self.discordTextChunkLimit = String(Int(limit))
|
||||
} else {
|
||||
self.discordTextChunkLimit = ""
|
||||
}
|
||||
if let mode = discord?["replyToMode"]?.stringValue, ["off", "first", "all"].contains(mode) {
|
||||
self.discordReplyToMode = mode
|
||||
} else {
|
||||
self.discordReplyToMode = "off"
|
||||
}
|
||||
if let guilds = discord?["guilds"]?.dictionaryValue {
|
||||
self.discordGuilds = guilds
|
||||
.map { key, value in
|
||||
let entry = value.dictionaryValue ?? [:]
|
||||
let slug = entry["slug"]?.stringValue ?? ""
|
||||
let requireMention = entry["requireMention"]?.boolValue ?? false
|
||||
let users = entry["users"]?.arrayValue?
|
||||
.compactMap { item -> String? in
|
||||
if let str = item.stringValue { return str }
|
||||
if let intVal = item.intValue { return String(intVal) }
|
||||
if let doubleVal = item.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
.joined(separator: ", ") ?? ""
|
||||
let channels: [DiscordGuildChannelForm]
|
||||
if let channelMap = entry["channels"]?.dictionaryValue {
|
||||
channels = channelMap.map { channelKey, channelValue in
|
||||
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||
let channelRequireMention =
|
||||
channelEntry["requireMention"]?.boolValue ?? false
|
||||
return DiscordGuildChannelForm(
|
||||
key: channelKey,
|
||||
allow: allow,
|
||||
requireMention: channelRequireMention)
|
||||
}
|
||||
} else {
|
||||
channels = []
|
||||
}
|
||||
return DiscordGuildForm(
|
||||
key: key,
|
||||
slug: slug,
|
||||
requireMention: requireMention,
|
||||
users: users,
|
||||
channels: channels)
|
||||
}
|
||||
.sorted { $0.key < $1.key }
|
||||
} else {
|
||||
self.discordGuilds = []
|
||||
}
|
||||
let discordActions = discord?["actions"]?.dictionaryValue
|
||||
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
|
||||
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
|
||||
self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true
|
||||
self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true
|
||||
self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true
|
||||
self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true
|
||||
self.discordActionPins = discordActions?["pins"]?.boolValue ?? true
|
||||
self.discordActionSearch = discordActions?["search"]?.boolValue ?? true
|
||||
self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true
|
||||
self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true
|
||||
self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true
|
||||
self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true
|
||||
self.discordActionEvents = discordActions?["events"]?.boolValue ?? true
|
||||
self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false
|
||||
self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false
|
||||
self.discordEnableReactions = discord?["enableReactions"]?.boolValue ?? true
|
||||
let slash = discord?["slashCommand"]?.dictionaryValue
|
||||
self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false
|
||||
self.discordSlashName = slash?["name"]?.stringValue ?? ""
|
||||
@@ -716,11 +596,6 @@ final class ConnectionsStore {
|
||||
}
|
||||
|
||||
var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:]
|
||||
if self.discordDmEnabled {
|
||||
dm.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
dm["enabled"] = false
|
||||
}
|
||||
let allow = self.discordAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
@@ -769,80 +644,10 @@ final class ConnectionsStore {
|
||||
discord.removeValue(forKey: "historyLimit")
|
||||
}
|
||||
|
||||
let chunkLimit = self.discordTextChunkLimit.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if chunkLimit.isEmpty {
|
||||
discord.removeValue(forKey: "textChunkLimit")
|
||||
} else if let value = Int(chunkLimit), value > 0 {
|
||||
discord["textChunkLimit"] = value
|
||||
if self.discordEnableReactions {
|
||||
discord.removeValue(forKey: "enableReactions")
|
||||
} else {
|
||||
discord.removeValue(forKey: "textChunkLimit")
|
||||
}
|
||||
|
||||
let replyToMode = self.discordReplyToMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyToMode.isEmpty || replyToMode == "off" {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
} else if ["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = replyToMode
|
||||
} else {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
}
|
||||
|
||||
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
|
||||
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !slug.isEmpty { payload["slug"] = slug }
|
||||
if entry.requireMention { payload["requireMention"] = true }
|
||||
let users = entry.users
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if !users.isEmpty { payload["users"] = users }
|
||||
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
|
||||
let channelKey = channel.key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !channelKey.isEmpty else { return }
|
||||
var channelPayload: [String: Any] = [:]
|
||||
if !channel.allow { channelPayload["allow"] = false }
|
||||
if channel.requireMention { channelPayload["requireMention"] = true }
|
||||
channelsResult[channelKey] = channelPayload
|
||||
}
|
||||
if !channels.isEmpty { payload["channels"] = channels }
|
||||
result[key] = payload
|
||||
}
|
||||
if guilds.isEmpty {
|
||||
discord.removeValue(forKey: "guilds")
|
||||
} else {
|
||||
discord["guilds"] = guilds
|
||||
}
|
||||
|
||||
var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:]
|
||||
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
|
||||
if value == defaultValue {
|
||||
actions.removeValue(forKey: key)
|
||||
} else {
|
||||
actions[key] = value
|
||||
}
|
||||
}
|
||||
setAction("reactions", value: self.discordActionReactions, defaultValue: true)
|
||||
setAction("stickers", value: self.discordActionStickers, defaultValue: true)
|
||||
setAction("polls", value: self.discordActionPolls, defaultValue: true)
|
||||
setAction("permissions", value: self.discordActionPermissions, defaultValue: true)
|
||||
setAction("messages", value: self.discordActionMessages, defaultValue: true)
|
||||
setAction("threads", value: self.discordActionThreads, defaultValue: true)
|
||||
setAction("pins", value: self.discordActionPins, defaultValue: true)
|
||||
setAction("search", value: self.discordActionSearch, defaultValue: true)
|
||||
setAction("memberInfo", value: self.discordActionMemberInfo, defaultValue: true)
|
||||
setAction("roleInfo", value: self.discordActionRoleInfo, defaultValue: true)
|
||||
setAction("channelInfo", value: self.discordActionChannelInfo, defaultValue: true)
|
||||
setAction("voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true)
|
||||
setAction("events", value: self.discordActionEvents, defaultValue: true)
|
||||
setAction("roles", value: self.discordActionRoles, defaultValue: false)
|
||||
setAction("moderation", value: self.discordActionModeration, defaultValue: false)
|
||||
if actions.isEmpty {
|
||||
discord.removeValue(forKey: "actions")
|
||||
} else {
|
||||
discord["actions"] = actions
|
||||
discord["enableReactions"] = false
|
||||
}
|
||||
|
||||
var slash: [String: Any] = (discord["slashCommand"] as? [String: Any]) ?? [:]
|
||||
|
||||
@@ -133,16 +133,10 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func onboardingPage(@ViewBuilder _ content: () -> some View) -> some View {
|
||||
let scrollIndicatorGutter: CGFloat = 18
|
||||
return ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
content()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
.padding(.trailing, scrollIndicatorGutter)
|
||||
VStack(spacing: 16) {
|
||||
content()
|
||||
Spacer()
|
||||
}
|
||||
.scrollIndicators(.automatic)
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, alignment: .top)
|
||||
}
|
||||
|
||||
@@ -442,7 +442,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func permissionsPage() -> some View {
|
||||
return self.onboardingPage {
|
||||
self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
||||
|
||||
@@ -613,27 +613,23 @@ public struct SessionsPatchParams: Codable {
|
||||
public let key: String
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let groupactivation: AnyCodable?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
groupactivation: AnyCodable?
|
||||
) {
|
||||
self.key = key
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
self.model = model
|
||||
self.groupactivation = groupactivation
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
case model
|
||||
case groupactivation = "groupActivation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ struct ClawdisChatComposer: View {
|
||||
Circle()
|
||||
.fill(self.viewModel.healthOK ? .green : .orange)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(self.activeSessionLabel)
|
||||
Text(self.viewModel.sessionKey)
|
||||
.font(.caption2.weight(.semibold))
|
||||
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
|
||||
.font(.caption2)
|
||||
@@ -221,12 +221,6 @@ struct ClawdisChatComposer: View {
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var activeSessionLabel: String {
|
||||
let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey }
|
||||
let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed
|
||||
}
|
||||
|
||||
private var editorOverlay: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
|
||||
@@ -94,7 +94,6 @@ git commit -m "Add Clawd workspace"
|
||||
- **eightctl** — Control your sleep, from the terminal.
|
||||
- **imsg** — Send, read, stream iMessage & SMS.
|
||||
- **wacli** — WhatsApp CLI: sync, search, send.
|
||||
- **discord** — Discord actions: react, stickers, polls.
|
||||
- **gog** — Google Suite CLI: Gmail, Calendar, Drive, Contacts.
|
||||
- **spotify-player** — Terminal Spotify client to search/queue/control playback.
|
||||
- **sag** — ElevenLabs speech with mac-style say UX; streams to speakers by default.
|
||||
|
||||
@@ -37,7 +37,6 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
- [ ] Generate the Sparkle appcast (HTML notes via `scripts/make_appcast.sh`) and update `appcast.xml`.
|
||||
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
|
||||
- [ ] Follow `docs/mac/release.md` for the exact commands and required env vars.
|
||||
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
|
||||
- If notarizing, use the `clawdis-notary` keychain profile created from App Store Connect API env vars (see `docs/mac/release.md`).
|
||||
|
||||
6) **Publish (npm)**
|
||||
|
||||
+1
-10
@@ -70,16 +70,7 @@ Legacy Pi/Tau session folders are **not** read.
|
||||
|
||||
## Steering while streaming
|
||||
|
||||
When queue mode is `steer`, inbound messages are injected into the current run.
|
||||
The queue is checked **after each tool call**; if a queued message is present,
|
||||
remaining tool calls from the current assistant message are skipped (error tool
|
||||
results with "Skipped due to queued user message."), then the queued user
|
||||
message is injected before the next assistant response.
|
||||
|
||||
When queue mode is `followup` or `collect`, inbound messages are held until the
|
||||
current turn ends, then a new agent turn starts with the queued payloads. See
|
||||
`docs/queue.md` for mode + debounce/cap behavior.
|
||||
|
||||
Incoming user messages are queued while the agent is streaming. The queue is checked **after each tool call**. If a queued message is present, remaining tool calls from the current assistant message are skipped (error tool results with "Skipped due to queued user message."), then the queued user message is injected before the next assistant response.
|
||||
Block streaming sends completed assistant blocks as soon as they finish; disable
|
||||
via `agent.blockStreamingDefault: "off"` if you only want the final response.
|
||||
Tune the boundary via `agent.blockStreamingBreak` (`text_end` vs `message_end`).
|
||||
|
||||
@@ -16,7 +16,6 @@ Key parameters:
|
||||
- `yieldMs` (default 20000): auto‑background after this delay
|
||||
- `background` (bool): background immediately
|
||||
- `timeout` (seconds, default 1800): kill the process after this timeout
|
||||
- `stdinMode` (`pipe` | `pty`): use a real TTY when `pty` is requested and node-pty loads (otherwise warns + falls back)
|
||||
- `workdir`, `env`
|
||||
|
||||
Behavior:
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
summary: "Bash tool usage, stdin modes, and TTY support"
|
||||
read_when:
|
||||
- Using or modifying the bash tool
|
||||
- Debugging stdin or TTY behavior
|
||||
---
|
||||
|
||||
# Bash tool
|
||||
|
||||
Run shell commands in the workspace. Supports foreground + background execution via `process`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `command` (required)
|
||||
- `yieldMs` (default 20000): auto-background after delay
|
||||
- `background` (bool): background immediately
|
||||
- `timeout` (seconds, default 1800): kill on expiry
|
||||
- `stdinMode` (`pipe` | `pty`):
|
||||
- `pipe` (default): classic stdin/stdout/stderr pipes
|
||||
- `pty`: real TTY via node-pty (merged stdout/stderr)
|
||||
|
||||
## TTY mode (`stdinMode: "pty"`)
|
||||
|
||||
- Uses node-pty if available. If node-pty fails to load/start, the tool warns and falls back to `pipe`.
|
||||
- Output streams are merged (no separate stderr).
|
||||
- `process write` sends raw input; `eof: true` sends Ctrl-D (`\x04`).
|
||||
|
||||
## Examples
|
||||
|
||||
Foreground:
|
||||
```json
|
||||
{"tool":"bash","command":"ls -la"}
|
||||
```
|
||||
|
||||
Background + poll:
|
||||
```json
|
||||
{"tool":"bash","command":"npm run build","yieldMs":1000}
|
||||
{"tool":"process","action":"poll","sessionId":"<id>"}
|
||||
```
|
||||
|
||||
TTY command:
|
||||
```json
|
||||
{"tool":"bash","command":"htop","stdinMode":"pty","background":true}
|
||||
```
|
||||
+71
-80
@@ -10,11 +10,11 @@ CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comme
|
||||
|
||||
If the file is missing, CLAWDIS 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`)
|
||||
- tune group mention behavior (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`)
|
||||
- customize message prefixes (`messages`)
|
||||
- set the agent's workspace (`agent.workspace`)
|
||||
- set the agent’s workspace (`agent.workspace`)
|
||||
- tune the embedded agent (`agent`) and session behavior (`session`)
|
||||
- set the agent's identity (`identity`)
|
||||
- set the agent’s identity (`identity`)
|
||||
|
||||
## Minimal config (recommended starting point)
|
||||
|
||||
@@ -25,26 +25,6 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
|
||||
}
|
||||
```
|
||||
|
||||
## Self-chat mode (recommended for group control)
|
||||
|
||||
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: { workspace: "~/clawd" },
|
||||
whatsapp: {
|
||||
// Allowlist is DMs only; including your own number enables self-chat mode.
|
||||
allowFrom: ["+15555550123"],
|
||||
groups: { "*": { requireMention: true } }
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["@clawd", "reisponde"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common options
|
||||
|
||||
### `identity`
|
||||
@@ -98,8 +78,7 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
|
||||
|
||||
### `whatsapp.allowFrom`
|
||||
|
||||
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (DMs only).
|
||||
If empty, the default allowlist is your own WhatsApp number (self-chat mode).
|
||||
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -110,13 +89,24 @@ If empty, the default allowlist is your own WhatsApp number (self-chat mode).
|
||||
}
|
||||
```
|
||||
|
||||
### `whatsapp.groups`
|
||||
|
||||
Per-group mention gating for WhatsApp groups. Default group config lives at `whatsapp.groups."*"`.
|
||||
|
||||
```json5
|
||||
{
|
||||
whatsapp: {
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123@g.us": { requireMention: false } // group JID
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `routing.groupChat`
|
||||
|
||||
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
|
||||
|
||||
**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.
|
||||
Group mention patterns + history handling shared across surfaces (WhatsApp/iMessage/Telegram/Discord).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -128,26 +118,8 @@ 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`).
|
||||
|
||||
To respond **only** to specific text triggers (ignoring native @-mentions):
|
||||
```json5
|
||||
{
|
||||
whatsapp: {
|
||||
// Include your own number to enable self-chat mode (ignore native @-mentions).
|
||||
allowFrom: ["+15555550123"],
|
||||
groups: { "*": { requireMention: true } }
|
||||
},
|
||||
routing: {
|
||||
groupChat: {
|
||||
// Only these text patterns will trigger responses
|
||||
mentionPatterns: ["reisponde", "@clawd"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `routing.queue`
|
||||
|
||||
Controls how inbound messages behave when an agent run is already active.
|
||||
@@ -156,16 +128,13 @@ Controls how inbound messages behave when an agent run is already active.
|
||||
{
|
||||
routing: {
|
||||
queue: {
|
||||
mode: "collect", // steer | followup | collect | steer-backlog (steer+backlog ok) | interrupt (queue=steer legacy)
|
||||
debounceMs: 1000,
|
||||
cap: 20,
|
||||
drop: "summarize", // old | new | summarize
|
||||
mode: "interrupt", // global default: queue | interrupt
|
||||
bySurface: {
|
||||
whatsapp: "collect",
|
||||
telegram: "collect",
|
||||
discord: "steer-backlog",
|
||||
imessage: "collect",
|
||||
webchat: "collect"
|
||||
whatsapp: "interrupt",
|
||||
telegram: "interrupt",
|
||||
discord: "queue",
|
||||
imessage: "interrupt",
|
||||
webchat: "queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +172,12 @@ Set `telegram.enabled: false` to disable automatic startup.
|
||||
telegram: {
|
||||
enabled: true,
|
||||
botToken: "your-bot-token",
|
||||
requireMention: true,
|
||||
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
||||
replyToMode: "off",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123456789": { requireMention: false } // group chat id
|
||||
},
|
||||
allowFrom: ["123456789"],
|
||||
mediaMaxMb: 5,
|
||||
proxy: "socks5://localhost:9050",
|
||||
@@ -213,6 +187,8 @@ Set `telegram.enabled: false` to disable automatic startup.
|
||||
}
|
||||
}
|
||||
```
|
||||
Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention` → `telegram.groups."*".requireMention` → default `true`.
|
||||
Reply threading is controlled via `telegram.replyToMode` (`off` | `first` | `all`) and reply tags in the model output.
|
||||
|
||||
### `discord` (bot transport)
|
||||
|
||||
@@ -223,24 +199,9 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "your-bot-token",
|
||||
textChunkLimit: 2000, // optional outbound chunk size (chars)
|
||||
mediaMaxMb: 8, // clamp inbound media size
|
||||
actions: { // tool action gates (false disables)
|
||||
reactions: true,
|
||||
stickers: true,
|
||||
polls: true,
|
||||
permissions: true,
|
||||
messages: true,
|
||||
threads: true,
|
||||
pins: true,
|
||||
search: true,
|
||||
memberInfo: true,
|
||||
roleInfo: true,
|
||||
roles: false,
|
||||
channelInfo: true,
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
moderation: false
|
||||
},
|
||||
enableReactions: true, // allow agent-triggered reactions
|
||||
replyToMode: "off", // off | first | all
|
||||
slashCommand: { // user-installed app slash commands
|
||||
enabled: true,
|
||||
@@ -255,6 +216,7 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
groupChannels: ["clawd-dm"] // optional group DM allowlist
|
||||
},
|
||||
guilds: {
|
||||
"*": { requireMention: true }, // default per-guild mention gating
|
||||
"123456789012345678": { // guild id (preferred) or slug
|
||||
slug: "friends-of-clawd",
|
||||
requireMention: false, // per-guild default
|
||||
@@ -271,7 +233,23 @@ Configure the Discord bot by setting the bot token and optional gating:
|
||||
```
|
||||
|
||||
Clawdis starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
|
||||
Reply threading is controlled via `discord.replyToMode` (`off` | `first` | `all`) and reply tags in the model output.
|
||||
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
|
||||
Use `discord.guilds."*"` for default per-guild settings.
|
||||
|
||||
### `signal` (signal-cli JSON-RPC)
|
||||
|
||||
Clawdis can send/receive Signal via `signal-cli` (daemon or existing HTTP URL).
|
||||
|
||||
```json5
|
||||
{
|
||||
signal: {
|
||||
enabled: true,
|
||||
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
||||
mediaMaxMb: 8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `imessage` (imsg CLI)
|
||||
|
||||
@@ -283,7 +261,12 @@ Clawdis spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
|
||||
enabled: true,
|
||||
cliPath: "imsg",
|
||||
dbPath: "~/Library/Messages/chat.db",
|
||||
textChunkLimit: 4000, // optional outbound chunk size (chars)
|
||||
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
"123": { requireMention: false } // chat_id for the group
|
||||
},
|
||||
includeAttachments: false,
|
||||
mediaMaxMb: 16,
|
||||
service: "auto",
|
||||
@@ -296,6 +279,7 @@ Notes:
|
||||
- Requires Full Disk Access to the Messages DB.
|
||||
- The first send will prompt for Messages automation permission.
|
||||
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
|
||||
- Group mention gating lives in `imessage.groups` (default at `imessage.groups."*"`).
|
||||
|
||||
### `agent.workspace`
|
||||
|
||||
@@ -312,6 +296,7 @@ Default: `~/clawd`.
|
||||
### `messages`
|
||||
|
||||
Controls inbound/outbound prefixes and timestamps.
|
||||
Outbound text chunking is configured per provider via `*.textChunkLimit` (e.g. `whatsapp.textChunkLimit`, `telegram.textChunkLimit`).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -366,6 +351,8 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
},
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
blockStreamingDefault: "on",
|
||||
blockStreamingBreak: "text_end",
|
||||
timeoutSeconds: 600,
|
||||
mediaMaxMb: 5,
|
||||
heartbeat: {
|
||||
@@ -390,6 +377,14 @@ deprecation fallback.
|
||||
Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
`ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment.
|
||||
|
||||
`agent.blockStreamingDefault` controls whether completed assistant blocks are
|
||||
sent immediately (default: `on`). Set to `off` to only deliver the final
|
||||
consolidated reply.
|
||||
|
||||
`agent.blockStreamingBreak` controls what “block” means:
|
||||
- `text_end` (default): end of each assistant text content block (before tool calls)
|
||||
- `message_end`: end of the whole assistant message (may wait across tools)
|
||||
|
||||
`agent.heartbeat` configures periodic heartbeat runs:
|
||||
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
|
||||
`0m` to disable.
|
||||
@@ -706,6 +701,7 @@ Endpoints:
|
||||
Mapping notes:
|
||||
- `match.path` matches the sub-path after `/hooks` (e.g. `/hooks/gmail` → `gmail`).
|
||||
- `match.source` matches a payload field (e.g. `{ source: "gmail" }`) so you can use a generic `/hooks/ingest` path.
|
||||
- `hooks.mappings` are evaluated before presets; first match wins.
|
||||
- Templates like `{{messages[0].subject}}` read from the payload.
|
||||
- `transform` can point to a JS/TS module that returns a hook action.
|
||||
|
||||
@@ -730,11 +726,6 @@ Gmail helper config (used by `clawdis hooks gmail setup` / `run`):
|
||||
}
|
||||
```
|
||||
|
||||
Gateway auto-start:
|
||||
- If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
|
||||
`gog gmail watch serve` on boot and auto-renews the watch.
|
||||
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to disable the auto-start (for manual runs).
|
||||
|
||||
Note: when `tailscale.mode` is on, Clawdis defaults `serve.path` to `/` so
|
||||
Tailscale can proxy `/gmail-pubsub` correctly (it strips the set-path prefix).
|
||||
|
||||
|
||||
+7
-54
@@ -27,7 +27,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
||||
8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
|
||||
9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
|
||||
10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
|
||||
11. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`).
|
||||
11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
||||
12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session.
|
||||
|
||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||
@@ -50,23 +50,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
enabled: true,
|
||||
token: "abc.123",
|
||||
mediaMaxMb: 8,
|
||||
actions: {
|
||||
reactions: true,
|
||||
stickers: true,
|
||||
polls: true,
|
||||
permissions: true,
|
||||
messages: true,
|
||||
threads: true,
|
||||
pins: true,
|
||||
search: true,
|
||||
memberInfo: true,
|
||||
roleInfo: true,
|
||||
roles: false,
|
||||
channelInfo: true,
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
moderation: false
|
||||
},
|
||||
enableReactions: true,
|
||||
replyToMode: "off",
|
||||
slashCommand: {
|
||||
enabled: true,
|
||||
@@ -109,32 +93,7 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea
|
||||
- `slashCommand`: optional config for user-installed slash commands (ephemeral responses).
|
||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||
- `reactions` (covers react + read reactions)
|
||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
|
||||
### Tool action defaults
|
||||
|
||||
| Action group | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| reactions | enabled | React + list reactions + emojiList |
|
||||
| stickers | enabled | Send stickers |
|
||||
| polls | enabled | Create polls |
|
||||
| permissions | enabled | Channel permission snapshot |
|
||||
| messages | enabled | Read/send/edit/delete |
|
||||
| threads | enabled | Create/list/reply |
|
||||
| pins | enabled | Pin/unpin/list |
|
||||
| search | enabled | Message search (preview spec) |
|
||||
| memberInfo | enabled | Member info |
|
||||
| roleInfo | enabled | Role list |
|
||||
| channelInfo | enabled | Channel info + list |
|
||||
| voiceStatus | enabled | Voice state lookup |
|
||||
| events | enabled | List/create scheduled events |
|
||||
| roles | disabled | Role add/remove |
|
||||
| moderation | disabled | Timeout/kick/ban |
|
||||
- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`).
|
||||
- `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag.
|
||||
|
||||
## Reply tags
|
||||
@@ -160,16 +119,10 @@ Slash command notes:
|
||||
- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules).
|
||||
- Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist.
|
||||
|
||||
## Tool actions
|
||||
The agent can call `discord` with actions like:
|
||||
- `react` / `reactions` (add or list reactions)
|
||||
- `sticker`, `poll`, `permissions`
|
||||
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
|
||||
- `threadCreate`, `threadList`, `threadReply`
|
||||
- `pinMessage`, `unpinMessage`, `listPins`
|
||||
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
|
||||
- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate`
|
||||
- `timeout`, `kick`, `ban`
|
||||
## Reactions
|
||||
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
|
||||
- `action: "react"`
|
||||
- `channelId`, `messageId`, `emoji`
|
||||
|
||||
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
|
||||
Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.
|
||||
|
||||
+1
-109
@@ -32,33 +32,6 @@ Some features are platform-specific:
|
||||
- **iMessage** — macOS only (uses `imsg` CLI)
|
||||
- **Clawdis.app** — macOS native app (optional, gateway works without it)
|
||||
|
||||
### What are the minimum system requirements?
|
||||
|
||||
**Basically nothing!** The gateway is very lightweight — all heavy compute happens on Anthropic's servers.
|
||||
|
||||
- **RAM:** 512MB-1GB is enough (community member runs on 1GB VPS!)
|
||||
- **CPU:** 1 core is fine for personal use
|
||||
- **Disk:** ~500MB for Clawdis + 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:
|
||||
|
||||
```bash
|
||||
bun clawdis gateway
|
||||
```
|
||||
|
||||
### How do I install on Linux without Homebrew?
|
||||
|
||||
Build CLIs from source! Example for `gogcli`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/steipete/gogcli.git
|
||||
cd gogcli
|
||||
make
|
||||
sudo mv bin/gog /usr/local/bin/
|
||||
```
|
||||
|
||||
Most of Peter's tools are Go binaries — clone, build, move to PATH. No brew needed.
|
||||
|
||||
### I'm getting "unauthorized" errors on health check
|
||||
|
||||
You need a config file. Run the onboarding wizard:
|
||||
@@ -110,20 +83,10 @@ The macOS app onboarding is still being polished and can have quirks (e.g., What
|
||||
|
||||
They're **separate billing**! An API key does NOT use your subscription.
|
||||
|
||||
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back. Or just run:
|
||||
|
||||
```bash
|
||||
pnpm clawdis login
|
||||
```
|
||||
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back.
|
||||
|
||||
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdis/` to your server. The auth is just a JSON file.
|
||||
|
||||
### Does enterprise OAuth work?
|
||||
|
||||
**Not currently.** Enterprise accounts use SSO which requires a different auth flow that pi-coding-agent doesn't support yet.
|
||||
|
||||
**Workaround:** Ask your enterprise admin to provision an API key via the Anthropic console, then use that with `ANTHROPIC_API_KEY`.
|
||||
|
||||
### OAuth callback not working (containers/headless)?
|
||||
|
||||
OAuth needs the callback to reach the machine running the CLI. Options:
|
||||
@@ -308,33 +271,6 @@ cat ~/.clawdis/clawdis.json | grep workspace
|
||||
- **Signal** — Via `signal-cli` (see [signal.md](./signal.md)).
|
||||
- **WebChat** — Browser-based chat UI.
|
||||
|
||||
### Discord: Bot works in channels but not DMs?
|
||||
|
||||
Discord has **separate allowlists** for channels vs DMs:
|
||||
|
||||
- `discord.guilds.*.users` — controls who can talk in server channels
|
||||
- `discord.dm.allowFrom` — controls who can DM the bot
|
||||
|
||||
If channels work but DMs don't, add `discord.dm.allowFrom` to your config:
|
||||
|
||||
```json
|
||||
{
|
||||
"discord": {
|
||||
"dm": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["YOUR_DISCORD_USER_ID"]
|
||||
},
|
||||
"guilds": {
|
||||
"your-server": {
|
||||
"users": ["YOUR_DISCORD_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Find your user ID: Discord Settings → Advanced → Developer Mode → right-click yourself → Copy User ID.
|
||||
|
||||
### Can I use multiple platforms at once?
|
||||
|
||||
Yes! One Clawdis gateway can connect to WhatsApp, Telegram, Discord, and more simultaneously. Each platform maintains its own sessions.
|
||||
@@ -463,50 +399,6 @@ Quick reference (send these in chat):
|
||||
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
||||
| `/verbose on\|off` | Toggle verbose mode |
|
||||
| `/activation mention\|always` | Group activation (owner-only) |
|
||||
| `/model <name>` | Switch AI model (see below) |
|
||||
| `/queue instant\|batch\|serial` | Message queuing mode |
|
||||
|
||||
### How do I switch models on the fly?
|
||||
|
||||
Use `/model` to switch without restarting:
|
||||
|
||||
```
|
||||
/model sonnet
|
||||
/model haiku
|
||||
/model opus
|
||||
```
|
||||
|
||||
**Setup:** Configure allowed models and aliases in `clawdis.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"model": "anthropic/claude-opus-4-5-20251022",
|
||||
"allowedModels": [
|
||||
"anthropic/claude-opus-4-5-20251022",
|
||||
"anthropic/claude-sonnet-4-5-20251022",
|
||||
"anthropic/claude-haiku-4-5-20251001"
|
||||
],
|
||||
"modelAliases": {
|
||||
"opus": "anthropic/claude-opus-4-5-20251022",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5-20251022",
|
||||
"haiku": "anthropic/claude-haiku-4-5-20251001"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tip:** `/model` is processed at the gateway level — it works even if you're rate-limited (429) on the current model!
|
||||
|
||||
### How do rapid-fire messages work?
|
||||
|
||||
Use `/queue` to control how messages sent in quick succession are handled:
|
||||
|
||||
- **`/queue instant`** — New messages interrupt/steer the current response
|
||||
- **`/queue batch`** — Messages queue up, processed after current turn
|
||||
- **`/queue serial`** — One at a time, in order
|
||||
|
||||
If you tend to send multiple short messages, `/queue instant` feels most natural.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -56,12 +56,7 @@ Want a custom endpoint? Use `--push-endpoint <url>` or `--tailscale off`.
|
||||
Platform note: on macOS the wizard installs `gcloud`, `gogcli`, and `tailscale`
|
||||
via Homebrew; on Linux install them manually first.
|
||||
|
||||
Gateway auto-start (recommended):
|
||||
- When `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
|
||||
`gog gmail watch serve` on boot and auto-renews the watch.
|
||||
- Set `CLAWDIS_SKIP_GMAIL_WATCHER=1` to opt out (useful if you run the daemon yourself).
|
||||
|
||||
Manual daemon (starts `gog gmail watch serve` + auto-renew):
|
||||
Run the daemon (starts `gog gmail watch serve` + auto-renew):
|
||||
|
||||
```bash
|
||||
clawdis hooks gmail run
|
||||
|
||||
+3
-8
@@ -21,16 +21,11 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
||||
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
|
||||
|
||||
## Build & package
|
||||
Notes:
|
||||
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
|
||||
- Universal by default (`arm64 x86_64`). Override with `BUILD_ARCHS="arm64"` if you need arm-only.
|
||||
|
||||
```bash
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
# From repo root; set release IDs so Sparkle feed is enabled
|
||||
BUNDLE_ID=com.steipete.clawdis \
|
||||
APP_VERSION=0.1.0 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
APP_BUILD=0.1.0 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||
scripts/package-mac-app.sh
|
||||
@@ -48,7 +43,7 @@ scripts/create-dmg.sh dist/Clawdis.app dist/Clawdis-0.1.0.dmg
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdis-notary \
|
||||
BUNDLE_ID=com.steipete.clawdis \
|
||||
APP_VERSION=0.1.0 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
APP_BUILD=0.1.0 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
+8
-24
@@ -3,7 +3,7 @@ summary: "Command queue design that serializes auto-reply command execution"
|
||||
read_when:
|
||||
- Changing auto-reply execution or concurrency
|
||||
---
|
||||
# Command Queue (2026-01-03)
|
||||
# Command Queue (2025-11-25)
|
||||
|
||||
We now serialize command-based auto-replies (WhatsApp Web listener) through a tiny in-process queue to prevent multiple commands from running at once, while allowing safe parallelism across sessions.
|
||||
|
||||
@@ -19,16 +19,13 @@ We now serialize command-based auto-replies (WhatsApp Web listener) through a ti
|
||||
- Typing indicators (`onReplyStart`) still fire immediately on enqueue so user experience is unchanged while we wait our turn.
|
||||
|
||||
## Queue modes (per surface)
|
||||
Inbound messages can steer the current run, wait for a followup turn, or do both:
|
||||
- `steer`: inject immediately into the current run (cancels pending tool calls after the next tool boundary). If not streaming, falls back to followup.
|
||||
- `followup`: enqueue for the next agent turn after the current run ends.
|
||||
- `collect`: coalesce all queued messages into a **single** followup turn (default).
|
||||
- `steer-backlog` (aka `steer+backlog`): steer now **and** preserve the message for a followup turn.
|
||||
- `interrupt` (legacy): abort the active run for that session, then run the newest message.
|
||||
- `queue` (legacy alias): same as `steer`.
|
||||
Inbound messages can either queue or interrupt when a run is already active:
|
||||
- `queue`: serialize per session; if the agent is streaming, the new message is appended to the current run.
|
||||
- `interrupt`: abort the active run for that session, then run the newest message.
|
||||
|
||||
Defaults (when unset in config):
|
||||
- All surfaces → `collect`
|
||||
- WhatsApp + Telegram → `interrupt`
|
||||
- Discord + WebChat → `queue`
|
||||
|
||||
Configure globally or per surface via `routing.queue`:
|
||||
|
||||
@@ -36,29 +33,16 @@ Configure globally or per surface via `routing.queue`:
|
||||
{
|
||||
routing: {
|
||||
queue: {
|
||||
mode: "collect",
|
||||
debounceMs: 1000,
|
||||
cap: 20,
|
||||
drop: "summarize",
|
||||
bySurface: { discord: "steer-backlog" }
|
||||
mode: "interrupt",
|
||||
bySurface: { discord: "queue", telegram: "interrupt" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Queue options
|
||||
Options apply to `followup`, `collect`, and `steer-backlog` (and to `steer` when it falls back to followup):
|
||||
- `debounceMs`: wait for quiet before starting a followup turn (prevents “continue, continue”).
|
||||
- `cap`: max queued messages per session.
|
||||
- `drop`: overflow policy (`old`, `new`, `summarize`).
|
||||
|
||||
Summarize keeps a short bullet list of dropped messages and injects it as a synthetic followup prompt.
|
||||
Defaults: `debounceMs: 1000`, `cap: 20`, `drop: summarize`.
|
||||
|
||||
## Per-session overrides
|
||||
- `/queue <mode>` as a standalone command stores the mode for the current session.
|
||||
- `/queue <mode>` embedded in a message applies **once** (no persistence).
|
||||
- Options can be combined: `/queue collect debounce:2s cap:25 drop:summarize`
|
||||
- `/queue default` or `/queue reset` clears the session override.
|
||||
|
||||
## Scope and guarantees
|
||||
|
||||
@@ -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 Clawdis behavior
|
||||
---
|
||||
# Gateway TUI refactor plan
|
||||
|
||||
Updated: 2026-01-03
|
||||
|
||||
## Goals
|
||||
- Match pi-mono interactive TUI feel (editor, streaming, tool cards, selectors).
|
||||
- Keep Clawdis 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 + Clawdis slash commands + history/stream wiring.
|
||||
- [x] Docs + changelog updated for the new TUI behavior.
|
||||
- [x] Gate: lint, build, tests, docs list.
|
||||
Vendored
-10
@@ -28,16 +28,6 @@ You wake up fresh each session. These files are your continuity:
|
||||
|
||||
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
|
||||
|
||||
### 🧠 Memory Recall - Use qmd!
|
||||
When you need to remember something from the past, use `qmd` instead of grepping files:
|
||||
```bash
|
||||
qmd query "what happened at Christmas" # Semantic search with reranking
|
||||
qmd search "specific phrase" # BM25 keyword search
|
||||
qmd vsearch "conceptual question" # Pure vector similarity
|
||||
```
|
||||
Index your memory folder: `qmd index memory/`
|
||||
Vectors + BM25 + reranking finds things even with different wording.
|
||||
|
||||
## Safety
|
||||
|
||||
- Don't exfiltrate private data. Ever.
|
||||
|
||||
@@ -21,7 +21,6 @@ Core parameters:
|
||||
- `yieldMs` (auto-background after timeout, default 20000)
|
||||
- `background` (immediate background)
|
||||
- `timeout` (seconds; kills the process if exceeded, default 1800)
|
||||
- `stdinMode` (`pipe` | `pty`; `pty` uses node-pty for a real TTY with fallback warning)
|
||||
|
||||
Notes:
|
||||
- Returns `status: "running"` with a `sessionId` when backgrounded.
|
||||
@@ -105,45 +104,6 @@ Core actions:
|
||||
Notes:
|
||||
- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
|
||||
|
||||
### `discord`
|
||||
Send Discord reactions, stickers, or polls.
|
||||
|
||||
Core actions:
|
||||
- `react` (`channelId`, `messageId`, `emoji`)
|
||||
- `reactions` (`channelId`, `messageId`, optional `limit`)
|
||||
- `sticker` (`to`, `stickerIds`, optional `content`)
|
||||
- `poll` (`to`, `question`, `answers`, optional `allowMultiselect`, `durationHours`, `content`)
|
||||
- `permissions` (`channelId`)
|
||||
- `readMessages` (`channelId`, optional `limit`/`before`/`after`/`around`)
|
||||
- `sendMessage` (`to`, `content`, optional `mediaUrl`, `replyTo`)
|
||||
- `editMessage` (`channelId`, `messageId`, `content`)
|
||||
- `deleteMessage` (`channelId`, `messageId`)
|
||||
- `threadCreate` (`channelId`, `name`, optional `messageId`, `autoArchiveMinutes`)
|
||||
- `threadList` (`guildId`, optional `channelId`, `includeArchived`, `before`, `limit`)
|
||||
- `threadReply` (`channelId`, `content`, optional `mediaUrl`, `replyTo`)
|
||||
- `pinMessage`/`unpinMessage` (`channelId`, `messageId`)
|
||||
- `listPins` (`channelId`)
|
||||
- `searchMessages` (`guildId`, `content`, optional `channelId`/`channelIds`, `authorId`/`authorIds`, `limit`)
|
||||
- `memberInfo` (`guildId`, `userId`)
|
||||
- `roleInfo` (`guildId`)
|
||||
- `emojiList` (`guildId`)
|
||||
- `roleAdd`/`roleRemove` (`guildId`, `userId`, `roleId`)
|
||||
- `channelInfo` (`channelId`)
|
||||
- `channelList` (`guildId`)
|
||||
- `voiceStatus` (`guildId`, `userId`)
|
||||
- `eventList` (`guildId`)
|
||||
- `eventCreate` (`guildId`, `name`, `startTime`, optional `endTime`, `description`, `channelId`, `entityType`, `location`)
|
||||
- `timeout` (`guildId`, `userId`, optional `durationMinutes`, `until`, `reason`)
|
||||
- `kick` (`guildId`, `userId`, optional `reason`)
|
||||
- `ban` (`guildId`, `userId`, optional `reason`, `deleteMessageDays`)
|
||||
|
||||
Notes:
|
||||
- `to` accepts `channel:<id>` or `user:<id>`.
|
||||
- Polls require 2–10 answers and default to 24 hours.
|
||||
- `reactions` returns per-emoji user lists (limited to 100 per reaction).
|
||||
- `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`.
|
||||
- `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays).
|
||||
|
||||
## Parameters (common)
|
||||
|
||||
Gateway-backed tools (`clawdis_canvas`, `clawdis_nodes`, `clawdis_cron`):
|
||||
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
---
|
||||
summary: "Terminal UI (TUI) for Clawdis via the Gateway"
|
||||
read_when:
|
||||
- You want a terminal UI that connects to the Gateway from any machine
|
||||
- You are debugging the TUI client or Gateway chat stream
|
||||
---
|
||||
# TUI (Gateway chat client)
|
||||
|
||||
Updated: 2026-01-03
|
||||
|
||||
## What it is
|
||||
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
|
||||
- Uses Gateway agent events for tool cards while streaming responses.
|
||||
- Works locally (loopback) or remotely (Tailscale/SSH tunnel) without running a separate agent process.
|
||||
|
||||
## Run
|
||||
```bash
|
||||
clawdis tui
|
||||
```
|
||||
|
||||
### Remote
|
||||
```bash
|
||||
clawdis tui --url ws://127.0.0.1:18789 --token <gateway-token>
|
||||
```
|
||||
Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
|
||||
## Options
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`).
|
||||
- `--token <token>`: Gateway token (if required).
|
||||
- `--password <password>`: Gateway password (if required).
|
||||
- `--session <key>`: Session key (default: `session.mainKey` or `main`).
|
||||
- `--deliver`: Deliver assistant replies to the provider (default off).
|
||||
- `--thinking <level>`: Override thinking level for sends.
|
||||
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
|
||||
- `--history-limit <n>`: History entries to load (default 200).
|
||||
|
||||
## Controls
|
||||
- Enter: send message
|
||||
- Esc: abort active run
|
||||
- Ctrl+C: clear input (press twice to exit)
|
||||
- Ctrl+D: exit
|
||||
- Ctrl+L: model picker
|
||||
- Ctrl+P: session picker
|
||||
- Ctrl+O: toggle tool output expansion
|
||||
- Ctrl+T: toggle thinking visibility
|
||||
|
||||
## Slash commands
|
||||
- `/help`
|
||||
- `/status`
|
||||
- `/session <key>` (or `/sessions`)
|
||||
- `/model <provider/model>` (or `/models`)
|
||||
- `/think <off|minimal|low|medium|high>`
|
||||
- `/verbose <on|off>`
|
||||
- `/activation <mention|always>`
|
||||
- `/deliver <on|off>`
|
||||
- `/new` or `/reset`
|
||||
- `/abort`
|
||||
- `/settings`
|
||||
- `/exit`
|
||||
|
||||
## Notes
|
||||
- The TUI shows Gateway chat deltas (`event: chat`) and agent tool events.
|
||||
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
|
||||
|
||||
## Files
|
||||
- CLI: `src/cli/tui-cli.ts`
|
||||
- Runner: `src/tui/tui.ts`
|
||||
- Gateway client: `src/tui/gateway-chat.ts`
|
||||
@@ -89,6 +89,7 @@ code transforms.
|
||||
Mapping options (summary):
|
||||
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
|
||||
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
|
||||
- `hooks.mappings` are evaluated before presets; first match wins.
|
||||
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
|
||||
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
|
||||
- TS transforms require a TS loader (e.g. `tsx`) or precompiled `.js` at runtime.
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"@mariozechner/pi-agent-core": "^0.31.1",
|
||||
"@mariozechner/pi-ai": "^0.31.1",
|
||||
"@mariozechner/pi-coding-agent": "^0.31.1",
|
||||
"@mariozechner/pi-tui": "^0.31.1",
|
||||
"@sinclair/typebox": "0.34.46",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -86,7 +85,6 @@
|
||||
"commander": "^14.0.2",
|
||||
"croner": "^9.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"discord-api-types": "^0.38.33",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
@@ -94,7 +92,6 @@
|
||||
"grammy": "^1.39.2",
|
||||
"json5": "^2.2.3",
|
||||
"long": "5.3.2",
|
||||
"node-pty": "^1.1.0",
|
||||
"playwright-core": "1.57.0",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -1,91 +1,5 @@
|
||||
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
|
||||
index ff9cbcfebfac6b4370d85dc838f5cacf2a60ed64..42096c82aec925b412258348a36ba4a7025b283b 100644
|
||||
--- a/dist/providers/google-shared.js
|
||||
+++ b/dist/providers/google-shared.js
|
||||
@@ -140,6 +140,72 @@ export function convertMessages(model, context) {
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
+/**
|
||||
+ * Sanitize JSON Schema for Google Cloud Code Assist API.
|
||||
+ * Removes unsupported keywords like patternProperties, const, anyOf, etc.
|
||||
+ * and converts to a format compatible with Google's function declarations.
|
||||
+ */
|
||||
+function sanitizeSchemaForGoogle(schema) {
|
||||
+ if (!schema || typeof schema !== 'object') {
|
||||
+ return schema;
|
||||
+ }
|
||||
+ // If it's an array, sanitize each element
|
||||
+ if (Array.isArray(schema)) {
|
||||
+ return schema.map(item => sanitizeSchemaForGoogle(item));
|
||||
+ }
|
||||
+ const sanitized = {};
|
||||
+ // List of unsupported JSON Schema keywords that Google's API doesn't understand
|
||||
+ const unsupportedKeywords = [
|
||||
+ 'patternProperties',
|
||||
+ 'const',
|
||||
+ 'anyOf',
|
||||
+ 'oneOf',
|
||||
+ 'allOf',
|
||||
+ 'not',
|
||||
+ '$schema',
|
||||
+ '$id',
|
||||
+ '$ref',
|
||||
+ '$defs',
|
||||
+ 'definitions',
|
||||
+ 'if',
|
||||
+ 'then',
|
||||
+ 'else',
|
||||
+ 'dependentSchemas',
|
||||
+ 'dependentRequired',
|
||||
+ 'unevaluatedProperties',
|
||||
+ 'unevaluatedItems',
|
||||
+ 'contentEncoding',
|
||||
+ 'contentMediaType',
|
||||
+ 'contentSchema',
|
||||
+ 'deprecated',
|
||||
+ 'readOnly',
|
||||
+ 'writeOnly',
|
||||
+ 'examples',
|
||||
+ '$comment',
|
||||
+ 'additionalProperties',
|
||||
+ ];
|
||||
+ // TODO(steipete): lossy schema scrub; revisit when Google supports these keywords.
|
||||
+ for (const [key, value] of Object.entries(schema)) {
|
||||
+ // Skip unsupported keywords
|
||||
+ if (unsupportedKeywords.includes(key)) {
|
||||
+ continue;
|
||||
+ }
|
||||
+ // Recursively sanitize nested objects
|
||||
+ if (key === 'properties' && typeof value === 'object' && value !== null) {
|
||||
+ sanitized[key] = {};
|
||||
+ for (const [propKey, propValue] of Object.entries(value)) {
|
||||
+ sanitized[key][propKey] = sanitizeSchemaForGoogle(propValue);
|
||||
+ }
|
||||
+ } else if (key === 'items' && typeof value === 'object') {
|
||||
+ sanitized[key] = sanitizeSchemaForGoogle(value);
|
||||
+ } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
+ sanitized[key] = sanitizeSchemaForGoogle(value);
|
||||
+ } else {
|
||||
+ sanitized[key] = value;
|
||||
+ }
|
||||
+ }
|
||||
+ return sanitized;
|
||||
+}
|
||||
/**
|
||||
* Convert tools to Gemini function declarations format.
|
||||
*/
|
||||
@@ -151,7 +216,7 @@ export function convertTools(tools) {
|
||||
functionDeclarations: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
- parameters: tool.parameters,
|
||||
+ parameters: sanitizeSchemaForGoogle(tool.parameters),
|
||||
})),
|
||||
},
|
||||
];
|
||||
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
|
||||
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..31bae0aface1319487ce62d35f1f3b6ed334863e 100644
|
||||
index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..c2bc63f483f3285b00755901ba97db810221cea6 100644
|
||||
--- a/dist/providers/openai-responses.js
|
||||
+++ b/dist/providers/openai-responses.js
|
||||
@@ -486,7 +486,6 @@ function convertTools(tools) {
|
||||
|
||||
Generated
+5
-26
@@ -9,7 +9,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai':
|
||||
hash: 969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae
|
||||
hash: bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832
|
||||
path: patches/@mariozechner__pi-ai.patch
|
||||
'@mariozechner/pi-coding-agent@0.31.1':
|
||||
hash: d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745
|
||||
@@ -33,13 +33,10 @@ importers:
|
||||
version: 0.31.1(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
version: 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1
|
||||
'@sinclair/typebox':
|
||||
specifier: 0.34.46
|
||||
version: 0.34.46
|
||||
@@ -70,9 +67,6 @@ importers:
|
||||
detect-libc:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2
|
||||
discord-api-types:
|
||||
specifier: ^0.38.33
|
||||
version: 0.38.37
|
||||
discord.js:
|
||||
specifier: ^14.25.1
|
||||
version: 14.25.1
|
||||
@@ -94,9 +88,6 @@ importers:
|
||||
long:
|
||||
specifier: 5.3.2
|
||||
version: 5.3.2
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
playwright-core:
|
||||
specifier: 1.57.0
|
||||
version: 1.57.0
|
||||
@@ -2167,9 +2158,6 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -2188,9 +2176,6 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-pty@1.1.0:
|
||||
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
|
||||
|
||||
node-wav@0.0.2:
|
||||
resolution: {integrity: sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==}
|
||||
engines: {node: '>=4.4.0'}
|
||||
@@ -3353,7 +3338,7 @@ snapshots:
|
||||
|
||||
'@mariozechner/pi-agent-core@0.31.1(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui': 0.31.1
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@@ -3363,7 +3348,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)':
|
||||
'@mariozechner/pi-ai@0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
|
||||
'@google/genai': 1.34.0
|
||||
@@ -3386,7 +3371,7 @@ snapshots:
|
||||
'@mariozechner/pi-coding-agent@0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-agent-core': 0.31.1(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=bf3e904ebaad236b8c3bb48c7d1150a1463735e783acaab6d15d6cd381b43832)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui': 0.31.1
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
@@ -4819,8 +4804,6 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
@@ -4833,10 +4816,6 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-pty@1.1.0:
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
node-wav@0.0.2:
|
||||
optional: true
|
||||
|
||||
|
||||
+17
-145
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_ROOT="$ROOT_DIR/dist/Clawdis.app"
|
||||
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
|
||||
BUILD_PATH="$ROOT_DIR/apps/macos/.build"
|
||||
PRODUCT="Clawdis"
|
||||
BUNDLE_ID="${BUNDLE_ID:-com.steipete.clawdis.debug}"
|
||||
PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")"
|
||||
@@ -16,9 +16,6 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec
|
||||
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
|
||||
APP_BUILD="${APP_BUILD:-$GIT_BUILD_NUMBER}"
|
||||
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
|
||||
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-arm64 x86_64}"
|
||||
IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE"
|
||||
PRIMARY_ARCH="${BUILD_ARCHS[0]}"
|
||||
SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
|
||||
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml}"
|
||||
AUTO_CHECKS=true
|
||||
@@ -26,104 +23,6 @@ if [[ "$BUNDLE_ID" == *.debug ]]; then
|
||||
SPARKLE_FEED_URL=""
|
||||
AUTO_CHECKS=false
|
||||
fi
|
||||
if [[ "$AUTO_CHECKS" == "true" && ! "$APP_BUILD" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: APP_BUILD must be numeric for Sparkle compare (CFBundleVersion). Got: $APP_BUILD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_path_for_arch() {
|
||||
echo "$BUILD_ROOT/$1"
|
||||
}
|
||||
|
||||
bin_for_arch() {
|
||||
echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/$PRODUCT"
|
||||
}
|
||||
|
||||
sparkle_framework_for_arch() {
|
||||
echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/Sparkle.framework"
|
||||
}
|
||||
|
||||
merge_framework_machos() {
|
||||
local primary="$1"
|
||||
local dest="$2"
|
||||
shift 2
|
||||
local others=("$@")
|
||||
|
||||
archs_for() {
|
||||
/usr/bin/lipo -info "$1" | /usr/bin/sed -E 's/.*are: //; s/.*architecture: //'
|
||||
}
|
||||
|
||||
arch_in_list() {
|
||||
local needle="$1"
|
||||
shift
|
||||
for item in "$@"; do
|
||||
if [[ "$item" == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
while IFS= read -r -d '' file; do
|
||||
if /usr/bin/file "$file" | /usr/bin/grep -q "Mach-O"; then
|
||||
local rel="${file#$primary/}"
|
||||
local primary_archs
|
||||
primary_archs=$(archs_for "$file")
|
||||
IFS=' ' read -r -a primary_arch_array <<< "$primary_archs"
|
||||
|
||||
local missing_files=()
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
for fw in "${others[@]}"; do
|
||||
local other_file="$fw/$rel"
|
||||
if [[ ! -f "$other_file" ]]; then
|
||||
echo "ERROR: Missing $rel in $fw" >&2
|
||||
rm -rf "$tmp_dir"
|
||||
exit 1
|
||||
fi
|
||||
if /usr/bin/file "$other_file" | /usr/bin/grep -q "Mach-O"; then
|
||||
local other_archs
|
||||
other_archs=$(archs_for "$other_file")
|
||||
IFS=' ' read -r -a other_arch_array <<< "$other_archs"
|
||||
for arch in "${other_arch_array[@]}"; do
|
||||
if ! arch_in_list "$arch" "${primary_arch_array[@]}"; then
|
||||
local thin_file="$tmp_dir/$(echo "$rel" | tr '/' '_')-$arch"
|
||||
/usr/bin/lipo -thin "$arch" "$other_file" -output "$thin_file"
|
||||
missing_files+=("$thin_file")
|
||||
primary_arch_array+=("$arch")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${#missing_files[@]}" -gt 0 ]]; then
|
||||
/usr/bin/lipo -create "$file" "${missing_files[@]}" -output "$dest/$rel"
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
fi
|
||||
done < <(find "$primary" -type f -print0)
|
||||
}
|
||||
|
||||
build_relay_binary() {
|
||||
local arch="$1"
|
||||
local out="$2"
|
||||
local define_arg="__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
|
||||
local bun_bin="bun"
|
||||
local -a cmd=("$bun_bin" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
|
||||
if [[ "$arch" == "x86_64" ]]; then
|
||||
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
|
||||
echo "ERROR: Rosetta is required to build the x86_64 relay. Install Rosetta and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
local bun_x86="${BUN_X86_64_BIN:-$HOME/.bun-x64/bin/bun}"
|
||||
if [[ -x "$bun_x86" ]]; then
|
||||
cmd=("$bun_x86" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
|
||||
fi
|
||||
arch -x86_64 "${cmd[@]}"
|
||||
else
|
||||
"${cmd[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "📦 Ensuring deps (pnpm install)"
|
||||
(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted)
|
||||
@@ -143,14 +42,11 @@ fi
|
||||
|
||||
cd "$ROOT_DIR/apps/macos"
|
||||
|
||||
echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [${BUILD_ARCHS[*]}]"
|
||||
for arch in "${BUILD_ARCHS[@]}"; do
|
||||
BUILD_PATH="$(build_path_for_arch "$arch")"
|
||||
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" --arch "$arch" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
|
||||
done
|
||||
echo "🔨 Building $PRODUCT ($BUILD_CONFIG)"
|
||||
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
|
||||
|
||||
BIN_PRIMARY="$(bin_for_arch "$PRIMARY_ARCH")"
|
||||
echo "pkg: binary $BIN_PRIMARY" >&2
|
||||
BIN="$BUILD_PATH/$BUILD_CONFIG/$PRODUCT"
|
||||
echo "pkg: binary $BIN" >&2
|
||||
echo "🧹 Cleaning old app bundle"
|
||||
rm -rf "$APP_ROOT"
|
||||
mkdir -p "$APP_ROOT/Contents/MacOS"
|
||||
@@ -181,32 +77,15 @@ else
|
||||
fi
|
||||
|
||||
echo "🚚 Copying binary"
|
||||
cp "$BIN_PRIMARY" "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
|
||||
BIN_INPUTS=()
|
||||
for arch in "${BUILD_ARCHS[@]}"; do
|
||||
BIN_INPUTS+=("$(bin_for_arch "$arch")")
|
||||
done
|
||||
/usr/bin/lipo -create "${BIN_INPUTS[@]}" -output "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||
fi
|
||||
cp "$BIN" "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||
chmod +x "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||
# SwiftPM outputs ad-hoc signed binaries; strip the signature before install_name_tool to avoid warnings.
|
||||
/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/Clawdis" 2>/dev/null || true
|
||||
|
||||
SPARKLE_FRAMEWORK_PRIMARY="$(sparkle_framework_for_arch "$PRIMARY_ARCH")"
|
||||
if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then
|
||||
SPARKLE_FRAMEWORK="$BUILD_PATH/$BUILD_CONFIG/Sparkle.framework"
|
||||
if [ -d "$SPARKLE_FRAMEWORK" ]; then
|
||||
echo "✨ Embedding Sparkle.framework"
|
||||
cp -R "$SPARKLE_FRAMEWORK_PRIMARY" "$APP_ROOT/Contents/Frameworks/"
|
||||
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
|
||||
OTHER_FRAMEWORKS=()
|
||||
for arch in "${BUILD_ARCHS[@]}"; do
|
||||
if [[ "$arch" == "$PRIMARY_ARCH" ]]; then
|
||||
continue
|
||||
fi
|
||||
OTHER_FRAMEWORKS+=("$(sparkle_framework_for_arch "$arch")")
|
||||
done
|
||||
merge_framework_machos "$SPARKLE_FRAMEWORK_PRIMARY" "$APP_ROOT/Contents/Frameworks/Sparkle.framework" "${OTHER_FRAMEWORKS[@]}"
|
||||
fi
|
||||
cp -R "$SPARKLE_FRAMEWORK" "$APP_ROOT/Contents/Frameworks/"
|
||||
chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework"
|
||||
fi
|
||||
|
||||
@@ -227,21 +106,14 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
|
||||
|
||||
echo "🧰 Building bundled relay (bun --compile)"
|
||||
mkdir -p "$RELAY_DIR"
|
||||
RELAY_OUT="$RELAY_DIR/clawdis"
|
||||
RELAY_BUILD_DIR="$RELAY_DIR/.relay-build"
|
||||
rm -rf "$RELAY_BUILD_DIR"
|
||||
mkdir -p "$RELAY_BUILD_DIR"
|
||||
for arch in "${BUILD_ARCHS[@]}"; do
|
||||
RELAY_ARCH_OUT="$RELAY_BUILD_DIR/clawdis-$arch"
|
||||
build_relay_binary "$arch" "$RELAY_ARCH_OUT"
|
||||
chmod +x "$RELAY_ARCH_OUT"
|
||||
done
|
||||
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
|
||||
/usr/bin/lipo -create "$RELAY_BUILD_DIR"/clawdis-* -output "$RELAY_OUT"
|
||||
else
|
||||
cp "$RELAY_BUILD_DIR/clawdis-${BUILD_ARCHS[0]}" "$RELAY_OUT"
|
||||
fi
|
||||
rm -rf "$RELAY_BUILD_DIR"
|
||||
RELAY_OUT="$RELAY_DIR/clawdis"
|
||||
bun build "$ROOT_DIR/dist/macos/relay.js" \
|
||||
--compile \
|
||||
--bytecode \
|
||||
--outfile "$RELAY_OUT" \
|
||||
-e electron \
|
||||
--define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
|
||||
chmod +x "$RELAY_OUT"
|
||||
|
||||
echo "🎨 Copying gateway A2UI host assets"
|
||||
rm -rf "$RELAY_DIR/a2ui"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
name: apple-notes
|
||||
description: Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes). Use when a user asks Clawdis to add a note, list notes, search notes, or manage note folders.
|
||||
homepage: https://github.com/antoniorodr/memo
|
||||
metadata: {"clawdis":{"emoji":"📝","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
|
||||
---
|
||||
|
||||
# Apple Notes CLI
|
||||
|
||||
Use `memo notes` to manage Apple Notes directly from the terminal. Create, view, edit, delete, search, move notes between folders, and export to HTML/Markdown.
|
||||
|
||||
Setup
|
||||
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
|
||||
- Manual (pip): `pip install .` (after cloning the repo)
|
||||
- macOS-only; if prompted, grant Automation access to Notes.app.
|
||||
|
||||
View Notes
|
||||
- List all notes: `memo notes`
|
||||
- Filter by folder: `memo notes -f "Folder Name"`
|
||||
- Search notes (fuzzy): `memo notes -s "query"`
|
||||
|
||||
Create Notes
|
||||
- Add a new note: `memo notes -a`
|
||||
- Opens an interactive editor to compose the note.
|
||||
- Quick add with title: `memo notes -a "Note Title"`
|
||||
|
||||
Edit Notes
|
||||
- Edit existing note: `memo notes -e`
|
||||
- Interactive selection of note to edit.
|
||||
|
||||
Delete Notes
|
||||
- Delete a note: `memo notes -d`
|
||||
- Interactive selection of note to delete.
|
||||
|
||||
Move Notes
|
||||
- Move note to folder: `memo notes -m`
|
||||
- Interactive selection of note and destination folder.
|
||||
|
||||
Export Notes
|
||||
- Export to HTML/Markdown: `memo notes -ex`
|
||||
- Exports selected note; uses Mistune for markdown processing.
|
||||
|
||||
Limitations
|
||||
- Cannot edit notes containing images or attachments.
|
||||
- Interactive prompts may require terminal access.
|
||||
|
||||
Notes
|
||||
- macOS-only.
|
||||
- Requires Apple Notes.app to be accessible.
|
||||
- For automation, grant permissions in System Settings > Privacy & Security > Automation.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
name: apple-reminders
|
||||
description: Manage Apple Reminders via the `memo` CLI on macOS (create, complete, and delete reminders). Use when a user asks Clawdis to add a reminder, mark reminders as done, or manage their reminder list.
|
||||
homepage: https://github.com/antoniorodr/memo
|
||||
metadata: {"clawdis":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
|
||||
---
|
||||
|
||||
# Apple Reminders CLI
|
||||
|
||||
Use `memo rem` to manage Apple Reminders directly from the terminal. Create, complete, and delete reminders with simple commands.
|
||||
|
||||
Setup
|
||||
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
|
||||
- Manual (pip): `pip install .` (after cloning the repo)
|
||||
- macOS-only; if prompted, grant Automation access to Reminders.app.
|
||||
|
||||
View Reminders
|
||||
- List all reminders: `memo rem`
|
||||
|
||||
Create Reminders
|
||||
- Add a new reminder: `memo rem -a`
|
||||
- Opens interactive prompt to create a reminder.
|
||||
- Quick add: `memo rem -a "Buy groceries"`
|
||||
|
||||
Complete Reminders
|
||||
- Mark reminder as done: `memo rem -c`
|
||||
- Interactive selection of reminder to complete.
|
||||
|
||||
Delete Reminders
|
||||
- Delete a reminder: `memo rem -d`
|
||||
- Interactive selection of reminder to delete.
|
||||
|
||||
Examples
|
||||
- Add a quick reminder: `memo rem -a "Call dentist tomorrow"`
|
||||
- List all reminders: `memo rem`
|
||||
- Complete a reminder: `memo rem -c` (then select from list)
|
||||
- Delete a reminder: `memo rem -d` (then select from list)
|
||||
|
||||
Notes
|
||||
- macOS-only.
|
||||
- Requires Apple Reminders.app to be accessible.
|
||||
- For automation, grant permissions in System Settings > Privacy & Security > Automation.
|
||||
- The `memo` CLI shares the same installation for both Notes and Reminders functionality.
|
||||
@@ -1,272 +0,0 @@
|
||||
---
|
||||
name: discord
|
||||
description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post stickers, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
|
||||
---
|
||||
|
||||
# Discord Actions
|
||||
|
||||
## Overview
|
||||
|
||||
Use `discord` to manage messages, reactions, threads, polls, and moderation. You can disable groups via `discord.actions.*` (defaults to enabled, except roles/moderation). The tool uses the bot token configured for Clawdis.
|
||||
|
||||
## Inputs to collect
|
||||
|
||||
- For reactions: `channelId`, `messageId`, and an `emoji`.
|
||||
- For stickers/polls: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text.
|
||||
- Polls also need a `question` plus 2–10 `answers`.
|
||||
|
||||
Message context lines include `discord message id` and `channel` fields you can reuse directly.
|
||||
|
||||
## Actions
|
||||
|
||||
### React to a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "react",
|
||||
"channelId": "123",
|
||||
"messageId": "456",
|
||||
"emoji": "✅"
|
||||
}
|
||||
```
|
||||
|
||||
### List reactions + users
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "reactions",
|
||||
"channelId": "123",
|
||||
"messageId": "456",
|
||||
"limit": 100
|
||||
}
|
||||
```
|
||||
|
||||
### Send a sticker
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sticker",
|
||||
"to": "channel:123",
|
||||
"stickerIds": ["9876543210"],
|
||||
"content": "Nice work!"
|
||||
}
|
||||
```
|
||||
|
||||
- Up to 3 sticker IDs per message.
|
||||
- `to` can be `user:<id>` for DMs.
|
||||
|
||||
### Create a poll
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "poll",
|
||||
"to": "channel:123",
|
||||
"question": "Lunch?",
|
||||
"answers": ["Pizza", "Sushi", "Salad"],
|
||||
"allowMultiselect": false,
|
||||
"durationHours": 24,
|
||||
"content": "Vote now"
|
||||
}
|
||||
```
|
||||
|
||||
- `durationHours` defaults to 24; max 32 days (768 hours).
|
||||
|
||||
### Check bot permissions for a channel
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "permissions",
|
||||
"channelId": "123"
|
||||
}
|
||||
```
|
||||
|
||||
## Ideas to try
|
||||
|
||||
- React with ✅/⚠️ to mark status updates.
|
||||
- Post a quick poll for release decisions or meeting times.
|
||||
- Send celebratory stickers after successful deploys.
|
||||
- Run weekly “priority check” polls in team channels.
|
||||
- DM stickers as acknowledgements when a user’s request is completed.
|
||||
|
||||
## Action gating
|
||||
|
||||
Use `discord.actions.*` to disable action groups:
|
||||
- `reactions` (react + reactions list + emojiList)
|
||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||
- `roles` (role add/remove, default `false`)
|
||||
- `moderation` (timeout/kick/ban, default `false`)
|
||||
### Read recent messages
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "readMessages",
|
||||
"channelId": "123",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Send/edit/delete a message
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "sendMessage",
|
||||
"to": "channel:123",
|
||||
"content": "Hello from Clawdis"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "editMessage",
|
||||
"channelId": "123",
|
||||
"messageId": "456",
|
||||
"content": "Fixed typo"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "deleteMessage",
|
||||
"channelId": "123",
|
||||
"messageId": "456"
|
||||
}
|
||||
```
|
||||
|
||||
### Threads
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "threadCreate",
|
||||
"channelId": "123",
|
||||
"name": "Bug triage",
|
||||
"messageId": "456"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "threadList",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "threadReply",
|
||||
"channelId": "777",
|
||||
"content": "Replying in thread"
|
||||
}
|
||||
```
|
||||
|
||||
### Pins
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "pinMessage",
|
||||
"channelId": "123",
|
||||
"messageId": "456"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "listPins",
|
||||
"channelId": "123"
|
||||
}
|
||||
```
|
||||
|
||||
### Search messages
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "searchMessages",
|
||||
"guildId": "999",
|
||||
"content": "release notes",
|
||||
"channelIds": ["123", "456"],
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Member + role info
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "memberInfo",
|
||||
"guildId": "999",
|
||||
"userId": "111"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "roleInfo",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
### List available custom emojis
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "emojiList",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
### Role changes (disabled by default)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "roleAdd",
|
||||
"guildId": "999",
|
||||
"userId": "111",
|
||||
"roleId": "222"
|
||||
}
|
||||
```
|
||||
|
||||
### Channel info
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channelInfo",
|
||||
"channelId": "123"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "channelList",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
### Voice status
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "voiceStatus",
|
||||
"guildId": "999",
|
||||
"userId": "111"
|
||||
}
|
||||
```
|
||||
|
||||
### Scheduled events
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "eventList",
|
||||
"guildId": "999"
|
||||
}
|
||||
```
|
||||
|
||||
### Moderation (disabled by default)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "timeout",
|
||||
"guildId": "999",
|
||||
"userId": "111",
|
||||
"durationMinutes": 10
|
||||
}
|
||||
```
|
||||
+4
-14
@@ -1,36 +1,26 @@
|
||||
---
|
||||
name: gog
|
||||
description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
|
||||
description: Google Workspace CLI for Gmail, Calendar, Drive, and Contacts.
|
||||
homepage: https://gogcli.sh
|
||||
metadata: {"clawdis":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
|
||||
---
|
||||
|
||||
# gog
|
||||
|
||||
Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
|
||||
Use `gog` for Gmail/Calendar/Drive/Contacts. Requires OAuth setup.
|
||||
|
||||
Setup (once)
|
||||
- `gog auth credentials /path/to/client_secret.json`
|
||||
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
|
||||
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts`
|
||||
- `gog auth list`
|
||||
|
||||
Common commands
|
||||
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
|
||||
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
|
||||
- Calendar: `gog calendar events <calendarId> --from <iso> --to <iso>`
|
||||
- Drive search: `gog drive search "query" --max 10`
|
||||
- Drive: `gog drive search "query" --max 10`
|
||||
- Contacts: `gog contacts list --max 20`
|
||||
- Sheets get: `gog sheets get <sheetId> "Tab!A1:D10" --json`
|
||||
- Sheets update: `gog sheets update <sheetId> "Tab!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED`
|
||||
- Sheets append: `gog sheets append <sheetId> "Tab!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS`
|
||||
- Sheets clear: `gog sheets clear <sheetId> "Tab!A2:Z"`
|
||||
- Sheets metadata: `gog sheets metadata <sheetId> --json`
|
||||
- Docs export: `gog docs export <docId> --format txt --out /tmp/doc.txt`
|
||||
- Docs cat: `gog docs cat <docId>`
|
||||
|
||||
Notes
|
||||
- Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`.
|
||||
- For scripting, prefer `--json` plus `--no-input`.
|
||||
- Sheets values can be passed via `--values-json` (recommended) or as inline rows.
|
||||
- Docs supports export/cat/copy. In-place edits require a Docs API client (not in gog).
|
||||
- Confirm before sending mail or creating events.
|
||||
@@ -41,22 +41,3 @@ Voice defaults
|
||||
- `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`
|
||||
|
||||
Confirm voice + speaker before long output.
|
||||
|
||||
## Chat voice responses
|
||||
|
||||
When Peter asks for a "voice" reply (e.g., "crazy scientist voice", "explain in voice"), generate audio and send it:
|
||||
|
||||
```bash
|
||||
# Generate audio file
|
||||
sag -v Clawd -o /tmp/voice-reply.mp3 "Your message here"
|
||||
|
||||
# Then include in reply:
|
||||
# MEDIA:/tmp/voice-reply.mp3
|
||||
```
|
||||
|
||||
Voice character tips:
|
||||
- Crazy scientist: Use `[excited]` tags, dramatic pauses `[short pause]`, vary intensity
|
||||
- Calm: Use `[whispers]` or slower pacing
|
||||
- Dramatic: Use `[sings]` or `[shouts]` sparingly
|
||||
|
||||
Default voice for Clawd: `lj2rcrvANS3gaWWnczSX` (or just `-v Clawd`)
|
||||
@@ -1,28 +1,19 @@
|
||||
---
|
||||
name: spotify-player
|
||||
description: Terminal Spotify playback/search via spogo (preferred) or spotify_player.
|
||||
description: Terminal Spotify client (TUI + CLI commands) for playback and search.
|
||||
homepage: https://www.spotify.com
|
||||
metadata: {"clawdis":{"emoji":"🎵","requires":{"anyBins":["spogo","spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spogo","tap":"steipete/tap","bins":["spogo"],"label":"Install spogo (brew)"},{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify_player (brew)"}]}}
|
||||
metadata: {"clawdis":{"emoji":"🎵","requires":{"bins":["spotify_player"]},"install":[{"id":"brew","kind":"brew","formula":"spotify_player","bins":["spotify_player"],"label":"Install spotify-player (brew)"}]}}
|
||||
---
|
||||
|
||||
# spogo / spotify_player
|
||||
# spotify_player
|
||||
|
||||
Use `spogo` **(preferred)** for Spotify playback/search. Fall back to `spotify_player` if needed.
|
||||
Use `spotify_player` for Spotify playback/search in the terminal.
|
||||
|
||||
Requirements
|
||||
- Spotify Premium account.
|
||||
- Either `spogo` or `spotify_player` installed.
|
||||
|
||||
spogo setup
|
||||
- Import cookies: `spogo auth import --browser chrome`
|
||||
- First-time auth: `spotify_player authenticate`.
|
||||
|
||||
Common CLI commands
|
||||
- Search: `spogo search track "query"`
|
||||
- Playback: `spogo play|pause|next|prev`
|
||||
- Devices: `spogo device list`, `spogo device set "<name|id>"`
|
||||
- Status: `spogo status`
|
||||
|
||||
spotify_player commands (fallback)
|
||||
- Search: `spotify_player search "query"`
|
||||
- Playback: `spotify_player playback play|pause|next|previous`
|
||||
- Connect device: `spotify_player connect`
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("bash process registry", () => {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||
stdinMode: "pipe",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 10,
|
||||
@@ -49,7 +48,6 @@ describe("bash process registry", () => {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123 } as ChildProcessWithoutNullStreams,
|
||||
stdinMode: "pipe",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 100,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { IPty } from "node-pty";
|
||||
|
||||
const DEFAULT_JOB_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const MIN_JOB_TTL_MS = 60 * 1000; // 1 minute
|
||||
@@ -16,15 +15,10 @@ let jobTtlMs = clampTtl(
|
||||
|
||||
export type ProcessStatus = "running" | "completed" | "failed" | "killed";
|
||||
|
||||
export type ProcessStdinMode = "pipe" | "pty";
|
||||
|
||||
export interface ProcessSession {
|
||||
id: string;
|
||||
command: string;
|
||||
child?: ChildProcessWithoutNullStreams;
|
||||
pty?: IPty;
|
||||
pid?: number;
|
||||
stdinMode: ProcessStdinMode;
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
startedAt: number;
|
||||
cwd?: string;
|
||||
maxOutputChars: number;
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("bash tool pty mode", () => {
|
||||
it("falls back to pipe with warning when node-pty fails to load", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node-pty", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const { createBashTool } = await import("./bash-tools.js");
|
||||
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
|
||||
const result = await tool.execute("call", {
|
||||
command: "echo test",
|
||||
stdinMode: "pty",
|
||||
});
|
||||
|
||||
const text = result.content.find((c) => c.type === "text")?.text ?? "";
|
||||
expect(text).toContain("Warning: node-pty failed to load");
|
||||
expect(text).toContain("falling back to pipe mode.");
|
||||
|
||||
vi.doUnmock("node-pty");
|
||||
});
|
||||
|
||||
it("uses node-pty when available", async () => {
|
||||
vi.resetModules();
|
||||
const spawn = vi.fn(() => {
|
||||
let onData: ((data: string) => void) | undefined;
|
||||
let onExit:
|
||||
| ((event: { exitCode: number | null; signal?: number | null }) => void)
|
||||
| undefined;
|
||||
const pty = {
|
||||
pid: 4321,
|
||||
onData: (cb: (data: string) => void) => {
|
||||
onData = cb;
|
||||
},
|
||||
onExit: (
|
||||
cb: (event: {
|
||||
exitCode: number | null;
|
||||
signal?: number | null;
|
||||
}) => void,
|
||||
) => {
|
||||
onExit = cb;
|
||||
},
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
setTimeout(() => {
|
||||
onData?.("hello\n");
|
||||
onExit?.({ exitCode: 0, signal: null });
|
||||
}, 10);
|
||||
return pty;
|
||||
});
|
||||
vi.doMock("node-pty", () => ({ spawn }));
|
||||
|
||||
const { createBashTool } = await import("./bash-tools.js");
|
||||
const tool = createBashTool({ backgroundMs: 10, timeoutSec: 1 });
|
||||
const result = await tool.execute("call", {
|
||||
command: "ignored",
|
||||
stdinMode: "pty",
|
||||
});
|
||||
|
||||
const text = result.content.find((c) => c.type === "text")?.text ?? "";
|
||||
expect(text).toContain("hello");
|
||||
expect(text).not.toContain("Warning:");
|
||||
|
||||
vi.doUnmock("node-pty");
|
||||
});
|
||||
});
|
||||
+66
-281
@@ -1,11 +1,7 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { IPty } from "node-pty";
|
||||
|
||||
import {
|
||||
addSession,
|
||||
@@ -18,7 +14,6 @@ import {
|
||||
listRunningSessions,
|
||||
markBackgrounded,
|
||||
markExited,
|
||||
type ProcessStdinMode,
|
||||
setJobTtlMs,
|
||||
} from "./bash-process-registry.js";
|
||||
import {
|
||||
@@ -34,23 +29,6 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||
1_000,
|
||||
150_000,
|
||||
);
|
||||
const DEFAULT_SHELL_PATH = "/bin/sh";
|
||||
const DEFAULT_PATH =
|
||||
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
const DEFAULT_PTY_NAME = "xterm-256color";
|
||||
|
||||
type PtyModule = typeof import("node-pty");
|
||||
type PtyLoadResult = { module: PtyModule | null; error?: unknown };
|
||||
let ptyModulePromise: Promise<PtyLoadResult> | null = null;
|
||||
|
||||
async function loadPtyModule(): Promise<PtyLoadResult> {
|
||||
if (!ptyModulePromise) {
|
||||
ptyModulePromise = import("node-pty")
|
||||
.then((mod) => ({ module: mod }))
|
||||
.catch((error) => ({ module: null, error }));
|
||||
}
|
||||
return ptyModulePromise;
|
||||
}
|
||||
|
||||
const stringEnum = (
|
||||
values: readonly string[],
|
||||
@@ -94,7 +72,7 @@ const bashSchema = Type.Object({
|
||||
),
|
||||
stdinMode: Type.Optional(
|
||||
stringEnum(["pipe", "pty"] as const, {
|
||||
description: "stdin mode (pipe or pty when node-pty is available)",
|
||||
description: "Only pipe is supported",
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -149,6 +127,9 @@ export function createBashTool(
|
||||
if (!params.command) {
|
||||
throw new Error("Provide a command to start.");
|
||||
}
|
||||
if (params.stdinMode && params.stdinMode !== "pipe") {
|
||||
throw new Error('Only stdinMode "pipe" is supported right now.');
|
||||
}
|
||||
|
||||
const yieldWindow = params.background
|
||||
? 0
|
||||
@@ -161,92 +142,25 @@ export function createBashTool(
|
||||
const maxOutput = DEFAULT_MAX_OUTPUT;
|
||||
const startedAt = Date.now();
|
||||
const sessionId = randomUUID();
|
||||
const warnings: string[] = [];
|
||||
const workdir = resolveWorkdir(
|
||||
params.workdir?.trim() || process.cwd(),
|
||||
warnings,
|
||||
);
|
||||
const workdir = params.workdir?.trim() || process.cwd();
|
||||
|
||||
const { shell, args: shellArgs } = getShellConfig();
|
||||
const env = params.env ? { ...process.env, ...params.env } : process.env;
|
||||
const requestedStdinMode = params.stdinMode === "pty" ? "pty" : "pipe";
|
||||
let stdinMode: ProcessStdinMode = requestedStdinMode;
|
||||
let warning: string | null = null;
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
let pty: IPty | undefined;
|
||||
|
||||
if (stdinMode === "pty") {
|
||||
const { module: ptyModule, error: ptyError } = await loadPtyModule();
|
||||
if (!ptyModule) {
|
||||
warning =
|
||||
`Warning: node-pty failed to load${formatPtyError(ptyError)}; ` +
|
||||
"falling back to pipe mode.";
|
||||
stdinMode = "pipe";
|
||||
} else {
|
||||
const ptyEnv = ensurePath({
|
||||
...env,
|
||||
TERM: env.TERM ?? DEFAULT_PTY_NAME,
|
||||
} as Record<string, string>);
|
||||
const ptyShell = resolveShellPath(shell, ptyEnv);
|
||||
try {
|
||||
pty = ptyModule.spawn(ptyShell, [...shellArgs, params.command], {
|
||||
cwd: workdir,
|
||||
env: ptyEnv,
|
||||
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
ptyShell !== DEFAULT_SHELL_PATH &&
|
||||
existsSync(DEFAULT_SHELL_PATH)
|
||||
) {
|
||||
try {
|
||||
pty = ptyModule.spawn(
|
||||
DEFAULT_SHELL_PATH,
|
||||
[...shellArgs, params.command],
|
||||
{
|
||||
cwd: workdir,
|
||||
env: ptyEnv,
|
||||
name: ptyEnv.TERM || DEFAULT_PTY_NAME,
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
},
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
warning =
|
||||
`Warning: node-pty failed to start${formatPtyError(fallbackError)}; ` +
|
||||
"falling back to pipe mode.";
|
||||
stdinMode = "pipe";
|
||||
}
|
||||
} else {
|
||||
warning =
|
||||
`Warning: node-pty failed to start${formatPtyError(error)}; ` +
|
||||
"falling back to pipe mode.";
|
||||
stdinMode = "pipe";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stdinMode === "pipe") {
|
||||
child = spawn(shell, [...shellArgs, params.command], {
|
||||
const child: ChildProcessWithoutNullStreams = spawn(
|
||||
shell,
|
||||
[...shellArgs, params.command],
|
||||
{
|
||||
cwd: workdir,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
if (warning) warnings.push(warning);
|
||||
},
|
||||
);
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
command: params.command,
|
||||
child,
|
||||
pty,
|
||||
pid: child?.pid ?? pty?.pid,
|
||||
stdinMode,
|
||||
startedAt,
|
||||
cwd: workdir,
|
||||
maxOutputChars: maxOutput,
|
||||
@@ -276,7 +190,9 @@ export function createBashTool(
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
killSession(session);
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal?.aborted) onAbort();
|
||||
@@ -296,46 +212,33 @@ export function createBashTool(
|
||||
const emitUpdate = () => {
|
||||
if (!onUpdate) return;
|
||||
const tailText = session.tail || session.aggregated;
|
||||
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: warningText + (tailText || "") }],
|
||||
content: [{ type: "text", text: tailText || "" }],
|
||||
details: {
|
||||
status: "running",
|
||||
sessionId,
|
||||
pid: session.pid ?? undefined,
|
||||
pid: child.pid ?? undefined,
|
||||
startedAt,
|
||||
tail: session.tail,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (child) {
|
||||
child.stdout.on("data", (data) => {
|
||||
const str = sanitizeBinaryOutput(data.toString());
|
||||
for (const chunk of chunkString(str)) {
|
||||
appendOutput(session, "stdout", chunk);
|
||||
emitUpdate();
|
||||
}
|
||||
});
|
||||
child.stdout.on("data", (data) => {
|
||||
const str = sanitizeBinaryOutput(data.toString());
|
||||
for (const chunk of chunkString(str)) {
|
||||
appendOutput(session, "stdout", chunk);
|
||||
emitUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
const str = sanitizeBinaryOutput(data.toString());
|
||||
for (const chunk of chunkString(str)) {
|
||||
appendOutput(session, "stderr", chunk);
|
||||
emitUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (pty) {
|
||||
pty.onData((data) => {
|
||||
const str = sanitizeBinaryOutput(data);
|
||||
for (const chunk of chunkString(str)) {
|
||||
appendOutput(session, "stdout", chunk);
|
||||
emitUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
child.stderr.on("data", (data) => {
|
||||
const str = sanitizeBinaryOutput(data.toString());
|
||||
for (const chunk of chunkString(str)) {
|
||||
appendOutput(session, "stderr", chunk);
|
||||
emitUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise<AgentToolResult<BashToolDetails>>(
|
||||
(resolve, reject) => {
|
||||
@@ -346,15 +249,14 @@ export function createBashTool(
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
|
||||
`Command still running (session ${sessionId}, pid ${session.pid ?? "n/a"}). ` +
|
||||
`Command still running (session ${sessionId}, pid ${child.pid ?? "n/a"}). ` +
|
||||
"Use process (list/poll/log/write/kill/clear/remove) for follow-up.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
status: "running",
|
||||
sessionId,
|
||||
pid: session.pid ?? undefined,
|
||||
pid: child.pid ?? undefined,
|
||||
startedAt,
|
||||
tail: session.tail,
|
||||
},
|
||||
@@ -381,10 +283,7 @@ export function createBashTool(
|
||||
}, yieldWindow);
|
||||
}
|
||||
|
||||
const handleExit = (
|
||||
code: number | null,
|
||||
exitSignal: NodeJS.Signals | number | null,
|
||||
) => {
|
||||
child.once("exit", (code, exitSignal) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
@@ -416,14 +315,7 @@ export function createBashTool(
|
||||
|
||||
settle(() =>
|
||||
resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
`${warnings.length ? `${warnings.join("\n")}\n\n` : ""}` +
|
||||
(aggregated || "(no output)"),
|
||||
},
|
||||
],
|
||||
content: [{ type: "text", text: aggregated || "(no output)" }],
|
||||
details: {
|
||||
status: "completed",
|
||||
exitCode: code ?? 0,
|
||||
@@ -432,26 +324,14 @@ export function createBashTool(
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
if (child) {
|
||||
child.once("exit", (code, exitSignal) => {
|
||||
handleExit(code, exitSignal);
|
||||
});
|
||||
|
||||
child.once("error", (err) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
markExited(session, null, null, "failed");
|
||||
settle(() => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
if (pty) {
|
||||
pty.onExit(({ exitCode, signal }) => {
|
||||
handleExit(exitCode ?? null, signal ?? null);
|
||||
});
|
||||
}
|
||||
child.once("error", (err) => {
|
||||
if (yieldTimer) clearTimeout(yieldTimer);
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
markExited(session, null, null, "failed");
|
||||
settle(() => reject(err));
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -503,7 +383,7 @@ export function createProcessTool(
|
||||
const running = listRunningSessions().map((s) => ({
|
||||
sessionId: s.id,
|
||||
status: "running",
|
||||
pid: s.pid ?? undefined,
|
||||
pid: s.child.pid ?? undefined,
|
||||
startedAt: s.startedAt,
|
||||
runtimeMs: Date.now() - s.startedAt,
|
||||
cwd: s.cwd,
|
||||
@@ -747,43 +627,25 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
if (session.stdinMode === "pty") {
|
||||
if (!session.pty || session.exited) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Session ${params.sessionId} stdin is not writable.`,
|
||||
},
|
||||
],
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
session.pty.write(params.data ?? "");
|
||||
if (params.eof) {
|
||||
session.pty.write("\x04");
|
||||
}
|
||||
} else {
|
||||
if (!session.child?.stdin || session.child.stdin.destroyed) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Session ${params.sessionId} stdin is not writable.`,
|
||||
},
|
||||
],
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.child?.stdin.write(params.data ?? "", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
if (!session.child.stdin || session.child.stdin.destroyed) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Session ${params.sessionId} stdin is not writable.`,
|
||||
},
|
||||
],
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.child.stdin.write(params.data ?? "", (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
if (params.eof) {
|
||||
session.child.stdin.end();
|
||||
}
|
||||
});
|
||||
if (params.eof) {
|
||||
session.child.stdin.end();
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
@@ -825,7 +687,9 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
killSession(session);
|
||||
if (session.child.pid) {
|
||||
killProcessTree(session.child.pid);
|
||||
}
|
||||
markExited(session, null, "SIGKILL", "failed");
|
||||
return {
|
||||
content: [
|
||||
@@ -861,7 +725,9 @@ export function createProcessTool(
|
||||
|
||||
case "remove": {
|
||||
if (session) {
|
||||
killSession(session);
|
||||
if (session.child.pid) {
|
||||
killProcessTree(session.child.pid);
|
||||
}
|
||||
markExited(session, null, "SIGKILL", "failed");
|
||||
return {
|
||||
content: [
|
||||
@@ -906,87 +772,6 @@ export function createProcessTool(
|
||||
|
||||
export const processTool = createProcessTool();
|
||||
|
||||
function killSession(session: {
|
||||
pid?: number;
|
||||
stdinMode: ProcessStdinMode;
|
||||
pty?: IPty;
|
||||
child?: ChildProcessWithoutNullStreams;
|
||||
}) {
|
||||
const pid = session.pid ?? session.child?.pid ?? session.pty?.pid;
|
||||
if (pid) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
if (session.stdinMode === "pty") {
|
||||
try {
|
||||
session.pty?.kill();
|
||||
} catch {
|
||||
// ignore kill failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkdir(workdir: string, warnings: string[]) {
|
||||
const current = safeCwd();
|
||||
const fallback = current ?? homedir();
|
||||
try {
|
||||
const stats = statSync(workdir);
|
||||
if (stats.isDirectory()) return workdir;
|
||||
} catch {
|
||||
// ignore, fallback below
|
||||
}
|
||||
warnings.push(
|
||||
`Warning: workdir "${workdir}" is unavailable; using "${fallback}".`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function safeCwd() {
|
||||
try {
|
||||
const cwd = process.cwd();
|
||||
return existsSync(cwd) ? cwd : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePath(env: Record<string, string>) {
|
||||
if (!env.PATH?.trim()) {
|
||||
env.PATH = DEFAULT_PATH;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function resolveShellPath(shell: string, env: Record<string, string>) {
|
||||
if (process.platform === "win32") return shell;
|
||||
if (shell.includes("/") && existsSync(shell)) {
|
||||
return shell;
|
||||
}
|
||||
const searchPath = env.PATH ?? "";
|
||||
for (const segment of searchPath.split(path.delimiter)) {
|
||||
if (!segment) continue;
|
||||
const candidate = path.join(segment, shell);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
if (existsSync(DEFAULT_SHELL_PATH)) {
|
||||
return DEFAULT_SHELL_PATH;
|
||||
}
|
||||
return shell;
|
||||
}
|
||||
|
||||
function formatPtyError(error: unknown) {
|
||||
if (!error) return "";
|
||||
if (typeof error === "string") return ` (${error})`;
|
||||
if (error instanceof Error) {
|
||||
const firstLine = error.message.split(/\r?\n/)[0]?.trim();
|
||||
return firstLine ? ` (${firstLine})` : "";
|
||||
}
|
||||
try {
|
||||
return ` (${JSON.stringify(error)})`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(
|
||||
value: number | undefined,
|
||||
defaultValue: number,
|
||||
|
||||
+22
-763
@@ -40,37 +40,8 @@ import {
|
||||
writeScreenRecordToFile,
|
||||
} from "../cli/nodes-screen.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { type DiscordActionConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createScheduledEventDiscord,
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelInfoDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchMemberInfoDiscord,
|
||||
fetchReactionsDiscord,
|
||||
fetchRoleInfoDiscord,
|
||||
fetchVoiceStatusDiscord,
|
||||
kickMemberDiscord,
|
||||
listGuildChannelsDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listPinsDiscord,
|
||||
listScheduledEventsDiscord,
|
||||
listThreadsDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
removeRoleDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
timeoutMemberDiscord,
|
||||
unpinMessageDiscord,
|
||||
} from "../discord/send.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { reactMessageDiscord } from "../discord/send.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { detectMime, imageMimeFromFormat } from "../media/mime.js";
|
||||
import { sanitizeToolResultImages } from "./tool-images.js";
|
||||
@@ -137,46 +108,6 @@ function readStringParam(
|
||||
return value;
|
||||
}
|
||||
|
||||
function readStringArrayParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
options: StringParamOptions & { required: true },
|
||||
): string[];
|
||||
function readStringArrayParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
options?: StringParamOptions,
|
||||
): string[] | undefined;
|
||||
function readStringArrayParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
options: StringParamOptions = {},
|
||||
) {
|
||||
const { required = false, label = key } = options;
|
||||
const raw = params[key];
|
||||
if (Array.isArray(raw)) {
|
||||
const values = raw
|
||||
.filter((entry) => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (values.length === 0) {
|
||||
if (required) throw new Error(`${label} required`);
|
||||
return undefined;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
const value = raw.trim();
|
||||
if (!value) {
|
||||
if (required) throw new Error(`${label} required`);
|
||||
return undefined;
|
||||
}
|
||||
return [value];
|
||||
}
|
||||
if (required) throw new Error(`${label} required`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function callGatewayTool<T = unknown>(
|
||||
method: string,
|
||||
opts: GatewayCallOptions,
|
||||
@@ -1555,709 +1486,37 @@ const DiscordToolSchema = Type.Union([
|
||||
messageId: Type.String(),
|
||||
emoji: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("reactions"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("sticker"),
|
||||
to: Type.String(),
|
||||
stickerIds: Type.Array(Type.String()),
|
||||
content: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("poll"),
|
||||
to: Type.String(),
|
||||
question: Type.String(),
|
||||
answers: Type.Array(Type.String()),
|
||||
allowMultiselect: Type.Optional(Type.Boolean()),
|
||||
durationHours: Type.Optional(Type.Number()),
|
||||
content: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("permissions"),
|
||||
channelId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("readMessages"),
|
||||
channelId: Type.String(),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
before: Type.Optional(Type.String()),
|
||||
after: Type.Optional(Type.String()),
|
||||
around: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("sendMessage"),
|
||||
to: Type.String(),
|
||||
content: Type.String(),
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
replyTo: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("editMessage"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
content: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("deleteMessage"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("threadCreate"),
|
||||
channelId: Type.String(),
|
||||
name: Type.String(),
|
||||
messageId: Type.Optional(Type.String()),
|
||||
autoArchiveMinutes: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("threadList"),
|
||||
guildId: Type.String(),
|
||||
channelId: Type.Optional(Type.String()),
|
||||
includeArchived: Type.Optional(Type.Boolean()),
|
||||
before: Type.Optional(Type.String()),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("threadReply"),
|
||||
channelId: Type.String(),
|
||||
content: Type.String(),
|
||||
mediaUrl: Type.Optional(Type.String()),
|
||||
replyTo: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("pinMessage"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("unpinMessage"),
|
||||
channelId: Type.String(),
|
||||
messageId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("listPins"),
|
||||
channelId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("searchMessages"),
|
||||
guildId: Type.String(),
|
||||
content: Type.String(),
|
||||
channelId: Type.Optional(Type.String()),
|
||||
channelIds: Type.Optional(Type.Array(Type.String())),
|
||||
authorId: Type.Optional(Type.String()),
|
||||
authorIds: Type.Optional(Type.Array(Type.String())),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("memberInfo"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("roleInfo"),
|
||||
guildId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("emojiList"),
|
||||
guildId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("roleAdd"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
roleId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("roleRemove"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
roleId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("channelInfo"),
|
||||
channelId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("channelList"),
|
||||
guildId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("voiceStatus"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("eventList"),
|
||||
guildId: Type.String(),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("eventCreate"),
|
||||
guildId: Type.String(),
|
||||
name: Type.String(),
|
||||
startTime: Type.String(),
|
||||
endTime: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
channelId: Type.Optional(Type.String()),
|
||||
entityType: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("voice"),
|
||||
Type.Literal("stage"),
|
||||
Type.Literal("external"),
|
||||
]),
|
||||
),
|
||||
location: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("timeout"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
durationMinutes: Type.Optional(Type.Number()),
|
||||
until: Type.Optional(Type.String()),
|
||||
reason: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("kick"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
reason: Type.Optional(Type.String()),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("ban"),
|
||||
guildId: Type.String(),
|
||||
userId: Type.String(),
|
||||
reason: Type.Optional(Type.String()),
|
||||
deleteMessageDays: Type.Optional(Type.Number()),
|
||||
}),
|
||||
]);
|
||||
|
||||
function createDiscordTool(): AnyAgentTool {
|
||||
return {
|
||||
label: "Clawdis Discord",
|
||||
name: "discord",
|
||||
description: "Manage Discord messages, reactions, and moderation.",
|
||||
name: "clawdis_discord",
|
||||
description:
|
||||
"React to Discord messages. Controlled by discord.enableReactions (default: true).",
|
||||
parameters: DiscordToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const cfg = loadConfig();
|
||||
const isActionEnabled = (
|
||||
key: keyof DiscordActionConfig,
|
||||
defaultValue = true,
|
||||
) => {
|
||||
const value = cfg.discord?.actions?.[key];
|
||||
if (value === undefined) return defaultValue;
|
||||
return value !== false;
|
||||
};
|
||||
if (action !== "react") throw new Error(`Unknown action: ${action}`);
|
||||
|
||||
switch (action) {
|
||||
case "react": {
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Discord reactions are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const emoji = readStringParam(params, "emoji", { required: true });
|
||||
await reactMessageDiscord(channelId, messageId, emoji);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "reactions": {
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Discord reactions are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const limitRaw = params.limit;
|
||||
const limit =
|
||||
typeof limitRaw === "number" && Number.isFinite(limitRaw)
|
||||
? limitRaw
|
||||
: undefined;
|
||||
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
||||
limit,
|
||||
});
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
case "sticker": {
|
||||
if (!isActionEnabled("stickers")) {
|
||||
throw new Error("Discord stickers are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content");
|
||||
const stickerIds = readStringArrayParam(params, "stickerIds", {
|
||||
required: true,
|
||||
label: "stickerIds",
|
||||
});
|
||||
await sendStickerDiscord(to, stickerIds, { content });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "poll": {
|
||||
if (!isActionEnabled("polls")) {
|
||||
throw new Error("Discord polls are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content");
|
||||
const question = readStringParam(params, "question", {
|
||||
required: true,
|
||||
});
|
||||
const answers = readStringArrayParam(params, "answers", {
|
||||
required: true,
|
||||
label: "answers",
|
||||
});
|
||||
const allowMultiselectRaw = params.allowMultiselect;
|
||||
const allowMultiselect =
|
||||
typeof allowMultiselectRaw === "boolean"
|
||||
? allowMultiselectRaw
|
||||
: undefined;
|
||||
const durationRaw = params.durationHours;
|
||||
const durationHours =
|
||||
typeof durationRaw === "number" && Number.isFinite(durationRaw)
|
||||
? durationRaw
|
||||
: undefined;
|
||||
await sendPollDiscord(
|
||||
to,
|
||||
{ question, answers, allowMultiselect, durationHours },
|
||||
{ content },
|
||||
);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "permissions": {
|
||||
if (!isActionEnabled("permissions")) {
|
||||
throw new Error("Discord permissions are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const permissions = await fetchChannelPermissionsDiscord(channelId);
|
||||
return jsonResult({ ok: true, permissions });
|
||||
}
|
||||
case "readMessages": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
throw new Error("Discord message reads are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messages = await readMessagesDiscord(channelId, {
|
||||
limit:
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
around: readStringParam(params, "around"),
|
||||
});
|
||||
return jsonResult({ ok: true, messages });
|
||||
}
|
||||
case "sendMessage": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
throw new Error("Discord message sends are disabled.");
|
||||
}
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const result = await sendMessageDiscord(to, content, {
|
||||
mediaUrl,
|
||||
replyTo,
|
||||
});
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
case "editMessage": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
throw new Error("Discord message edits are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const message = await editMessageDiscord(channelId, messageId, {
|
||||
content,
|
||||
});
|
||||
return jsonResult({ ok: true, message });
|
||||
}
|
||||
case "deleteMessage": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
throw new Error("Discord message deletes are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
await deleteMessageDiscord(channelId, messageId);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "threadCreate": {
|
||||
if (!isActionEnabled("threads")) {
|
||||
throw new Error("Discord threads are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
|
||||
const autoArchiveMinutes =
|
||||
typeof autoArchiveMinutesRaw === "number" &&
|
||||
Number.isFinite(autoArchiveMinutesRaw)
|
||||
? autoArchiveMinutesRaw
|
||||
: undefined;
|
||||
const thread = await createThreadDiscord(channelId, {
|
||||
name,
|
||||
messageId,
|
||||
autoArchiveMinutes,
|
||||
});
|
||||
return jsonResult({ ok: true, thread });
|
||||
}
|
||||
case "threadList": {
|
||||
if (!isActionEnabled("threads")) {
|
||||
throw new Error("Discord threads are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const includeArchived =
|
||||
typeof params.includeArchived === "boolean"
|
||||
? params.includeArchived
|
||||
: undefined;
|
||||
const before = readStringParam(params, "before");
|
||||
const limit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined;
|
||||
const threads = await listThreadsDiscord({
|
||||
guildId,
|
||||
channelId,
|
||||
includeArchived,
|
||||
before,
|
||||
limit,
|
||||
});
|
||||
return jsonResult({ ok: true, threads });
|
||||
}
|
||||
case "threadReply": {
|
||||
if (!isActionEnabled("threads")) {
|
||||
throw new Error("Discord threads are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const result = await sendMessageDiscord(
|
||||
`channel:${channelId}`,
|
||||
content,
|
||||
{
|
||||
mediaUrl,
|
||||
replyTo,
|
||||
},
|
||||
);
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
case "pinMessage": {
|
||||
if (!isActionEnabled("pins")) {
|
||||
throw new Error("Discord pins are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
await pinMessageDiscord(channelId, messageId);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "unpinMessage": {
|
||||
if (!isActionEnabled("pins")) {
|
||||
throw new Error("Discord pins are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
await unpinMessageDiscord(channelId, messageId);
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "listPins": {
|
||||
if (!isActionEnabled("pins")) {
|
||||
throw new Error("Discord pins are disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const pins = await listPinsDiscord(channelId);
|
||||
return jsonResult({ ok: true, pins });
|
||||
}
|
||||
case "searchMessages": {
|
||||
if (!isActionEnabled("search")) {
|
||||
throw new Error("Discord search is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const channelIds = readStringArrayParam(params, "channelIds");
|
||||
const authorId = readStringParam(params, "authorId");
|
||||
const authorIds = readStringArrayParam(params, "authorIds");
|
||||
const limit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: undefined;
|
||||
const channelIdList = [
|
||||
...(channelIds ?? []),
|
||||
...(channelId ? [channelId] : []),
|
||||
];
|
||||
const authorIdList = [
|
||||
...(authorIds ?? []),
|
||||
...(authorId ? [authorId] : []),
|
||||
];
|
||||
const results = await searchMessagesDiscord({
|
||||
guildId,
|
||||
content,
|
||||
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
});
|
||||
return jsonResult({ ok: true, results });
|
||||
}
|
||||
case "memberInfo": {
|
||||
if (!isActionEnabled("memberInfo")) {
|
||||
throw new Error("Discord member info is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const member = await fetchMemberInfoDiscord(guildId, userId);
|
||||
return jsonResult({ ok: true, member });
|
||||
}
|
||||
case "roleInfo": {
|
||||
if (!isActionEnabled("roleInfo")) {
|
||||
throw new Error("Discord role info is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const roles = await fetchRoleInfoDiscord(guildId);
|
||||
return jsonResult({ ok: true, roles });
|
||||
}
|
||||
case "emojiList": {
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("Discord reactions are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const emojis = await listGuildEmojisDiscord(guildId);
|
||||
return jsonResult({ ok: true, emojis });
|
||||
}
|
||||
case "roleAdd": {
|
||||
if (!isActionEnabled("roles", false)) {
|
||||
throw new Error("Discord role changes are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const roleId = readStringParam(params, "roleId", {
|
||||
required: true,
|
||||
});
|
||||
await addRoleDiscord({ guildId, userId, roleId });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "roleRemove": {
|
||||
if (!isActionEnabled("roles", false)) {
|
||||
throw new Error("Discord role changes are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const roleId = readStringParam(params, "roleId", {
|
||||
required: true,
|
||||
});
|
||||
await removeRoleDiscord({ guildId, userId, roleId });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "channelInfo": {
|
||||
if (!isActionEnabled("channelInfo")) {
|
||||
throw new Error("Discord channel info is disabled.");
|
||||
}
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const channel = await fetchChannelInfoDiscord(channelId);
|
||||
return jsonResult({ ok: true, channel });
|
||||
}
|
||||
case "channelList": {
|
||||
if (!isActionEnabled("channelInfo")) {
|
||||
throw new Error("Discord channel info is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const channels = await listGuildChannelsDiscord(guildId);
|
||||
return jsonResult({ ok: true, channels });
|
||||
}
|
||||
case "voiceStatus": {
|
||||
if (!isActionEnabled("voiceStatus")) {
|
||||
throw new Error("Discord voice status is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const voice = await fetchVoiceStatusDiscord(guildId, userId);
|
||||
return jsonResult({ ok: true, voice });
|
||||
}
|
||||
case "eventList": {
|
||||
if (!isActionEnabled("events")) {
|
||||
throw new Error("Discord events are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const events = await listScheduledEventsDiscord(guildId);
|
||||
return jsonResult({ ok: true, events });
|
||||
}
|
||||
case "eventCreate": {
|
||||
if (!isActionEnabled("events")) {
|
||||
throw new Error("Discord events are disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const startTime = readStringParam(params, "startTime", {
|
||||
required: true,
|
||||
});
|
||||
const endTime = readStringParam(params, "endTime");
|
||||
const description = readStringParam(params, "description");
|
||||
const channelId = readStringParam(params, "channelId");
|
||||
const location = readStringParam(params, "location");
|
||||
const entityTypeRaw = readStringParam(params, "entityType");
|
||||
const entityType =
|
||||
entityTypeRaw === "stage"
|
||||
? 1
|
||||
: entityTypeRaw === "external"
|
||||
? 3
|
||||
: 2;
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
scheduled_start_time: startTime,
|
||||
scheduled_end_time: endTime,
|
||||
entity_type: entityType,
|
||||
channel_id: channelId,
|
||||
entity_metadata:
|
||||
entityType === 3 && location ? { location } : undefined,
|
||||
privacy_level: 2,
|
||||
};
|
||||
const event = await createScheduledEventDiscord(guildId, payload);
|
||||
return jsonResult({ ok: true, event });
|
||||
}
|
||||
case "timeout": {
|
||||
if (!isActionEnabled("moderation", false)) {
|
||||
throw new Error("Discord moderation is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const durationMinutes =
|
||||
typeof params.durationMinutes === "number" &&
|
||||
Number.isFinite(params.durationMinutes)
|
||||
? params.durationMinutes
|
||||
: undefined;
|
||||
const until = readStringParam(params, "until");
|
||||
const reason = readStringParam(params, "reason");
|
||||
const member = await timeoutMemberDiscord({
|
||||
guildId,
|
||||
userId,
|
||||
durationMinutes,
|
||||
until,
|
||||
reason,
|
||||
});
|
||||
return jsonResult({ ok: true, member });
|
||||
}
|
||||
case "kick": {
|
||||
if (!isActionEnabled("moderation", false)) {
|
||||
throw new Error("Discord moderation is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const reason = readStringParam(params, "reason");
|
||||
await kickMemberDiscord({ guildId, userId, reason });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "ban": {
|
||||
if (!isActionEnabled("moderation", false)) {
|
||||
throw new Error("Discord moderation is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const reason = readStringParam(params, "reason");
|
||||
const deleteMessageDays =
|
||||
typeof params.deleteMessageDays === "number" &&
|
||||
Number.isFinite(params.deleteMessageDays)
|
||||
? params.deleteMessageDays
|
||||
: undefined;
|
||||
await banMemberDiscord({
|
||||
guildId,
|
||||
userId,
|
||||
reason,
|
||||
deleteMessageDays,
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
const cfg = loadConfig();
|
||||
if (cfg.discord?.enableReactions === false) {
|
||||
throw new Error(
|
||||
"Discord reactions are disabled (set discord.enableReactions=true).",
|
||||
);
|
||||
}
|
||||
|
||||
const channelId = readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
});
|
||||
const messageId = readStringParam(params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const emoji = readStringParam(params, "emoji", { required: true });
|
||||
|
||||
await reactMessageDiscord(channelId, messageId, emoji);
|
||||
return jsonResult({ ok: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,36 +16,6 @@ import {
|
||||
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
||||
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
||||
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||
|
||||
function truncateToolText(text: string): string {
|
||||
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
||||
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
||||
}
|
||||
|
||||
function sanitizeToolResult(result: unknown): unknown {
|
||||
if (!result || typeof result !== "object") return result;
|
||||
const record = result as Record<string, unknown>;
|
||||
const content = Array.isArray(record.content) ? record.content : null;
|
||||
if (!content) return record;
|
||||
const sanitized = content.map((item) => {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const type = typeof entry.type === "string" ? entry.type : undefined;
|
||||
if (type === "text" && typeof entry.text === "string") {
|
||||
return { ...entry, text: truncateToolText(entry.text) };
|
||||
}
|
||||
if (type === "image") {
|
||||
const data = typeof entry.data === "string" ? entry.data : undefined;
|
||||
const bytes = data ? data.length : undefined;
|
||||
const cleaned = { ...entry };
|
||||
delete cleaned.data;
|
||||
return { ...cleaned, bytes, omitted: true };
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return { ...record, content: sanitized };
|
||||
}
|
||||
|
||||
function stripThinkingSegments(text: string): string {
|
||||
if (!text || !THINKING_TAG_RE.test(text)) return text;
|
||||
@@ -217,36 +187,6 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "tool_execution_update") {
|
||||
const toolName = String(
|
||||
(evt as AgentEvent & { toolName: string }).toolName,
|
||||
);
|
||||
const toolCallId = String(
|
||||
(evt as AgentEvent & { toolCallId: string }).toolCallId,
|
||||
);
|
||||
const partial = (evt as AgentEvent & { partialResult?: unknown })
|
||||
.partialResult;
|
||||
const sanitized = sanitizeToolResult(partial);
|
||||
emitAgentEvent({
|
||||
runId: params.runId,
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "update",
|
||||
name: toolName,
|
||||
toolCallId,
|
||||
partialResult: sanitized,
|
||||
},
|
||||
});
|
||||
params.onAgentEvent?.({
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "update",
|
||||
name: toolName,
|
||||
toolCallId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "tool_execution_end") {
|
||||
const toolName = String(
|
||||
(evt as AgentEvent & { toolName: string }).toolName,
|
||||
@@ -257,8 +197,6 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
const isError = Boolean(
|
||||
(evt as AgentEvent & { isError: boolean }).isError,
|
||||
);
|
||||
const result = (evt as AgentEvent & { result?: unknown }).result;
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
const meta = toolMetaById.get(toolCallId);
|
||||
toolMetas.push({ toolName, meta });
|
||||
toolDebouncer.push(toolName, meta);
|
||||
@@ -272,7 +210,6 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
toolCallId,
|
||||
meta,
|
||||
isError,
|
||||
result: sanitizedResult,
|
||||
},
|
||||
});
|
||||
params.onAgentEvent?.({
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-stream-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("block streaming", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([
|
||||
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
||||
]);
|
||||
});
|
||||
|
||||
async function waitForCalls(fn: () => number, calls: number) {
|
||||
const deadline = Date.now() + 1500;
|
||||
while (fn() < calls) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
it("waits for block replies before returning final payloads", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
let releaseTyping: (() => void) | undefined;
|
||||
const typingGate = new Promise<void>((resolve) => {
|
||||
releaseTyping = resolve;
|
||||
});
|
||||
const onReplyStart = vi.fn(() => typingGate);
|
||||
const onBlockReply = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||
void params.onBlockReply?.({ text: "hello" });
|
||||
return {
|
||||
payloads: [{ text: "hello" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const replyPromise = getReplyFromConfig(
|
||||
{
|
||||
Body: "ping",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
MessageSid: "msg-123",
|
||||
Surface: "discord",
|
||||
},
|
||||
{
|
||||
onReplyStart,
|
||||
onBlockReply,
|
||||
},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
await waitForCalls(() => onReplyStart.mock.calls.length, 1);
|
||||
releaseTyping?.();
|
||||
|
||||
const res = await replyPromise;
|
||||
expect(res).toBeUndefined();
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,6 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(),
|
||||
@@ -99,47 +97,6 @@ describe("directive parsing", () => {
|
||||
expect(res.cleaned).toBe("please now");
|
||||
});
|
||||
|
||||
it("parses queue options and modes", () => {
|
||||
const res = extractQueueDirective(
|
||||
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
|
||||
);
|
||||
expect(res.hasDirective).toBe(true);
|
||||
expect(res.queueMode).toBe("steer-backlog");
|
||||
expect(res.debounceMs).toBe(2000);
|
||||
expect(res.cap).toBe(5);
|
||||
expect(res.dropPolicy).toBe("summarize");
|
||||
expect(res.cleaned).toBe("please now");
|
||||
});
|
||||
|
||||
it("errors on invalid queue options", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/queue collect debounce:bogus cap:zero drop:maybe",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Invalid debounce");
|
||||
expect(text).toContain("Invalid cap");
|
||||
expect(text).toContain("Invalid drop policy");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts reply_to_current tag", () => {
|
||||
const res = extractReplyToTag("ok [[reply_to_current]]", "msg-1");
|
||||
expect(res.replyToId).toBe("msg-1");
|
||||
@@ -319,43 +276,6 @@ describe("directive parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists queue options when directive is standalone", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/queue collect debounce:2s cap:5 drop:old",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toMatch(/^⚙️ Queue mode set to collect\./);
|
||||
expect(text).toMatch(/Queue debounce set to 2000ms/);
|
||||
expect(text).toMatch(/Queue cap set to 5/);
|
||||
expect(text).toMatch(/Queue drop set to old/);
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = Object.values(store)[0];
|
||||
expect(entry?.queueMode).toBe("collect");
|
||||
expect(entry?.queueDebounceMs).toBe(2000);
|
||||
expect(entry?.queueCap).toBe(5);
|
||||
expect(entry?.queueDrop).toBe("old");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("resets queue mode to default", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
@@ -392,9 +312,6 @@ describe("directive parsing", () => {
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = Object.values(store)[0];
|
||||
expect(entry?.queueMode).toBeUndefined();
|
||||
expect(entry?.queueDebounceMs).toBeUndefined();
|
||||
expect(entry?.queueCap).toBeUndefined();
|
||||
expect(entry?.queueDrop).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
isEmbeddedPiRunActive,
|
||||
isEmbeddedPiRunStreaming,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../agents/pi-embedded.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
function makeResult(text: string) {
|
||||
return {
|
||||
payloads: [{ text }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-queue-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = base;
|
||||
try {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
try {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup failures in tests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeCfg(home: string, queue?: Record<string, unknown>) {
|
||||
return {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
whatsapp: { allowFrom: ["*"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
routing: queue ? { queue } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("queue followups", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("collects queued messages and drains after run completes", async () => {
|
||||
vi.useFakeTimers();
|
||||
await withTempHome(async (home) => {
|
||||
const prompts: string[] = [];
|
||||
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||
prompts.push(params.prompt);
|
||||
if (params.prompt.includes("[Queued messages while agent was busy]")) {
|
||||
return makeResult("followup");
|
||||
}
|
||||
return makeResult("main");
|
||||
});
|
||||
|
||||
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(true);
|
||||
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(true);
|
||||
|
||||
const cfg = makeCfg(home, {
|
||||
mode: "collect",
|
||||
debounceMs: 200,
|
||||
cap: 10,
|
||||
drop: "summarize",
|
||||
});
|
||||
|
||||
const first = await getReplyFromConfig(
|
||||
{ Body: "first", From: "+1001", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
expect(first).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
|
||||
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(false);
|
||||
|
||||
const second = await getReplyFromConfig(
|
||||
{ Body: "second", From: "+1001", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
|
||||
expect(secondText).toBe("main");
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
prompts.some((p) =>
|
||||
p.includes("[Queued messages while agent was busy]"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes dropped followups when cap is exceeded", async () => {
|
||||
vi.useFakeTimers();
|
||||
await withTempHome(async (home) => {
|
||||
const prompts: string[] = [];
|
||||
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
|
||||
prompts.push(params.prompt);
|
||||
return makeResult("ok");
|
||||
});
|
||||
|
||||
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(true);
|
||||
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(false);
|
||||
|
||||
const cfg = makeCfg(home, {
|
||||
mode: "followup",
|
||||
debounceMs: 0,
|
||||
cap: 1,
|
||||
drop: "summarize",
|
||||
});
|
||||
|
||||
await getReplyFromConfig(
|
||||
{ Body: "one", From: "+1002", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
await getReplyFromConfig(
|
||||
{ Body: "two", From: "+1002", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
|
||||
await getReplyFromConfig(
|
||||
{ Body: "three", From: "+1002", To: "+2000" },
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(prompts.some((p) => p.includes("[Queue overflow]"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
`session:${key.trim() || "main"}`,
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
@@ -450,8 +448,6 @@ describe("trigger handling", () => {
|
||||
});
|
||||
|
||||
describe("group intro prompts", () => {
|
||||
const groupParticipationNote =
|
||||
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
|
||||
it("labels Discord groups using the surface metadata", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
@@ -481,7 +477,7 @@ describe("group intro prompts", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
|
||||
?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe(
|
||||
`You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
'You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -514,7 +510,7 @@ describe("group intro prompts", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
|
||||
?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe(
|
||||
`You are replying inside the WhatsApp group "Ops". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
'You are replying inside the WhatsApp group "Ops". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -547,7 +543,7 @@ describe("group intro prompts", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
|
||||
?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe(
|
||||
`You are replying inside the Telegram group "Dev Chat". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
'You are replying inside the Telegram group "Dev Chat". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+56
-764
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
549aa1fff40f95b4a899940ad2cde0adc3991423136ba02c23bcdbd621765ad7
|
||||
ac7d607313f8977b3fd7b5807201e0bf79d2c7366dbeff48747352d55cd3b253
|
||||
@@ -24,7 +24,6 @@ import { registerGatewayCli } from "./gateway-cli.js";
|
||||
import { registerHooksCli } from "./hooks-cli.js";
|
||||
import { registerNodesCli } from "./nodes-cli.js";
|
||||
import { forceFreePort } from "./ports.js";
|
||||
import { registerTuiCli } from "./tui-cli.js";
|
||||
|
||||
export { forceFreePort };
|
||||
|
||||
@@ -395,7 +394,6 @@ Examples:
|
||||
registerCanvasCli(program);
|
||||
registerGatewayCli(program);
|
||||
registerNodesCli(program);
|
||||
registerTuiCli(program);
|
||||
registerCronCli(program);
|
||||
registerDnsCli(program);
|
||||
registerHooksCli(program);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { runTui } from "../tui/tui.js";
|
||||
|
||||
export function registerTuiCli(program: Command) {
|
||||
program
|
||||
.command("tui")
|
||||
.description("Open a terminal UI connected to the Gateway")
|
||||
.option(
|
||||
"--url <url>",
|
||||
"Gateway WebSocket URL (defaults to gateway.remote.url when configured)",
|
||||
)
|
||||
.option("--token <token>", "Gateway token (if required)")
|
||||
.option("--password <password>", "Gateway password (if required)")
|
||||
.option(
|
||||
"--session <key>",
|
||||
"Session key (default: session.mainKey from config)",
|
||||
)
|
||||
.option("--deliver", "Deliver assistant replies", false)
|
||||
.option("--thinking <level>", "Thinking level override")
|
||||
.option("--timeout-ms <ms>", "Agent timeout in ms", "30000")
|
||||
.option("--history-limit <n>", "History entries to load", "200")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const timeoutMs = Number.parseInt(
|
||||
String(opts.timeoutMs ?? "30000"),
|
||||
10,
|
||||
);
|
||||
const historyLimit = Number.parseInt(
|
||||
String(opts.historyLimit ?? "200"),
|
||||
10,
|
||||
);
|
||||
await runTui({
|
||||
url: opts.url as string | undefined,
|
||||
token: opts.token as string | undefined,
|
||||
password: opts.password as string | undefined,
|
||||
session: opts.session as string | undefined,
|
||||
deliver: Boolean(opts.deliver),
|
||||
thinking: opts.thinking as string | undefined,
|
||||
timeoutMs: Number.isNaN(timeoutMs) ? undefined : timeoutMs,
|
||||
historyLimit: Number.isNaN(historyLimit) ? undefined : historyLimit,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
/**
|
||||
* VPS-aware Antigravity OAuth flow.
|
||||
*
|
||||
* On local machines: Uses the standard pi-ai loginAntigravity with local server callback.
|
||||
* On VPS/SSH/headless: Shows URL and prompts user to paste the callback URL manually.
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { loginAntigravity, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const CLIENT_ID = decode(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
);
|
||||
const CLIENT_SECRET = decode(
|
||||
"R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=",
|
||||
);
|
||||
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
// Antigravity requires these additional scopes
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
// Fallback project ID when discovery fails (same as pi-ai)
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
|
||||
/**
|
||||
* Detect if running in WSL (Windows Subsystem for Linux).
|
||||
*/
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if running in WSL2 specifically.
|
||||
*/
|
||||
function isWSL2(): boolean {
|
||||
if (!isWSL()) return false;
|
||||
try {
|
||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if running in a remote/headless environment where localhost callback won't work.
|
||||
*/
|
||||
export function isRemoteEnvironment(): boolean {
|
||||
// SSH session indicators
|
||||
if (
|
||||
process.env.SSH_CLIENT ||
|
||||
process.env.SSH_TTY ||
|
||||
process.env.SSH_CONNECTION
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Container/cloud environments
|
||||
if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Linux without display (and not WSL which can use wslview)
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
!process.env.DISPLAY &&
|
||||
!process.env.WAYLAND_DISPLAY &&
|
||||
!isWSL()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to skip the local OAuth callback server.
|
||||
*/
|
||||
export function shouldUseManualOAuthFlow(): boolean {
|
||||
return isWSL2() || isRemoteEnvironment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE verifier and challenge using Node.js crypto.
|
||||
*/
|
||||
function generatePKCESync(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Antigravity OAuth authorization URL.
|
||||
*/
|
||||
function buildAuthUrl(challenge: string, verifier: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the OAuth callback URL or code input.
|
||||
*/
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
expectedState: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return { error: "No input provided" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Try parsing as full URL
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state") ?? expectedState;
|
||||
|
||||
if (!code) {
|
||||
return { error: "Missing 'code' parameter in URL" };
|
||||
}
|
||||
if (!state) {
|
||||
return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||
}
|
||||
|
||||
return { code, state };
|
||||
} catch {
|
||||
// Not a URL - treat as raw code (need state from original request)
|
||||
if (!expectedState) {
|
||||
return { error: "Paste the full redirect URL, not just the code." };
|
||||
}
|
||||
return { code: trimmed, state: expectedState };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens.
|
||||
*/
|
||||
async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
verifier: string,
|
||||
): Promise<OAuthCredentials> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
if (!data.refresh_token) {
|
||||
throw new Error("No refresh token received. Please try again.");
|
||||
}
|
||||
|
||||
// Fetch user email
|
||||
const email = await getUserEmail(data.access_token);
|
||||
|
||||
// Fetch project ID
|
||||
const projectId = await fetchProjectId(data.access_token);
|
||||
|
||||
// Calculate expiry time (same as pi-ai: current time + expires_in - 5 min buffer)
|
||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
return {
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: expiresAt,
|
||||
projectId,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user email from access token.
|
||||
*/
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, email is optional
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the Antigravity project ID using the access token.
|
||||
*/
|
||||
async function fetchProjectId(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
};
|
||||
|
||||
// Try endpoints in order: prod first, then sandbox
|
||||
const endpoints = [
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) continue;
|
||||
|
||||
const data = (await response.json()) as {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
};
|
||||
|
||||
if (typeof data.cloudaicompanionProject === "string") {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (
|
||||
data.cloudaicompanionProject &&
|
||||
typeof data.cloudaicompanionProject === "object" &&
|
||||
data.cloudaicompanionProject.id
|
||||
) {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
} catch {
|
||||
// ignore failed endpoint, try next
|
||||
}
|
||||
}
|
||||
|
||||
// Use fallback project ID
|
||||
return DEFAULT_PROJECT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for input via readline.
|
||||
*/
|
||||
async function promptInput(message: string): Promise<string> {
|
||||
const rl = createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
return (await rl.question(message)).trim();
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VPS-aware Antigravity OAuth login.
|
||||
*
|
||||
* On local machines: Uses the standard pi-ai flow with automatic localhost callback.
|
||||
* On VPS/SSH: Shows URL and prompts user to paste the callback URL manually.
|
||||
*/
|
||||
export async function loginAntigravityVpsAware(
|
||||
onUrl: (url: string) => void | Promise<void>,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<OAuthCredentials | null> {
|
||||
// Check if we're in a remote environment
|
||||
if (shouldUseManualOAuthFlow()) {
|
||||
return loginAntigravityManual(onUrl, onProgress);
|
||||
}
|
||||
|
||||
// Use the standard pi-ai flow for local environments
|
||||
try {
|
||||
return await loginAntigravity(
|
||||
async ({ url, instructions }) => {
|
||||
await onUrl(url);
|
||||
onProgress?.(instructions ?? "Complete sign-in in browser...");
|
||||
},
|
||||
(msg) => onProgress?.(msg),
|
||||
);
|
||||
} catch (err) {
|
||||
// If the local server fails (e.g., port in use), fall back to manual
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("EADDRINUSE") ||
|
||||
err.message.includes("port") ||
|
||||
err.message.includes("listen"))
|
||||
) {
|
||||
onProgress?.("Local callback server failed. Switching to manual mode...");
|
||||
return loginAntigravityManual(onUrl, onProgress);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual Antigravity OAuth login for VPS/headless environments.
|
||||
*
|
||||
* Shows the OAuth URL and prompts user to paste the callback URL.
|
||||
*/
|
||||
export async function loginAntigravityManual(
|
||||
onUrl: (url: string) => void | Promise<void>,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<OAuthCredentials | null> {
|
||||
const { verifier, challenge } = generatePKCESync();
|
||||
const authUrl = buildAuthUrl(challenge, verifier);
|
||||
|
||||
// Show the URL to the user
|
||||
await onUrl(authUrl);
|
||||
|
||||
onProgress?.("Waiting for you to paste the callback URL...");
|
||||
|
||||
console.log("\n");
|
||||
console.log("=".repeat(60));
|
||||
console.log("VPS/Remote Mode - Manual OAuth");
|
||||
console.log("=".repeat(60));
|
||||
console.log("\n1. Open the URL above in your LOCAL browser");
|
||||
console.log("2. Complete the Google sign-in");
|
||||
console.log(
|
||||
"3. Your browser will redirect to a localhost URL that won't load",
|
||||
);
|
||||
console.log("4. Copy the ENTIRE URL from your browser's address bar");
|
||||
console.log("5. Paste it below\n");
|
||||
console.log("The URL will look like:");
|
||||
console.log("http://localhost:51121/oauth-callback?code=xxx&state=yyy\n");
|
||||
|
||||
const callbackInput = await promptInput("Paste the redirect URL here: ");
|
||||
|
||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||
if ("error" in parsed) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
|
||||
// Verify state matches
|
||||
if (parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
|
||||
onProgress?.("Exchanging authorization code for tokens...");
|
||||
|
||||
return exchangeCodeForTokens(parsed.code, verifier);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
@@ -23,10 +24,6 @@ import { resolveGatewayService } from "../daemon/service.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import {
|
||||
isRemoteEnvironment,
|
||||
loginAntigravityVpsAware,
|
||||
} from "./antigravity-oauth.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyMinimaxConfig,
|
||||
@@ -226,17 +223,13 @@ async function promptAuthConfig(
|
||||
message: "Model/auth choice",
|
||||
options: [
|
||||
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
|
||||
{
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
],
|
||||
}),
|
||||
runtime,
|
||||
) as "oauth" | "antigravity" | "apiKey" | "minimax" | "skip";
|
||||
) as "oauth" | "apiKey" | "minimax" | "skip";
|
||||
|
||||
let next = cfg;
|
||||
|
||||
@@ -273,59 +266,6 @@ async function promptAuthConfig(
|
||||
spin.stop("OAuth failed");
|
||||
runtime.error(String(err));
|
||||
}
|
||||
} else if (authChoice === "antigravity") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
note(
|
||||
isRemote
|
||||
? [
|
||||
"You are running in a remote/VPS environment.",
|
||||
"A URL will be shown for you to open in your LOCAL browser.",
|
||||
"After signing in, copy the redirect URL and paste it back here.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Google authentication.",
|
||||
"Sign in with your Google account that has Antigravity access.",
|
||||
"The callback will be captured automatically on localhost:51121.",
|
||||
].join("\n"),
|
||||
"Google Antigravity OAuth",
|
||||
);
|
||||
const spin = spinner();
|
||||
spin.start("Starting OAuth flow…");
|
||||
let oauthCreds: OAuthCredentials | null = null;
|
||||
try {
|
||||
oauthCreds = await loginAntigravityVpsAware(
|
||||
async (url) => {
|
||||
if (isRemote) {
|
||||
spin.stop("OAuth URL ready");
|
||||
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
|
||||
} else {
|
||||
spin.message("Complete sign-in in browser…");
|
||||
await openUrl(url);
|
||||
runtime.log(`Open: ${url}`);
|
||||
}
|
||||
},
|
||||
(msg) => spin.message(msg),
|
||||
);
|
||||
spin.stop("Antigravity OAuth complete");
|
||||
if (oauthCreds) {
|
||||
await writeOAuthCredentials("google-antigravity", oauthCreds);
|
||||
// Set default model to Claude Opus 4.5 via Antigravity
|
||||
next = {
|
||||
...next,
|
||||
agent: {
|
||||
...next.agent,
|
||||
model: "google-antigravity/claude-opus-4-5-thinking",
|
||||
},
|
||||
};
|
||||
note(
|
||||
"Default model set to google-antigravity/claude-opus-4-5-thinking",
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
runtime.error(String(err));
|
||||
}
|
||||
} else if (authChoice === "apiKey") {
|
||||
const key = guardCancel(
|
||||
await text({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveClawdisAgentDir } from "../agents/agent-paths.js";
|
||||
@@ -9,7 +9,7 @@ import type { ClawdisConfig } from "../config/config.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
|
||||
export async function writeOAuthCredentials(
|
||||
provider: OAuthProvider,
|
||||
provider: "anthropic",
|
||||
creds: OAuthCredentials,
|
||||
): Promise<void> {
|
||||
const dir = path.join(CONFIG_DIR, "credentials");
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
@@ -22,10 +23,6 @@ import { resolveGatewayService } from "../daemon/service.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import {
|
||||
isRemoteEnvironment,
|
||||
loginAntigravityVpsAware,
|
||||
} from "./antigravity-oauth.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyMinimaxConfig,
|
||||
@@ -201,10 +198,6 @@ export async function runInteractiveOnboarding(
|
||||
message: "Model/auth choice",
|
||||
options: [
|
||||
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
|
||||
{
|
||||
value: "antigravity",
|
||||
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
|
||||
},
|
||||
{ value: "apiKey", label: "Anthropic API key" },
|
||||
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
|
||||
{ value: "skip", label: "Skip for now" },
|
||||
@@ -246,59 +239,6 @@ export async function runInteractiveOnboarding(
|
||||
spin.stop("OAuth failed");
|
||||
runtime.error(String(err));
|
||||
}
|
||||
} else if (authChoice === "antigravity") {
|
||||
const isRemote = isRemoteEnvironment();
|
||||
note(
|
||||
isRemote
|
||||
? [
|
||||
"You are running in a remote/VPS environment.",
|
||||
"A URL will be shown for you to open in your LOCAL browser.",
|
||||
"After signing in, copy the redirect URL and paste it back here.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Google authentication.",
|
||||
"Sign in with your Google account that has Antigravity access.",
|
||||
"The callback will be captured automatically on localhost:51121.",
|
||||
].join("\n"),
|
||||
"Google Antigravity OAuth",
|
||||
);
|
||||
const spin = spinner();
|
||||
spin.start("Starting OAuth flow…");
|
||||
let oauthCreds: OAuthCredentials | null = null;
|
||||
try {
|
||||
oauthCreds = await loginAntigravityVpsAware(
|
||||
async (url) => {
|
||||
if (isRemote) {
|
||||
spin.stop("OAuth URL ready");
|
||||
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
|
||||
} else {
|
||||
spin.message("Complete sign-in in browser…");
|
||||
await openUrl(url);
|
||||
runtime.log(`Open: ${url}`);
|
||||
}
|
||||
},
|
||||
(msg) => spin.message(msg),
|
||||
);
|
||||
spin.stop("Antigravity OAuth complete");
|
||||
if (oauthCreds) {
|
||||
await writeOAuthCredentials("google-antigravity", oauthCreds);
|
||||
// Set default model to Claude Opus 4.5 via Antigravity
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
agent: {
|
||||
...nextConfig.agent,
|
||||
model: "google-antigravity/claude-opus-4-5-thinking",
|
||||
},
|
||||
};
|
||||
note(
|
||||
"Default model set to google-antigravity/claude-opus-4-5-thinking",
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
runtime.error(String(err));
|
||||
}
|
||||
} else if (authChoice === "apiKey") {
|
||||
const key = guardCancel(
|
||||
await text({
|
||||
|
||||
@@ -98,10 +98,8 @@ export async function runNonInteractiveOnboarding(
|
||||
await setAnthropicApiKey(key);
|
||||
} else if (authChoice === "minimax") {
|
||||
nextConfig = applyMinimaxConfig(nextConfig);
|
||||
} else if (authChoice === "oauth" || authChoice === "antigravity") {
|
||||
runtime.error(
|
||||
`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`,
|
||||
);
|
||||
} else if (authChoice === "oauth") {
|
||||
runtime.error("OAuth requires interactive mode.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
export type OnboardMode = "local" | "remote";
|
||||
export type AuthChoice =
|
||||
| "oauth"
|
||||
| "antigravity"
|
||||
| "apiKey"
|
||||
| "minimax"
|
||||
| "skip";
|
||||
export type AuthChoice = "oauth" | "apiKey" | "minimax" | "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
|
||||
|
||||
+5
-68
@@ -221,24 +221,6 @@ export type DiscordSlashCommandConfig = {
|
||||
ephemeral?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordActionConfig = {
|
||||
reactions?: boolean;
|
||||
stickers?: boolean;
|
||||
polls?: boolean;
|
||||
permissions?: boolean;
|
||||
messages?: boolean;
|
||||
threads?: boolean;
|
||||
pins?: boolean;
|
||||
search?: boolean;
|
||||
memberInfo?: boolean;
|
||||
roleInfo?: boolean;
|
||||
roles?: boolean;
|
||||
channelInfo?: boolean;
|
||||
voiceStatus?: boolean;
|
||||
events?: boolean;
|
||||
moderation?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
/** If false, do not start the Discord provider. Default: true. */
|
||||
enabled?: boolean;
|
||||
@@ -247,8 +229,8 @@ export type DiscordConfig = {
|
||||
textChunkLimit?: number;
|
||||
mediaMaxMb?: number;
|
||||
historyLimit?: number;
|
||||
/** Per-action tool gating (default: true for all). */
|
||||
actions?: DiscordActionConfig;
|
||||
/** Allow agent-triggered Discord reactions (default: true). */
|
||||
enableReactions?: boolean;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
slashCommand?: DiscordSlashCommandConfig;
|
||||
@@ -309,15 +291,7 @@ export type IMessageConfig = {
|
||||
>;
|
||||
};
|
||||
|
||||
export type QueueMode =
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "steer+backlog"
|
||||
| "queue"
|
||||
| "interrupt";
|
||||
export type QueueDropPolicy = "old" | "new" | "summarize";
|
||||
export type QueueMode = "queue" | "interrupt";
|
||||
|
||||
export type QueueModeBySurface = {
|
||||
whatsapp?: QueueMode;
|
||||
@@ -343,9 +317,6 @@ export type RoutingConfig = {
|
||||
queue?: {
|
||||
mode?: QueueMode;
|
||||
bySurface?: QueueModeBySurface;
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
drop?: QueueDropPolicy;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -701,20 +672,7 @@ const GroupChatSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const QueueModeSchema = z.union([
|
||||
z.literal("steer"),
|
||||
z.literal("followup"),
|
||||
z.literal("collect"),
|
||||
z.literal("steer-backlog"),
|
||||
z.literal("steer+backlog"),
|
||||
z.literal("queue"),
|
||||
z.literal("interrupt"),
|
||||
]);
|
||||
const QueueDropSchema = z.union([
|
||||
z.literal("old"),
|
||||
z.literal("new"),
|
||||
z.literal("summarize"),
|
||||
]);
|
||||
const QueueModeSchema = z.union([z.literal("queue"), z.literal("interrupt")]);
|
||||
const ReplyToModeSchema = z.union([
|
||||
z.literal("off"),
|
||||
z.literal("first"),
|
||||
@@ -803,9 +761,6 @@ const RoutingSchema = z
|
||||
.object({
|
||||
mode: QueueModeSchema.optional(),
|
||||
bySurface: QueueModeBySurfaceSchema,
|
||||
debounceMs: z.number().int().nonnegative().optional(),
|
||||
cap: z.number().int().positive().optional(),
|
||||
drop: QueueDropSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -1077,25 +1032,7 @@ const ClawdisSchema = z.object({
|
||||
.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
actions: z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
stickers: z.boolean().optional(),
|
||||
polls: z.boolean().optional(),
|
||||
permissions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
threads: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
search: z.boolean().optional(),
|
||||
memberInfo: z.boolean().optional(),
|
||||
roleInfo: z.boolean().optional(),
|
||||
roles: z.boolean().optional(),
|
||||
channelInfo: z.boolean().optional(),
|
||||
voiceStatus: z.boolean().optional(),
|
||||
events: z.boolean().optional(),
|
||||
moderation: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
enableReactions: z.boolean().optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
dm: z
|
||||
.object({
|
||||
|
||||
+1
-11
@@ -34,17 +34,7 @@ export type SessionEntry = {
|
||||
modelOverride?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
queueMode?:
|
||||
| "steer"
|
||||
| "followup"
|
||||
| "collect"
|
||||
| "steer-backlog"
|
||||
| "steer+backlog"
|
||||
| "queue"
|
||||
| "interrupt";
|
||||
queueDebounceMs?: number;
|
||||
queueCap?: number;
|
||||
queueDrop?: "old" | "new" | "summarize";
|
||||
queueMode?: "queue" | "interrupt";
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
|
||||
@@ -396,11 +396,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
};
|
||||
const replyTarget = ctxPayload.To ?? undefined;
|
||||
if (!replyTarget) {
|
||||
runtime.error?.(danger("discord: missing reply target"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDirectMessage) {
|
||||
const sessionCfg = cfg.session;
|
||||
@@ -435,7 +430,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
.then(async () => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
target: replyTarget,
|
||||
target: ctxPayload.To,
|
||||
token,
|
||||
runtime,
|
||||
replyToMode,
|
||||
@@ -478,7 +473,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
|
||||
await deliverReplies({
|
||||
replies,
|
||||
target: replyTarget,
|
||||
target: ctxPayload.To,
|
||||
token,
|
||||
runtime,
|
||||
replyToMode,
|
||||
@@ -487,7 +482,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
didSendReply = true;
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
`discord: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${ctxPayload.To}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
||||
+2
-400
@@ -1,27 +1,7 @@
|
||||
import { PermissionsBitField, Routes } from "discord.js";
|
||||
import { Routes } from "discord.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchReactionsDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listThreadsDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
removeRoleDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
timeoutMemberDiscord,
|
||||
unpinMessageDiscord,
|
||||
} from "./send.js";
|
||||
import { sendMessageDiscord } from "./send.js";
|
||||
|
||||
vi.mock("../web/media.js", () => ({
|
||||
loadWebMedia: vi.fn().mockResolvedValue({
|
||||
@@ -34,23 +14,11 @@ vi.mock("../web/media.js", () => ({
|
||||
|
||||
const makeRest = () => {
|
||||
const postMock = vi.fn();
|
||||
const putMock = vi.fn();
|
||||
const getMock = vi.fn();
|
||||
const patchMock = vi.fn();
|
||||
const deleteMock = vi.fn();
|
||||
return {
|
||||
rest: {
|
||||
post: postMock,
|
||||
put: putMock,
|
||||
get: getMock,
|
||||
patch: patchMock,
|
||||
delete: deleteMock,
|
||||
} as unknown as import("discord.js").REST,
|
||||
postMock,
|
||||
putMock,
|
||||
getMock,
|
||||
patchMock,
|
||||
deleteMock,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -148,369 +116,3 @@ describe("sendMessageDiscord", () => {
|
||||
expect(secondBody?.message_reference).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactMessageDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reacts with unicode emoji", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t" });
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes variation selectors in unicode emoji", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t" });
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%AD%90"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reacts with custom emoji syntax", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
await reactMessageDiscord("chan1", "msg1", "<:party_blob:123>", {
|
||||
rest,
|
||||
token: "t",
|
||||
});
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchReactionsDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns reactions with users", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock
|
||||
.mockResolvedValueOnce({
|
||||
reactions: [
|
||||
{ count: 2, emoji: { name: "✅", id: null } },
|
||||
{ count: 1, emoji: { name: "party_blob", id: "123" } },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "u1", username: "alpha", discriminator: "0001" },
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: "u2", username: "beta" }]);
|
||||
const res = await fetchReactionsDiscord("chan1", "msg1", {
|
||||
rest,
|
||||
token: "t",
|
||||
});
|
||||
expect(res).toEqual([
|
||||
{
|
||||
emoji: { id: null, name: "✅", raw: "✅" },
|
||||
count: 2,
|
||||
users: [{ id: "u1", username: "alpha", tag: "alpha#0001" }],
|
||||
},
|
||||
{
|
||||
emoji: { id: "123", name: "party_blob", raw: "party_blob:123" },
|
||||
count: 1,
|
||||
users: [{ id: "u2", username: "beta", tag: "beta" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchChannelPermissionsDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calculates permissions from guild roles", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
const perms = new PermissionsBitField([
|
||||
PermissionsBitField.Flags.ViewChannel,
|
||||
PermissionsBitField.Flags.SendMessages,
|
||||
]);
|
||||
getMock
|
||||
.mockResolvedValueOnce({
|
||||
id: "chan1",
|
||||
guild_id: "guild1",
|
||||
permission_overwrites: [],
|
||||
})
|
||||
.mockResolvedValueOnce({ id: "bot1" })
|
||||
.mockResolvedValueOnce({
|
||||
id: "guild1",
|
||||
roles: [
|
||||
{ id: "guild1", permissions: perms.bitfield.toString() },
|
||||
{ id: "role2", permissions: "0" },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ roles: ["role2"] });
|
||||
const res = await fetchChannelPermissionsDiscord("chan1", {
|
||||
rest,
|
||||
token: "t",
|
||||
});
|
||||
expect(res.guildId).toBe("guild1");
|
||||
expect(res.permissions).toContain("ViewChannel");
|
||||
expect(res.permissions).toContain("SendMessages");
|
||||
expect(res.isDm).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMessagesDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes query params as URLSearchParams", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue([]);
|
||||
await readMessagesDiscord(
|
||||
"chan1",
|
||||
{ limit: 5, before: "10" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
const options = call?.[1] as { query?: URLSearchParams };
|
||||
expect(options.query?.toString()).toBe("limit=5&before=10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit/delete message helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("edits message content", async () => {
|
||||
const { rest, patchMock } = makeRest();
|
||||
patchMock.mockResolvedValue({ id: "m1" });
|
||||
await editMessageDiscord(
|
||||
"chan1",
|
||||
"m1",
|
||||
{ content: "hello" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessage("chan1", "m1"),
|
||||
expect.objectContaining({ body: { content: "hello" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes message", async () => {
|
||||
const { rest, deleteMock } = makeRest();
|
||||
deleteMock.mockResolvedValue({});
|
||||
await deleteMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessage("chan1", "m1"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pin helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("pins and unpins messages", async () => {
|
||||
const { rest, putMock, deleteMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
deleteMock.mockResolvedValue({});
|
||||
await pinMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
await unpinMessageDiscord("chan1", "m1", { rest, token: "t" });
|
||||
expect(putMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
|
||||
expect(deleteMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchMessagesDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses URLSearchParams for search", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue({ total_results: 0, messages: [] });
|
||||
await searchMessagesDiscord(
|
||||
{ guildId: "g1", content: "hello", limit: 5 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
const options = call?.[1] as { query?: URLSearchParams };
|
||||
expect(options.query?.toString()).toBe("content=hello&limit=5");
|
||||
});
|
||||
|
||||
it("supports channel/author arrays and clamps limit", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue({ total_results: 0, messages: [] });
|
||||
await searchMessagesDiscord(
|
||||
{
|
||||
guildId: "g1",
|
||||
content: "hello",
|
||||
channelIds: ["c1", "c2"],
|
||||
authorIds: ["u1"],
|
||||
limit: 99,
|
||||
},
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
const call = getMock.mock.calls[0];
|
||||
const options = call?.[1] as { query?: URLSearchParams };
|
||||
expect(options.query?.toString()).toBe(
|
||||
"content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("threads and moderation helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a thread", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "t1" });
|
||||
await createThreadDiscord(
|
||||
"chan1",
|
||||
{ name: "thread", messageId: "m1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("chan1", "m1"),
|
||||
expect.objectContaining({ body: { name: "thread" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists active threads by guild", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue({ threads: [] });
|
||||
await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" });
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
|
||||
});
|
||||
|
||||
it("times out a member", async () => {
|
||||
const { rest, patchMock } = makeRest();
|
||||
patchMock.mockResolvedValue({ id: "m1" });
|
||||
await timeoutMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", durationMinutes: 10 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(patchMock).toHaveBeenCalledWith(
|
||||
Routes.guildMember("g1", "u1"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
communication_disabled_until: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds and removes roles", async () => {
|
||||
const { rest, putMock, deleteMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
deleteMock.mockResolvedValue({});
|
||||
await addRoleDiscord(
|
||||
{ guildId: "g1", userId: "u1", roleId: "r1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
await removeRoleDiscord(
|
||||
{ guildId: "g1", userId: "u1", roleId: "r1" },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildMemberRole("g1", "u1", "r1"),
|
||||
);
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
Routes.guildMemberRole("g1", "u1", "r1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("bans a member", async () => {
|
||||
const { rest, putMock } = makeRest();
|
||||
putMock.mockResolvedValue({});
|
||||
await banMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", deleteMessageDays: 2 },
|
||||
{ rest, token: "t" },
|
||||
);
|
||||
expect(putMock).toHaveBeenCalledWith(
|
||||
Routes.guildBan("g1", "u1"),
|
||||
expect.objectContaining({ body: { delete_message_days: 2 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listGuildEmojisDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists emojis for a guild", async () => {
|
||||
const { rest, getMock } = makeRest();
|
||||
getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
|
||||
await listGuildEmojisDiscord("g1", { rest, token: "t" });
|
||||
expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendStickerDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends sticker payloads", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendStickerDiscord("channel:789", ["123"], {
|
||||
rest,
|
||||
token: "t",
|
||||
content: "hiya",
|
||||
});
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
content: "hiya",
|
||||
sticker_ids: ["123"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPollDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sends polls with answers", async () => {
|
||||
const { rest, postMock } = makeRest();
|
||||
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
|
||||
const res = await sendPollDiscord(
|
||||
"channel:789",
|
||||
{
|
||||
question: "Lunch?",
|
||||
answers: ["Pizza", "Sushi"],
|
||||
},
|
||||
{
|
||||
rest,
|
||||
token: "t",
|
||||
},
|
||||
);
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
poll: {
|
||||
question: { text: "Lunch?" },
|
||||
answers: [
|
||||
{ poll_media: { text: "Pizza" } },
|
||||
{ poll_media: { text: "Sushi" } },
|
||||
],
|
||||
duration: 24,
|
||||
allow_multiselect: false,
|
||||
layout_type: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+3
-632
@@ -1,16 +1,4 @@
|
||||
import { 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 {
|
||||
APIChannel,
|
||||
APIGuild,
|
||||
APIGuildMember,
|
||||
APIGuildScheduledEvent,
|
||||
APIMessage,
|
||||
APIRole,
|
||||
APIVoiceState,
|
||||
RESTPostAPIGuildScheduledEventJSONBody,
|
||||
} from "discord-api-types/v10";
|
||||
import { REST, Routes } from "discord.js";
|
||||
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@@ -18,10 +6,6 @@ import { loadWebMedia } from "../web/media.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_TEXT_LIMIT = 2000;
|
||||
const DISCORD_MAX_STICKERS = 3;
|
||||
const DISCORD_POLL_MIN_ANSWERS = 2;
|
||||
const DISCORD_POLL_MAX_ANSWERS = 10;
|
||||
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
|
||||
|
||||
type DiscordRecipient =
|
||||
| {
|
||||
@@ -46,88 +30,11 @@ export type DiscordSendResult = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
export type DiscordPollInput = {
|
||||
question: string;
|
||||
answers: string[];
|
||||
allowMultiselect?: boolean;
|
||||
durationHours?: number;
|
||||
};
|
||||
|
||||
export type DiscordReactOpts = {
|
||||
token?: string;
|
||||
rest?: REST;
|
||||
};
|
||||
|
||||
export type DiscordReactionUser = {
|
||||
id: string;
|
||||
username?: string;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
export type DiscordReactionSummary = {
|
||||
emoji: { id?: string | null; name?: string | null; raw: string };
|
||||
count: number;
|
||||
users: DiscordReactionUser[];
|
||||
};
|
||||
|
||||
export type DiscordPermissionsSummary = {
|
||||
channelId: string;
|
||||
guildId?: string;
|
||||
permissions: string[];
|
||||
raw: string;
|
||||
isDm: boolean;
|
||||
};
|
||||
|
||||
export type DiscordMessageQuery = {
|
||||
limit?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
around?: string;
|
||||
};
|
||||
|
||||
export type DiscordMessageEdit = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type DiscordThreadCreate = {
|
||||
name: string;
|
||||
messageId?: string;
|
||||
autoArchiveMinutes?: number;
|
||||
};
|
||||
|
||||
export type DiscordThreadList = {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
includeArchived?: boolean;
|
||||
before?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type DiscordSearchQuery = {
|
||||
guildId: string;
|
||||
content: string;
|
||||
channelIds?: string[];
|
||||
authorIds?: string[];
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type DiscordRoleChange = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export type DiscordModerationTarget = {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type DiscordTimeoutTarget = DiscordModerationTarget & {
|
||||
durationMinutes?: number;
|
||||
until?: string;
|
||||
};
|
||||
|
||||
function resolveToken(explicit?: string) {
|
||||
const cfgToken = loadConfig().discord?.token;
|
||||
const token = normalizeDiscordToken(
|
||||
@@ -149,7 +56,7 @@ function normalizeReactionEmoji(raw: string) {
|
||||
const customMatch = trimmed.match(/^<a?:([^:>]+):(\d+)>$/);
|
||||
const identifier = customMatch
|
||||
? `${customMatch[1]}:${customMatch[2]}`
|
||||
: trimmed.replace(/[\uFE0E\uFE0F]/g, "");
|
||||
: trimmed;
|
||||
return encodeURIComponent(identifier);
|
||||
}
|
||||
|
||||
@@ -183,49 +90,6 @@ function parseRecipient(raw: string): DiscordRecipient {
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
function normalizeStickerIds(raw: string[]) {
|
||||
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
throw new Error("At least one sticker id is required");
|
||||
}
|
||||
if (ids.length > DISCORD_MAX_STICKERS) {
|
||||
throw new Error("Discord supports up to 3 stickers per message");
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
return {
|
||||
question: { text: question },
|
||||
answers: answers.map((answer) => ({ poll_media: { text: answer } })),
|
||||
duration,
|
||||
allow_multiselect: input.allowMultiselect ?? false,
|
||||
layout_type: PollLayoutType.Default,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveChannelId(
|
||||
rest: REST,
|
||||
recipient: DiscordRecipient,
|
||||
@@ -312,31 +176,6 @@ async function sendDiscordMedia(
|
||||
return res;
|
||||
}
|
||||
|
||||
function buildReactionIdentifier(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
if (emoji.id && emoji.name) {
|
||||
return `${emoji.name}:${emoji.id}`;
|
||||
}
|
||||
return emoji.name ?? "";
|
||||
}
|
||||
|
||||
function formatReactionEmoji(emoji: {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}) {
|
||||
return buildReactionIdentifier(emoji);
|
||||
}
|
||||
|
||||
async function fetchBotUserId(rest: REST) {
|
||||
const me = (await rest.get(Routes.user("@me"))) as { id?: string };
|
||||
if (!me?.id) {
|
||||
throw new Error("Failed to resolve bot user id");
|
||||
}
|
||||
return me.id;
|
||||
}
|
||||
|
||||
export async function sendMessageDiscord(
|
||||
to: string,
|
||||
text: string,
|
||||
@@ -368,52 +207,6 @@ export async function sendMessageDiscord(
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendStickerDiscord(
|
||||
to: string,
|
||||
stickerIds: string[],
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(rest, recipient);
|
||||
const content = opts.content?.trim();
|
||||
const stickers = normalizeStickerIds(stickerIds);
|
||||
const res = (await rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: content || undefined,
|
||||
sticker_ids: stickers,
|
||||
},
|
||||
})) as { id: string; channel_id: string };
|
||||
return {
|
||||
messageId: res.id ? String(res.id) : "unknown",
|
||||
channelId: String(res.channel_id ?? channelId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPollDiscord(
|
||||
to: string,
|
||||
poll: DiscordPollInput,
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(rest, recipient);
|
||||
const content = opts.content?.trim();
|
||||
const payload = normalizePollInput(poll);
|
||||
const res = (await rest.post(Routes.channelMessages(channelId), {
|
||||
body: {
|
||||
content: content || undefined,
|
||||
poll: payload,
|
||||
},
|
||||
})) as { id: string; channel_id: string };
|
||||
return {
|
||||
messageId: res.id ? String(res.id) : "unknown",
|
||||
channelId: String(res.channel_id ?? channelId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function reactMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
@@ -423,428 +216,6 @@ export async function reactMessageDiscord(
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const encoded = normalizeReactionEmoji(emoji);
|
||||
await rest.put(
|
||||
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function fetchReactionsDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts & { limit?: number } = {},
|
||||
): Promise<DiscordReactionSummary[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const message = (await rest.get(
|
||||
Routes.channelMessage(channelId, messageId),
|
||||
)) as {
|
||||
reactions?: Array<{
|
||||
count: number;
|
||||
emoji: { id?: string | null; name?: string | null };
|
||||
}>;
|
||||
};
|
||||
const reactions = message.reactions ?? [];
|
||||
if (reactions.length === 0) return [];
|
||||
const limit =
|
||||
typeof opts.limit === "number" && Number.isFinite(opts.limit)
|
||||
? Math.min(Math.max(Math.floor(opts.limit), 1), 100)
|
||||
: 100;
|
||||
|
||||
const summaries: DiscordReactionSummary[] = [];
|
||||
for (const reaction of reactions) {
|
||||
const identifier = buildReactionIdentifier(reaction.emoji);
|
||||
if (!identifier) continue;
|
||||
const encoded = encodeURIComponent(identifier);
|
||||
const users = (await rest.get(
|
||||
Routes.channelMessageReaction(channelId, messageId, encoded),
|
||||
{ query: new URLSearchParams({ limit: String(limit) }) },
|
||||
)) as Array<{ id: string; username?: string; discriminator?: string }>;
|
||||
summaries.push({
|
||||
emoji: {
|
||||
id: reaction.emoji.id ?? null,
|
||||
name: reaction.emoji.name ?? null,
|
||||
raw: formatReactionEmoji(reaction.emoji),
|
||||
},
|
||||
count: reaction.count,
|
||||
users: users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
tag:
|
||||
user.username && user.discriminator
|
||||
? `${user.username}#${user.discriminator}`
|
||||
: user.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
export async function fetchChannelPermissionsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<DiscordPermissionsSummary> {
|
||||
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 guildId = "guild_id" in channel ? channel.guild_id : undefined;
|
||||
if (!guildId) {
|
||||
return {
|
||||
channelId,
|
||||
permissions: [],
|
||||
raw: "0",
|
||||
isDm: true,
|
||||
};
|
||||
}
|
||||
|
||||
const botId = await fetchBotUserId(rest);
|
||||
const [guild, member] = await Promise.all([
|
||||
rest.get(Routes.guild(guildId)) as Promise<APIGuild>,
|
||||
rest.get(Routes.guildMember(guildId, botId)) as Promise<APIGuildMember>,
|
||||
]);
|
||||
|
||||
const rolesById = new Map<string, APIRole>(
|
||||
(guild.roles ?? []).map((role) => [role.id, role]),
|
||||
);
|
||||
const base = new PermissionsBitField();
|
||||
const everyoneRole = rolesById.get(guildId);
|
||||
if (everyoneRole?.permissions) {
|
||||
base.add(BigInt(everyoneRole.permissions));
|
||||
}
|
||||
for (const roleId of member.roles ?? []) {
|
||||
const role = rolesById.get(roleId);
|
||||
if (role?.permissions) {
|
||||
base.add(BigInt(role.permissions));
|
||||
}
|
||||
}
|
||||
|
||||
const permissions = new PermissionsBitField(base);
|
||||
const overwrites =
|
||||
"permission_overwrites" in channel
|
||||
? (channel.permission_overwrites ?? [])
|
||||
: [];
|
||||
for (const overwrite of overwrites) {
|
||||
if (overwrite.id === guildId) {
|
||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
||||
}
|
||||
}
|
||||
for (const overwrite of overwrites) {
|
||||
if (member.roles?.includes(overwrite.id)) {
|
||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
||||
}
|
||||
}
|
||||
for (const overwrite of overwrites) {
|
||||
if (overwrite.id === botId) {
|
||||
permissions.remove(BigInt(overwrite.deny ?? "0"));
|
||||
permissions.add(BigInt(overwrite.allow ?? "0"));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
channelId,
|
||||
guildId,
|
||||
permissions: permissions.toArray(),
|
||||
raw: permissions.bitfield.toString(),
|
||||
isDm: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readMessagesDiscord(
|
||||
channelId: string,
|
||||
query: DiscordMessageQuery = {},
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const limit =
|
||||
typeof query.limit === "number" && Number.isFinite(query.limit)
|
||||
? Math.min(Math.max(Math.floor(query.limit), 1), 100)
|
||||
: undefined;
|
||||
const params = new URLSearchParams();
|
||||
if (limit) params.set("limit", String(limit));
|
||||
if (query.before) params.set("before", query.before);
|
||||
if (query.after) params.set("after", query.after);
|
||||
if (query.around) params.set("around", query.around);
|
||||
return (await rest.get(Routes.channelMessages(channelId), {
|
||||
query: params,
|
||||
})) as APIMessage[];
|
||||
}
|
||||
|
||||
export async function editMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
payload: DiscordMessageEdit,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||
body: { content: payload.content },
|
||||
})) as APIMessage;
|
||||
}
|
||||
|
||||
export async function deleteMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.delete(Routes.channelMessage(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function pinMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.put(Routes.channelPin(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function unpinMessageDiscord(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.delete(Routes.channelPin(channelId, messageId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function listPinsDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIMessage[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
|
||||
}
|
||||
|
||||
export async function createThreadDiscord(
|
||||
channelId: string,
|
||||
payload: DiscordThreadCreate,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const body: Record<string, unknown> = { name: payload.name };
|
||||
if (payload.autoArchiveMinutes) {
|
||||
body.auto_archive_duration = payload.autoArchiveMinutes;
|
||||
}
|
||||
const route = Routes.threads(channelId, payload.messageId);
|
||||
return await rest.post(route, { body });
|
||||
}
|
||||
|
||||
export async function listThreadsDiscord(
|
||||
payload: DiscordThreadList,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
if (payload.includeArchived) {
|
||||
if (!payload.channelId) {
|
||||
throw new Error("channelId required to list archived threads");
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (payload.before) params.set("before", payload.before);
|
||||
if (payload.limit) params.set("limit", String(payload.limit));
|
||||
return await rest.get(Routes.channelThreads(payload.channelId, "public"), {
|
||||
query: params,
|
||||
});
|
||||
}
|
||||
return await rest.get(Routes.guildActiveThreads(payload.guildId));
|
||||
}
|
||||
|
||||
export async function searchMessagesDiscord(
|
||||
query: DiscordSearchQuery,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const params = new URLSearchParams();
|
||||
params.set("content", query.content);
|
||||
if (query.channelIds?.length) {
|
||||
for (const channelId of query.channelIds) {
|
||||
params.append("channel_id", channelId);
|
||||
}
|
||||
}
|
||||
if (query.authorIds?.length) {
|
||||
for (const authorId of query.authorIds) {
|
||||
params.append("author_id", authorId);
|
||||
}
|
||||
}
|
||||
if (query.limit) {
|
||||
const limit = Math.min(Math.max(Math.floor(query.limit), 1), 25);
|
||||
params.set("limit", String(limit));
|
||||
}
|
||||
return await rest.get(`/guilds/${query.guildId}/messages/search`, {
|
||||
query: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listGuildEmojisDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return await rest.get(Routes.guildEmojis(guildId));
|
||||
}
|
||||
|
||||
export async function fetchMemberInfoDiscord(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildMember> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(
|
||||
Routes.guildMember(guildId, userId),
|
||||
)) as APIGuildMember;
|
||||
}
|
||||
|
||||
export async function fetchRoleInfoDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIRole[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
|
||||
}
|
||||
|
||||
export async function addRoleDiscord(
|
||||
payload: DiscordRoleChange,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.put(
|
||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeRoleDiscord(
|
||||
payload: DiscordRoleChange,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.delete(
|
||||
Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId),
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function fetchChannelInfoDiscord(
|
||||
channelId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(Routes.channel(channelId))) as APIChannel;
|
||||
}
|
||||
|
||||
export async function listGuildChannelsDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIChannel[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
|
||||
}
|
||||
|
||||
export async function fetchVoiceStatusDiscord(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIVoiceState> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(
|
||||
Routes.guildVoiceState(guildId, userId),
|
||||
)) as APIVoiceState;
|
||||
}
|
||||
|
||||
export async function listScheduledEventsDiscord(
|
||||
guildId: string,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildScheduledEvent[]> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.get(
|
||||
Routes.guildScheduledEvents(guildId),
|
||||
)) as APIGuildScheduledEvent[];
|
||||
}
|
||||
|
||||
export async function createScheduledEventDiscord(
|
||||
guildId: string,
|
||||
payload: RESTPostAPIGuildScheduledEventJSONBody,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildScheduledEvent> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
return (await rest.post(Routes.guildScheduledEvents(guildId), {
|
||||
body: payload,
|
||||
})) as APIGuildScheduledEvent;
|
||||
}
|
||||
|
||||
export async function timeoutMemberDiscord(
|
||||
payload: DiscordTimeoutTarget,
|
||||
opts: DiscordReactOpts = {},
|
||||
): Promise<APIGuildMember> {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
let until = payload.until;
|
||||
if (!until && payload.durationMinutes) {
|
||||
const ms = payload.durationMinutes * 60 * 1000;
|
||||
until = new Date(Date.now() + ms).toISOString();
|
||||
}
|
||||
return (await rest.patch(
|
||||
Routes.guildMember(payload.guildId, payload.userId),
|
||||
{
|
||||
body: { communication_disabled_until: until ?? null },
|
||||
reason: payload.reason,
|
||||
},
|
||||
)) as APIGuildMember;
|
||||
}
|
||||
|
||||
export async function kickMemberDiscord(
|
||||
payload: DiscordModerationTarget,
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
|
||||
reason: payload.reason,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function banMemberDiscord(
|
||||
payload: DiscordModerationTarget & { deleteMessageDays?: number },
|
||||
opts: DiscordReactOpts = {},
|
||||
) {
|
||||
const token = resolveToken(opts.token);
|
||||
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
|
||||
const deleteMessageDays =
|
||||
typeof payload.deleteMessageDays === "number" &&
|
||||
Number.isFinite(payload.deleteMessageDays)
|
||||
? Math.min(Math.max(Math.floor(payload.deleteMessageDays), 0), 7)
|
||||
: undefined;
|
||||
await rest.put(Routes.guildBan(payload.guildId, payload.userId), {
|
||||
body:
|
||||
deleteMessageDays !== undefined
|
||||
? { delete_message_days: deleteMessageDays }
|
||||
: undefined,
|
||||
reason: payload.reason,
|
||||
});
|
||||
await rest.put(Routes.channelMessageReaction(channelId, messageId, encoded));
|
||||
return { ok: true };
|
||||
}
|
||||
+3
-3
@@ -27,7 +27,6 @@ export async function callGateway<T = unknown>(
|
||||
const config = loadConfig();
|
||||
const isRemoteMode = config.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
||||
const authToken = config.gateway?.auth?.token;
|
||||
const url =
|
||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||
? opts.url.trim()
|
||||
@@ -45,8 +44,9 @@ export async function callGateway<T = unknown>(
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim().length > 0
|
||||
? authToken.trim()
|
||||
(typeof config.gateway?.auth?.token === "string" &&
|
||||
config.gateway.auth.token.trim().length > 0
|
||||
? config.gateway.auth.token.trim()
|
||||
: undefined));
|
||||
const password =
|
||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||
|
||||
@@ -151,7 +151,6 @@ export async function applyHookMappings(
|
||||
}
|
||||
}
|
||||
|
||||
if (!base.action) return { ok: true, action: null, skipped: true };
|
||||
const merged = mergeAction(base.action, override, mapping.action);
|
||||
if (!merged.ok) return merged;
|
||||
return merged;
|
||||
|
||||
@@ -297,7 +297,6 @@ export const SessionsPatchParamsSchema = Type.Object(
|
||||
key: NonEmptyString,
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
groupActivation: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("mention"),
|
||||
|
||||
@@ -4074,16 +4074,6 @@ describe("gateway server", () => {
|
||||
expect(main2?.thinkingLevel).toBe("medium");
|
||||
expect(main2?.verboseLevel).toBeUndefined();
|
||||
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||
const modelPatched = await rpcReq<{
|
||||
ok: true;
|
||||
entry: { modelOverride?: string; providerOverride?: string };
|
||||
}>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" });
|
||||
expect(modelPatched.ok).toBe(true);
|
||||
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
|
||||
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
|
||||
|
||||
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
|
||||
ws,
|
||||
"sessions.compact",
|
||||
|
||||
+1
-172
@@ -20,13 +20,7 @@ import {
|
||||
type ModelCatalogEntry,
|
||||
resetModelCatalogCacheForTest,
|
||||
} from "../agents/model-catalog.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import { installSkill } from "../agents/skills-install.js";
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
|
||||
@@ -83,7 +77,6 @@ import {
|
||||
} from "../discord/index.js";
|
||||
import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
|
||||
import { isVerbose } from "../globals.js";
|
||||
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||
import {
|
||||
monitorIMessageProvider,
|
||||
sendMessageIMessage,
|
||||
@@ -2964,74 +2957,6 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
|
||||
if ("model" in p) {
|
||||
const raw = p.model;
|
||||
if (raw === null) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid model: empty",
|
||||
},
|
||||
};
|
||||
}
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid model: ${trimmed}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const catalog = await loadGatewayModelCatalog();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `model not allowed: ${key}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in p) {
|
||||
const raw = p.groupActivation;
|
||||
if (raw === null) {
|
||||
@@ -5578,83 +5503,6 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
|
||||
if ("model" in p) {
|
||||
const raw = p.model;
|
||||
if (raw === null) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"invalid model: empty",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid model: ${trimmed}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const catalog = await loadGatewayModelCatalog();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const key = modelKey(
|
||||
resolved.ref.provider,
|
||||
resolved.ref.model,
|
||||
);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`model not allowed: ${key}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in p) {
|
||||
const raw = p.groupActivation;
|
||||
if (raw === null) {
|
||||
@@ -7011,24 +6859,6 @@ export async function startGatewayServer(
|
||||
logBrowser.error(`server failed to start: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Start Gmail watcher if configured (hooks.gmail.account).
|
||||
if (process.env.CLAWDIS_SKIP_GMAIL_WATCHER !== "1") {
|
||||
try {
|
||||
const gmailResult = await startGmailWatcher(cfgAtStart);
|
||||
if (gmailResult.started) {
|
||||
logHooks.info("gmail watcher started");
|
||||
} else if (
|
||||
gmailResult.reason &&
|
||||
gmailResult.reason !== "hooks not enabled" &&
|
||||
gmailResult.reason !== "no gmail account configured"
|
||||
) {
|
||||
logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logHooks.error(`gmail watcher failed to start: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Launch configured providers (WhatsApp Web, Discord, Telegram) so gateway replies via the
|
||||
// surface the message came from. Tests can opt out via CLAWDIS_SKIP_PROVIDERS.
|
||||
if (process.env.CLAWDIS_SKIP_PROVIDERS !== "1") {
|
||||
@@ -7087,7 +6917,6 @@ export async function startGatewayServer(
|
||||
await stopDiscordProvider();
|
||||
await stopSignalProvider();
|
||||
await stopIMessageProvider();
|
||||
await stopGmailWatcher();
|
||||
cron.stop();
|
||||
heartbeatRunner.stop();
|
||||
broadcast("shutdown", {
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* Gmail Watcher Service
|
||||
*
|
||||
* Automatically starts `gog gmail watch serve` when the gateway starts,
|
||||
* if hooks.gmail is configured with an account.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { hasBinary } from "../agents/skills.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
buildGogWatchServeArgs,
|
||||
buildGogWatchStartArgs,
|
||||
type GmailHookRuntimeConfig,
|
||||
resolveGmailHookRuntimeConfig,
|
||||
} from "./gmail.js";
|
||||
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
|
||||
|
||||
const log = createSubsystemLogger("gmail-watcher");
|
||||
|
||||
let watcherProcess: ChildProcess | null = null;
|
||||
let renewInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let shuttingDown = false;
|
||||
let currentConfig: GmailHookRuntimeConfig | null = null;
|
||||
|
||||
/**
|
||||
* Check if gog binary is available
|
||||
*/
|
||||
function isGogAvailable(): boolean {
|
||||
return hasBinary("gog");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Gmail watch (registers with Gmail API)
|
||||
*/
|
||||
async function startGmailWatch(
|
||||
cfg: Pick<GmailHookRuntimeConfig, "account" | "label" | "topic">,
|
||||
): Promise<boolean> {
|
||||
const args = ["gog", ...buildGogWatchStartArgs(cfg)];
|
||||
try {
|
||||
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
|
||||
if (result.code !== 0) {
|
||||
const message =
|
||||
result.stderr || result.stdout || "gog watch start failed";
|
||||
log.error(`watch start failed: ${message}`);
|
||||
return false;
|
||||
}
|
||||
log.info(`watch started for ${cfg.account}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.error(`watch start error: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the gog gmail watch serve process
|
||||
*/
|
||||
function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
|
||||
const args = buildGogWatchServeArgs(cfg);
|
||||
log.info(`starting gog ${args.join(" ")}`);
|
||||
|
||||
const child = spawn("gog", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) log.info(`[gog] ${line}`);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) log.warn(`[gog] ${line}`);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log.error(`gog process error: ${String(err)}`);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (shuttingDown) return;
|
||||
log.warn(`gog exited (code=${code}, signal=${signal}); restarting in 5s`);
|
||||
watcherProcess = null;
|
||||
setTimeout(() => {
|
||||
if (shuttingDown || !currentConfig) return;
|
||||
watcherProcess = spawnGogServe(currentConfig);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
export type GmailWatcherStartResult = {
|
||||
started: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the Gmail watcher service.
|
||||
* Called automatically by the gateway if hooks.gmail is configured.
|
||||
*/
|
||||
export async function startGmailWatcher(
|
||||
cfg: ClawdisConfig,
|
||||
): Promise<GmailWatcherStartResult> {
|
||||
// Check if gmail hooks are configured
|
||||
if (!cfg.hooks?.enabled) {
|
||||
return { started: false, reason: "hooks not enabled" };
|
||||
}
|
||||
|
||||
if (!cfg.hooks?.gmail?.account) {
|
||||
return { started: false, reason: "no gmail account configured" };
|
||||
}
|
||||
|
||||
// Check if gog is available
|
||||
const gogAvailable = isGogAvailable();
|
||||
if (!gogAvailable) {
|
||||
return { started: false, reason: "gog binary not found" };
|
||||
}
|
||||
|
||||
// Resolve the full runtime config
|
||||
const resolved = resolveGmailHookRuntimeConfig(cfg, {});
|
||||
if (!resolved.ok) {
|
||||
return { started: false, reason: resolved.error };
|
||||
}
|
||||
|
||||
const runtimeConfig = resolved.value;
|
||||
currentConfig = runtimeConfig;
|
||||
|
||||
// Set up Tailscale endpoint if needed
|
||||
if (runtimeConfig.tailscale.mode !== "off") {
|
||||
try {
|
||||
await ensureTailscaleEndpoint({
|
||||
mode: runtimeConfig.tailscale.mode,
|
||||
path: runtimeConfig.tailscale.path,
|
||||
port: runtimeConfig.serve.port,
|
||||
});
|
||||
log.info(
|
||||
`tailscale ${runtimeConfig.tailscale.mode} configured for port ${runtimeConfig.serve.port}`,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error(`tailscale setup failed: ${String(err)}`);
|
||||
return {
|
||||
started: false,
|
||||
reason: `tailscale setup failed: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Start the Gmail watch (register with Gmail API)
|
||||
const watchStarted = await startGmailWatch(runtimeConfig);
|
||||
if (!watchStarted) {
|
||||
log.warn("gmail watch start failed, but continuing with serve");
|
||||
}
|
||||
|
||||
// Spawn the gog serve process
|
||||
shuttingDown = false;
|
||||
watcherProcess = spawnGogServe(runtimeConfig);
|
||||
|
||||
// Set up renewal interval
|
||||
const renewMs = runtimeConfig.renewEveryMinutes * 60_000;
|
||||
renewInterval = setInterval(() => {
|
||||
if (shuttingDown) return;
|
||||
void startGmailWatch(runtimeConfig);
|
||||
}, renewMs);
|
||||
|
||||
log.info(
|
||||
`gmail watcher started for ${runtimeConfig.account} (renew every ${runtimeConfig.renewEveryMinutes}m)`,
|
||||
);
|
||||
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Gmail watcher service.
|
||||
*/
|
||||
export async function stopGmailWatcher(): Promise<void> {
|
||||
shuttingDown = true;
|
||||
|
||||
if (renewInterval) {
|
||||
clearInterval(renewInterval);
|
||||
renewInterval = null;
|
||||
}
|
||||
|
||||
if (watcherProcess) {
|
||||
log.info("stopping gmail watcher");
|
||||
watcherProcess.kill("SIGTERM");
|
||||
|
||||
// Wait a bit for graceful shutdown
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (watcherProcess) {
|
||||
watcherProcess.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 3000);
|
||||
|
||||
watcherProcess?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
watcherProcess = null;
|
||||
}
|
||||
|
||||
currentConfig = null;
|
||||
log.info("gmail watcher stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Gmail watcher is running.
|
||||
*/
|
||||
export function isGmailWatcherRunning(): boolean {
|
||||
return watcherProcess !== null && !shuttingDown;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { convertTools } from "@mariozechner/pi-ai/dist/providers/google-shared.js";
|
||||
import type { Tool } from "@mariozechner/pi-ai/dist/types.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> => {
|
||||
expect(value).toBeTruthy();
|
||||
expect(typeof value).toBe("object");
|
||||
expect(Array.isArray(value)).toBe(false);
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("google-shared convertTools", () => {
|
||||
it("strips unsupported JSON Schema keywords", () => {
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "example",
|
||||
description: "Example tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
patternProperties: {
|
||||
"^x-": { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
const: "fast",
|
||||
},
|
||||
options: {
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
},
|
||||
list: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
const: "item",
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const converted = convertTools(tools);
|
||||
const params = asRecord(
|
||||
converted?.[0]?.functionDeclarations?.[0]?.parameters,
|
||||
);
|
||||
const properties = asRecord(params.properties);
|
||||
const mode = asRecord(properties.mode);
|
||||
const options = asRecord(properties.options);
|
||||
const list = asRecord(properties.list);
|
||||
const items = asRecord(list.items);
|
||||
|
||||
expect(params).not.toHaveProperty("patternProperties");
|
||||
expect(params).not.toHaveProperty("additionalProperties");
|
||||
expect(mode).not.toHaveProperty("const");
|
||||
expect(options).not.toHaveProperty("anyOf");
|
||||
expect(items).not.toHaveProperty("const");
|
||||
expect(params.required).toEqual(["mode"]);
|
||||
});
|
||||
|
||||
it("keeps supported schema fields", () => {
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "settings",
|
||||
description: "Settings tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: {
|
||||
type: "object",
|
||||
properties: {
|
||||
retries: { type: "number", minimum: 1 },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["retries"],
|
||||
},
|
||||
},
|
||||
required: ["config"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const converted = convertTools(tools);
|
||||
const params = asRecord(
|
||||
converted?.[0]?.functionDeclarations?.[0]?.parameters,
|
||||
);
|
||||
const config = asRecord(asRecord(params.properties).config);
|
||||
const configProps = asRecord(config.properties);
|
||||
const retries = asRecord(configProps.retries);
|
||||
const tags = asRecord(configProps.tags);
|
||||
const items = asRecord(tags.items);
|
||||
|
||||
expect(params.type).toBe("object");
|
||||
expect(config.type).toBe("object");
|
||||
expect(retries.minimum).toBe(1);
|
||||
expect(tags.type).toBe("array");
|
||||
expect(items.type).toBe("string");
|
||||
expect(config.required).toEqual(["retries"]);
|
||||
expect(params.required).toEqual(["config"]);
|
||||
});
|
||||
});
|
||||
@@ -217,7 +217,6 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { SlashCommand } from "@mariozechner/pi-tui";
|
||||
|
||||
const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"];
|
||||
const VERBOSE_LEVELS = ["on", "off"];
|
||||
const ACTIVATION_LEVELS = ["mention", "always"];
|
||||
const TOGGLE = ["on", "off"];
|
||||
|
||||
export type ParsedCommand = {
|
||||
name: string;
|
||||
args: string;
|
||||
};
|
||||
|
||||
export function parseCommand(input: string): ParsedCommand {
|
||||
const trimmed = input.replace(/^\//, "").trim();
|
||||
if (!trimmed) return { name: "", args: "" };
|
||||
const [name, ...rest] = trimmed.split(/\s+/);
|
||||
return { name: name.toLowerCase(), args: rest.join(" ").trim() };
|
||||
}
|
||||
|
||||
export function getSlashCommands(): SlashCommand[] {
|
||||
return [
|
||||
{ name: "help", description: "Show slash command help" },
|
||||
{ name: "status", description: "Show gateway status summary" },
|
||||
{ name: "session", description: "Switch session (or open picker)" },
|
||||
{ name: "sessions", description: "Open session picker" },
|
||||
{
|
||||
name: "model",
|
||||
description: "Set model (or open picker)",
|
||||
},
|
||||
{ name: "models", description: "Open model picker" },
|
||||
{
|
||||
name: "think",
|
||||
description: "Set thinking level",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "verbose",
|
||||
description: "Set verbose on/off",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
VERBOSE_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "activation",
|
||||
description: "Set group activation",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
ACTIVATION_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "deliver",
|
||||
description: "Toggle delivery of assistant replies",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
{ name: "abort", description: "Abort active run" },
|
||||
{ name: "new", description: "Reset the session" },
|
||||
{ name: "reset", description: "Reset the session" },
|
||||
{ name: "settings", description: "Open settings" },
|
||||
{ name: "exit", description: "Exit the TUI" },
|
||||
{ name: "quit", description: "Exit the TUI" },
|
||||
];
|
||||
}
|
||||
|
||||
export function helpText(): string {
|
||||
return [
|
||||
"Slash commands:",
|
||||
"/help",
|
||||
"/status",
|
||||
"/session <key> (or /sessions)",
|
||||
"/model <provider/model> (or /models)",
|
||||
"/think <off|minimal|low|medium|high>",
|
||||
"/verbose <on|off>",
|
||||
"/activation <mention|always>",
|
||||
"/deliver <on|off>",
|
||||
"/new or /reset",
|
||||
"/abort",
|
||||
"/settings",
|
||||
"/exit",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
|
||||
import { markdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
export class AssistantMessageComponent extends Container {
|
||||
private body: Markdown;
|
||||
|
||||
constructor(text: string) {
|
||||
super();
|
||||
this.body = new Markdown(text, 1, 0, markdownTheme, {
|
||||
color: (line) => theme.fg(line),
|
||||
});
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.body);
|
||||
}
|
||||
|
||||
setText(text: string) {
|
||||
this.body.setText(text);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||
import { ToolExecutionComponent } from "./tool-execution.js";
|
||||
import { UserMessageComponent } from "./user-message.js";
|
||||
|
||||
export class ChatLog extends Container {
|
||||
private toolById = new Map<string, ToolExecutionComponent>();
|
||||
private streamingAssistant: AssistantMessageComponent | null = null;
|
||||
private streamingRunId: string | null = null;
|
||||
private streamingText: string | null = null;
|
||||
private toolsExpanded = false;
|
||||
|
||||
clearAll() {
|
||||
this.clear();
|
||||
this.toolById.clear();
|
||||
this.streamingAssistant = null;
|
||||
this.streamingRunId = null;
|
||||
this.streamingText = null;
|
||||
}
|
||||
|
||||
addSystem(text: string) {
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.system(text), 1, 0));
|
||||
}
|
||||
|
||||
addUser(text: string) {
|
||||
this.addChild(new UserMessageComponent(text));
|
||||
}
|
||||
|
||||
startAssistant(text: string, runId?: string) {
|
||||
const component = new AssistantMessageComponent(text);
|
||||
this.streamingAssistant = component;
|
||||
this.streamingRunId = runId ?? null;
|
||||
this.streamingText = text;
|
||||
this.addChild(component);
|
||||
return component;
|
||||
}
|
||||
|
||||
updateAssistant(text: string, runId?: string) {
|
||||
if (
|
||||
!this.streamingAssistant ||
|
||||
(runId && this.streamingRunId && runId !== this.streamingRunId)
|
||||
) {
|
||||
this.startAssistant(text, runId);
|
||||
return;
|
||||
}
|
||||
this.streamingText = text;
|
||||
this.streamingAssistant.setText(text);
|
||||
}
|
||||
|
||||
finalizeAssistant(text: string, runId?: string) {
|
||||
if (
|
||||
this.streamingAssistant &&
|
||||
(!runId || runId === this.streamingRunId || text === this.streamingText)
|
||||
) {
|
||||
this.streamingText = text;
|
||||
this.streamingAssistant.setText(text);
|
||||
} else {
|
||||
this.startAssistant(text, runId);
|
||||
}
|
||||
this.streamingAssistant = null;
|
||||
this.streamingRunId = null;
|
||||
this.streamingText = null;
|
||||
}
|
||||
|
||||
startTool(toolCallId: string, toolName: string, args: unknown) {
|
||||
const existing = this.toolById.get(toolCallId);
|
||||
if (existing) {
|
||||
existing.setArgs(args);
|
||||
return existing;
|
||||
}
|
||||
const component = new ToolExecutionComponent(toolName, args);
|
||||
component.setExpanded(this.toolsExpanded);
|
||||
this.toolById.set(toolCallId, component);
|
||||
this.addChild(component);
|
||||
return component;
|
||||
}
|
||||
|
||||
updateToolArgs(toolCallId: string, args: unknown) {
|
||||
const existing = this.toolById.get(toolCallId);
|
||||
if (!existing) return;
|
||||
existing.setArgs(args);
|
||||
}
|
||||
|
||||
updateToolResult(
|
||||
toolCallId: string,
|
||||
result: unknown,
|
||||
opts?: { isError?: boolean; partial?: boolean },
|
||||
) {
|
||||
const existing = this.toolById.get(toolCallId);
|
||||
if (!existing) return;
|
||||
if (opts?.partial) {
|
||||
existing.setPartialResult(result as Record<string, unknown>);
|
||||
return;
|
||||
}
|
||||
existing.setResult(result as Record<string, unknown>, {
|
||||
isError: opts?.isError,
|
||||
});
|
||||
}
|
||||
|
||||
setToolsExpanded(expanded: boolean) {
|
||||
this.toolsExpanded = expanded;
|
||||
for (const tool of this.toolById.values()) {
|
||||
tool.setExpanded(expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
Editor,
|
||||
isAltEnter,
|
||||
isCtrlC,
|
||||
isCtrlD,
|
||||
isCtrlL,
|
||||
isCtrlO,
|
||||
isCtrlP,
|
||||
isCtrlT,
|
||||
isEscape,
|
||||
isShiftTab,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
export class CustomEditor extends Editor {
|
||||
onEscape?: () => void;
|
||||
onCtrlC?: () => void;
|
||||
onCtrlD?: () => void;
|
||||
onCtrlL?: () => void;
|
||||
onCtrlO?: () => void;
|
||||
onCtrlP?: () => void;
|
||||
onCtrlT?: () => void;
|
||||
onShiftTab?: () => void;
|
||||
onAltEnter?: () => void;
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (isAltEnter(data) && this.onAltEnter) {
|
||||
this.onAltEnter();
|
||||
return;
|
||||
}
|
||||
if (isCtrlL(data) && this.onCtrlL) {
|
||||
this.onCtrlL();
|
||||
return;
|
||||
}
|
||||
if (isCtrlO(data) && this.onCtrlO) {
|
||||
this.onCtrlO();
|
||||
return;
|
||||
}
|
||||
if (isCtrlP(data) && this.onCtrlP) {
|
||||
this.onCtrlP();
|
||||
return;
|
||||
}
|
||||
if (isCtrlT(data) && this.onCtrlT) {
|
||||
this.onCtrlT();
|
||||
return;
|
||||
}
|
||||
if (isShiftTab(data) && this.onShiftTab) {
|
||||
this.onShiftTab();
|
||||
return;
|
||||
}
|
||||
if (isEscape(data) && this.onEscape && !this.isShowingAutocomplete()) {
|
||||
this.onEscape();
|
||||
return;
|
||||
}
|
||||
if (isCtrlC(data) && this.onCtrlC) {
|
||||
this.onCtrlC();
|
||||
return;
|
||||
}
|
||||
if (isCtrlD(data)) {
|
||||
if (this.getText().length === 0 && this.onCtrlD) {
|
||||
this.onCtrlD();
|
||||
}
|
||||
return;
|
||||
}
|
||||
super.handleInput(data);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import {
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SettingItem,
|
||||
SettingsList,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { selectListTheme, settingsListTheme } from "../theme/theme.js";
|
||||
|
||||
export function createSelectList(items: SelectItem[], maxVisible = 7) {
|
||||
return new SelectList(items, maxVisible, selectListTheme);
|
||||
}
|
||||
|
||||
export function createSettingsList(
|
||||
items: SettingItem[],
|
||||
onChange: (id: string, value: string) => void,
|
||||
onCancel: () => void,
|
||||
maxVisible = 7,
|
||||
) {
|
||||
return new SettingsList(
|
||||
items,
|
||||
maxVisible,
|
||||
settingsListTheme,
|
||||
onChange,
|
||||
onCancel,
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { markdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
type ToolResultContent = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
omitted?: boolean;
|
||||
};
|
||||
|
||||
type ToolResult = {
|
||||
content?: ToolResultContent[];
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const PREVIEW_LINES = 12;
|
||||
|
||||
function formatArgs(toolName: string, args: unknown): string {
|
||||
if (!args || typeof args !== "object") return "";
|
||||
const record = args as Record<string, unknown>;
|
||||
if (toolName === "bash" && typeof record.command === "string") {
|
||||
return record.command;
|
||||
}
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
if (path) return path;
|
||||
try {
|
||||
return JSON.stringify(args);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(result?: ToolResult): string {
|
||||
if (!result?.content) return "";
|
||||
const lines: string[] = [];
|
||||
for (const entry of result.content) {
|
||||
if (entry.type === "text" && entry.text) {
|
||||
lines.push(entry.text);
|
||||
} else if (entry.type === "image") {
|
||||
const mime = entry.mimeType ?? "image";
|
||||
const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : "";
|
||||
const omitted = entry.omitted ? " (omitted)" : "";
|
||||
lines.push(`[${mime}${size}${omitted}]`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
export class ToolExecutionComponent extends Container {
|
||||
private box: Box;
|
||||
private header: Text;
|
||||
private argsLine: Text;
|
||||
private output: Markdown;
|
||||
private toolName: string;
|
||||
private args: unknown;
|
||||
private result?: ToolResult;
|
||||
private expanded = false;
|
||||
private isError = false;
|
||||
private isPartial = true;
|
||||
|
||||
constructor(toolName: string, args: unknown) {
|
||||
super();
|
||||
this.toolName = toolName;
|
||||
this.args = args;
|
||||
this.box = new Box(1, 1, (line) => theme.toolPendingBg(line));
|
||||
this.header = new Text("", 0, 0);
|
||||
this.argsLine = new Text("", 0, 0);
|
||||
this.output = new Markdown("", 0, 0, markdownTheme, {
|
||||
color: (line) => theme.toolOutput(line),
|
||||
});
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.box);
|
||||
this.box.addChild(this.header);
|
||||
this.box.addChild(this.argsLine);
|
||||
this.box.addChild(this.output);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setArgs(args: unknown) {
|
||||
this.args = args;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean) {
|
||||
this.expanded = expanded;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setResult(result: ToolResult | undefined, opts?: { isError?: boolean }) {
|
||||
this.result = result;
|
||||
this.isPartial = false;
|
||||
this.isError = Boolean(opts?.isError);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setPartialResult(result: ToolResult | undefined) {
|
||||
this.result = result;
|
||||
this.isPartial = true;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private refresh() {
|
||||
const bg = this.isPartial
|
||||
? theme.toolPendingBg
|
||||
: this.isError
|
||||
? theme.toolErrorBg
|
||||
: theme.toolSuccessBg;
|
||||
this.box.setBgFn((line) => bg(line));
|
||||
|
||||
const title = `${this.toolName}${this.isPartial ? " (running)" : ""}`;
|
||||
this.header.setText(theme.toolTitle(theme.bold(title)));
|
||||
|
||||
const argLine = formatArgs(this.toolName, this.args);
|
||||
this.argsLine.setText(argLine ? theme.dim(argLine) : theme.dim(" "));
|
||||
|
||||
const raw = extractText(this.result);
|
||||
const text = raw || (this.isPartial ? "…" : "");
|
||||
if (!this.expanded && text) {
|
||||
const lines = text.split("\n");
|
||||
const preview =
|
||||
lines.length > PREVIEW_LINES
|
||||
? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…`
|
||||
: text;
|
||||
this.output.setText(preview);
|
||||
} else {
|
||||
this.output.setText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
|
||||
import { markdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
export class UserMessageComponent extends Container {
|
||||
private body: Markdown;
|
||||
|
||||
constructor(text: string) {
|
||||
super();
|
||||
this.body = new Markdown(text, 1, 1, markdownTheme, {
|
||||
bgColor: (line) => theme.userBg(line),
|
||||
color: (line) => theme.userText(line),
|
||||
});
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.body);
|
||||
}
|
||||
|
||||
setText(text: string) {
|
||||
this.body.setText(text);
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import {
|
||||
type HelloOk,
|
||||
PROTOCOL_VERSION,
|
||||
type SessionsListParams,
|
||||
type SessionsPatchParams,
|
||||
} from "../gateway/protocol/index.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export type GatewayConnectionOptions = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export type ChatSendOptions = {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
thinking?: string;
|
||||
deliver?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type GatewayEvent = {
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
};
|
||||
|
||||
export type GatewaySessionList = {
|
||||
ts: number;
|
||||
path: string;
|
||||
count: number;
|
||||
defaults?: { model?: string | null; contextTokens?: number | null };
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
sessionId?: string;
|
||||
updatedAt?: number | null;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
model?: string;
|
||||
contextTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
displayName?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GatewayModelChoice = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
};
|
||||
|
||||
export class GatewayChatClient {
|
||||
private client: GatewayClient;
|
||||
private readyPromise: Promise<void>;
|
||||
private resolveReady?: () => void;
|
||||
readonly connection: { url: string; token?: string; password?: string };
|
||||
hello?: HelloOk;
|
||||
|
||||
onEvent?: (evt: GatewayEvent) => void;
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: (reason: string) => void;
|
||||
onGap?: (info: { expected: number; received: number }) => void;
|
||||
|
||||
constructor(opts: GatewayConnectionOptions) {
|
||||
const resolved = resolveGatewayConnection(opts);
|
||||
this.connection = resolved;
|
||||
|
||||
this.readyPromise = new Promise((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
|
||||
this.client = new GatewayClient({
|
||||
url: resolved.url,
|
||||
token: resolved.token,
|
||||
password: resolved.password,
|
||||
clientName: "clawdis-tui",
|
||||
clientVersion: VERSION,
|
||||
platform: process.platform,
|
||||
mode: "tui",
|
||||
instanceId: randomUUID(),
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
onHelloOk: (hello) => {
|
||||
this.hello = hello;
|
||||
this.resolveReady?.();
|
||||
this.onConnected?.();
|
||||
},
|
||||
onEvent: (evt) => {
|
||||
this.onEvent?.({
|
||||
event: evt.event,
|
||||
payload: evt.payload,
|
||||
seq: evt.seq,
|
||||
});
|
||||
},
|
||||
onClose: (_code, reason) => {
|
||||
this.onDisconnected?.(reason);
|
||||
},
|
||||
onGap: (info) => {
|
||||
this.onGap?.(info);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
this.client.start();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.client.stop();
|
||||
}
|
||||
|
||||
async waitForReady() {
|
||||
await this.readyPromise;
|
||||
}
|
||||
|
||||
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> {
|
||||
const runId = randomUUID();
|
||||
await this.client.request("chat.send", {
|
||||
sessionKey: opts.sessionKey,
|
||||
message: opts.message,
|
||||
thinking: opts.thinking,
|
||||
deliver: opts.deliver,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
idempotencyKey: runId,
|
||||
});
|
||||
return { runId };
|
||||
}
|
||||
|
||||
async abortChat(opts: { sessionKey: string; runId: string }) {
|
||||
return await this.client.request<{ ok: boolean; aborted: boolean }>(
|
||||
"chat.abort",
|
||||
{
|
||||
sessionKey: opts.sessionKey,
|
||||
runId: opts.runId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async loadHistory(opts: { sessionKey: string; limit?: number }) {
|
||||
return await this.client.request("chat.history", {
|
||||
sessionKey: opts.sessionKey,
|
||||
limit: opts.limit,
|
||||
});
|
||||
}
|
||||
|
||||
async listSessions(opts?: SessionsListParams) {
|
||||
return await this.client.request<GatewaySessionList>("sessions.list", {
|
||||
limit: opts?.limit,
|
||||
activeMinutes: opts?.activeMinutes,
|
||||
includeGlobal: opts?.includeGlobal,
|
||||
includeUnknown: opts?.includeUnknown,
|
||||
});
|
||||
}
|
||||
|
||||
async patchSession(opts: SessionsPatchParams) {
|
||||
return await this.client.request("sessions.patch", opts);
|
||||
}
|
||||
|
||||
async resetSession(key: string) {
|
||||
return await this.client.request("sessions.reset", { key });
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return await this.client.request("status");
|
||||
}
|
||||
|
||||
async listModels(): Promise<GatewayModelChoice[]> {
|
||||
const res = await this.client.request<{ models?: GatewayModelChoice[] }>(
|
||||
"models.list",
|
||||
);
|
||||
return Array.isArray(res?.models) ? res.models : [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
|
||||
const config = loadConfig();
|
||||
const isRemoteMode = config.gateway?.mode === "remote";
|
||||
const remote = isRemoteMode ? config.gateway?.remote : undefined;
|
||||
const authToken = config.gateway?.auth?.token;
|
||||
|
||||
const url =
|
||||
(typeof opts.url === "string" && opts.url.trim().length > 0
|
||||
? opts.url.trim()
|
||||
: undefined) ||
|
||||
(typeof remote?.url === "string" && remote.url.trim().length > 0
|
||||
? remote.url.trim()
|
||||
: undefined) ||
|
||||
"ws://127.0.0.1:18789";
|
||||
|
||||
const token =
|
||||
(typeof opts.token === "string" && opts.token.trim().length > 0
|
||||
? opts.token.trim()
|
||||
: undefined) ||
|
||||
(isRemoteMode
|
||||
? typeof remote?.token === "string" && remote.token.trim().length > 0
|
||||
? remote.token.trim()
|
||||
: undefined
|
||||
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
|
||||
(typeof authToken === "string" && authToken.trim().length > 0
|
||||
? authToken.trim()
|
||||
: undefined));
|
||||
|
||||
const password =
|
||||
(typeof opts.password === "string" && opts.password.trim().length > 0
|
||||
? opts.password.trim()
|
||||
: undefined) ||
|
||||
process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() ||
|
||||
(typeof remote?.password === "string" && remote.password.trim().length > 0
|
||||
? remote.password.trim()
|
||||
: undefined);
|
||||
|
||||
return { url, token, password };
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import type {
|
||||
EditorTheme,
|
||||
MarkdownTheme,
|
||||
SelectListTheme,
|
||||
SettingsListTheme,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
|
||||
const palette = {
|
||||
text: "#E8E3D5",
|
||||
dim: "#7B7F87",
|
||||
accent: "#F6C453",
|
||||
accentSoft: "#F2A65A",
|
||||
border: "#3C414B",
|
||||
userBg: "#2B2F36",
|
||||
userText: "#F3EEE0",
|
||||
systemText: "#9BA3B2",
|
||||
toolPendingBg: "#1F2A2F",
|
||||
toolSuccessBg: "#1E2D23",
|
||||
toolErrorBg: "#2F1F1F",
|
||||
toolTitle: "#F6C453",
|
||||
toolOutput: "#E1DACB",
|
||||
quote: "#8CC8FF",
|
||||
quoteBorder: "#3B4D6B",
|
||||
code: "#F0C987",
|
||||
codeBlock: "#1E232A",
|
||||
codeBorder: "#343A45",
|
||||
link: "#7DD3A5",
|
||||
error: "#F97066",
|
||||
success: "#7DD3A5",
|
||||
};
|
||||
|
||||
const fg = (hex: string) => (text: string) => chalk.hex(hex)(text);
|
||||
const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text);
|
||||
|
||||
export const theme = {
|
||||
fg: fg(palette.text),
|
||||
dim: fg(palette.dim),
|
||||
accent: fg(palette.accent),
|
||||
accentSoft: fg(palette.accentSoft),
|
||||
success: fg(palette.success),
|
||||
error: fg(palette.error),
|
||||
header: (text: string) => chalk.bold(fg(palette.accent)(text)),
|
||||
system: fg(palette.systemText),
|
||||
userBg: bg(palette.userBg),
|
||||
userText: fg(palette.userText),
|
||||
toolTitle: fg(palette.toolTitle),
|
||||
toolOutput: fg(palette.toolOutput),
|
||||
toolPendingBg: bg(palette.toolPendingBg),
|
||||
toolSuccessBg: bg(palette.toolSuccessBg),
|
||||
toolErrorBg: bg(palette.toolErrorBg),
|
||||
border: fg(palette.border),
|
||||
bold: (text: string) => chalk.bold(text),
|
||||
italic: (text: string) => chalk.italic(text),
|
||||
};
|
||||
|
||||
export const markdownTheme: MarkdownTheme = {
|
||||
heading: (text) => chalk.bold(fg(palette.accent)(text)),
|
||||
link: (text) => fg(palette.link)(text),
|
||||
linkUrl: (text) => chalk.dim(text),
|
||||
code: (text) => fg(palette.code)(text),
|
||||
codeBlock: (text) => fg(palette.code)(text),
|
||||
codeBlockBorder: (text) => fg(palette.codeBorder)(text),
|
||||
quote: (text) => fg(palette.quote)(text),
|
||||
quoteBorder: (text) => fg(palette.quoteBorder)(text),
|
||||
hr: (text) => fg(palette.border)(text),
|
||||
listBullet: (text) => fg(palette.accentSoft)(text),
|
||||
bold: (text) => chalk.bold(text),
|
||||
italic: (text) => chalk.italic(text),
|
||||
strikethrough: (text) => chalk.strikethrough(text),
|
||||
underline: (text) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const selectListTheme: SelectListTheme = {
|
||||
selectedPrefix: (text) => fg(palette.accent)(text),
|
||||
selectedText: (text) => chalk.bold(fg(palette.accent)(text)),
|
||||
description: (text) => fg(palette.dim)(text),
|
||||
scrollInfo: (text) => fg(palette.dim)(text),
|
||||
noMatch: (text) => fg(palette.dim)(text),
|
||||
};
|
||||
|
||||
export const settingsListTheme: SettingsListTheme = {
|
||||
label: (text, selected) =>
|
||||
selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),
|
||||
value: (text, selected) =>
|
||||
selected ? fg(palette.accentSoft)(text) : fg(palette.dim)(text),
|
||||
description: (text) => fg(palette.systemText)(text),
|
||||
cursor: fg(palette.accent)("→ "),
|
||||
hint: (text) => fg(palette.dim)(text),
|
||||
};
|
||||
|
||||
export const editorTheme: EditorTheme = {
|
||||
borderColor: (text) => fg(palette.border)(text),
|
||||
selectList: selectListTheme,
|
||||
};
|
||||
-762
@@ -1,762 +0,0 @@
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
type Component,
|
||||
Container,
|
||||
ProcessTerminal,
|
||||
Text,
|
||||
TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
|
||||
import { ChatLog } from "./components/chat-log.js";
|
||||
import { CustomEditor } from "./components/custom-editor.js";
|
||||
import {
|
||||
createSelectList,
|
||||
createSettingsList,
|
||||
} from "./components/selectors.js";
|
||||
import { GatewayChatClient } from "./gateway-chat.js";
|
||||
import { editorTheme, theme } from "./theme/theme.js";
|
||||
|
||||
export type TuiOptions = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
session?: string;
|
||||
deliver?: boolean;
|
||||
thinking?: string;
|
||||
timeoutMs?: number;
|
||||
historyLimit?: number;
|
||||
};
|
||||
|
||||
type ChatEvent = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
message?: unknown;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
type AgentEvent = {
|
||||
runId: string;
|
||||
stream: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type SessionInfo = {
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
model?: string;
|
||||
contextTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
updatedAt?: number | null;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
function extractTextBlocks(
|
||||
content: unknown,
|
||||
opts?: { includeThinking?: boolean },
|
||||
): string {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "text" && typeof record.text === "string") {
|
||||
parts.push(record.text);
|
||||
}
|
||||
if (
|
||||
opts?.includeThinking &&
|
||||
record.type === "thinking" &&
|
||||
typeof record.thinking === "string"
|
||||
) {
|
||||
parts.push(`[thinking]\n${record.thinking}`);
|
||||
}
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
function extractTextFromMessage(
|
||||
message: unknown,
|
||||
opts?: { includeThinking?: boolean },
|
||||
): string {
|
||||
if (!message || typeof message !== "object") return "";
|
||||
const record = message as Record<string, unknown>;
|
||||
return extractTextBlocks(record.content, opts);
|
||||
}
|
||||
|
||||
function formatTokens(total?: number | null, context?: number | null) {
|
||||
if (!total && !context) return "tokens ?";
|
||||
if (!context) return `tokens ${total ?? 0}`;
|
||||
const pct =
|
||||
typeof total === "number" && context > 0
|
||||
? Math.min(999, Math.round((total / context) * 100))
|
||||
: null;
|
||||
return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function runTui(opts: TuiOptions) {
|
||||
const config = loadConfig();
|
||||
const defaultSession =
|
||||
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
||||
let currentSessionKey = defaultSession;
|
||||
let currentSessionId: string | null = null;
|
||||
let activeChatRunId: string | null = null;
|
||||
const finalizedRuns = new Map<string, number>();
|
||||
let historyLoaded = false;
|
||||
let isConnected = false;
|
||||
let toolsExpanded = false;
|
||||
let showThinking = false;
|
||||
let deliverDefault = Boolean(opts.deliver);
|
||||
let sessionInfo: SessionInfo = {};
|
||||
let lastCtrlCAt = 0;
|
||||
|
||||
const client = new GatewayChatClient({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
});
|
||||
|
||||
const header = new Text("", 1, 0);
|
||||
const status = new Text("", 1, 0);
|
||||
const footer = new Text("", 1, 0);
|
||||
const chatLog = new ChatLog();
|
||||
const editor = new CustomEditor(editorTheme);
|
||||
const overlay = new Container();
|
||||
const root = new Container();
|
||||
root.addChild(header);
|
||||
root.addChild(overlay);
|
||||
root.addChild(chatLog);
|
||||
root.addChild(status);
|
||||
root.addChild(footer);
|
||||
root.addChild(editor);
|
||||
|
||||
const tui = new TUI(new ProcessTerminal());
|
||||
tui.addChild(root);
|
||||
tui.setFocus(editor);
|
||||
|
||||
const updateHeader = () => {
|
||||
header.setText(
|
||||
theme.header(
|
||||
`clawdis tui - ${client.connection.url} - session ${currentSessionKey}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const setStatus = (text: string) => {
|
||||
status.setText(theme.dim(text));
|
||||
};
|
||||
|
||||
const updateFooter = () => {
|
||||
const connection = isConnected ? "connected" : "disconnected";
|
||||
const sessionLabel = sessionInfo.displayName
|
||||
? `${currentSessionKey} (${sessionInfo.displayName})`
|
||||
: currentSessionKey;
|
||||
const modelLabel = sessionInfo.model ?? "unknown";
|
||||
const tokens = formatTokens(
|
||||
sessionInfo.totalTokens ?? null,
|
||||
sessionInfo.contextTokens ?? null,
|
||||
);
|
||||
const think = sessionInfo.thinkingLevel ?? "off";
|
||||
const verbose = sessionInfo.verboseLevel ?? "off";
|
||||
const deliver = deliverDefault ? "on" : "off";
|
||||
footer.setText(
|
||||
theme.dim(
|
||||
`${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | ${tokens} | deliver ${deliver}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const closeOverlay = () => {
|
||||
overlay.clear();
|
||||
tui.setFocus(editor);
|
||||
};
|
||||
|
||||
const openOverlay = (component: Component) => {
|
||||
overlay.clear();
|
||||
overlay.addChild(component);
|
||||
tui.setFocus(component);
|
||||
};
|
||||
|
||||
const refreshSessionInfo = async () => {
|
||||
try {
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
});
|
||||
const entry = result.sessions.find(
|
||||
(row) => row.key === currentSessionKey,
|
||||
);
|
||||
sessionInfo = {
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model ?? result.defaults?.model ?? undefined,
|
||||
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
|
||||
totalTokens: entry?.totalTokens ?? null,
|
||||
updatedAt: entry?.updatedAt ?? null,
|
||||
displayName: entry?.displayName,
|
||||
};
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
||||
}
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const history = await client.loadHistory({
|
||||
sessionKey: currentSessionKey,
|
||||
limit: opts.historyLimit ?? 200,
|
||||
});
|
||||
const record = history as {
|
||||
messages?: unknown[];
|
||||
sessionId?: string;
|
||||
thinkingLevel?: string;
|
||||
};
|
||||
currentSessionId =
|
||||
typeof record.sessionId === "string" ? record.sessionId : null;
|
||||
sessionInfo.thinkingLevel =
|
||||
record.thinkingLevel ?? sessionInfo.thinkingLevel;
|
||||
chatLog.clearAll();
|
||||
chatLog.addSystem(`session ${currentSessionKey}`);
|
||||
for (const entry of record.messages ?? []) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const message = entry as Record<string, unknown>;
|
||||
if (message.role === "user") {
|
||||
const text = extractTextFromMessage(message);
|
||||
if (text) chatLog.addUser(text);
|
||||
continue;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const text = extractTextFromMessage(message, {
|
||||
includeThinking: showThinking,
|
||||
});
|
||||
if (text) chatLog.finalizeAssistant(text);
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const toolCallId = asString(message.toolCallId, "");
|
||||
const toolName = asString(message.toolName, "tool");
|
||||
const component = chatLog.startTool(toolCallId, toolName, {});
|
||||
component.setResult(
|
||||
{
|
||||
content: Array.isArray(message.content)
|
||||
? (message.content as Record<string, unknown>[])
|
||||
: [],
|
||||
details:
|
||||
typeof message.details === "object" && message.details
|
||||
? (message.details as Record<string, unknown>)
|
||||
: undefined,
|
||||
},
|
||||
{ isError: Boolean(message.isError) },
|
||||
);
|
||||
}
|
||||
}
|
||||
historyLoaded = true;
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`history failed: ${String(err)}`);
|
||||
}
|
||||
await refreshSessionInfo();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const setSession = async (key: string) => {
|
||||
currentSessionKey = key;
|
||||
activeChatRunId = null;
|
||||
currentSessionId = null;
|
||||
historyLoaded = false;
|
||||
updateHeader();
|
||||
await loadHistory();
|
||||
};
|
||||
|
||||
const abortActive = async () => {
|
||||
if (!activeChatRunId) {
|
||||
chatLog.addSystem("no active run");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.abortChat({
|
||||
sessionKey: currentSessionKey,
|
||||
runId: activeChatRunId,
|
||||
});
|
||||
setStatus("aborted");
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`abort failed: ${String(err)}`);
|
||||
setStatus("abort failed");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const noteFinalizedRun = (runId: string) => {
|
||||
finalizedRuns.set(runId, Date.now());
|
||||
if (finalizedRuns.size <= 200) return;
|
||||
const keepUntil = Date.now() - 10 * 60 * 1000;
|
||||
for (const [key, ts] of finalizedRuns) {
|
||||
if (finalizedRuns.size <= 150) break;
|
||||
if (ts < keepUntil) finalizedRuns.delete(key);
|
||||
}
|
||||
if (finalizedRuns.size > 200) {
|
||||
for (const key of finalizedRuns.keys()) {
|
||||
finalizedRuns.delete(key);
|
||||
if (finalizedRuns.size <= 150) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as ChatEvent;
|
||||
if (evt.sessionKey !== currentSessionKey) return;
|
||||
if (finalizedRuns.has(evt.runId)) {
|
||||
if (evt.state === "delta") return;
|
||||
if (evt.state === "final") return;
|
||||
}
|
||||
if (evt.state === "delta") {
|
||||
const text = extractTextFromMessage(evt.message, {
|
||||
includeThinking: showThinking,
|
||||
});
|
||||
if (!text) return;
|
||||
chatLog.updateAssistant(text, evt.runId);
|
||||
setStatus("streaming");
|
||||
}
|
||||
if (evt.state === "final") {
|
||||
const text = extractTextFromMessage(evt.message, {
|
||||
includeThinking: showThinking,
|
||||
});
|
||||
chatLog.finalizeAssistant(text || "(no output)", evt.runId);
|
||||
noteFinalizedRun(evt.runId);
|
||||
activeChatRunId = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
if (evt.state === "aborted") {
|
||||
chatLog.addSystem("run aborted");
|
||||
activeChatRunId = null;
|
||||
setStatus("aborted");
|
||||
}
|
||||
if (evt.state === "error") {
|
||||
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||
activeChatRunId = null;
|
||||
setStatus("error");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const handleAgentEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as AgentEvent;
|
||||
if (!currentSessionId || evt.runId !== currentSessionId) return;
|
||||
if (evt.stream === "tool") {
|
||||
const data = evt.data ?? {};
|
||||
const phase = asString(data.phase, "");
|
||||
const toolCallId = asString(data.toolCallId, "");
|
||||
const toolName = asString(data.name, "tool");
|
||||
if (!toolCallId) return;
|
||||
if (phase === "start") {
|
||||
chatLog.startTool(toolCallId, toolName, data.args);
|
||||
} else if (phase === "update") {
|
||||
chatLog.updateToolResult(toolCallId, data.partialResult, {
|
||||
partial: true,
|
||||
});
|
||||
} else if (phase === "result") {
|
||||
chatLog.updateToolResult(toolCallId, data.result, {
|
||||
isError: Boolean(data.isError),
|
||||
});
|
||||
}
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (evt.stream === "job") {
|
||||
const state = typeof evt.data?.state === "string" ? evt.data.state : "";
|
||||
if (state === "started") setStatus("running");
|
||||
if (state === "done") setStatus("idle");
|
||||
if (state === "error") setStatus("error");
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
const openModelSelector = async () => {
|
||||
try {
|
||||
const models = await client.listModels();
|
||||
if (models.length === 0) {
|
||||
chatLog.addSystem("no models available");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
const items = models.map((model) => ({
|
||||
value: `${model.provider}/${model.id}`,
|
||||
label: `${model.provider}/${model.id}`,
|
||||
description: model.name && model.name !== model.id ? model.name : "",
|
||||
}));
|
||||
const selector = createSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
model: item.value,
|
||||
});
|
||||
chatLog.addSystem(`model set to ${item.value}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||
}
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
})();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
};
|
||||
openOverlay(selector);
|
||||
tui.requestRender();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`model list failed: ${String(err)}`);
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
const openSessionSelector = async () => {
|
||||
try {
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
});
|
||||
const items = result.sessions.map((session) => ({
|
||||
value: session.key,
|
||||
label: session.displayName ?? session.key,
|
||||
description: session.updatedAt
|
||||
? new Date(session.updatedAt).toLocaleString()
|
||||
: "",
|
||||
}));
|
||||
const selector = createSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
closeOverlay();
|
||||
await setSession(item.value);
|
||||
tui.requestRender();
|
||||
})();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
};
|
||||
openOverlay(selector);
|
||||
tui.requestRender();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
const openSettings = () => {
|
||||
const items = [
|
||||
{
|
||||
id: "deliver",
|
||||
label: "Deliver replies",
|
||||
currentValue: deliverDefault ? "on" : "off",
|
||||
values: ["off", "on"],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tool output",
|
||||
currentValue: toolsExpanded ? "expanded" : "collapsed",
|
||||
values: ["collapsed", "expanded"],
|
||||
},
|
||||
{
|
||||
id: "thinking",
|
||||
label: "Show thinking",
|
||||
currentValue: showThinking ? "on" : "off",
|
||||
values: ["off", "on"],
|
||||
},
|
||||
];
|
||||
const settings = createSettingsList(
|
||||
items,
|
||||
(id, value) => {
|
||||
if (id === "deliver") {
|
||||
deliverDefault = value === "on";
|
||||
updateFooter();
|
||||
}
|
||||
if (id === "tools") {
|
||||
toolsExpanded = value === "expanded";
|
||||
chatLog.setToolsExpanded(toolsExpanded);
|
||||
}
|
||||
if (id === "thinking") {
|
||||
showThinking = value === "on";
|
||||
void loadHistory();
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
() => {
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
},
|
||||
);
|
||||
openOverlay(settings);
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const handleCommand = async (raw: string) => {
|
||||
const { name, args } = parseCommand(raw);
|
||||
if (!name) return;
|
||||
switch (name) {
|
||||
case "help":
|
||||
chatLog.addSystem(helpText());
|
||||
break;
|
||||
case "status":
|
||||
try {
|
||||
const status = await client.getStatus();
|
||||
chatLog.addSystem(
|
||||
typeof status === "string"
|
||||
? status
|
||||
: JSON.stringify(status, null, 2),
|
||||
);
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`status failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "session":
|
||||
if (!args) {
|
||||
await openSessionSelector();
|
||||
} else {
|
||||
await setSession(args);
|
||||
}
|
||||
break;
|
||||
case "sessions":
|
||||
await openSessionSelector();
|
||||
break;
|
||||
case "model":
|
||||
if (!args) {
|
||||
await openModelSelector();
|
||||
} else {
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
model: args,
|
||||
});
|
||||
chatLog.addSystem(`model set to ${args}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "models":
|
||||
await openModelSelector();
|
||||
break;
|
||||
case "think":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /think <off|minimal|low|medium|high>");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
thinkingLevel: args,
|
||||
});
|
||||
chatLog.addSystem(`thinking set to ${args}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`think failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "verbose":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /verbose <on|off>");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
verboseLevel: args,
|
||||
});
|
||||
chatLog.addSystem(`verbose set to ${args}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`verbose failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "activation":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /activation <mention|always>");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
groupActivation: args === "always" ? "always" : "mention",
|
||||
});
|
||||
chatLog.addSystem(`activation set to ${args}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`activation failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "deliver":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /deliver <on|off>");
|
||||
break;
|
||||
}
|
||||
deliverDefault = args === "on";
|
||||
updateFooter();
|
||||
chatLog.addSystem(`deliver ${deliverDefault ? "on" : "off"}`);
|
||||
break;
|
||||
case "new":
|
||||
case "reset":
|
||||
try {
|
||||
await client.resetSession(currentSessionKey);
|
||||
chatLog.addSystem(`session ${currentSessionKey} reset`);
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`reset failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "abort":
|
||||
await abortActive();
|
||||
break;
|
||||
case "settings":
|
||||
openSettings();
|
||||
break;
|
||||
case "exit":
|
||||
case "quit":
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
break;
|
||||
default:
|
||||
chatLog.addSystem(`unknown command: /${name}`);
|
||||
break;
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
try {
|
||||
chatLog.addUser(text);
|
||||
tui.requestRender();
|
||||
setStatus("sending");
|
||||
const { runId } = await client.sendChat({
|
||||
sessionKey: currentSessionKey,
|
||||
message: text,
|
||||
thinking: opts.thinking,
|
||||
deliver: deliverDefault,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
activeChatRunId = runId;
|
||||
setStatus("waiting");
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`send failed: ${String(err)}`);
|
||||
setStatus("error");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
editor.setAutocompleteProvider(
|
||||
new CombinedAutocompleteProvider(getSlashCommands(), process.cwd()),
|
||||
);
|
||||
editor.onSubmit = (text) => {
|
||||
const value = text.trim();
|
||||
editor.setText("");
|
||||
if (!value) return;
|
||||
if (value.startsWith("/")) {
|
||||
void handleCommand(value);
|
||||
return;
|
||||
}
|
||||
void sendMessage(value);
|
||||
};
|
||||
|
||||
editor.onEscape = () => {
|
||||
void abortActive();
|
||||
};
|
||||
editor.onCtrlC = () => {
|
||||
const now = Date.now();
|
||||
if (editor.getText().trim().length > 0) {
|
||||
editor.setText("");
|
||||
setStatus("cleared input");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (now - lastCtrlCAt < 1000) {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
lastCtrlCAt = now;
|
||||
setStatus("press ctrl+c again to exit");
|
||||
tui.requestRender();
|
||||
};
|
||||
editor.onCtrlD = () => {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
editor.onCtrlO = () => {
|
||||
toolsExpanded = !toolsExpanded;
|
||||
chatLog.setToolsExpanded(toolsExpanded);
|
||||
setStatus(toolsExpanded ? "tools expanded" : "tools collapsed");
|
||||
tui.requestRender();
|
||||
};
|
||||
editor.onCtrlL = () => {
|
||||
void openModelSelector();
|
||||
};
|
||||
editor.onCtrlP = () => {
|
||||
void openSessionSelector();
|
||||
};
|
||||
editor.onCtrlT = () => {
|
||||
showThinking = !showThinking;
|
||||
void loadHistory();
|
||||
};
|
||||
|
||||
client.onEvent = (evt) => {
|
||||
if (evt.event === "chat") handleChatEvent(evt.payload);
|
||||
if (evt.event === "agent") handleAgentEvent(evt.payload);
|
||||
};
|
||||
|
||||
client.onConnected = () => {
|
||||
isConnected = true;
|
||||
setStatus("connected");
|
||||
updateHeader();
|
||||
if (!historyLoaded) {
|
||||
void loadHistory().then(() => {
|
||||
chatLog.addSystem("gateway connected");
|
||||
tui.requestRender();
|
||||
});
|
||||
} else {
|
||||
chatLog.addSystem("gateway reconnected");
|
||||
}
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
client.onDisconnected = (reason) => {
|
||||
isConnected = false;
|
||||
chatLog.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
||||
setStatus("disconnected");
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
client.onGap = (info) => {
|
||||
chatLog.addSystem(
|
||||
`event gap: expected ${info.expected}, got ${info.received}`,
|
||||
);
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
updateHeader();
|
||||
setStatus("connecting");
|
||||
updateFooter();
|
||||
chatLog.addSystem("connecting...");
|
||||
tui.start();
|
||||
client.start();
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
|
||||
@@ -210,10 +210,6 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
|
||||
+15
-32
@@ -26,20 +26,14 @@ import type {
|
||||
SkillStatusReport,
|
||||
StatusSummary,
|
||||
} from "./types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
type CronFormState,
|
||||
type DiscordForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
import type {
|
||||
CronFormState,
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SignalForm,
|
||||
TelegramForm,
|
||||
} from "./ui-types";
|
||||
import {
|
||||
loadChatHistory,
|
||||
sendChat,
|
||||
handleChatEvent,
|
||||
type ChatEventPayload,
|
||||
} from "./controllers/chat";
|
||||
import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadConfig } from "./controllers/config";
|
||||
import {
|
||||
@@ -144,16 +138,12 @@ export class ClawdisApp extends LitElement {
|
||||
@state() discordForm: DiscordForm = {
|
||||
enabled: true,
|
||||
token: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
historyLimit: "",
|
||||
textChunkLimit: "",
|
||||
replyToMode: "off",
|
||||
guilds: [],
|
||||
actions: { ...defaultDiscordActions },
|
||||
enableReactions: true,
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
slashSessionPrefix: "",
|
||||
@@ -359,8 +349,7 @@ export class ClawdisApp extends LitElement {
|
||||
].slice(0, 250);
|
||||
|
||||
if (evt.event === "chat") {
|
||||
const payload = evt.payload as ChatEventPayload | undefined;
|
||||
const state = handleChatEvent(this, payload);
|
||||
const state = handleChatEvent(this, evt.payload as unknown);
|
||||
if (state === "final") void loadChatHistory(this);
|
||||
return;
|
||||
}
|
||||
@@ -479,26 +468,20 @@ export class ClawdisApp extends LitElement {
|
||||
if (this.theme !== "system") return;
|
||||
this.applyResolvedTheme(event.matches ? "dark" : "light");
|
||||
};
|
||||
if (typeof this.themeMedia.addEventListener === "function") {
|
||||
if ("addEventListener" in this.themeMedia) {
|
||||
this.themeMedia.addEventListener("change", this.themeMediaHandler);
|
||||
return;
|
||||
} else {
|
||||
this.themeMedia.addListener(this.themeMediaHandler);
|
||||
}
|
||||
const legacy = this.themeMedia as MediaQueryList & {
|
||||
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.addListener(this.themeMediaHandler);
|
||||
}
|
||||
|
||||
private detachThemeListener() {
|
||||
if (!this.themeMedia || !this.themeMediaHandler) return;
|
||||
if (typeof this.themeMedia.removeEventListener === "function") {
|
||||
if ("removeEventListener" in this.themeMedia) {
|
||||
this.themeMedia.removeEventListener("change", this.themeMediaHandler);
|
||||
return;
|
||||
} else {
|
||||
this.themeMedia.removeListener(this.themeMediaHandler);
|
||||
}
|
||||
const legacy = this.themeMedia as MediaQueryList & {
|
||||
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.removeListener(this.themeMediaHandler);
|
||||
this.themeMedia = null;
|
||||
this.themeMediaHandler = null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ChatState = {
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type ChatEventPayload = {
|
||||
type ChatEventPayload = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import type { ConfigSnapshot } from "../types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
type DiscordActionForm,
|
||||
type DiscordForm,
|
||||
type DiscordGuildChannelForm,
|
||||
type DiscordGuildForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
|
||||
|
||||
export type ConfigState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -97,16 +88,9 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
|
||||
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
|
||||
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
|
||||
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
|
||||
const discordGuilds = discord.guilds;
|
||||
const readAction = (key: keyof DiscordActionForm) =>
|
||||
typeof discordActions[key] === "boolean"
|
||||
? (discordActions[key] as boolean)
|
||||
: defaultDiscordActions[key];
|
||||
state.discordForm = {
|
||||
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
|
||||
token: typeof discord.token === "string" ? discord.token : "",
|
||||
dmEnabled: typeof discordDm.enabled === "boolean" ? discordDm.enabled : true,
|
||||
allowFrom: toList(discordDm.allowFrom),
|
||||
groupEnabled:
|
||||
typeof discordDm.groupEnabled === "boolean" ? discordDm.groupEnabled : false,
|
||||
@@ -115,74 +99,8 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "",
|
||||
historyLimit:
|
||||
typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "",
|
||||
textChunkLimit:
|
||||
typeof discord.textChunkLimit === "number"
|
||||
? String(discord.textChunkLimit)
|
||||
: "",
|
||||
replyToMode:
|
||||
discord.replyToMode === "first" || discord.replyToMode === "all"
|
||||
? discord.replyToMode
|
||||
: "off",
|
||||
guilds: Array.isArray(discordGuilds)
|
||||
? []
|
||||
: typeof discordGuilds === "object" && discordGuilds
|
||||
? Object.entries(discordGuilds as Record<string, unknown>).map(
|
||||
([key, value]): DiscordGuildForm => {
|
||||
const entry =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const channelsRaw =
|
||||
entry.channels && typeof entry.channels === "object"
|
||||
? (entry.channels as Record<string, unknown>)
|
||||
: {};
|
||||
const channels = Object.entries(channelsRaw).map(
|
||||
([channelKey, channelValue]): DiscordGuildChannelForm => {
|
||||
const channel =
|
||||
channelValue && typeof channelValue === "object"
|
||||
? (channelValue as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
key: channelKey,
|
||||
allow:
|
||||
typeof channel.allow === "boolean" ? channel.allow : true,
|
||||
requireMention:
|
||||
typeof channel.requireMention === "boolean"
|
||||
? channel.requireMention
|
||||
: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
key,
|
||||
slug: typeof entry.slug === "string" ? entry.slug : "",
|
||||
requireMention:
|
||||
typeof entry.requireMention === "boolean"
|
||||
? entry.requireMention
|
||||
: false,
|
||||
users: toList(entry.users),
|
||||
channels,
|
||||
};
|
||||
},
|
||||
)
|
||||
: [],
|
||||
actions: {
|
||||
reactions: readAction("reactions"),
|
||||
stickers: readAction("stickers"),
|
||||
polls: readAction("polls"),
|
||||
permissions: readAction("permissions"),
|
||||
messages: readAction("messages"),
|
||||
threads: readAction("threads"),
|
||||
pins: readAction("pins"),
|
||||
search: readAction("search"),
|
||||
memberInfo: readAction("memberInfo"),
|
||||
roleInfo: readAction("roleInfo"),
|
||||
channelInfo: readAction("channelInfo"),
|
||||
voiceStatus: readAction("voiceStatus"),
|
||||
events: readAction("events"),
|
||||
roles: readAction("roles"),
|
||||
moderation: readAction("moderation"),
|
||||
},
|
||||
enableReactions:
|
||||
typeof discord.enableReactions === "boolean" ? discord.enableReactions : true,
|
||||
slashEnabled: typeof slash.enabled === "boolean" ? slash.enabled : false,
|
||||
slashName: typeof slash.name === "string" ? slash.name : "",
|
||||
slashSessionPrefix:
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import type { GatewayBrowserClient } from "../gateway";
|
||||
import { parseList } from "../format";
|
||||
import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types";
|
||||
import {
|
||||
defaultDiscordActions,
|
||||
type DiscordActionForm,
|
||||
type DiscordForm,
|
||||
type DiscordGuildChannelForm,
|
||||
type DiscordGuildForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "../ui-types";
|
||||
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
|
||||
|
||||
export type ConnectionsState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -125,14 +116,6 @@ export function updateDiscordForm(
|
||||
state: ConnectionsState,
|
||||
patch: Partial<DiscordForm>,
|
||||
) {
|
||||
if (patch.actions) {
|
||||
state.discordForm = {
|
||||
...state.discordForm,
|
||||
...patch,
|
||||
actions: { ...state.discordForm.actions, ...patch.actions },
|
||||
};
|
||||
return;
|
||||
}
|
||||
state.discordForm = { ...state.discordForm, ...patch };
|
||||
}
|
||||
|
||||
@@ -235,8 +218,6 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
const groupChannels = parseList(form.groupChannels);
|
||||
const dm = { ...(discord.dm ?? {}) } as Record<string, unknown>;
|
||||
if (form.dmEnabled) delete dm.enabled;
|
||||
else dm.enabled = false;
|
||||
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
|
||||
else delete dm.allowFrom;
|
||||
if (form.groupEnabled) dm.groupEnabled = true;
|
||||
@@ -265,75 +246,10 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
}
|
||||
}
|
||||
|
||||
const chunkLimitRaw = form.textChunkLimit.trim();
|
||||
if (chunkLimitRaw.length === 0) {
|
||||
delete discord.textChunkLimit;
|
||||
if (form.enableReactions) {
|
||||
delete discord.enableReactions;
|
||||
} else {
|
||||
const chunkLimit = Number(chunkLimitRaw);
|
||||
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
|
||||
discord.textChunkLimit = chunkLimit;
|
||||
} else {
|
||||
delete discord.textChunkLimit;
|
||||
}
|
||||
}
|
||||
|
||||
if (form.replyToMode === "off") {
|
||||
delete discord.replyToMode;
|
||||
} else {
|
||||
discord.replyToMode = form.replyToMode;
|
||||
}
|
||||
|
||||
const guildsForm = Array.isArray(form.guilds) ? form.guilds : [];
|
||||
const guilds: Record<string, unknown> = {};
|
||||
guildsForm.forEach((guild: DiscordGuildForm) => {
|
||||
const key = String(guild.key ?? "").trim();
|
||||
if (!key) return;
|
||||
const entry: Record<string, unknown> = {};
|
||||
const slug = String(guild.slug ?? "").trim();
|
||||
if (slug) entry.slug = slug;
|
||||
if (guild.requireMention) entry.requireMention = true;
|
||||
const users = parseList(guild.users);
|
||||
if (users.length > 0) entry.users = users;
|
||||
const channels: Record<string, unknown> = {};
|
||||
const channelForms = Array.isArray(guild.channels) ? guild.channels : [];
|
||||
channelForms.forEach((channel: DiscordGuildChannelForm) => {
|
||||
const channelKey = String(channel.key ?? "").trim();
|
||||
if (!channelKey) return;
|
||||
const channelEntry: Record<string, unknown> = {};
|
||||
if (channel.allow === false) channelEntry.allow = false;
|
||||
if (channel.requireMention) channelEntry.requireMention = true;
|
||||
channels[channelKey] = channelEntry;
|
||||
});
|
||||
if (Object.keys(channels).length > 0) entry.channels = channels;
|
||||
guilds[key] = entry;
|
||||
});
|
||||
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
|
||||
else delete discord.guilds;
|
||||
|
||||
const actions: Partial<DiscordActionForm> = {};
|
||||
const applyAction = (key: keyof DiscordActionForm) => {
|
||||
const value = form.actions[key];
|
||||
if (value !== defaultDiscordActions[key]) actions[key] = value;
|
||||
};
|
||||
applyAction("reactions");
|
||||
applyAction("stickers");
|
||||
applyAction("polls");
|
||||
applyAction("permissions");
|
||||
applyAction("messages");
|
||||
applyAction("threads");
|
||||
applyAction("pins");
|
||||
applyAction("search");
|
||||
applyAction("memberInfo");
|
||||
applyAction("roleInfo");
|
||||
applyAction("channelInfo");
|
||||
applyAction("voiceStatus");
|
||||
applyAction("events");
|
||||
applyAction("roles");
|
||||
applyAction("moderation");
|
||||
if (Object.keys(actions).length > 0) {
|
||||
discord.actions = actions;
|
||||
} else {
|
||||
delete discord.actions;
|
||||
discord.enableReactions = false;
|
||||
}
|
||||
|
||||
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
|
||||
|
||||
+1
-55
@@ -11,72 +11,18 @@ export type TelegramForm = {
|
||||
export type DiscordForm = {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
dmEnabled: boolean;
|
||||
allowFrom: string;
|
||||
groupEnabled: boolean;
|
||||
groupChannels: string;
|
||||
mediaMaxMb: string;
|
||||
historyLimit: string;
|
||||
textChunkLimit: string;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
guilds: DiscordGuildForm[];
|
||||
actions: DiscordActionForm;
|
||||
enableReactions: boolean;
|
||||
slashEnabled: boolean;
|
||||
slashName: string;
|
||||
slashSessionPrefix: string;
|
||||
slashEphemeral: boolean;
|
||||
};
|
||||
|
||||
export type DiscordGuildForm = {
|
||||
key: string;
|
||||
slug: string;
|
||||
requireMention: boolean;
|
||||
users: string;
|
||||
channels: DiscordGuildChannelForm[];
|
||||
};
|
||||
|
||||
export type DiscordGuildChannelForm = {
|
||||
key: string;
|
||||
allow: boolean;
|
||||
requireMention: boolean;
|
||||
};
|
||||
|
||||
export type DiscordActionForm = {
|
||||
reactions: boolean;
|
||||
stickers: boolean;
|
||||
polls: boolean;
|
||||
permissions: boolean;
|
||||
messages: boolean;
|
||||
threads: boolean;
|
||||
pins: boolean;
|
||||
search: boolean;
|
||||
memberInfo: boolean;
|
||||
roleInfo: boolean;
|
||||
channelInfo: boolean;
|
||||
voiceStatus: boolean;
|
||||
events: boolean;
|
||||
roles: boolean;
|
||||
moderation: boolean;
|
||||
};
|
||||
|
||||
export const defaultDiscordActions: DiscordActionForm = {
|
||||
reactions: true,
|
||||
stickers: true,
|
||||
polls: true,
|
||||
permissions: true,
|
||||
messages: true,
|
||||
threads: true,
|
||||
pins: true,
|
||||
search: true,
|
||||
memberInfo: true,
|
||||
roleInfo: true,
|
||||
channelInfo: true,
|
||||
voiceStatus: true,
|
||||
events: true,
|
||||
roles: false,
|
||||
moderation: false,
|
||||
};
|
||||
|
||||
export type SignalForm = {
|
||||
enabled: boolean;
|
||||
account: string;
|
||||
|
||||
@@ -43,10 +43,7 @@ export function renderChat(props: ChatProps) {
|
||||
props.onSessionKeyChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${sessionOptions.map(
|
||||
(entry) =>
|
||||
html`<option value=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
(entry) => html`<option value=${entry.key}>${entry.key}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -118,7 +115,6 @@ export function renderChat(props: ChatProps) {
|
||||
type SessionOption = {
|
||||
key: string;
|
||||
updatedAt?: number | null;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
function resolveSessionOptions(
|
||||
|
||||
@@ -2,31 +2,7 @@ import { html, nothing } from "lit";
|
||||
|
||||
import { formatAgo } from "../format";
|
||||
import type { ProvidersStatusSnapshot } from "../types";
|
||||
import type {
|
||||
DiscordActionForm,
|
||||
DiscordForm,
|
||||
IMessageForm,
|
||||
SignalForm,
|
||||
TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
const discordActionOptions = [
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "stickers", label: "Stickers" },
|
||||
{ key: "polls", label: "Polls" },
|
||||
{ key: "permissions", label: "Permissions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "threads", label: "Threads" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "search", label: "Search" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "roleInfo", label: "Role info" },
|
||||
{ key: "channelInfo", label: "Channel info" },
|
||||
{ key: "voiceStatus", label: "Voice status" },
|
||||
{ key: "events", label: "Events" },
|
||||
{ key: "roles", label: "Role changes" },
|
||||
{ key: "moderation", label: "Moderation" },
|
||||
] satisfies Array<{ key: keyof DiscordActionForm; label: string }>;
|
||||
import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types";
|
||||
|
||||
export type ConnectionsProps = {
|
||||
connected: boolean;
|
||||
@@ -500,19 +476,6 @@ function renderProvider(
|
||||
placeholder="123456789, username#1234"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>DMs enabled</span>
|
||||
<select
|
||||
.value=${props.discordForm.dmEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Group DMs</span>
|
||||
<select
|
||||
@@ -560,267 +523,18 @@ function renderProvider(
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Text chunk limit</span>
|
||||
<input
|
||||
.value=${props.discordForm.textChunkLimit}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
textChunkLimit: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="2000"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reply to mode</span>
|
||||
<span>Reactions</span>
|
||||
<select
|
||||
.value=${props.discordForm.replyToMode}
|
||||
.value=${props.discordForm.enableReactions ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
replyToMode: (e.target as HTMLSelectElement).value as
|
||||
| "off"
|
||||
| "first"
|
||||
| "all",
|
||||
enableReactions: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="first">First</option>
|
||||
<option value="all">All</option>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="field full">
|
||||
<span>Guilds</span>
|
||||
<div class="card-sub">
|
||||
Add each guild (id or slug) and optional channel rules. Empty channel
|
||||
entries still allow that channel.
|
||||
</div>
|
||||
<div class="list">
|
||||
${props.discordForm.guilds.map(
|
||||
(guild, guildIndex) => html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Guild id / slug</span>
|
||||
<input
|
||||
.value=${guild.key}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
key: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slug</span>
|
||||
<input
|
||||
.value=${guild.slug}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
slug: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention</span>
|
||||
<select
|
||||
.value=${guild.requireMention ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
requireMention:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Users allowlist</span>
|
||||
<input
|
||||
.value=${guild.users}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
users: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
placeholder="123456789, username#1234"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
${guild.channels.length
|
||||
? html`
|
||||
<div class="form-grid" style="margin-top: 8px;">
|
||||
${guild.channels.map(
|
||||
(channel, channelIndex) => html`
|
||||
<label class="field">
|
||||
<span>Channel id / slug</span>
|
||||
<input
|
||||
.value=${channel.key}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
key: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow</span>
|
||||
<select
|
||||
.value=${channel.allow ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
allow:
|
||||
(e.target as HTMLSelectElement).value ===
|
||||
"yes",
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention</span>
|
||||
<select
|
||||
.value=${channel.requireMention ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
requireMention:
|
||||
(e.target as HTMLSelectElement).value ===
|
||||
"yes",
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span> </span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [
|
||||
...props.discordForm.guilds,
|
||||
];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels.splice(channelIndex, 1);
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<span>Channels</span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
{ key: "", allow: true, requireMention: false },
|
||||
];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Add channel
|
||||
</button>
|
||||
<button
|
||||
class="btn danger"
|
||||
@click=${() => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next.splice(guildIndex, 1);
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Remove guild
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-top: 8px;"
|
||||
@click=${() =>
|
||||
props.onDiscordChange({
|
||||
guilds: [
|
||||
...props.discordForm.guilds,
|
||||
{
|
||||
key: "",
|
||||
slug: "",
|
||||
requireMention: false,
|
||||
users: "",
|
||||
channels: [],
|
||||
},
|
||||
],
|
||||
})}
|
||||
>
|
||||
Add guild
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Slash command</span>
|
||||
<select
|
||||
@@ -871,28 +585,6 @@ function renderProvider(
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
|
||||
<div class="form-grid" style="margin-top: 8px;">
|
||||
${discordActionOptions.map(
|
||||
(action) => html`<label class="field">
|
||||
<span>${action.label}</span>
|
||||
<select
|
||||
.value=${props.discordForm.actions[action.key] ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
actions: {
|
||||
...props.discordForm.actions,
|
||||
[action.key]: (e.target as HTMLSelectElement).value === "yes",
|
||||
},
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${props.discordTokenLocked
|
||||
? html`<div class="callout" style="margin-top: 12px;">
|
||||
DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it.
|
||||
|
||||
Reference in new issue
Block a user