Merge branch 'main' into feature/twitch-plugin
This commit is contained in:
commit
506135925b
86
.github/workflows/ci.yml
vendored
86
.github/workflows/ci.yml
vendored
@ -32,20 +32,29 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
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
|
- name: Runtime versions
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
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)
|
- name: Install dependencies (frozen)
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@ -108,6 +117,20 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
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
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
@ -118,16 +141,11 @@ jobs:
|
|||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
bun -v
|
bun -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
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
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@ -168,6 +186,8 @@ jobs:
|
|||||||
|
|
||||||
checks-windows:
|
checks-windows:
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@ -212,6 +232,20 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
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
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
@ -222,16 +256,11 @@ jobs:
|
|||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
bun -v
|
bun -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
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
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@ -279,20 +308,29 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
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
|
- name: Runtime versions
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
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
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
|||||||
19
.github/workflows/install-smoke.yml
vendored
19
.github/workflows/install-smoke.yml
vendored
@ -13,12 +13,19 @@ jobs:
|
|||||||
- name: Checkout CLI
|
- name: Checkout CLI
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm (corepack retry)
|
||||||
uses: pnpm/action-setup@v3
|
run: |
|
||||||
with:
|
set -euo pipefail
|
||||||
version: 10
|
corepack enable
|
||||||
- name: Enable Corepack
|
for attempt in 1 2 3; do
|
||||||
run: corepack enable
|
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)
|
- name: Install pnpm deps (minimal)
|
||||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||||
|
|||||||
67
CHANGELOG.md
67
CHANGELOG.md
@ -5,57 +5,66 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.24
|
## 2026.1.24
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
|
- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice
|
||||||
- Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. 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
|
### 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.
|
- 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: 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 verbose installer troubleshooting guidance.
|
||||||
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
- 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
|
- 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
|
- Docs: update Fly.io guide notes.
|
||||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||||
- 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
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.
|
||||||
- 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: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
- 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.
|
- 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.
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
|
||||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
|
||||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
- 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
|
||||||
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
|
- 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: 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
|
- 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: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
- Agents: use the active auth profile for auto-compaction recovery.
|
- 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.
|
- 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: 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: 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: 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: 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)
|
- 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.
|
- 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: 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: 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.
|
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
|||||||
@ -40,3 +40,13 @@ Please include in your PR:
|
|||||||
- [ ] Confirm you understand what the code does
|
- [ ] 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.
|
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!
|
||||||
|
|||||||
@ -196,7 +196,7 @@ Provider options:
|
|||||||
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
||||||
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
||||||
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
- `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.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
||||||
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
||||||
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
||||||
|
|||||||
@ -205,7 +205,7 @@ Notes:
|
|||||||
## Capabilities & limits
|
## Capabilities & limits
|
||||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
- 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).
|
- 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).
|
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
||||||
- Mention-gated guild replies by default to avoid noisy bots.
|
- Mention-gated guild replies by default to avoid noisy bots.
|
||||||
- Reply context is injected when a message references another message (quoted content + ids).
|
- Reply context is injected when a message references another message (quoted content + ids).
|
||||||
@ -307,7 +307,7 @@ ack reaction after the bot replies.
|
|||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
- `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.
|
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `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).
|
- `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).
|
||||||
|
|||||||
@ -219,7 +219,7 @@ This is useful when you want an isolated personality/model for a specific thread
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
- 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).
|
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
||||||
|
|
||||||
## Addressing / delivery targets
|
## Addressing / delivery targets
|
||||||
@ -254,7 +254,7 @@ Provider options:
|
|||||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
- `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:
|
Related global options:
|
||||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||||
|
|||||||
@ -215,7 +215,7 @@ Provider options:
|
|||||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
- `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.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.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).
|
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||||
|
|||||||
@ -415,7 +415,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
- `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.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.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.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||||
|
|||||||
@ -114,7 +114,7 @@ Provider options:
|
|||||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||||
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||||
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
- `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.blockStreaming`: disable block streaming for this channel.
|
||||||
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||||
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||||
|
|||||||
@ -111,7 +111,7 @@ Groups:
|
|||||||
|
|
||||||
## Media + limits
|
## Media + limits
|
||||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
- 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`).
|
- Attachments supported (base64 fetched from `signal-cli`).
|
||||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
- 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.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["<phone_or_uuid>"].historyLimit`.
|
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
- `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).
|
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
|
|||||||
@ -26,7 +26,7 @@ Minimal config:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Setup
|
### 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-...`).
|
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-...`).
|
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-...`).
|
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)
|
## Scopes (current vs optional)
|
||||||
Slack's Conversations API is type-scoped: you only need the scopes for the
|
Slack's Conversations API is type-scoped: you only need the scopes for the
|
||||||
conversation types you actually touch (channels, groups, im, mpim). See
|
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)
|
### Bot token scopes (required)
|
||||||
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
- `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)
|
- `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`
|
- `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`
|
- `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)
|
- `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`)
|
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
|
||||||
https://api.channels.slack.com/methods/reactions.get
|
https://docs.slack.dev/reference/methods/reactions.get
|
||||||
https://api.channels.slack.com/methods/reactions.add
|
https://docs.slack.dev/reference/methods/reactions.add
|
||||||
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
|
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
|
||||||
https://api.channels.slack.com/scopes/pins:read
|
https://docs.slack.dev/reference/scopes/pins.read
|
||||||
https://api.channels.slack.com/scopes/pins:write
|
https://docs.slack.dev/reference/scopes/pins.write
|
||||||
- `emoji:read` (`emoji.list`)
|
- `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`)
|
- `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)
|
### User token scopes (optional, read-only by default)
|
||||||
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
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`)
|
- `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)
|
- `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)
|
- `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`)
|
- `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)
|
- `files:read` (only if we start listing/reading file metadata)
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
@ -349,7 +349,7 @@ ack reaction after the bot replies.
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
- 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).
|
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
||||||
|
|
||||||
## Reply threading
|
## Reply threading
|
||||||
|
|||||||
@ -135,7 +135,7 @@ Notes:
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
- 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).
|
- 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.
|
- 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).
|
- 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.<account>.capabilities.inlineButtons`: per-account override.
|
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `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.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
|
|||||||
@ -271,7 +271,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
- 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).
|
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
||||||
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,7 @@ Legend:
|
|||||||
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
- `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`.
|
||||||
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
- `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send).
|
||||||
- Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`).
|
- 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.
|
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
|
||||||
|
|
||||||
**Boundary semantics:**
|
**Boundary semantics:**
|
||||||
|
|||||||
@ -1131,7 +1131,7 @@ Reaction notification modes:
|
|||||||
- `own`: reactions on the bot's own messages (default).
|
- `own`: reactions on the bot's own messages (default).
|
||||||
- `all`: all reactions on all messages.
|
- `all`: all reactions on all messages.
|
||||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
- `allowlist`: reactions from `guilds.<id>.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).
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
### `channels.googlechat` (Chat API webhook)
|
### `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.
|
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||||
- Default: root (`/`) (unchanged).
|
- Default: root (`/`) (unchanged).
|
||||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
|
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
|
||||||
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
|
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
|
||||||
|
(Tailscale Serve) or `127.0.0.1`.
|
||||||
|
|
||||||
Related docs:
|
Related docs:
|
||||||
- [Control UI](/web/control-ui)
|
- [Control UI](/web/control-ui)
|
||||||
|
|||||||
@ -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
|
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
||||||
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
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`.
|
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||||
|
|
||||||
`clawdbot security audit` warns when this setting is enabled.
|
`clawdbot security audit` warns when this setting is enabled.
|
||||||
|
|||||||
41
docs/tools/creating-skills.md
Normal file
41
docs/tools/creating-skills.md
Normal file
@ -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).
|
||||||
@ -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
|
This disables device identity + pairing for the Control UI (even on HTTPS). Use
|
||||||
trust the network.
|
only if you trust the network.
|
||||||
|
|
||||||
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,18 @@ Restart the Gateway after enabling.
|
|||||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
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_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
||||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
||||||
|
|||||||
151
extensions/google-gemini-cli-auth/oauth.test.ts
Normal file
151
extensions/google-gemini-cli-auth/oauth.test.ts
Normal file
@ -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<typeof import("node:fs")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
|
||||||
|
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
|
||||||
|
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
|
||||||
|
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
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 { 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_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||||
const CLIENT_SECRET_KEYS = [
|
const CLIENT_SECRET_KEYS = [
|
||||||
@ -47,15 +48,98 @@ function resolveEnv(keys: string[]): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
|
||||||
const clientId = resolveEnv(CLIENT_ID_KEYS);
|
|
||||||
if (!clientId) {
|
/** @internal */
|
||||||
throw new Error(
|
export function clearCredentialsCache(): void {
|
||||||
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
|
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 null;
|
||||||
return { clientId, clientSecret };
|
}
|
||||||
|
|
||||||
|
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 {
|
function isWSL(): boolean {
|
||||||
|
|||||||
@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType =
|
const contentInfo =
|
||||||
"info" in content && content.info && "mimetype" in content.info
|
"info" in content && content.info && typeof content.info === "object"
|
||||||
? (content.info as { mimetype?: string }).mimetype
|
? (content.info as { mimetype?: string; size?: number })
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const contentType = contentInfo?.mimetype;
|
||||||
|
const contentSize =
|
||||||
|
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
||||||
if (mediaUrl?.startsWith("mxc://")) {
|
if (mediaUrl?.startsWith("mxc://")) {
|
||||||
try {
|
try {
|
||||||
media = await downloadMatrixMedia({
|
media = await downloadMatrixMedia({
|
||||||
client,
|
client,
|
||||||
mxcUrl: mediaUrl,
|
mxcUrl: mediaUrl,
|
||||||
contentType,
|
contentType,
|
||||||
|
sizeBytes: contentSize,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
file: contentFile,
|
file: contentFile,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => {
|
|||||||
|
|
||||||
it("decrypts encrypted media when file payloads are present", async () => {
|
it("decrypts encrypted media when file payloads are present", async () => {
|
||||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||||
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
|
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
downloadContent,
|
|
||||||
crypto: { decryptMedia },
|
crypto: { decryptMedia },
|
||||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||||
@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => {
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(decryptMedia).toHaveBeenCalled();
|
// decryptMedia should be called with just the file object (it handles download internally)
|
||||||
|
expect(decryptMedia).toHaveBeenCalledWith(file);
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||||
Buffer.from("decrypted"),
|
Buffer.from("decrypted"),
|
||||||
"image/png",
|
"image/png",
|
||||||
@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => {
|
|||||||
);
|
);
|
||||||
expect(result?.path).toBe("/tmp/media");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Download and decrypt encrypted media from a Matrix room.
|
* 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: {
|
async function fetchEncryptedMediaBuffer(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: {
|
|||||||
throw new Error("Cannot decrypt media: crypto not enabled");
|
throw new Error("Cannot decrypt media: crypto not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download the encrypted content
|
// decryptMedia handles downloading and decrypting the encrypted content internally
|
||||||
const encryptedBuffer = await params.client.downloadContent(params.file.url);
|
const decrypted = await params.client.crypto.decryptMedia(params.file);
|
||||||
if (encryptedBuffer.byteLength > params.maxBytes) {
|
|
||||||
|
if (decrypted.byteLength > params.maxBytes) {
|
||||||
throw new Error("Matrix media exceeds configured size limit");
|
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 };
|
return { buffer: decrypted };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: {
|
|||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
mxcUrl: string;
|
mxcUrl: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
file?: EncryptedFile;
|
file?: EncryptedFile;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@ -77,6 +74,12 @@ export async function downloadMatrixMedia(params: {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
let fetched: { buffer: Buffer; headerType?: 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) {
|
if (params.file) {
|
||||||
// Encrypted media
|
// Encrypted media
|
||||||
|
|||||||
@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & {
|
|||||||
file?: EncryptedFile;
|
file?: EncryptedFile;
|
||||||
info?: {
|
info?: {
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
|
size?: number;
|
||||||
};
|
};
|
||||||
"m.relates_to"?: {
|
"m.relates_to"?: {
|
||||||
rel_type?: string;
|
rel_type?: string;
|
||||||
|
|||||||
@ -54,6 +54,8 @@ export function createClawdbotTools(options?: {
|
|||||||
hasRepliedRef?: { value: boolean };
|
hasRepliedRef?: { value: boolean };
|
||||||
/** If true, the model has native vision capability */
|
/** If true, the model has native vision capability */
|
||||||
modelHasVision?: boolean;
|
modelHasVision?: boolean;
|
||||||
|
/** Explicit agent ID override for cron/hook sessions. */
|
||||||
|
requesterAgentIdOverride?: string;
|
||||||
}): AnyAgentTool[] {
|
}): AnyAgentTool[] {
|
||||||
const imageTool = options?.agentDir?.trim()
|
const imageTool = options?.agentDir?.trim()
|
||||||
? createImageTool({
|
? createImageTool({
|
||||||
@ -105,7 +107,10 @@ export function createClawdbotTools(options?: {
|
|||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
}),
|
}),
|
||||||
createAgentsListTool({ agentSessionKey: options?.agentSessionKey }),
|
createAgentsListTool({
|
||||||
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||||
|
}),
|
||||||
createSessionsListTool({
|
createSessionsListTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
sandboxed: options?.sandboxed,
|
sandboxed: options?.sandboxed,
|
||||||
@ -129,6 +134,7 @@ export function createClawdbotTools(options?: {
|
|||||||
agentGroupChannel: options?.agentGroupChannel,
|
agentGroupChannel: options?.agentGroupChannel,
|
||||||
agentGroupSpace: options?.agentGroupSpace,
|
agentGroupSpace: options?.agentGroupSpace,
|
||||||
sandboxed: options?.sandboxed,
|
sandboxed: options?.sandboxed,
|
||||||
|
requesterAgentIdOverride: options?.requesterAgentIdOverride,
|
||||||
}),
|
}),
|
||||||
createSessionStatusTool({
|
createSessionStatusTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
|||||||
@ -1,252 +1,139 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
|
||||||
import { DEFAULT_PROVIDER } from "./defaults.js";
|
|
||||||
import {
|
import {
|
||||||
buildAllowedModelSet,
|
|
||||||
modelKey,
|
|
||||||
parseModelRef,
|
parseModelRef,
|
||||||
resolveAllowedModelRef,
|
resolveModelRefFromString,
|
||||||
resolveHooksGmailModel,
|
resolveConfiguredModelRef,
|
||||||
|
buildModelAliasIndex,
|
||||||
|
normalizeProviderId,
|
||||||
|
modelKey,
|
||||||
} from "./model-selection.js";
|
} from "./model-selection.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
const catalog = [
|
describe("model-selection", () => {
|
||||||
{
|
describe("normalizeProviderId", () => {
|
||||||
provider: "openai",
|
it("should normalize provider names", () => {
|
||||||
id: "gpt-4",
|
expect(normalizeProviderId("Anthropic")).toBe("anthropic");
|
||||||
name: "GPT-4",
|
expect(normalizeProviderId("Z.ai")).toBe("zai");
|
||||||
},
|
expect(normalizeProviderId("z-ai")).toBe("zai");
|
||||||
];
|
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
|
||||||
|
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
|
||||||
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",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes google gemini 3 models to preview ids", () => {
|
describe("parseModelRef", () => {
|
||||||
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({
|
it("should parse full model refs", () => {
|
||||||
provider: "google",
|
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({
|
||||||
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({
|
|
||||||
provider: "anthropic",
|
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", () => {
|
describe("buildModelAliasIndex", () => {
|
||||||
const cfg = {
|
it("should build alias index from config", () => {
|
||||||
agents: {
|
const cfg: Partial<ClawdbotConfig> = {
|
||||||
defaults: {
|
agents: {
|
||||||
models: {
|
defaults: {
|
||||||
"openai/gpt-4": { alias: "GPT4" },
|
models: {
|
||||||
|
"anthropic/claude-3-5-sonnet": { alias: "fast" },
|
||||||
|
"openai/gpt-4o": { alias: "smart" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
} satisfies ClawdbotConfig;
|
|
||||||
const resolved = resolveAllowedModelRef({
|
const index = buildModelAliasIndex({
|
||||||
cfg,
|
cfg: cfg as ClawdbotConfig,
|
||||||
catalog: [
|
defaultProvider: "anthropic",
|
||||||
{ provider: "openai", id: "gpt-4", name: "GPT-4" },
|
});
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
|
|
||||||
],
|
expect(index.byAlias.get("fast")?.ref).toEqual({
|
||||||
raw: "anthropic/claude-sonnet-4-1",
|
provider: "anthropic",
|
||||||
defaultProvider: "openai",
|
model: "claude-3-5-sonnet",
|
||||||
defaultModel: "gpt-4",
|
});
|
||||||
|
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<ClawdbotConfig> = {
|
||||||
|
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<ClawdbotConfig> = {};
|
||||||
|
const result = resolveConfiguredModelRef({
|
||||||
|
cfg: cfg as ClawdbotConfig,
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModel: "gpt-4",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ provider: "openai", model: "gpt-4" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -131,14 +131,24 @@ export function resolveConfiguredModelRef(params: {
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
defaultProvider: params.defaultProvider,
|
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({
|
const resolved = resolveModelRefFromString({
|
||||||
raw: trimmed,
|
raw: trimmed,
|
||||||
defaultProvider: params.defaultProvider,
|
defaultProvider: params.defaultProvider,
|
||||||
aliasIndex,
|
aliasIndex,
|
||||||
});
|
});
|
||||||
if (resolved) return resolved.ref;
|
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 };
|
return { provider: params.defaultProvider, model: params.defaultModel };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -313,6 +313,7 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
replyToMode: options?.replyToMode,
|
replyToMode: options?.replyToMode,
|
||||||
hasRepliedRef: options?.hasRepliedRef,
|
hasRepliedRef: options?.hasRepliedRef,
|
||||||
modelHasVision: options?.modelHasVision,
|
modelHasVision: options?.modelHasVision,
|
||||||
|
requesterAgentIdOverride: agentId,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const coreToolNames = new Set(
|
const coreToolNames = new Set(
|
||||||
|
|||||||
@ -19,7 +19,11 @@ type AgentListEntry = {
|
|||||||
configured: boolean;
|
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 {
|
return {
|
||||||
label: "Agents",
|
label: "Agents",
|
||||||
name: "agents_list",
|
name: "agents_list",
|
||||||
@ -37,7 +41,9 @@ export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAg
|
|||||||
})
|
})
|
||||||
: alias;
|
: alias;
|
||||||
const requesterAgentId = normalizeAgentId(
|
const requesterAgentId = normalizeAgentId(
|
||||||
parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID,
|
opts?.requesterAgentIdOverride ??
|
||||||
|
parseAgentSessionKey(requesterInternalKey)?.agentId ??
|
||||||
|
DEFAULT_AGENT_ID,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
|
const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? [];
|
||||||
|
|||||||
@ -67,6 +67,8 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
agentGroupChannel?: string | null;
|
agentGroupChannel?: string | null;
|
||||||
agentGroupSpace?: string | null;
|
agentGroupSpace?: string | null;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
|
/** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */
|
||||||
|
requesterAgentIdOverride?: string;
|
||||||
}): AnyAgentTool {
|
}): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
label: "Sessions",
|
label: "Sessions",
|
||||||
@ -129,7 +131,7 @@ export function createSessionsSpawnTool(opts?: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const requesterAgentId = normalizeAgentId(
|
const requesterAgentId = normalizeAgentId(
|
||||||
parseAgentSessionKey(requesterInternalKey)?.agentId,
|
opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId,
|
||||||
);
|
);
|
||||||
const targetAgentId = requestedAgentId
|
const targetAgentId = requestedAgentId
|
||||||
? normalizeAgentId(requestedAgentId)
|
? normalizeAgentId(requestedAgentId)
|
||||||
|
|||||||
@ -310,10 +310,16 @@ describe("chunkTextWithMode", () => {
|
|||||||
expect(chunks).toEqual(["Line one\nLine two"]);
|
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 text = "Line one\nLine two";
|
||||||
const chunks = chunkTextWithMode(text, 1000, "newline");
|
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));
|
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";
|
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";
|
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
|
||||||
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([
|
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]);
|
||||||
"```js\nconst a = 1;\nconst b = 2;\n```",
|
});
|
||||||
"After",
|
|
||||||
]);
|
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"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,9 @@ export type TextChunkProvider = ChannelId | typeof INTERNAL_MESSAGE_CHANNEL;
|
|||||||
/**
|
/**
|
||||||
* Chunking mode for outbound messages:
|
* Chunking mode for outbound messages:
|
||||||
* - "length": Split only when exceeding textChunkLimit (default)
|
* - "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";
|
export type ChunkMode = "length" | "newline";
|
||||||
|
|
||||||
@ -164,44 +166,93 @@ export function chunkByNewline(
|
|||||||
return chunks;
|
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.
|
* Unified chunking function that dispatches based on mode.
|
||||||
*/
|
*/
|
||||||
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||||
if (mode === "newline") {
|
if (mode === "newline") {
|
||||||
const chunks: string[] = [];
|
return chunkByParagraph(text, limit);
|
||||||
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 chunkText(text, limit);
|
return chunkText(text, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chunkMarkdownTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
export function chunkMarkdownTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
|
||||||
if (mode === "newline") {
|
if (mode === "newline") {
|
||||||
const spans = parseFenceSpans(text);
|
// Paragraph chunking is fence-safe because we never split at arbitrary indices.
|
||||||
const chunks: string[] = [];
|
// If a paragraph must be split by length, defer to the markdown-aware chunker.
|
||||||
const lineChunks = chunkByNewline(text, limit, {
|
const paragraphChunks = chunkByParagraph(text, limit, { splitLongParagraphs: false });
|
||||||
splitLongLines: false,
|
const out: string[] = [];
|
||||||
trimLines: false,
|
for (const chunk of paragraphChunks) {
|
||||||
isSafeBreak: (index) => isSafeFenceBreak(spans, index),
|
const nested = chunkMarkdownText(chunk, limit);
|
||||||
});
|
if (!nested.length && chunk) out.push(chunk);
|
||||||
for (const line of lineChunks) {
|
else out.push(...nested);
|
||||||
const nested = chunkMarkdownText(line, limit);
|
|
||||||
if (!nested.length && line) {
|
|
||||||
chunks.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
chunks.push(...nested);
|
|
||||||
}
|
}
|
||||||
return chunks;
|
return out;
|
||||||
}
|
}
|
||||||
return chunkMarkdownText(text, limit);
|
return chunkMarkdownText(text, limit);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,8 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
|
|||||||
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
// 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 #)
|
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||||
if (/^#+(\s|$)/.test(trimmed)) continue;
|
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
|
// Found a non-empty, non-comment line - there's actionable content
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
INTERNAL_MESSAGE_CHANNEL,
|
INTERNAL_MESSAGE_CHANNEL,
|
||||||
listDeliverableMessageChannels,
|
listDeliverableMessageChannels,
|
||||||
} from "../../utils/message-channel.js";
|
} 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_MIN = 800;
|
||||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||||
@ -69,15 +69,11 @@ export function resolveBlockStreamingChunking(
|
|||||||
});
|
});
|
||||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||||
|
|
||||||
// When chunkMode is "newline", use newline-based streaming
|
// Note: chunkMode="newline" used to imply splitting on each newline, but outbound
|
||||||
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
// delivery now treats it as paragraph-aware chunking (only split on blank lines).
|
||||||
if (channelChunkMode === "newline") {
|
// Block streaming should follow the same rule, so we do NOT special-case newline
|
||||||
// For newline mode: use very low minChars to flush quickly on newlines
|
// mode here.
|
||||||
const minChars = Math.max(1, Math.floor(chunkCfg?.minChars ?? 1));
|
// (chunkMode no longer alters block streaming behavior)
|
||||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? textLimit));
|
|
||||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
|
||||||
return { minChars, maxChars, breakPreference: "newline" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
||||||
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
|
||||||
@ -103,11 +99,8 @@ export function resolveBlockStreamingCoalescing(
|
|||||||
): BlockStreamingCoalescing | undefined {
|
): BlockStreamingCoalescing | undefined {
|
||||||
const providerKey = normalizeChunkProvider(provider);
|
const providerKey = normalizeChunkProvider(provider);
|
||||||
|
|
||||||
// When chunkMode is "newline", disable coalescing to send each line immediately
|
// Note: chunkMode="newline" is paragraph-aware in outbound delivery (blank-line splits),
|
||||||
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
// so block streaming should not disable coalescing or flush per single newline.
|
||||||
if (channelChunkMode === "newline") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
|
const providerId = providerKey ? normalizeChannelId(providerKey) : null;
|
||||||
const providerChunkLimit = providerId
|
const providerChunkLimit = providerId
|
||||||
|
|||||||
@ -58,7 +58,7 @@ describe("chunkDiscordText", () => {
|
|||||||
maxLines: 50,
|
maxLines: 50,
|
||||||
chunkMode: "newline",
|
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", () => {
|
it("reserves space for closing fences when chunking", () => {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
|||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
||||||
|
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||||
import {
|
import {
|
||||||
connectReq,
|
connectReq,
|
||||||
getFreePort,
|
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<void>((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 () => {
|
test("accepts device token auth for paired device", async () => {
|
||||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
||||||
|
|||||||
@ -318,13 +318,13 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
let devicePublicKey: string | null = null;
|
let devicePublicKey: string | null = null;
|
||||||
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
||||||
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
||||||
|
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
|
||||||
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||||
|
const allowInsecureControlUi =
|
||||||
|
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
const allowInsecureControlUi =
|
const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth;
|
||||||
isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true;
|
|
||||||
const canSkipDevice =
|
|
||||||
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
|
|
||||||
|
|
||||||
if (isControlUi && !allowInsecureControlUi) {
|
if (isControlUi && !allowInsecureControlUi) {
|
||||||
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
||||||
@ -569,7 +569,8 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (device && devicePublicKey) {
|
const skipPairing = allowInsecureControlUi && hasSharedAuth;
|
||||||
|
if (device && devicePublicKey && !skipPairing) {
|
||||||
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
|
||||||
const pairing = await requestDevicePairing({
|
const pairing = await requestDevicePairing({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
|
|||||||
40
src/infra/node-shell.test.ts
Normal file
40
src/infra/node-shell.test.ts
Normal file
@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,7 +2,7 @@ export function buildNodeShellCommand(command: string, platform?: string | null)
|
|||||||
const normalized = String(platform ?? "")
|
const normalized = String(platform ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (normalized.includes("win")) {
|
if (normalized.startsWith("win")) {
|
||||||
return ["cmd.exe", "/d", "/s", "/c", command];
|
return ["cmd.exe", "/d", "/s", "/c", command];
|
||||||
}
|
}
|
||||||
return ["/bin/sh", "-lc", command];
|
return ["/bin/sh", "-lc", command];
|
||||||
|
|||||||
@ -192,7 +192,7 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
expect(sendWhatsApp).toHaveBeenNthCalledWith(
|
expect(sendWhatsApp).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
"+1555",
|
"+1555",
|
||||||
"\nLine two",
|
"Line two",
|
||||||
expect.objectContaining({ verbose: false }),
|
expect.objectContaining({ verbose: false }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -241,9 +241,8 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
payloads: [{ text }],
|
payloads: [{ text }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(chunker).toHaveBeenCalledTimes(2);
|
expect(chunker).toHaveBeenCalledTimes(1);
|
||||||
expect(chunker).toHaveBeenNthCalledWith(1, "```js\nconst a = 1;\nconst b = 2;\n```", 4000);
|
expect(chunker).toHaveBeenNthCalledWith(1, text, 4000);
|
||||||
expect(chunker).toHaveBeenNthCalledWith(2, "After", 4000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses iMessage media maxBytes from agent fallback", async () => {
|
it("uses iMessage media maxBytes from agent fallback", async () => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
chunkByNewline,
|
chunkByParagraph,
|
||||||
chunkMarkdownTextWithMode,
|
chunkMarkdownTextWithMode,
|
||||||
resolveChunkMode,
|
resolveChunkMode,
|
||||||
resolveTextChunkLimit,
|
resolveTextChunkLimit,
|
||||||
@ -239,14 +239,15 @@ export async function deliverOutboundPayloads(params: {
|
|||||||
}
|
}
|
||||||
if (chunkMode === "newline") {
|
if (chunkMode === "newline") {
|
||||||
const mode = handler.chunkerMode ?? "text";
|
const mode = handler.chunkerMode ?? "text";
|
||||||
const lineChunks =
|
const blockChunks =
|
||||||
mode === "markdown"
|
mode === "markdown"
|
||||||
? chunkMarkdownTextWithMode(text, textLimit, "newline")
|
? chunkMarkdownTextWithMode(text, textLimit, "newline")
|
||||||
: chunkByNewline(text, textLimit, { splitLongLines: false });
|
: chunkByParagraph(text, textLimit);
|
||||||
if (!lineChunks.length && text) lineChunks.push(text);
|
|
||||||
for (const lineChunk of lineChunks) {
|
if (!blockChunks.length && text) blockChunks.push(text);
|
||||||
const chunks = handler.chunker(lineChunk, textLimit);
|
for (const blockChunk of blockChunks) {
|
||||||
if (!chunks.length && lineChunk) chunks.push(lineChunk);
|
const chunks = handler.chunker(blockChunk, textLimit);
|
||||||
|
if (!chunks.length && blockChunk) chunks.push(blockChunk);
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
results.push(await handler.sendText(chunk));
|
results.push(await handler.sendText(chunk));
|
||||||
|
|||||||
@ -17,6 +17,9 @@ vi.mock("grammy", () => ({
|
|||||||
public fileName?: string,
|
public fileName?: string,
|
||||||
) {}
|
) {}
|
||||||
},
|
},
|
||||||
|
GrammyError: class GrammyError extends Error {
|
||||||
|
description = "";
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("deliverReplies", () => {
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { type Bot, InputFile } from "grammy";
|
import { type Bot, GrammyError, InputFile } from "grammy";
|
||||||
import {
|
import {
|
||||||
markdownToTelegramChunks,
|
markdownToTelegramChunks,
|
||||||
markdownToTelegramHtml,
|
markdownToTelegramHtml,
|
||||||
@ -22,6 +22,7 @@ import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js"
|
|||||||
import type { TelegramContext } from "./types.js";
|
import type { TelegramContext } from "./types.js";
|
||||||
|
|
||||||
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
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: {
|
export async function deliverReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
@ -155,9 +156,39 @@ export async function deliverReplies(params: {
|
|||||||
// Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]])
|
// Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]])
|
||||||
// Switch typing indicator to record_voice before sending.
|
// Switch typing indicator to record_voice before sending.
|
||||||
await params.onVoiceRecording?.();
|
await params.onVoiceRecording?.();
|
||||||
await bot.api.sendVoice(chatId, file, {
|
try {
|
||||||
...mediaParams,
|
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 {
|
} else {
|
||||||
// Audio file - displays with metadata (title, duration) - DEFAULT
|
// Audio file - displays with metadata (title, duration) - DEFAULT
|
||||||
await bot.api.sendAudio(chatId, file, {
|
await bot.api.sendAudio(chatId, file, {
|
||||||
@ -228,6 +259,43 @@ export async function resolveMedia(
|
|||||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
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<typeof markdownToTelegramChunks>;
|
||||||
|
replyToId?: number;
|
||||||
|
replyToMode: ReplyToMode;
|
||||||
|
hasReplied: boolean;
|
||||||
|
messageThreadId?: number;
|
||||||
|
linkPreview?: boolean;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
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?: {
|
function buildTelegramSendParams(opts?: {
|
||||||
replyToMessageId?: number;
|
replyToMessageId?: number;
|
||||||
messageThreadId?: number;
|
messageThreadId?: number;
|
||||||
|
|||||||
@ -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 {
|
:root {
|
||||||
/* Background - Deep Navy Slate */
|
/* Background - Warmer dark with depth */
|
||||||
--bg: #0c0d12;
|
--bg: #12141a;
|
||||||
--bg-accent: #0d0e14;
|
--bg-accent: #14161d;
|
||||||
--bg-elevated: #181a21;
|
--bg-elevated: #1a1d25;
|
||||||
--bg-hover: #252830;
|
--bg-hover: #262a35;
|
||||||
--bg-muted: #252830;
|
--bg-muted: #262a35;
|
||||||
|
|
||||||
/* Card / Surface */
|
/* Card / Surface - More contrast between levels */
|
||||||
--card: #13151c;
|
--card: #181b22;
|
||||||
--card-foreground: #f8fafc;
|
--card-foreground: #f4f4f5;
|
||||||
--card-highlight: rgba(255, 255, 255, 0.04);
|
--card-highlight: rgba(255, 255, 255, 0.05);
|
||||||
--popover: #13151c;
|
--popover: #181b22;
|
||||||
--popover-foreground: #f8fafc;
|
--popover-foreground: #f4f4f5;
|
||||||
|
|
||||||
/* Panel */
|
/* Panel */
|
||||||
--panel: #0c0d12;
|
--panel: #12141a;
|
||||||
--panel-strong: #181a21;
|
--panel-strong: #1a1d25;
|
||||||
--panel-hover: #252830;
|
--panel-hover: #262a35;
|
||||||
--chrome: rgba(12, 13, 18, 0.95);
|
--chrome: rgba(18, 20, 26, 0.95);
|
||||||
--chrome-strong: rgba(12, 13, 18, 0.98);
|
--chrome-strong: rgba(18, 20, 26, 0.98);
|
||||||
|
|
||||||
/* Text */
|
/* Text - Slightly warmer */
|
||||||
--text: #f8fafc;
|
--text: #e4e4e7;
|
||||||
--text-strong: #ffffff;
|
--text-strong: #fafafa;
|
||||||
--chat-text: #f8fafc;
|
--chat-text: #e4e4e7;
|
||||||
--muted: #94a3b8;
|
--muted: #71717a;
|
||||||
--muted-strong: #64748b;
|
--muted-strong: #52525b;
|
||||||
--muted-foreground: #94a3b8;
|
--muted-foreground: #71717a;
|
||||||
|
|
||||||
/* Border */
|
/* Border - Subtle but defined */
|
||||||
--border: #333842;
|
--border: #27272a;
|
||||||
--border-strong: #454d5c;
|
--border-strong: #3f3f46;
|
||||||
--border-hover: #5a6373;
|
--border-hover: #52525b;
|
||||||
--input: #333842;
|
--input: #27272a;
|
||||||
--ring: #ff4d4d;
|
--ring: #ff5c5c;
|
||||||
|
|
||||||
/* Accent - The signature red */
|
/* Accent - Punchy signature red */
|
||||||
--accent: #ff4d4d;
|
--accent: #ff5c5c;
|
||||||
--accent-hover: #ff6666;
|
--accent-hover: #ff7070;
|
||||||
--accent-muted: #ff4d4d;
|
--accent-muted: #ff5c5c;
|
||||||
--accent-subtle: rgba(255, 77, 77, 0.12);
|
--accent-subtle: rgba(255, 92, 92, 0.15);
|
||||||
--accent-foreground: #f8fafc;
|
--accent-foreground: #fafafa;
|
||||||
--primary: #ff4d4d;
|
--accent-glow: rgba(255, 92, 92, 0.25);
|
||||||
|
--primary: #ff5c5c;
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
/* Secondary */
|
/* Secondary - Teal accent for variety */
|
||||||
--secondary: #252830;
|
--secondary: #1e2028;
|
||||||
--secondary-foreground: #f8fafc;
|
--secondary-foreground: #f4f4f5;
|
||||||
--accent-2: #3b82f6;
|
--accent-2: #14b8a6;
|
||||||
--accent-2-muted: rgba(59, 130, 246, 0.7);
|
--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: #22c55e;
|
||||||
--ok-muted: rgba(34, 197, 94, 0.7);
|
--ok-muted: rgba(34, 197, 94, 0.75);
|
||||||
--ok-subtle: rgba(34, 197, 94, 0.1);
|
--ok-subtle: rgba(34, 197, 94, 0.12);
|
||||||
--destructive: #ef4444;
|
--destructive: #ef4444;
|
||||||
--destructive-foreground: #fafafa;
|
--destructive-foreground: #fafafa;
|
||||||
--warn: #eab308;
|
--warn: #f59e0b;
|
||||||
--warn-muted: rgba(234, 179, 8, 0.7);
|
--warn-muted: rgba(245, 158, 11, 0.75);
|
||||||
--warn-subtle: rgba(234, 179, 8, 0.1);
|
--warn-subtle: rgba(245, 158, 11, 0.12);
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--danger-muted: rgba(239, 68, 68, 0.7);
|
--danger-muted: rgba(239, 68, 68, 0.75);
|
||||||
--danger-subtle: rgba(239, 68, 68, 0.1);
|
--danger-subtle: rgba(239, 68, 68, 0.12);
|
||||||
--info: #3b82f6;
|
--info: #3b82f6;
|
||||||
|
|
||||||
/* Focus */
|
/* Focus - With glow */
|
||||||
--focus: rgba(255, 77, 77, 0.2);
|
--focus: rgba(255, 92, 92, 0.25);
|
||||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
|
--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 */
|
||||||
--grid-line: rgba(255, 255, 255, 0.03);
|
--grid-line: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
/* Theme transition */
|
/* Theme transition */
|
||||||
--theme-switch-x: 50%;
|
--theme-switch-x: 50%;
|
||||||
--theme-switch-y: 50%;
|
--theme-switch-y: 50%;
|
||||||
|
|
||||||
/* Typography */
|
/* Typography - Space Grotesk for personality */
|
||||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
--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-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
|
||||||
/* Shadows - minimal */
|
/* Shadows - Richer with subtle color */
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
--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 */
|
/* Radii - Slightly larger for friendlier feel */
|
||||||
--radius-sm: 4px;
|
--radius-sm: 6px;
|
||||||
--radius-md: 6px;
|
--radius-md: 8px;
|
||||||
--radius-lg: 8px;
|
--radius-lg: 12px;
|
||||||
--radius-xl: 12px;
|
--radius-xl: 16px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
--radius: 6px;
|
--radius: 8px;
|
||||||
|
|
||||||
/* Transitions */
|
/* Transitions - Snappy but smooth */
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 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-normal: 200ms;
|
||||||
--duration-slow: 300ms;
|
--duration-slow: 350ms;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme */
|
/* Light theme - Clean with subtle warmth */
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
--bg: #f8f8f7;
|
--bg: #fafafa;
|
||||||
--bg-accent: #f3f2f0;
|
--bg-accent: #f5f5f5;
|
||||||
--bg-elevated: #ffffff;
|
--bg-elevated: #ffffff;
|
||||||
--bg-hover: #eae8e6;
|
--bg-hover: #f0f0f0;
|
||||||
--bg-muted: #eae8e6;
|
--bg-muted: #f0f0f0;
|
||||||
--bg-content: #f0efed;
|
--bg-content: #f5f5f5;
|
||||||
|
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--card-foreground: #1c1917;
|
--card-foreground: #18181b;
|
||||||
--card-highlight: rgba(0, 0, 0, 0.04);
|
--card-highlight: rgba(0, 0, 0, 0.03);
|
||||||
--popover: #ffffff;
|
--popover: #ffffff;
|
||||||
--popover-foreground: #1c1917;
|
--popover-foreground: #18181b;
|
||||||
|
|
||||||
--panel: #f8f8f7;
|
--panel: #fafafa;
|
||||||
--panel-strong: #f0efed;
|
--panel-strong: #f5f5f5;
|
||||||
--panel-hover: #e5e3e1;
|
--panel-hover: #ebebeb;
|
||||||
--chrome: rgba(248, 248, 247, 0.95);
|
--chrome: rgba(250, 250, 250, 0.95);
|
||||||
--chrome-strong: rgba(248, 248, 247, 0.98);
|
--chrome-strong: rgba(250, 250, 250, 0.98);
|
||||||
|
|
||||||
--text: #44403c;
|
--text: #3f3f46;
|
||||||
--text-strong: #292524;
|
--text-strong: #18181b;
|
||||||
--chat-text: #44403c;
|
--chat-text: #3f3f46;
|
||||||
--muted: #5c5856;
|
--muted: #71717a;
|
||||||
--muted-strong: #44403c;
|
--muted-strong: #52525b;
|
||||||
--muted-foreground: #5c5856;
|
--muted-foreground: #71717a;
|
||||||
|
|
||||||
--border: #e0dedc;
|
--border: #e4e4e7;
|
||||||
--border-strong: #d6d3d1;
|
--border-strong: #d4d4d8;
|
||||||
--border-hover: #a8a5a0;
|
--border-hover: #a1a1aa;
|
||||||
--input: #e0dedc;
|
--input: #e4e4e7;
|
||||||
|
|
||||||
--accent: #b91c1c;
|
--accent: #dc2626;
|
||||||
--accent-hover: #dc2626;
|
--accent-hover: #ef4444;
|
||||||
--accent-muted: #b91c1c;
|
--accent-muted: #dc2626;
|
||||||
--accent-subtle: rgba(185, 28, 28, 0.18);
|
--accent-subtle: rgba(220, 38, 38, 0.12);
|
||||||
--accent-foreground: #ffffff;
|
--accent-foreground: #ffffff;
|
||||||
--primary: #b91c1c;
|
--accent-glow: rgba(220, 38, 38, 0.15);
|
||||||
|
--primary: #dc2626;
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
--secondary: #eae8e6;
|
--secondary: #f4f4f5;
|
||||||
--secondary-foreground: #44403c;
|
--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: #16a34a;
|
||||||
--ok-muted: rgba(21, 128, 61, 0.75);
|
--ok-muted: rgba(22, 163, 74, 0.75);
|
||||||
--ok-subtle: rgba(21, 128, 61, 0.12);
|
--ok-subtle: rgba(22, 163, 74, 0.1);
|
||||||
--destructive: #b91c1c;
|
--destructive: #dc2626;
|
||||||
--destructive-foreground: #fafafa;
|
--destructive-foreground: #fafafa;
|
||||||
--warn: #a16207;
|
--warn: #d97706;
|
||||||
--warn-muted: rgba(161, 98, 7, 0.75);
|
--warn-muted: rgba(217, 119, 6, 0.75);
|
||||||
--warn-subtle: rgba(161, 98, 7, 0.12);
|
--warn-subtle: rgba(217, 119, 6, 0.1);
|
||||||
--danger: #b91c1c;
|
--danger: #dc2626;
|
||||||
--danger-muted: rgba(185, 28, 28, 0.75);
|
--danger-muted: rgba(220, 38, 38, 0.75);
|
||||||
--danger-subtle: rgba(185, 28, 28, 0.12);
|
--danger-subtle: rgba(220, 38, 38, 0.1);
|
||||||
--info: #1d4ed8;
|
--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;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@ -181,8 +198,8 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 400 14px/1.5 var(--font-body);
|
font: 400 14px/1.55 var(--font-body);
|
||||||
letter-spacing: -0.011em;
|
letter-spacing: -0.02em;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@ -270,11 +287,11 @@ select {
|
|||||||
background: var(--border-strong);
|
background: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations - Polished with spring feel */
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(4px);
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -294,7 +311,7 @@ select {
|
|||||||
@keyframes scale-in {
|
@keyframes scale-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.96);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -305,7 +322,7 @@ select {
|
|||||||
@keyframes dashboard-enter {
|
@keyframes dashboard-enter {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(12px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
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 styles */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@ -1,63 +1,75 @@
|
|||||||
@import './chat.css';
|
@import './chat.css';
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Cards
|
Cards - Refined with depth
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 16px;
|
padding: 20px;
|
||||||
animation: rise 0.3s var(--ease-out);
|
animation: rise 0.35s var(--ease-out) backwards;
|
||||||
transition: border-color var(--duration-fast) ease;
|
transition:
|
||||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
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 {
|
.card:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-md), inset 0 1px 0 var(--card-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-sub {
|
.card-sub {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-top: 4px;
|
margin-top: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Stats
|
Stats - Bold values, subtle labels
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--border);
|
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);
|
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat:hover {
|
.stat:hover {
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 20px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
margin-top: 4px;
|
margin-top: 6px;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.ok {
|
.stat-value.ok {
|
||||||
@ -70,11 +82,12 @@
|
|||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-title {
|
.note-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
@ -262,7 +275,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Status Dot
|
Status Dot - With glow for emphasis
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
.statusDot {
|
.statusDot {
|
||||||
@ -270,14 +283,18 @@
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||||
|
animation: pulse-subtle 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusDot.ok {
|
.statusDot.ok {
|
||||||
background: var(--ok);
|
background: var(--ok);
|
||||||
|
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Buttons
|
Buttons - Tactile with personality
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@ -287,23 +304,30 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
padding: 8px 14px;
|
padding: 9px 16px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
border-color var(--duration-fast) ease,
|
border-color var(--duration-fast) var(--ease-out),
|
||||||
background var(--duration-fast) ease;
|
background var(--duration-fast) var(--ease-out),
|
||||||
|
box-shadow var(--duration-fast) var(--ease-out),
|
||||||
|
transform var(--duration-fast) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active {
|
.btn:active {
|
||||||
background: var(--secondary);
|
background: var(--secondary);
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn svg {
|
.btn svg {
|
||||||
@ -321,11 +345,13 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--primary-foreground);
|
color: var(--primary-foreground);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary:hover {
|
.btn.primary:hover {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
border-color: var(--accent-hover);
|
border-color: var(--accent-hover);
|
||||||
|
box-shadow: var(--shadow-md), 0 0 20px var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard shortcut badge (shadcn style) */
|
/* Keyboard shortcut badge (shadcn style) */
|
||||||
@ -497,32 +523,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Callouts
|
Callouts - Informative with subtle depth
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
.callout {
|
.callout {
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--secondary);
|
background: var(--secondary);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.callout.danger {
|
.callout.danger {
|
||||||
border-color: var(--danger-subtle);
|
border-color: rgba(239, 68, 68, 0.25);
|
||||||
background: var(--danger-subtle);
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(239, 68, 68, 0.04) 100%);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.callout.info {
|
.callout.info {
|
||||||
border-color: rgba(59, 130, 246, 0.2);
|
border-color: rgba(59, 130, 246, 0.25);
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%);
|
||||||
color: var(--info);
|
color: var(--info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.callout.success {
|
.callout.success {
|
||||||
border-color: var(--ok-subtle);
|
border-color: rgba(34, 197, 94, 0.25);
|
||||||
background: var(--ok-subtle);
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08) 0%, rgba(34, 197, 94, 0.04) 100%);
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -565,6 +593,7 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .code-block,
|
:root[data-theme="light"] .code-block,
|
||||||
@ -655,13 +684,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Chips
|
Chips - Compact and punchy
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
.chip-row {
|
.chip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
@ -669,10 +698,18 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
padding: 4px 10px;
|
padding: 5px 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: var(--secondary);
|
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 {
|
.chip input {
|
||||||
@ -681,12 +718,14 @@
|
|||||||
|
|
||||||
.chip-ok {
|
.chip-ok {
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
border-color: var(--ok-subtle);
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
background: var(--ok-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-warn {
|
.chip-warn {
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
border-color: var(--warn-subtle);
|
border-color: rgba(245, 158, 11, 0.3);
|
||||||
|
background: var(--warn-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px minmax(0, 1fr);
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
min-height: calc(100vh - 160px);
|
height: calc(100vh - 160px);
|
||||||
margin: -16px;
|
margin: -16px;
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -24,6 +24,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-accent);
|
background: var(--bg-accent);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .config-sidebar {
|
:root[data-theme="light"] .config-sidebar {
|
||||||
@ -245,7 +247,7 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions Bar */
|
/* Actions Bar */
|
||||||
@ -413,6 +415,9 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-section-hero__desc {
|
.config-section-hero__desc {
|
||||||
@ -586,6 +591,9 @@
|
|||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-section-card__desc {
|
.config-section-card__desc {
|
||||||
|
|||||||
@ -132,18 +132,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.03em;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-sub {
|
.brand-sub {
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -195,8 +196,8 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
transition:
|
transition:
|
||||||
width var(--shell-focus-duration) var(--shell-focus-ease),
|
width var(--shell-focus-duration) var(--shell-focus-ease),
|
||||||
padding var(--shell-focus-duration) var(--shell-focus-ease),
|
padding var(--shell-focus-duration) var(--shell-focus-ease),
|
||||||
@ -204,6 +205,10 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari */
|
||||||
|
}
|
||||||
|
|
||||||
.shell--chat-focus .nav {
|
.shell--chat-focus .nav {
|
||||||
width: 0;
|
width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@ -412,10 +417,10 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
padding: 8px 8px 24px;
|
padding: 12px 16px 32px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 24px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -456,18 +461,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 24px;
|
font-size: 26px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.035em;
|
||||||
line-height: 1.2;
|
line-height: 1.15;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-sub {
|
.page-sub {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin-top: 4px;
|
margin-top: 6px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-meta {
|
.page-meta {
|
||||||
@ -501,7 +507,7 @@
|
|||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-cols-2 {
|
.grid-cols-2 {
|
||||||
@ -514,25 +520,26 @@
|
|||||||
|
|
||||||
.stat-grid {
|
.stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-grid {
|
.note-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user