diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be0d8926f..9f944b361 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,20 +32,29 @@ jobs: node-version: 22.x check-latest: true + - name: Setup pnpm (corepack retry) + run: | + set -euo pipefail + corepack enable + for attempt in 1 2 3; do + if corepack prepare pnpm@10.23.0 --activate; then + pnpm -v + exit 0 + fi + echo "corepack prepare failed (attempt $attempt/3). Retrying..." + sleep $((attempt * 10)) + done + exit 1 + - name: Runtime versions run: | node -v npm -v + pnpm -v - name: Capture node path run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" - - name: Enable corepack and pin pnpm - run: | - corepack enable - corepack prepare pnpm@10.23.0 --activate - pnpm -v - - name: Install dependencies (frozen) env: CI: true @@ -108,6 +117,20 @@ jobs: node-version: 22.x check-latest: true + - name: Setup pnpm (corepack retry) + run: | + set -euo pipefail + corepack enable + for attempt in 1 2 3; do + if corepack prepare pnpm@10.23.0 --activate; then + pnpm -v + exit 0 + fi + echo "corepack prepare failed (attempt $attempt/3). Retrying..." + sleep $((attempt * 10)) + done + exit 1 + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -118,16 +141,11 @@ jobs: node -v npm -v bun -v + pnpm -v - name: Capture node path run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" - - name: Enable corepack and pin pnpm - run: | - corepack enable - corepack prepare pnpm@10.23.0 --activate - pnpm -v - - name: Install dependencies env: CI: true @@ -168,6 +186,8 @@ jobs: checks-windows: runs-on: blacksmith-4vcpu-windows-2025 + env: + NODE_OPTIONS: --max-old-space-size=4096 defaults: run: shell: bash @@ -212,6 +232,20 @@ jobs: node-version: 22.x check-latest: true + - name: Setup pnpm (corepack retry) + run: | + set -euo pipefail + corepack enable + for attempt in 1 2 3; do + if corepack prepare pnpm@10.23.0 --activate; then + pnpm -v + exit 0 + fi + echo "corepack prepare failed (attempt $attempt/3). Retrying..." + sleep $((attempt * 10)) + done + exit 1 + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -222,16 +256,11 @@ jobs: node -v npm -v bun -v + pnpm -v - name: Capture node path run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" - - name: Enable corepack and pin pnpm - run: | - corepack enable - corepack prepare pnpm@10.23.0 --activate - pnpm -v - - name: Install dependencies env: CI: true @@ -279,20 +308,29 @@ jobs: node-version: 22.x check-latest: true + - name: Setup pnpm (corepack retry) + run: | + set -euo pipefail + corepack enable + for attempt in 1 2 3; do + if corepack prepare pnpm@10.23.0 --activate; then + pnpm -v + exit 0 + fi + echo "corepack prepare failed (attempt $attempt/3). Retrying..." + sleep $((attempt * 10)) + done + exit 1 + - name: Runtime versions run: | node -v npm -v + pnpm -v - name: Capture node path run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" - - name: Enable corepack and pin pnpm - run: | - corepack enable - corepack prepare pnpm@10.23.0 --activate - pnpm -v - - name: Install dependencies env: CI: true diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 84d1b7f32..16eba4eed 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -13,12 +13,19 @@ jobs: - name: Checkout CLI uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 10 - - name: Enable Corepack - run: corepack enable + - name: Setup pnpm (corepack retry) + run: | + set -euo pipefail + corepack enable + for attempt in 1 2 3; do + if corepack prepare pnpm@10.23.0 --activate; then + pnpm -v + exit 0 + fi + echo "corepack prepare failed (attempt $attempt/3). Retrying..." + sleep $((attempt * 10)) + done + exit 1 - name: Install pnpm deps (minimal) run: pnpm install --ignore-scripts --frozen-lockfile diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0b9f221..2bc3ebae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,57 +5,66 @@ Docs: https://docs.clawd.bot ## 2026.1.24 ### Highlights -- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama -- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice +- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice +- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg. +- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts +- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands +- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram ### Changes -- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts -- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web -- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts - Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg. -- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido. +- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts +- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts +- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. +- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram +- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web +- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg. +- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands +- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. +- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags - Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround). - Docs: add verbose installer troubleshooting guidance. - Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua. -- Docs: update Fly.io guide notes. - Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock -- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands -- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. -- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram -- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. -- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal. -- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags +- Docs: update Fly.io guide notes. +- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido. ### Fixes -- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. -- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles -- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. -- Web UI: hide internal `message_id` hints in chat bubbles. +- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589. - Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. - Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg. -- Heartbeat: normalize target identifiers for consistent routing. -- TUI: reload history after gateway reconnect to restore session state. (#1663) -- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) -- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338. -- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt. +- Web UI: hide internal `message_id` hints in chat bubbles. +- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. +- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47. +- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles +- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. - Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev. - Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal -- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. +- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338. +- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) +- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt. +- Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken. +- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) +- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido. +- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. +- Google Chat: normalize space targets without double `spaces/` prefix. - Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz. - Agents: use the active auth profile for auto-compaction recovery. -- Models: default missing custom provider fields so minimal configs are accepted. - Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204. +- Models: default missing custom provider fields so minimal configs are accepted. +- Messaging: keep newline chunking safe for fenced markdown blocks across channels. +- Messaging: treat newline chunking as paragraph-aware (blank-line splits) to keep lists and headings together. (#1726) Thanks @tyler6204. +- TUI: reload history after gateway reconnect to restore session state. (#1663) +- Heartbeat: normalize target identifiers for consistent routing. +- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. +- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19. +- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. - Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671) - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690) - Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work. - Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676) - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. -- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) -- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido. -- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. -- Google Chat: normalize space targets without double `spaces/` prefix. -- Messaging: keep newline chunking safe for fenced markdown blocks across channels. - Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal. - Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal. - Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b37ea389..d6eb0532f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,3 +40,13 @@ Please include in your PR: - [ ] Confirm you understand what the code does AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. + +## Current Focus & Roadmap 🗺 + +We are currently prioritizing: +- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram). +- **UX**: Improving the onboarding wizard and error messages. +- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience. +- **Performance**: Optimizing token usage and compaction logic. + +Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels! diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index 1dd8e560d..a1f4a0892 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -196,7 +196,7 @@ Provider options: - `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`). - `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`). - `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000). -- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming. +- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. - `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8). - `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables). - `channels.bluebubbles.dmHistoryLimit`: DM history limit. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index f63fd45c9..12dd28084 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -205,7 +205,7 @@ Notes: ## Capabilities & limits - DMs and guild text channels (threads are treated as separate channels; voice not supported). - Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17). -- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB). - Mention-gated guild replies by default to avoid noisy bots. - Reply context is injected when a message references another message (quoted content + ids). @@ -307,7 +307,7 @@ ack reaction after the bot replies. - `guilds..requireMention`: per-guild mention requirement (overridable per channel). - `guilds..reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`). - `textChunkLimit`: outbound text chunk size (chars). Default: 2000. -- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking. +- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. - `maxLinesPerMessage`: soft max line count per message. Default: 17. - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables). diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 316822dc5..bae945e8c 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -219,7 +219,7 @@ This is useful when you want an isolated personality/model for a specific thread ## Limits - Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16). ## Addressing / delivery targets @@ -254,7 +254,7 @@ Provider options: - `channels.imessage.includeAttachments`: ingest attachments into context. - `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.imessage.textChunkLimit`: outbound chunk size (chars). -- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. Related global options: - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`). diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 77a2989d5..2d9025f51 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -215,7 +215,7 @@ Provider options: - `channels.matrix.initialSyncLimit`: initial sync limit. - `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). - `channels.matrix.textChunkLimit`: outbound text chunk size (chars). -- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible. - `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index de3b064b2..2f6ed5f83 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -415,7 +415,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) - `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. - `channels.msteams.textChunkLimit`: outbound text chunk size. -- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). - `channels.msteams.requireMention`: require @mention in channels/groups (default true). - `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)). diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md index 43c1595ed..abc696444 100644 --- a/docs/channels/nextcloud-talk.md +++ b/docs/channels/nextcloud-talk.md @@ -114,7 +114,7 @@ Provider options: - `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables). - `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit). - `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars). -- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel. - `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning. - `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB). diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 0ba89385d..c154b0591 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -111,7 +111,7 @@ Groups: ## Media + limits - Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Attachments supported (base64 fetched from `signal-cli`). - Default media cap: `channels.signal.mediaMaxMb` (default 8). - Use `channels.signal.ignoreAttachments` to skip downloading media. @@ -170,7 +170,7 @@ Provider options: - `channels.signal.historyLimit`: max group messages to include as context (0 disables). - `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms[""].historyLimit`. - `channels.signal.textChunkLimit`: outbound chunk size (chars). -- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB). Related global options: diff --git a/docs/channels/slack.md b/docs/channels/slack.md index bf7bcbf49..5f768db0e 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -26,7 +26,7 @@ Minimal config: ``` ### Setup -1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. +1) Create a Slack app (From scratch) in https://api.slack.com/apps. 2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). 4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`). @@ -245,29 +245,29 @@ If you enable native commands, add one `slash_commands` entry per command you wa ## Scopes (current vs optional) Slack's Conversations API is type-scoped: you only need the scopes for the conversation types you actually touch (channels, groups, im, mpim). See -https://api.channels.slack.com/docs/conversations-api for the overview. +https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overview. ### Bot token scopes (required) - `chat:write` (send/update/delete messages via `chat.postMessage`) - https://api.channels.slack.com/methods/chat.postMessage + https://docs.slack.dev/reference/methods/chat.postMessage - `im:write` (open DMs via `conversations.open` for user DMs) - https://api.channels.slack.com/methods/conversations.open + https://docs.slack.dev/reference/methods/conversations.open - `channels:history`, `groups:history`, `im:history`, `mpim:history` - https://api.channels.slack.com/methods/conversations.history + https://docs.slack.dev/reference/methods/conversations.history - `channels:read`, `groups:read`, `im:read`, `mpim:read` - https://api.channels.slack.com/methods/conversations.info + https://docs.slack.dev/reference/methods/conversations.info - `users:read` (user lookup) - https://api.channels.slack.com/methods/users.info + https://docs.slack.dev/reference/methods/users.info - `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`) - https://api.channels.slack.com/methods/reactions.get - https://api.channels.slack.com/methods/reactions.add + https://docs.slack.dev/reference/methods/reactions.get + https://docs.slack.dev/reference/methods/reactions.add - `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`) - https://api.channels.slack.com/scopes/pins:read - https://api.channels.slack.com/scopes/pins:write + https://docs.slack.dev/reference/scopes/pins.read + https://docs.slack.dev/reference/scopes/pins.write - `emoji:read` (`emoji.list`) - https://api.channels.slack.com/scopes/emoji:read + https://docs.slack.dev/reference/scopes/emoji.read - `files:write` (uploads via `files.uploadV2`) - https://api.channels.slack.com/messaging/files/uploading + https://docs.slack.dev/messaging/working-with-files/#upload ### User token scopes (optional, read-only by default) Add these under **User Token Scopes** if you configure `channels.slack.userToken`. @@ -284,9 +284,9 @@ Add these under **User Token Scopes** if you configure `channels.slack.userToken - `mpim:write` (only if we add group-DM open/DM start via `conversations.open`) - `groups:write` (only if we add private-channel management: create/rename/invite/archive) - `chat:write.public` (only if we want to post to channels the bot isn't in) - https://api.channels.slack.com/scopes/chat:write.public + https://docs.slack.dev/reference/scopes/chat.write.public - `users:read.email` (only if we need email fields from `users.info`) - https://api.channels.slack.com/changelog/2017-04-narrowing-email-access + https://docs.slack.dev/changelog/2017-04-narrowing-email-access - `files:read` (only if we start listing/reading file metadata) ## Config @@ -349,7 +349,7 @@ ack reaction after the bot replies. ## Limits - Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Media uploads are capped by `channels.slack.mediaMaxMb` (default 20). ## Reply threading diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index eb558cf74..e708e2e64 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -135,7 +135,7 @@ Notes: ## Limits - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). - Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs. - Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). @@ -524,7 +524,7 @@ Provider options: - `channels.telegram.accounts..capabilities.inlineButtons`: per-account override. - `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.textChunkLimit`: outbound chunk size (chars). -- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 517c71b93..4759cf4c9 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -271,7 +271,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately ## Limits - Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB). - Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB). diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 8019e4cca..6f9609ca6 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -38,7 +38,7 @@ Legend: - `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`. - `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send). - Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`). -- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on each line before length chunking). +- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on blank lines (paragraph boundaries) before length chunking). - Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping. **Boundary semantics:** diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 020ca9c90..868126101 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1131,7 +1131,7 @@ Reaction notification modes: - `own`: reactions on the bot's own messages (default). - `all`: all reactions on all messages. - `allowlist`: reactions from `guilds..users` on all messages (empty list disables). -Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on line boundaries before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars. +Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars. Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry). ### `channels.googlechat` (Chat API webhook) @@ -2847,8 +2847,9 @@ Control UI base path: - `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served. - Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`. - Default: root (`/`) (unchanged). -- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity). - Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`. +- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips + device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS + (Tailscale Serve) or `127.0.0.1`. Related docs: - [Control UI](/web/control-ui) diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 48e3fa59c..ed0054411 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -58,7 +58,7 @@ When the audit prints findings, treat this as a priority order: The Control UI needs a **secure context** (HTTPS or localhost) to generate device identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back -to **token-only auth** on plain HTTP and skips device pairing. This is a security +to **token-only auth** and skips device pairing (even on HTTPS). This is a security downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`. `clawdbot security audit` warns when this setting is enabled. diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md new file mode 100644 index 000000000..77e3415d2 --- /dev/null +++ b/docs/tools/creating-skills.md @@ -0,0 +1,41 @@ +# Creating Custom Skills 🛠 + +Clawdbot is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant. + +## What is a Skill? +A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources. + +## Step-by-Step: Your First Skill + +### 1. Create the Directory +Skills live in your workspace, usually `~/clawd/skills/`. Create a new folder for your skill: +```bash +mkdir -p ~/clawd/skills/hello-world +``` + +### 2. Define the `SKILL.md` +Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions. + +```markdown +--- +name: hello_world +description: A simple skill that says hello. +--- + +# Hello World Skill +When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!". +``` + +### 3. Add Tools (Optional) +You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`). + +### 4. Refresh Clawdbot +Ask your agent to "refresh skills" or restart the gateway. Clawdbot will discover the new directory and index the `SKILL.md`. + +## Best Practices +- **Be Concise**: Instruct the model on *what* to do, not how to be an AI. +- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input. +- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test. + +## Shared Skills +You can also browse and contribute skills to [ClawdHub](https://clawdhub.com). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index ede005259..188479679 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity. } ``` -This disables device identity + pairing for the Control UI. Use only if you -trust the network. +This disables device identity + pairing for the Control UI (even on HTTPS). Use +only if you trust the network. See [Tailscale](/gateway/tailscale) for HTTPS setup guidance. diff --git a/extensions/google-gemini-cli-auth/README.md b/extensions/google-gemini-cli-auth/README.md index 6e4bdbd2b..99eab057f 100644 --- a/extensions/google-gemini-cli-auth/README.md +++ b/extensions/google-gemini-cli-auth/README.md @@ -18,7 +18,18 @@ Restart the Gateway after enabling. clawdbot models auth login --provider google-gemini-cli --set-default ``` -## Env vars +## Requirements + +Requires the Gemini CLI to be installed (credentials are extracted automatically): + +```bash +brew install gemini-cli +# or: npm install -g @google/gemini-cli +``` + +## Env vars (optional) + +Override auto-detected credentials with: - `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID` - `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts new file mode 100644 index 000000000..d776d9be3 --- /dev/null +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { join } from "node:path"; + +// Mock fs module before importing the module under test +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +const mockRealpathSync = vi.fn(); +const mockReaddirSync = vi.fn(); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (...args: Parameters) => mockExistsSync(...args), + readFileSync: (...args: Parameters) => mockReadFileSync(...args), + realpathSync: (...args: Parameters) => mockRealpathSync(...args), + readdirSync: (...args: Parameters) => mockReaddirSync(...args), + }; +}); + +describe("extractGeminiCliCredentials", () => { + const normalizePath = (value: string) => value.replace(/\\/g, "/"); + const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com"; + const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123"; + const FAKE_OAUTH2_CONTENT = ` + const clientId = "${FAKE_CLIENT_ID}"; + const clientSecret = "${FAKE_CLIENT_SECRET}"; + `; + + let originalPath: string | undefined; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + originalPath = process.env.PATH; + }); + + afterEach(() => { + process.env.PATH = originalPath; + }); + + it("returns null when gemini binary is not in PATH", async () => { + process.env.PATH = "/nonexistent"; + mockExistsSync.mockReturnValue(false); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + it("extracts credentials from oauth2.js in known path", async () => { + const fakeBinDir = "/fake/bin"; + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; + const fakeOauth2Path = + "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) return true; + if (normalized === normalizePath(fakeOauth2Path)) return true; + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + const result = extractGeminiCliCredentials(); + + expect(result).toEqual({ + clientId: FAKE_CLIENT_ID, + clientSecret: FAKE_CLIENT_SECRET, + }); + }); + + it("returns null when oauth2.js cannot be found", async () => { + const fakeBinDir = "/fake/bin"; + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation( + (p: string) => normalizePath(p) === normalizePath(fakeGeminiPath), + ); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + it("returns null when oauth2.js lacks credentials", async () => { + const fakeBinDir = "/fake/bin"; + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; + const fakeOauth2Path = + "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) return true; + if (normalized === normalizePath(fakeOauth2Path)) return true; + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue("// no credentials here"); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + it("caches credentials after first extraction", async () => { + const fakeBinDir = "/fake/bin"; + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeResolvedPath = "/fake/lib/node_modules/@google/gemini-cli/dist/index.js"; + const fakeOauth2Path = + "/fake/lib/node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"; + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) return true; + if (normalized === normalizePath(fakeOauth2Path)) return true; + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + + // First call + const result1 = extractGeminiCliCredentials(); + expect(result1).not.toBeNull(); + + // Second call should use cache (readFileSync not called again) + const readCount = mockReadFileSync.mock.calls.length; + const result2 = extractGeminiCliCredentials(); + expect(result2).toEqual(result1); + expect(mockReadFileSync.mock.calls.length).toBe(readCount); + }); +}); diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google-gemini-cli-auth/oauth.ts index 0fc68aa5a..405b94641 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google-gemini-cli-auth/oauth.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createServer } from "node:http"; +import { delimiter, dirname, join } from "node:path"; const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; const CLIENT_SECRET_KEYS = [ @@ -47,15 +48,98 @@ function resolveEnv(keys: string[]): string | undefined { return undefined; } -function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { - const clientId = resolveEnv(CLIENT_ID_KEYS); - if (!clientId) { - throw new Error( - "Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).", - ); +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +/** @internal */ +export function clearCredentialsCache(): void { + cachedGeminiCliCredentials = null; +} + +/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */ +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials; + + try { + const geminiPath = findInPath("gemini"); + if (!geminiPath) return null; + + const resolvedPath = realpathSync(geminiPath); + const geminiCliDir = dirname(dirname(resolvedPath)); + + const searchPaths = [ + join(geminiCliDir, "node_modules", "@google", "gemini-cli-core", "dist", "src", "code_assist", "oauth2.js"), + join(geminiCliDir, "node_modules", "@google", "gemini-cli-core", "dist", "code_assist", "oauth2.js"), + ]; + + let content: string | null = null; + for (const p of searchPaths) { + if (existsSync(p)) { + content = readFileSync(p, "utf8"); + break; + } + } + if (!content) { + const found = findFile(geminiCliDir, "oauth2.js", 10); + if (found) content = readFileSync(found, "utf8"); + } + if (!content) return null; + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + // Gemini CLI not installed or extraction failed } - const clientSecret = resolveEnv(CLIENT_SECRET_KEYS); - return { clientId, clientSecret }; + return null; +} + +function findInPath(name: string): string | null { + const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of exts) { + const p = join(dir, name + ext); + if (existsSync(p)) return p; + } + } + return null; +} + +function findFile(dir: string, name: string, depth: number): string | null { + if (depth <= 0) return null; + try { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isFile() && e.name === name) return p; + if (e.isDirectory() && !e.name.startsWith(".")) { + const found = findFile(p, name, depth - 1); + if (found) return found; + } + } + } catch {} + return null; +} + +function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { + // 1. Check env vars first (user override) + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + // 2. Try to extract from installed Gemini CLI + const extracted = extractGeminiCliCredentials(); + if (extracted) { + return extracted; + } + + // 3. No credentials available + throw new Error( + "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", + ); } function isWSL(): boolean { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 2ba7cbef0..4542e113a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } - const contentType = - "info" in content && content.info && "mimetype" in content.info - ? (content.info as { mimetype?: string }).mimetype + const contentInfo = + "info" in content && content.info && typeof content.info === "object" + ? (content.info as { mimetype?: string; size?: number }) : undefined; + const contentType = contentInfo?.mimetype; + const contentSize = + typeof contentInfo?.size === "number" ? contentInfo.size : undefined; if (mediaUrl?.startsWith("mxc://")) { try { media = await downloadMatrixMedia({ client, mxcUrl: mediaUrl, contentType, + sizeBytes: contentSize, maxBytes: mediaMaxBytes, file: contentFile, }); diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index d8fd51888..10cbd8b47 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => { it("decrypts encrypted media when file payloads are present", async () => { const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted")); const client = { - downloadContent, crypto: { decryptMedia }, mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), } as unknown as import("matrix-bot-sdk").MatrixClient; @@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => { file, }); - expect(decryptMedia).toHaveBeenCalled(); + // decryptMedia should be called with just the file object (it handles download internally) + expect(decryptMedia).toHaveBeenCalledWith(file); expect(saveMediaBuffer).toHaveBeenCalledWith( Buffer.from("decrypted"), "image/png", @@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => { ); expect(result?.path).toBe("/tmp/media"); }); + + it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { + const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + + const client = { + crypto: { decryptMedia }, + mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), + } as unknown as import("matrix-bot-sdk").MatrixClient; + + const file = { + url: "mxc://example/file", + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }; + + await expect( + downloadMatrixMedia({ + client, + mxcUrl: "mxc://example/file", + contentType: "image/png", + sizeBytes: 2048, + maxBytes: 1024, + file, + }), + ).rejects.toThrow("Matrix media exceeds configured size limit"); + + expect(decryptMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index b60320e41..1ade1d19c 100644 --- a/extensions/matrix/src/matrix/monitor/media.ts +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: { /** * Download and decrypt encrypted media from a Matrix room. + * Uses matrix-bot-sdk's decryptMedia which handles both download and decryption. */ async function fetchEncryptedMediaBuffer(params: { client: MatrixClient; @@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: { throw new Error("Cannot decrypt media: crypto not enabled"); } - // Download the encrypted content - const encryptedBuffer = await params.client.downloadContent(params.file.url); - if (encryptedBuffer.byteLength > params.maxBytes) { + // decryptMedia handles downloading and decrypting the encrypted content internally + const decrypted = await params.client.crypto.decryptMedia(params.file); + + if (decrypted.byteLength > params.maxBytes) { throw new Error("Matrix media exceeds configured size limit"); } - // Decrypt using matrix-bot-sdk crypto - const decrypted = await params.client.crypto.decryptMedia( - Buffer.from(encryptedBuffer), - params.file, - ); - return { buffer: decrypted }; } @@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: { client: MatrixClient; mxcUrl: string; contentType?: string; + sizeBytes?: number; maxBytes: number; file?: EncryptedFile; }): Promise<{ @@ -77,6 +74,12 @@ export async function downloadMatrixMedia(params: { placeholder: string; } | null> { let fetched: { buffer: Buffer; headerType?: string } | null; + if ( + typeof params.sizeBytes === "number" && + params.sizeBytes > params.maxBytes + ) { + throw new Error("Matrix media exceeds configured size limit"); + } if (params.file) { // Encrypted media diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts index d77bdac67..c77cf0282 100644 --- a/extensions/matrix/src/matrix/monitor/types.ts +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & { file?: EncryptedFile; info?: { mimetype?: string; + size?: number; }; "m.relates_to"?: { rel_type?: string; diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 91de31937..b420cad6f 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -54,6 +54,8 @@ export function createClawdbotTools(options?: { hasRepliedRef?: { value: boolean }; /** If true, the model has native vision capability */ modelHasVision?: boolean; + /** Explicit agent ID override for cron/hook sessions. */ + requesterAgentIdOverride?: string; }): AnyAgentTool[] { const imageTool = options?.agentDir?.trim() ? createImageTool({ @@ -105,7 +107,10 @@ export function createClawdbotTools(options?: { agentSessionKey: options?.agentSessionKey, config: options?.config, }), - createAgentsListTool({ agentSessionKey: options?.agentSessionKey }), + createAgentsListTool({ + agentSessionKey: options?.agentSessionKey, + requesterAgentIdOverride: options?.requesterAgentIdOverride, + }), createSessionsListTool({ agentSessionKey: options?.agentSessionKey, sandboxed: options?.sandboxed, @@ -129,6 +134,7 @@ export function createClawdbotTools(options?: { agentGroupChannel: options?.agentGroupChannel, agentGroupSpace: options?.agentGroupSpace, sandboxed: options?.sandboxed, + requesterAgentIdOverride: options?.requesterAgentIdOverride, }), createSessionStatusTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 391aee318..5e72ad396 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -1,252 +1,139 @@ -import { describe, expect, it } from "vitest"; - -import type { ClawdbotConfig } from "../config/config.js"; -import { DEFAULT_PROVIDER } from "./defaults.js"; +import { describe, it, expect, vi } from "vitest"; import { - buildAllowedModelSet, - modelKey, parseModelRef, - resolveAllowedModelRef, - resolveHooksGmailModel, + resolveModelRefFromString, + resolveConfiguredModelRef, + buildModelAliasIndex, + normalizeProviderId, + modelKey, } from "./model-selection.js"; +import type { ClawdbotConfig } from "../config/config.js"; -const catalog = [ - { - provider: "openai", - id: "gpt-4", - name: "GPT-4", - }, -]; - -describe("buildAllowedModelSet", () => { - it("always allows the configured default model", () => { - const cfg = { - agents: { - defaults: { - models: { - "openai/gpt-4": { alias: "gpt4" }, - }, - }, - }, - } as ClawdbotConfig; - - const allowed = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: "claude-cli", - defaultModel: "opus-4.5", - }); - - expect(allowed.allowAny).toBe(false); - expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true); - expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true); - }); - - it("includes the default model when no allowlist is set", () => { - const cfg = { - agents: { defaults: {} }, - } as ClawdbotConfig; - - const allowed = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: "claude-cli", - defaultModel: "opus-4.5", - }); - - expect(allowed.allowAny).toBe(true); - expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true); - expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true); - }); - - it("allows explicit custom providers from models.providers", () => { - const cfg = { - agents: { - defaults: { - models: { - "moonshot/kimi-k2-0905-preview": { alias: "kimi" }, - }, - }, - }, - models: { - mode: "merge", - providers: { - moonshot: { - baseUrl: "https://api.moonshot.ai/v1", - apiKey: "x", - api: "openai-completions", - models: [{ id: "kimi-k2-0905-preview", name: "Kimi" }], - }, - }, - }, - } as ClawdbotConfig; - - const allowed = buildAllowedModelSet({ - cfg, - catalog: [], - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-5", - }); - - expect(allowed.allowAny).toBe(false); - expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true); - }); -}); - -describe("parseModelRef", () => { - it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => { - const ref = parseModelRef("anthropic/opus-4.5", "anthropic"); - expect(ref).toEqual({ - provider: "anthropic", - model: "claude-opus-4-5", +describe("model-selection", () => { + describe("normalizeProviderId", () => { + it("should normalize provider names", () => { + expect(normalizeProviderId("Anthropic")).toBe("anthropic"); + expect(normalizeProviderId("Z.ai")).toBe("zai"); + expect(normalizeProviderId("z-ai")).toBe("zai"); + expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode"); + expect(normalizeProviderId("qwen")).toBe("qwen-portal"); }); }); - it("normalizes google gemini 3 models to preview ids", () => { - expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({ - provider: "google", - model: "gemini-3-pro-preview", - }); - expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({ - provider: "google", - model: "gemini-3-flash-preview", - }); - }); - - it("normalizes default-provider google models", () => { - expect(parseModelRef("gemini-3-pro", "google")).toEqual({ - provider: "google", - model: "gemini-3-pro-preview", - }); - }); -}); - -describe("resolveHooksGmailModel", () => { - it("returns null when hooks.gmail.model is not set", () => { - const cfg = {} satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - expect(result).toBeNull(); - }); - - it("returns null when hooks.gmail.model is empty", () => { - const cfg = { - hooks: { gmail: { model: "" } }, - } satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - expect(result).toBeNull(); - }); - - it("parses provider/model from hooks.gmail.model", () => { - const cfg = { - hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } }, - } satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - expect(result).toEqual({ - provider: "openrouter", - model: "meta-llama/llama-3.3-70b:free", - }); - }); - - it("resolves alias from agent.models", () => { - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, - }, - }, - }, - hooks: { gmail: { model: "Sonnet" } }, - } satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - expect(result).toEqual({ - provider: "anthropic", - model: "claude-sonnet-4-1", - }); - }); - - it("uses default provider when model omits provider", () => { - const cfg = { - hooks: { gmail: { model: "claude-haiku-3-5" } }, - } satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: "anthropic", - }); - expect(result).toEqual({ - provider: "anthropic", - model: "claude-haiku-3-5", - }); - }); -}); - -describe("resolveAllowedModelRef", () => { - it("resolves aliases when allowed", () => { - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, - }, - }, - }, - } satisfies ClawdbotConfig; - const resolved = resolveAllowedModelRef({ - cfg, - catalog: [ - { - provider: "anthropic", - id: "claude-sonnet-4-1", - name: "Sonnet", - }, - ], - raw: "Sonnet", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-5", - }); - expect("error" in resolved).toBe(false); - if ("ref" in resolved) { - expect(resolved.ref).toEqual({ + describe("parseModelRef", () => { + it("should parse full model refs", () => { + expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({ provider: "anthropic", - model: "claude-sonnet-4-1", + model: "claude-3-5-sonnet", }); - } + }); + + it("should use default provider if none specified", () => { + expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({ + provider: "anthropic", + model: "claude-3-5-sonnet", + }); + }); + + it("should return null for empty strings", () => { + expect(parseModelRef("", "anthropic")).toBeNull(); + expect(parseModelRef(" ", "anthropic")).toBeNull(); + }); + + it("should handle invalid slash usage", () => { + expect(parseModelRef("/", "anthropic")).toBeNull(); + expect(parseModelRef("anthropic/", "anthropic")).toBeNull(); + expect(parseModelRef("/model", "anthropic")).toBeNull(); + }); }); - it("rejects disallowed models", () => { - const cfg = { - agents: { - defaults: { - models: { - "openai/gpt-4": { alias: "GPT4" }, + describe("buildModelAliasIndex", () => { + it("should build alias index from config", () => { + const cfg: Partial = { + agents: { + defaults: { + models: { + "anthropic/claude-3-5-sonnet": { alias: "fast" }, + "openai/gpt-4o": { alias: "smart" }, + }, }, }, - }, - } satisfies ClawdbotConfig; - const resolved = resolveAllowedModelRef({ - cfg, - catalog: [ - { provider: "openai", id: "gpt-4", name: "GPT-4" }, - { provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" }, - ], - raw: "anthropic/claude-sonnet-4-1", - defaultProvider: "openai", - defaultModel: "gpt-4", + }; + + const index = buildModelAliasIndex({ + cfg: cfg as ClawdbotConfig, + defaultProvider: "anthropic", + }); + + expect(index.byAlias.get("fast")?.ref).toEqual({ + provider: "anthropic", + model: "claude-3-5-sonnet", + }); + expect(index.byAlias.get("smart")?.ref).toEqual({ provider: "openai", model: "gpt-4o" }); + expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]); }); - expect(resolved).toEqual({ - error: "model not allowed: anthropic/claude-sonnet-4-1", + }); + + describe("resolveModelRefFromString", () => { + it("should resolve from string with alias", () => { + const index = { + byAlias: new Map([ + ["fast", { alias: "fast", ref: { provider: "anthropic", model: "sonnet" } }], + ]), + byKey: new Map(), + }; + + const resolved = resolveModelRefFromString({ + raw: "fast", + defaultProvider: "openai", + aliasIndex: index, + }); + + expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" }); + expect(resolved?.alias).toBe("fast"); + }); + + it("should resolve direct ref if no alias match", () => { + const resolved = resolveModelRefFromString({ + raw: "openai/gpt-4", + defaultProvider: "anthropic", + }); + expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" }); + }); + }); + + describe("resolveConfiguredModelRef", () => { + it("should fall back to anthropic and warn if provider is missing for non-alias", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const cfg: Partial = { + agents: { + defaults: { + model: "claude-3-5-sonnet", + }, + }, + }; + + const result = resolveConfiguredModelRef({ + cfg: cfg as ClawdbotConfig, + defaultProvider: "google", + defaultModel: "gemini-pro", + }); + + expect(result).toEqual({ provider: "anthropic", model: "claude-3-5-sonnet" }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Falling back to "anthropic/claude-3-5-sonnet"'), + ); + warnSpy.mockRestore(); + }); + + it("should use default provider/model if config is empty", () => { + const cfg: Partial = {}; + const result = resolveConfiguredModelRef({ + cfg: cfg as ClawdbotConfig, + defaultProvider: "openai", + defaultModel: "gpt-4", + }); + expect(result).toEqual({ provider: "openai", model: "gpt-4" }); }); }); }); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index a330423b3..e05370edd 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -131,14 +131,24 @@ export function resolveConfiguredModelRef(params: { cfg: params.cfg, defaultProvider: params.defaultProvider, }); + if (!trimmed.includes("/")) { + const aliasKey = normalizeAliasKey(trimmed); + const aliasMatch = aliasIndex.byAlias.get(aliasKey); + if (aliasMatch) return aliasMatch.ref; + + // Default to anthropic if no provider is specified, but warn as this is deprecated. + console.warn( + `[clawdbot] Model "${trimmed}" specified without provider. Falling back to "anthropic/${trimmed}". Please use "anthropic/${trimmed}" in your config.`, + ); + return { provider: "anthropic", model: trimmed }; + } + const resolved = resolveModelRefFromString({ raw: trimmed, defaultProvider: params.defaultProvider, aliasIndex, }); if (resolved) return resolved.ref; - // TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated. - return { provider: "anthropic", model: trimmed }; } return { provider: params.defaultProvider, model: params.defaultModel }; } diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index ba0fe13bc..bd745da03 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -313,6 +313,7 @@ export function createClawdbotCodingTools(options?: { replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, modelHasVision: options?.modelHasVision, + requesterAgentIdOverride: agentId, }), ]; const coreToolNames = new Set( diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 40c7172cb..28f136844 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -19,7 +19,11 @@ type AgentListEntry = { configured: boolean; }; -export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAgentTool { +export function createAgentsListTool(opts?: { + agentSessionKey?: string; + /** Explicit agent ID override for cron/hook sessions. */ + requesterAgentIdOverride?: string; +}): AnyAgentTool { return { label: "Agents", name: "agents_list", @@ -37,7 +41,9 @@ export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAg }) : alias; const requesterAgentId = normalizeAgentId( - parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID, + opts?.requesterAgentIdOverride ?? + parseAgentSessionKey(requesterInternalKey)?.agentId ?? + DEFAULT_AGENT_ID, ); const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? []; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 838badfc3..e5e1391d1 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -67,6 +67,8 @@ export function createSessionsSpawnTool(opts?: { agentGroupChannel?: string | null; agentGroupSpace?: string | null; sandboxed?: boolean; + /** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */ + requesterAgentIdOverride?: string; }): AnyAgentTool { return { label: "Sessions", @@ -129,7 +131,7 @@ export function createSessionsSpawnTool(opts?: { }); const requesterAgentId = normalizeAgentId( - parseAgentSessionKey(requesterInternalKey)?.agentId, + opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId, ); const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 01069d852..545899843 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -310,10 +310,16 @@ describe("chunkTextWithMode", () => { expect(chunks).toEqual(["Line one\nLine two"]); }); - it("uses newline-based chunking for newline mode", () => { + it("uses paragraph-based chunking for newline mode", () => { const text = "Line one\nLine two"; const chunks = chunkTextWithMode(text, 1000, "newline"); - expect(chunks).toEqual(["Line one", "Line two"]); + expect(chunks).toEqual(["Line one\nLine two"]); + }); + + it("splits on blank lines for newline mode", () => { + const text = "Para one\n\nPara two"; + const chunks = chunkTextWithMode(text, 1000, "newline"); + expect(chunks).toEqual(["Para one", "Para two"]); }); }); @@ -323,17 +329,35 @@ describe("chunkMarkdownTextWithMode", () => { expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000)); }); - it("uses newline-based chunking for newline mode", () => { + it("uses paragraph-based chunking for newline mode", () => { const text = "Line one\nLine two"; - expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one", "Line two"]); + expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one\nLine two"]); }); - it("does not split inside code fences for newline mode", () => { + it("splits on blank lines for newline mode", () => { + const text = "Para one\n\nPara two"; + expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Para one", "Para two"]); + }); + + it("does not split single-newline code fences in newline mode", () => { const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter"; - expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([ - "```js\nconst a = 1;\nconst b = 2;\n```", - "After", - ]); + expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]); + }); + + it("defers long markdown paragraphs to markdown chunking in newline mode", () => { + const text = `\`\`\`js\n${"const a = 1;\n".repeat(20)}\`\`\``; + expect(chunkMarkdownTextWithMode(text, 40, "newline")).toEqual(chunkMarkdownText(text, 40)); + }); + + it("does not split on blank lines inside a fenced code block", () => { + const text = "```python\ndef my_function():\n x = 1\n\n y = 2\n return x + y\n```"; + expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]); + }); + + it("splits on blank lines between a code fence and following paragraph", () => { + const fence = "```python\ndef my_function():\n x = 1\n\n y = 2\n return x + y\n```"; + const text = `${fence}\n\nAfter`; + expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([fence, "After"]); }); }); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index c77c0cd9f..c4fd31ed8 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -13,7 +13,9 @@ export type TextChunkProvider = ChannelId | typeof INTERNAL_MESSAGE_CHANNEL; /** * Chunking mode for outbound messages: * - "length": Split only when exceeding textChunkLimit (default) - * - "newline": Split on every newline, with fallback to length-based for long lines + * - "newline": Prefer breaking on "soft" boundaries. Historically this split on every + * newline; now it only breaks on paragraph boundaries (blank lines) unless the text + * exceeds the length limit. */ export type ChunkMode = "length" | "newline"; @@ -164,44 +166,93 @@ export function chunkByNewline( return chunks; } +/** + * Split text into chunks on paragraph boundaries (blank lines), preserving lists and + * single-newline line wraps inside paragraphs. + * + * - Only breaks at paragraph separators ("\n\n" or more, allowing whitespace on blank lines) + * - Packs multiple paragraphs into a single chunk up to `limit` + * - Falls back to length-based splitting when a single paragraph exceeds `limit` + * (unless `splitLongParagraphs` is disabled) + */ +export function chunkByParagraph( + text: string, + limit: number, + opts?: { splitLongParagraphs?: boolean }, +): string[] { + if (!text) return []; + if (limit <= 0) return [text]; + const splitLongParagraphs = opts?.splitLongParagraphs !== false; + + // Normalize to \n so blank line detection is consistent. + const normalized = text.replace(/\r\n?/g, "\n"); + + // Fast-path: if there are no blank-line paragraph separators, do not split. + // (We *do not* early-return based on `limit` — newline mode is about paragraph + // boundaries, not only exceeding a length limit.) + const paragraphRe = /\n[\t ]*\n+/; + if (!paragraphRe.test(normalized)) { + if (normalized.length <= limit) return [normalized]; + if (!splitLongParagraphs) return [normalized]; + return chunkText(normalized, limit); + } + + const spans = parseFenceSpans(normalized); + + const parts: string[] = []; + const re = /\n[\t ]*\n+/g; // paragraph break: blank line(s), allowing whitespace + let lastIndex = 0; + for (const match of normalized.matchAll(re)) { + const idx = match.index ?? 0; + + // Do not split on blank lines that occur inside fenced code blocks. + if (!isSafeFenceBreak(spans, idx)) { + continue; + } + + parts.push(normalized.slice(lastIndex, idx)); + lastIndex = idx + match[0].length; + } + parts.push(normalized.slice(lastIndex)); + + const chunks: string[] = []; + for (const part of parts) { + const paragraph = part.replace(/\s+$/g, ""); + if (!paragraph.trim()) continue; + if (paragraph.length <= limit) { + chunks.push(paragraph); + } else if (!splitLongParagraphs) { + chunks.push(paragraph); + } else { + chunks.push(...chunkText(paragraph, limit)); + } + } + + return chunks; +} + /** * Unified chunking function that dispatches based on mode. */ export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] { if (mode === "newline") { - const chunks: string[] = []; - const lineChunks = chunkByNewline(text, limit, { splitLongLines: false }); - for (const line of lineChunks) { - const nested = chunkText(line, limit); - if (!nested.length && line) { - chunks.push(line); - continue; - } - chunks.push(...nested); - } - return chunks; + return chunkByParagraph(text, limit); } return chunkText(text, limit); } export function chunkMarkdownTextWithMode(text: string, limit: number, mode: ChunkMode): string[] { if (mode === "newline") { - const spans = parseFenceSpans(text); - const chunks: string[] = []; - const lineChunks = chunkByNewline(text, limit, { - splitLongLines: false, - trimLines: false, - isSafeBreak: (index) => isSafeFenceBreak(spans, index), - }); - for (const line of lineChunks) { - const nested = chunkMarkdownText(line, limit); - if (!nested.length && line) { - chunks.push(line); - continue; - } - chunks.push(...nested); + // Paragraph chunking is fence-safe because we never split at arbitrary indices. + // If a paragraph must be split by length, defer to the markdown-aware chunker. + const paragraphChunks = chunkByParagraph(text, limit, { splitLongParagraphs: false }); + const out: string[] = []; + for (const chunk of paragraphChunks) { + const nested = chunkMarkdownText(chunk, limit); + if (!nested.length && chunk) out.push(chunk); + else out.push(...nested); } - return chunks; + return out; } return chunkMarkdownText(text, limit); } diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 50567ad87..cca6c6ad1 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -32,6 +32,8 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | // This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content // (Those aren't valid markdown headers - ATX headers require space after #) if (/^#+(\s|$)/.test(trimmed)) continue; + // Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- " + if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue; // Found a non-empty, non-comment line - there's actionable content return false; } diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 82fa85919..875920731 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -7,7 +7,7 @@ import { INTERNAL_MESSAGE_CHANNEL, listDeliverableMessageChannels, } from "../../utils/message-channel.js"; -import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; +import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; @@ -69,15 +69,11 @@ export function resolveBlockStreamingChunking( }); const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; - // When chunkMode is "newline", use newline-based streaming - const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); - if (channelChunkMode === "newline") { - // For newline mode: use very low minChars to flush quickly on newlines - const minChars = Math.max(1, Math.floor(chunkCfg?.minChars ?? 1)); - const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? textLimit)); - const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); - return { minChars, maxChars, breakPreference: "newline" }; - } + // Note: chunkMode="newline" used to imply splitting on each newline, but outbound + // delivery now treats it as paragraph-aware chunking (only split on blank lines). + // Block streaming should follow the same rule, so we do NOT special-case newline + // mode here. + // (chunkMode no longer alters block streaming behavior) const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX)); const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); @@ -103,11 +99,8 @@ export function resolveBlockStreamingCoalescing( ): BlockStreamingCoalescing | undefined { const providerKey = normalizeChunkProvider(provider); - // When chunkMode is "newline", disable coalescing to send each line immediately - const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); - if (channelChunkMode === "newline") { - return undefined; - } + // Note: chunkMode="newline" is paragraph-aware in outbound delivery (blank-line splits), + // so block streaming should not disable coalescing or flush per single newline. const providerId = providerKey ? normalizeChannelId(providerKey) : null; const providerChunkLimit = providerId diff --git a/src/discord/chunk.test.ts b/src/discord/chunk.test.ts index f8e18e2b4..13ec1b8e7 100644 --- a/src/discord/chunk.test.ts +++ b/src/discord/chunk.test.ts @@ -58,7 +58,7 @@ describe("chunkDiscordText", () => { maxLines: 50, chunkMode: "newline", }); - expect(chunks).toEqual(["```js\nconst a = 1;\nconst b = 2;\n```", "After"]); + expect(chunks).toEqual([text]); }); it("reserves space for closing fences when chunking", () => { diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index b97a2d321..a2645a75d 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { getHandshakeTimeoutMs } from "./server-constants.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; import { connectReq, getFreePort, @@ -286,6 +287,70 @@ describe("gateway server auth/connect", () => { } }); + test("allows control ui with device identity when insecure auth is enabled", async () => { + testState.gatewayControlUi = { allowInsecureAuth: true }; + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + trustedProxies: ["127.0.0.1"], + }, + } as any); + const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; + const port = await getFreePort(); + const server = await startGatewayServer(port); + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { "x-forwarded-for": "203.0.113.10" }, + }); + const challengePromise = onceMessage<{ payload?: unknown }>( + ws, + (o) => o.type === "event" && o.event === "connect.challenge", + ); + await new Promise((resolve) => ws.once("open", resolve)); + const challenge = await challengePromise; + const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + role: "operator", + scopes: [], + signedAtMs, + token: "secret", + nonce: String(nonce), + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce: String(nonce), + }; + const res = await connectReq(ws, { + token: "secret", + device, + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(true); + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + test("accepts device token auth for paired device", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 5bca2e5ed..5ef3f26e7 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -318,13 +318,13 @@ export function attachGatewayWsMessageHandler(params: { let devicePublicKey: string | null = null; const hasTokenAuth = Boolean(connectParams.auth?.token); const hasPasswordAuth = Boolean(connectParams.auth?.password); + const hasSharedAuth = hasTokenAuth || hasPasswordAuth; const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; + const allowInsecureControlUi = + isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; if (!device) { - const allowInsecureControlUi = - isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; - const canSkipDevice = - isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth; + const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth; if (isControlUi && !allowInsecureControlUi) { const errorMessage = "control ui requires HTTPS or localhost (secure context)"; @@ -569,7 +569,8 @@ export function attachGatewayWsMessageHandler(params: { return; } - if (device && devicePublicKey) { + const skipPairing = allowInsecureControlUi && hasSharedAuth; + if (device && devicePublicKey && !skipPairing) { const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ deviceId: device.id, diff --git a/src/infra/node-shell.test.ts b/src/infra/node-shell.test.ts new file mode 100644 index 000000000..8f95a29a8 --- /dev/null +++ b/src/infra/node-shell.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { buildNodeShellCommand } from "./node-shell.js"; + +describe("buildNodeShellCommand", () => { + it("uses cmd.exe for win32", () => { + expect(buildNodeShellCommand("echo hi", "win32")).toEqual([ + "cmd.exe", + "/d", + "/s", + "/c", + "echo hi", + ]); + }); + + it("uses cmd.exe for windows labels", () => { + expect(buildNodeShellCommand("echo hi", "windows")).toEqual([ + "cmd.exe", + "/d", + "/s", + "/c", + "echo hi", + ]); + expect(buildNodeShellCommand("echo hi", "Windows 11")).toEqual([ + "cmd.exe", + "/d", + "/s", + "/c", + "echo hi", + ]); + }); + + it("uses /bin/sh for darwin", () => { + expect(buildNodeShellCommand("echo hi", "darwin")).toEqual(["/bin/sh", "-lc", "echo hi"]); + }); + + it("uses /bin/sh when platform missing", () => { + expect(buildNodeShellCommand("echo hi")).toEqual(["/bin/sh", "-lc", "echo hi"]); + }); +}); diff --git a/src/infra/node-shell.ts b/src/infra/node-shell.ts index 26d5ff1d3..8a59fb524 100644 --- a/src/infra/node-shell.ts +++ b/src/infra/node-shell.ts @@ -2,7 +2,7 @@ export function buildNodeShellCommand(command: string, platform?: string | null) const normalized = String(platform ?? "") .trim() .toLowerCase(); - if (normalized.includes("win")) { + if (normalized.startsWith("win")) { return ["cmd.exe", "/d", "/s", "/c", command]; } return ["/bin/sh", "-lc", command]; diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index d259366b4..a80a3f482 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -192,7 +192,7 @@ describe("deliverOutboundPayloads", () => { expect(sendWhatsApp).toHaveBeenNthCalledWith( 2, "+1555", - "\nLine two", + "Line two", expect.objectContaining({ verbose: false }), ); }); @@ -241,9 +241,8 @@ describe("deliverOutboundPayloads", () => { payloads: [{ text }], }); - expect(chunker).toHaveBeenCalledTimes(2); - expect(chunker).toHaveBeenNthCalledWith(1, "```js\nconst a = 1;\nconst b = 2;\n```", 4000); - expect(chunker).toHaveBeenNthCalledWith(2, "After", 4000); + expect(chunker).toHaveBeenCalledTimes(1); + expect(chunker).toHaveBeenNthCalledWith(1, text, 4000); }); it("uses iMessage media maxBytes from agent fallback", async () => { diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index cd0ccd5ff..d246889e9 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,5 +1,5 @@ import { - chunkByNewline, + chunkByParagraph, chunkMarkdownTextWithMode, resolveChunkMode, resolveTextChunkLimit, @@ -239,14 +239,15 @@ export async function deliverOutboundPayloads(params: { } if (chunkMode === "newline") { const mode = handler.chunkerMode ?? "text"; - const lineChunks = + const blockChunks = mode === "markdown" ? chunkMarkdownTextWithMode(text, textLimit, "newline") - : chunkByNewline(text, textLimit, { splitLongLines: false }); - if (!lineChunks.length && text) lineChunks.push(text); - for (const lineChunk of lineChunks) { - const chunks = handler.chunker(lineChunk, textLimit); - if (!chunks.length && lineChunk) chunks.push(lineChunk); + : chunkByParagraph(text, textLimit); + + if (!blockChunks.length && text) blockChunks.push(text); + for (const blockChunk of blockChunks) { + const chunks = handler.chunker(blockChunk, textLimit); + if (!chunks.length && blockChunk) chunks.push(blockChunk); for (const chunk of chunks) { throwIfAborted(abortSignal); results.push(await handler.sendText(chunk)); diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index ca1b3f4cd..404cc2fc2 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -17,6 +17,9 @@ vi.mock("grammy", () => ({ public fileName?: string, ) {} }, + GrammyError: class GrammyError extends Error { + description = ""; + }, })); describe("deliverReplies", () => { @@ -164,4 +167,111 @@ describe("deliverReplies", () => { }), ); }); + + it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi + .fn() + .mockRejectedValue( + new Error( + "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", + ), + ); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await deliverReplies({ + replies: [ + { mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true }, + ], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }); + + // Voice was attempted but failed + expect(sendVoice).toHaveBeenCalledTimes(1); + // Fallback to text succeeded + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.stringContaining("Hello there"), + expect.any(Object), + ); + }); + + it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi.fn().mockRejectedValue(new Error("Network error")); + const sendMessage = vi.fn(); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await expect( + deliverReplies({ + replies: [{ mediaUrl: "https://example.com/note.ogg", text: "Hello", audioAsVoice: true }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }), + ).rejects.toThrow("Network error"); + + expect(sendVoice).toHaveBeenCalledTimes(1); + // Text fallback should NOT be attempted for other errors + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi + .fn() + .mockRejectedValue( + new Error( + "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", + ), + ); + const sendMessage = vi.fn(); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await expect( + deliverReplies({ + replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }), + ).rejects.toThrow("VOICE_MESSAGES_FORBIDDEN"); + + expect(sendVoice).toHaveBeenCalledTimes(1); + expect(sendMessage).not.toHaveBeenCalled(); + }); }); diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 2d117d748..4edc91c8a 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -1,4 +1,4 @@ -import { type Bot, InputFile } from "grammy"; +import { type Bot, GrammyError, InputFile } from "grammy"; import { markdownToTelegramChunks, markdownToTelegramHtml, @@ -22,6 +22,7 @@ import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js" import type { TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -155,9 +156,39 @@ export async function deliverReplies(params: { // Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]]) // Switch typing indicator to record_voice before sending. await params.onVoiceRecording?.(); - await bot.api.sendVoice(chatId, file, { - ...mediaParams, - }); + try { + await bot.api.sendVoice(chatId, file, { + ...mediaParams, + }); + } catch (voiceErr) { + // Fall back to text if voice messages are forbidden in this chat. + // This happens when the recipient has Telegram Premium privacy settings + // that block voice messages (Settings > Privacy > Voice Messages). + if (isVoiceMessagesForbidden(voiceErr)) { + const fallbackText = reply.text; + if (!fallbackText || !fallbackText.trim()) { + throw voiceErr; + } + logVerbose( + "telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text", + ); + hasReplied = await sendTelegramVoiceFallbackText({ + bot, + chatId, + runtime, + text: fallbackText, + chunkText, + replyToId, + replyToMode, + hasReplied, + messageThreadId, + linkPreview, + }); + // Skip this media item; continue with next. + continue; + } + throw voiceErr; + } } else { // Audio file - displays with metadata (title, duration) - DEFAULT await bot.api.sendAudio(chatId, file, { @@ -228,6 +259,43 @@ export async function resolveMedia( return { path: saved.path, contentType: saved.contentType, placeholder }; } +function isVoiceMessagesForbidden(err: unknown): boolean { + if (err instanceof GrammyError) { + return VOICE_FORBIDDEN_RE.test(err.description); + } + return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err)); +} + +async function sendTelegramVoiceFallbackText(opts: { + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + text: string; + chunkText: (markdown: string) => ReturnType; + replyToId?: number; + replyToMode: ReplyToMode; + hasReplied: boolean; + messageThreadId?: number; + linkPreview?: boolean; +}): Promise { + const chunks = opts.chunkText(opts.text); + let hasReplied = opts.hasReplied; + for (const chunk of chunks) { + await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { + replyToMessageId: + opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined, + messageThreadId: opts.messageThreadId, + textMode: "html", + plainText: chunk.text, + linkPreview: opts.linkPreview, + }); + if (opts.replyToId && !hasReplied) { + hasReplied = true; + } + } + return hasReplied; +} + function buildTelegramSendParams(opts?: { replyToMessageId?: number; messageThreadId?: number; diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 82b7b0ffb..7baab70a2 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -1,171 +1,188 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); :root { - /* Background - Deep Navy Slate */ - --bg: #0c0d12; - --bg-accent: #0d0e14; - --bg-elevated: #181a21; - --bg-hover: #252830; - --bg-muted: #252830; + /* Background - Warmer dark with depth */ + --bg: #12141a; + --bg-accent: #14161d; + --bg-elevated: #1a1d25; + --bg-hover: #262a35; + --bg-muted: #262a35; - /* Card / Surface */ - --card: #13151c; - --card-foreground: #f8fafc; - --card-highlight: rgba(255, 255, 255, 0.04); - --popover: #13151c; - --popover-foreground: #f8fafc; + /* Card / Surface - More contrast between levels */ + --card: #181b22; + --card-foreground: #f4f4f5; + --card-highlight: rgba(255, 255, 255, 0.05); + --popover: #181b22; + --popover-foreground: #f4f4f5; /* Panel */ - --panel: #0c0d12; - --panel-strong: #181a21; - --panel-hover: #252830; - --chrome: rgba(12, 13, 18, 0.95); - --chrome-strong: rgba(12, 13, 18, 0.98); + --panel: #12141a; + --panel-strong: #1a1d25; + --panel-hover: #262a35; + --chrome: rgba(18, 20, 26, 0.95); + --chrome-strong: rgba(18, 20, 26, 0.98); - /* Text */ - --text: #f8fafc; - --text-strong: #ffffff; - --chat-text: #f8fafc; - --muted: #94a3b8; - --muted-strong: #64748b; - --muted-foreground: #94a3b8; + /* Text - Slightly warmer */ + --text: #e4e4e7; + --text-strong: #fafafa; + --chat-text: #e4e4e7; + --muted: #71717a; + --muted-strong: #52525b; + --muted-foreground: #71717a; - /* Border */ - --border: #333842; - --border-strong: #454d5c; - --border-hover: #5a6373; - --input: #333842; - --ring: #ff4d4d; + /* Border - Subtle but defined */ + --border: #27272a; + --border-strong: #3f3f46; + --border-hover: #52525b; + --input: #27272a; + --ring: #ff5c5c; - /* Accent - The signature red */ - --accent: #ff4d4d; - --accent-hover: #ff6666; - --accent-muted: #ff4d4d; - --accent-subtle: rgba(255, 77, 77, 0.12); - --accent-foreground: #f8fafc; - --primary: #ff4d4d; + /* Accent - Punchy signature red */ + --accent: #ff5c5c; + --accent-hover: #ff7070; + --accent-muted: #ff5c5c; + --accent-subtle: rgba(255, 92, 92, 0.15); + --accent-foreground: #fafafa; + --accent-glow: rgba(255, 92, 92, 0.25); + --primary: #ff5c5c; --primary-foreground: #ffffff; - /* Secondary */ - --secondary: #252830; - --secondary-foreground: #f8fafc; - --accent-2: #3b82f6; - --accent-2-muted: rgba(59, 130, 246, 0.7); + /* Secondary - Teal accent for variety */ + --secondary: #1e2028; + --secondary-foreground: #f4f4f5; + --accent-2: #14b8a6; + --accent-2-muted: rgba(20, 184, 166, 0.7); + --accent-2-subtle: rgba(20, 184, 166, 0.15); - /* Semantic */ + /* Semantic - More saturated */ --ok: #22c55e; - --ok-muted: rgba(34, 197, 94, 0.7); - --ok-subtle: rgba(34, 197, 94, 0.1); + --ok-muted: rgba(34, 197, 94, 0.75); + --ok-subtle: rgba(34, 197, 94, 0.12); --destructive: #ef4444; --destructive-foreground: #fafafa; - --warn: #eab308; - --warn-muted: rgba(234, 179, 8, 0.7); - --warn-subtle: rgba(234, 179, 8, 0.1); + --warn: #f59e0b; + --warn-muted: rgba(245, 158, 11, 0.75); + --warn-subtle: rgba(245, 158, 11, 0.12); --danger: #ef4444; - --danger-muted: rgba(239, 68, 68, 0.7); - --danger-subtle: rgba(239, 68, 68, 0.1); + --danger-muted: rgba(239, 68, 68, 0.75); + --danger-subtle: rgba(239, 68, 68, 0.12); --info: #3b82f6; - /* Focus */ - --focus: rgba(255, 77, 77, 0.2); + /* Focus - With glow */ + --focus: rgba(255, 92, 92, 0.25); --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow); /* Grid */ - --grid-line: rgba(255, 255, 255, 0.03); + --grid-line: rgba(255, 255, 255, 0.04); /* Theme transition */ --theme-switch-x: 50%; --theme-switch-y: 50%; - /* Typography */ + /* Typography - Space Grotesk for personality */ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; - --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - /* Shadows - minimal */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + /* Shadows - Richer with subtle color */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-glow: 0 0 30px var(--accent-glow); - /* Radii - shadcn uses smaller radii */ - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - --radius-xl: 12px; + /* Radii - Slightly larger for friendlier feel */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; --radius-full: 9999px; - --radius: 6px; + --radius: 8px; - /* Transitions */ + /* Transitions - Snappy but smooth */ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --duration-fast: 150ms; + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --duration-fast: 120ms; --duration-normal: 200ms; - --duration-slow: 300ms; + --duration-slow: 350ms; color-scheme: dark; } -/* Light theme */ +/* Light theme - Clean with subtle warmth */ :root[data-theme="light"] { - --bg: #f8f8f7; - --bg-accent: #f3f2f0; + --bg: #fafafa; + --bg-accent: #f5f5f5; --bg-elevated: #ffffff; - --bg-hover: #eae8e6; - --bg-muted: #eae8e6; - --bg-content: #f0efed; + --bg-hover: #f0f0f0; + --bg-muted: #f0f0f0; + --bg-content: #f5f5f5; --card: #ffffff; - --card-foreground: #1c1917; - --card-highlight: rgba(0, 0, 0, 0.04); + --card-foreground: #18181b; + --card-highlight: rgba(0, 0, 0, 0.03); --popover: #ffffff; - --popover-foreground: #1c1917; + --popover-foreground: #18181b; - --panel: #f8f8f7; - --panel-strong: #f0efed; - --panel-hover: #e5e3e1; - --chrome: rgba(248, 248, 247, 0.95); - --chrome-strong: rgba(248, 248, 247, 0.98); + --panel: #fafafa; + --panel-strong: #f5f5f5; + --panel-hover: #ebebeb; + --chrome: rgba(250, 250, 250, 0.95); + --chrome-strong: rgba(250, 250, 250, 0.98); - --text: #44403c; - --text-strong: #292524; - --chat-text: #44403c; - --muted: #5c5856; - --muted-strong: #44403c; - --muted-foreground: #5c5856; + --text: #3f3f46; + --text-strong: #18181b; + --chat-text: #3f3f46; + --muted: #71717a; + --muted-strong: #52525b; + --muted-foreground: #71717a; - --border: #e0dedc; - --border-strong: #d6d3d1; - --border-hover: #a8a5a0; - --input: #e0dedc; + --border: #e4e4e7; + --border-strong: #d4d4d8; + --border-hover: #a1a1aa; + --input: #e4e4e7; - --accent: #b91c1c; - --accent-hover: #dc2626; - --accent-muted: #b91c1c; - --accent-subtle: rgba(185, 28, 28, 0.18); + --accent: #dc2626; + --accent-hover: #ef4444; + --accent-muted: #dc2626; + --accent-subtle: rgba(220, 38, 38, 0.12); --accent-foreground: #ffffff; - --primary: #b91c1c; + --accent-glow: rgba(220, 38, 38, 0.15); + --primary: #dc2626; --primary-foreground: #ffffff; - --secondary: #eae8e6; - --secondary-foreground: #44403c; + --secondary: #f4f4f5; + --secondary-foreground: #3f3f46; + --accent-2: #0d9488; + --accent-2-muted: rgba(13, 148, 136, 0.75); + --accent-2-subtle: rgba(13, 148, 136, 0.12); - --ok: #15803d; - --ok-muted: rgba(21, 128, 61, 0.75); - --ok-subtle: rgba(21, 128, 61, 0.12); - --destructive: #b91c1c; + --ok: #16a34a; + --ok-muted: rgba(22, 163, 74, 0.75); + --ok-subtle: rgba(22, 163, 74, 0.1); + --destructive: #dc2626; --destructive-foreground: #fafafa; - --warn: #a16207; - --warn-muted: rgba(161, 98, 7, 0.75); - --warn-subtle: rgba(161, 98, 7, 0.12); - --danger: #b91c1c; - --danger-muted: rgba(185, 28, 28, 0.75); - --danger-subtle: rgba(185, 28, 28, 0.12); - --info: #1d4ed8; + --warn: #d97706; + --warn-muted: rgba(217, 119, 6, 0.75); + --warn-subtle: rgba(217, 119, 6, 0.1); + --danger: #dc2626; + --danger-muted: rgba(220, 38, 38, 0.75); + --danger-subtle: rgba(220, 38, 38, 0.1); + --info: #2563eb; - --focus: rgba(185, 28, 28, 0.25); + --focus: rgba(220, 38, 38, 0.2); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow); - --grid-line: rgba(0, 0, 0, 0.06); + --grid-line: rgba(0, 0, 0, 0.05); + + /* Light shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-glow: 0 0 24px var(--accent-glow); color-scheme: light; } @@ -181,8 +198,8 @@ body { body { margin: 0; - font: 400 14px/1.5 var(--font-body); - letter-spacing: -0.011em; + font: 400 14px/1.55 var(--font-body); + letter-spacing: -0.02em; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; @@ -270,11 +287,11 @@ select { background: var(--border-strong); } -/* Animations */ +/* Animations - Polished with spring feel */ @keyframes rise { from { opacity: 0; - transform: translateY(4px); + transform: translateY(8px); } to { opacity: 1; @@ -294,7 +311,7 @@ select { @keyframes scale-in { from { opacity: 0; - transform: scale(0.96); + transform: scale(0.95); } to { opacity: 1; @@ -305,7 +322,7 @@ select { @keyframes dashboard-enter { from { opacity: 0; - transform: translateY(8px); + transform: translateY(12px); } to { opacity: 1; @@ -322,6 +339,32 @@ select { } } +@keyframes pulse-subtle { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +@keyframes glow-pulse { + 0%, 100% { + box-shadow: 0 0 0 rgba(255, 92, 92, 0); + } + 50% { + box-shadow: 0 0 20px var(--accent-glow); + } +} + +/* Stagger animation delays for grouped elements */ +.stagger-1 { animation-delay: 0ms; } +.stagger-2 { animation-delay: 50ms; } +.stagger-3 { animation-delay: 100ms; } +.stagger-4 { animation-delay: 150ms; } +.stagger-5 { animation-delay: 200ms; } +.stagger-6 { animation-delay: 250ms; } + /* Focus visible styles */ :focus-visible { outline: none; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index bdaca288f..a78e0ef0a 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,63 +1,75 @@ @import './chat.css'; /* =========================================== - Cards + Cards - Refined with depth =========================================== */ .card { border: 1px solid var(--border); background: var(--card); border-radius: var(--radius-lg); - padding: 16px; - animation: rise 0.3s var(--ease-out); - transition: border-color var(--duration-fast) ease; - box-shadow: inset 0 1px 0 var(--card-highlight); + padding: 20px; + animation: rise 0.35s var(--ease-out) backwards; + transition: + border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); + box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight); } .card:hover { border-color: var(--border-strong); + box-shadow: var(--shadow-md), inset 0 1px 0 var(--card-highlight); } .card-title { - font-size: 14px; + font-size: 15px; font-weight: 600; + letter-spacing: -0.02em; color: var(--text-strong); } .card-sub { color: var(--muted); font-size: 13px; - margin-top: 4px; + margin-top: 6px; + line-height: 1.5; } /* =========================================== - Stats + Stats - Bold values, subtle labels =========================================== */ .stat { background: var(--card); border-radius: var(--radius-md); - padding: 12px 14px; + padding: 14px 16px; border: 1px solid var(--border); - transition: border-color var(--duration-fast) ease; + transition: + border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out); box-shadow: inset 0 1px 0 var(--card-highlight); } .stat:hover { border-color: var(--border-strong); + box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight); } .stat-label { color: var(--muted); - font-size: 12px; + font-size: 11px; font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; } .stat-value { - font-size: 20px; - font-weight: 600; - margin-top: 4px; - letter-spacing: -0.02em; + font-size: 24px; + font-weight: 700; + margin-top: 6px; + letter-spacing: -0.03em; + line-height: 1.1; } .stat-value.ok { @@ -70,11 +82,12 @@ .stat-card { display: grid; - gap: 4px; + gap: 6px; } .note-title { font-weight: 600; + letter-spacing: -0.01em; } /* =========================================== @@ -262,7 +275,7 @@ } /* =========================================== - Status Dot + Status Dot - With glow for emphasis =========================================== */ .statusDot { @@ -270,14 +283,18 @@ height: 8px; border-radius: var(--radius-full); background: var(--danger); + box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); + animation: pulse-subtle 2s ease-in-out infinite; } .statusDot.ok { background: var(--ok); + box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); + animation: none; } /* =========================================== - Buttons + Buttons - Tactile with personality =========================================== */ .btn { @@ -287,23 +304,30 @@ gap: 8px; border: 1px solid var(--border); background: var(--bg-elevated); - padding: 8px 14px; + padding: 9px 16px; border-radius: var(--radius-md); font-size: 13px; font-weight: 500; + letter-spacing: -0.01em; cursor: pointer; transition: - border-color var(--duration-fast) ease, - background var(--duration-fast) ease; + border-color var(--duration-fast) var(--ease-out), + background var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); } .btn:hover { background: var(--bg-hover); border-color: var(--border-strong); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); } .btn:active { background: var(--secondary); + transform: translateY(0); + box-shadow: none; } .btn svg { @@ -321,11 +345,13 @@ border-color: var(--accent); background: var(--accent); color: var(--primary-foreground); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } .btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); + box-shadow: var(--shadow-md), 0 0 20px var(--accent-glow); } /* Keyboard shortcut badge (shadcn style) */ @@ -497,32 +523,34 @@ } /* =========================================== - Callouts + Callouts - Informative with subtle depth =========================================== */ .callout { - padding: 12px 14px; + padding: 14px 16px; border-radius: var(--radius-md); background: var(--secondary); border: 1px solid var(--border); font-size: 13px; + line-height: 1.5; + position: relative; } .callout.danger { - border-color: var(--danger-subtle); - background: var(--danger-subtle); + border-color: rgba(239, 68, 68, 0.25); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(239, 68, 68, 0.04) 100%); color: var(--danger); } .callout.info { - border-color: rgba(59, 130, 246, 0.2); - background: rgba(59, 130, 246, 0.1); + border-color: rgba(59, 130, 246, 0.25); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); color: var(--info); } .callout.success { - border-color: var(--ok-subtle); - background: var(--ok-subtle); + border-color: rgba(34, 197, 94, 0.25); + background: linear-gradient(135deg, rgba(34, 197, 94, 0.08) 0%, rgba(34, 197, 94, 0.04) 100%); color: var(--ok); } @@ -565,6 +593,7 @@ border: 1px solid var(--border); max-height: 360px; overflow: auto; + max-width: 100%; } :root[data-theme="light"] .code-block, @@ -655,13 +684,13 @@ } /* =========================================== - Chips + Chips - Compact and punchy =========================================== */ .chip-row { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; } .chip { @@ -669,10 +698,18 @@ font-weight: 500; border: 1px solid var(--border); border-radius: var(--radius-full); - padding: 4px 10px; + padding: 5px 12px; color: var(--muted); background: var(--secondary); - transition: border-color var(--duration-fast) ease; + transition: + border-color var(--duration-fast) var(--ease-out), + background var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); +} + +.chip:hover { + border-color: var(--border-strong); + transform: translateY(-1px); } .chip input { @@ -681,12 +718,14 @@ .chip-ok { color: var(--ok); - border-color: var(--ok-subtle); + border-color: rgba(34, 197, 94, 0.3); + background: var(--ok-subtle); } .chip-warn { color: var(--warn); - border-color: var(--warn-subtle); + border-color: rgba(245, 158, 11, 0.3); + background: var(--warn-subtle); } /* =========================================== diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 29ce19d8e..7d96ac13f 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -7,7 +7,7 @@ display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 0; - min-height: calc(100vh - 160px); + height: calc(100vh - 160px); margin: -16px; border-radius: var(--radius-xl); overflow: hidden; @@ -24,6 +24,8 @@ flex-direction: column; background: var(--bg-accent); border-right: 1px solid var(--border); + min-height: 0; + overflow: hidden; } :root[data-theme="light"] .config-sidebar { @@ -245,7 +247,7 @@ min-height: 0; min-width: 0; background: var(--panel); - overflow-y: auto; + overflow: hidden; } /* Actions Bar */ @@ -413,6 +415,9 @@ font-size: 16px; font-weight: 600; letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .config-section-hero__desc { @@ -586,6 +591,9 @@ font-size: 17px; font-weight: 600; letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .config-section-card__desc { diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 3648fc782..c2a5c6fe3 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -132,18 +132,19 @@ } .brand-title { - font-size: 15px; - font-weight: 600; - letter-spacing: -0.02em; + font-size: 16px; + font-weight: 700; + letter-spacing: -0.03em; line-height: 1.1; color: var(--text-strong); } .brand-sub { - font-size: 11px; + font-size: 10px; font-weight: 500; color: var(--muted); - letter-spacing: 0.02em; + letter-spacing: 0.05em; + text-transform: uppercase; line-height: 1; } @@ -195,8 +196,8 @@ overflow-y: auto; overflow-x: hidden; padding: 16px 12px; - border-right: 1px solid var(--border); background: var(--bg); + scrollbar-width: none; /* Firefox */ transition: width var(--shell-focus-duration) var(--shell-focus-ease), padding var(--shell-focus-duration) var(--shell-focus-ease), @@ -204,6 +205,10 @@ min-height: 0; } +.nav::-webkit-scrollbar { + display: none; /* Chrome/Safari */ +} + .shell--chat-focus .nav { width: 0; padding: 0; @@ -412,10 +417,10 @@ .content { grid-area: content; - padding: 8px 8px 24px; + padding: 12px 16px 32px; display: flex; flex-direction: column; - gap: 20px; + gap: 24px; min-height: 0; overflow-y: auto; overflow-x: hidden; @@ -456,18 +461,19 @@ } .page-title { - font-size: 24px; - font-weight: 600; - letter-spacing: -0.02em; - line-height: 1.2; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.035em; + line-height: 1.15; color: var(--text-strong); } .page-sub { color: var(--muted); - font-size: 13px; + font-size: 14px; font-weight: 400; - margin-top: 4px; + margin-top: 6px; + letter-spacing: -0.01em; } .page-meta { @@ -501,7 +507,7 @@ .grid { display: grid; - gap: 16px; + gap: 20px; } .grid-cols-2 { @@ -514,25 +520,26 @@ .stat-grid { display: grid; - gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 14px; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .note-grid { display: grid; - gap: 12px; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } .row { display: flex; - gap: 10px; + gap: 12px; align-items: center; } .stack { display: grid; gap: 12px; + grid-template-columns: minmax(0, 1fr); } .filters {