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