From 2fcbed21119d9aed6bde6ed79ace6262a9701161 Mon Sep 17 00:00:00 2001 From: David Gelberg <57605064+mousberg@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:29:25 +0000 Subject: [PATCH 01/24] UI: refresh dashboard design system (#1786) * UI: refresh dashboard design system - Typography: swap Inter for Space Grotesk (geometric, techy) - Colors: punchier accent red, add teal secondary, warmer darks - Cards: better shadows, hover lift effect, increased padding - Stats: uppercase labels, larger bold values - Buttons: hover lift micro-interaction, glow on primary - Status dots: glow effects and subtle pulse animation - Callouts: gradient backgrounds for depth - Navigation: active state accent bar indicator - Layout: more breathing room, bolder page titles * UI: remove nav active bar indicator * UI: hide nav scrollbar, remove nav border * fix: add changelog entry for dashboard refresh (#1786) (thanks @mousberg) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + ui/src/styles/base.css | 285 ++++++++++++++++++++--------------- ui/src/styles/components.css | 108 ++++++++----- ui/src/styles/layout.css | 46 +++--- 4 files changed, 264 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0b9f221..d52378f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot - 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. +- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg. - 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. diff --git a/ui/src/styles/base.css b/ui/src/styles/base.css index 82b7b0ffb..7baab70a2 100644 --- a/ui/src/styles/base.css +++ b/ui/src/styles/base.css @@ -1,171 +1,188 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"); :root { - /* Background - Deep Navy Slate */ - --bg: #0c0d12; - --bg-accent: #0d0e14; - --bg-elevated: #181a21; - --bg-hover: #252830; - --bg-muted: #252830; + /* Background - Warmer dark with depth */ + --bg: #12141a; + --bg-accent: #14161d; + --bg-elevated: #1a1d25; + --bg-hover: #262a35; + --bg-muted: #262a35; - /* Card / Surface */ - --card: #13151c; - --card-foreground: #f8fafc; - --card-highlight: rgba(255, 255, 255, 0.04); - --popover: #13151c; - --popover-foreground: #f8fafc; + /* Card / Surface - More contrast between levels */ + --card: #181b22; + --card-foreground: #f4f4f5; + --card-highlight: rgba(255, 255, 255, 0.05); + --popover: #181b22; + --popover-foreground: #f4f4f5; /* Panel */ - --panel: #0c0d12; - --panel-strong: #181a21; - --panel-hover: #252830; - --chrome: rgba(12, 13, 18, 0.95); - --chrome-strong: rgba(12, 13, 18, 0.98); + --panel: #12141a; + --panel-strong: #1a1d25; + --panel-hover: #262a35; + --chrome: rgba(18, 20, 26, 0.95); + --chrome-strong: rgba(18, 20, 26, 0.98); - /* Text */ - --text: #f8fafc; - --text-strong: #ffffff; - --chat-text: #f8fafc; - --muted: #94a3b8; - --muted-strong: #64748b; - --muted-foreground: #94a3b8; + /* Text - Slightly warmer */ + --text: #e4e4e7; + --text-strong: #fafafa; + --chat-text: #e4e4e7; + --muted: #71717a; + --muted-strong: #52525b; + --muted-foreground: #71717a; - /* Border */ - --border: #333842; - --border-strong: #454d5c; - --border-hover: #5a6373; - --input: #333842; - --ring: #ff4d4d; + /* Border - Subtle but defined */ + --border: #27272a; + --border-strong: #3f3f46; + --border-hover: #52525b; + --input: #27272a; + --ring: #ff5c5c; - /* Accent - The signature red */ - --accent: #ff4d4d; - --accent-hover: #ff6666; - --accent-muted: #ff4d4d; - --accent-subtle: rgba(255, 77, 77, 0.12); - --accent-foreground: #f8fafc; - --primary: #ff4d4d; + /* Accent - Punchy signature red */ + --accent: #ff5c5c; + --accent-hover: #ff7070; + --accent-muted: #ff5c5c; + --accent-subtle: rgba(255, 92, 92, 0.15); + --accent-foreground: #fafafa; + --accent-glow: rgba(255, 92, 92, 0.25); + --primary: #ff5c5c; --primary-foreground: #ffffff; - /* Secondary */ - --secondary: #252830; - --secondary-foreground: #f8fafc; - --accent-2: #3b82f6; - --accent-2-muted: rgba(59, 130, 246, 0.7); + /* Secondary - Teal accent for variety */ + --secondary: #1e2028; + --secondary-foreground: #f4f4f5; + --accent-2: #14b8a6; + --accent-2-muted: rgba(20, 184, 166, 0.7); + --accent-2-subtle: rgba(20, 184, 166, 0.15); - /* Semantic */ + /* Semantic - More saturated */ --ok: #22c55e; - --ok-muted: rgba(34, 197, 94, 0.7); - --ok-subtle: rgba(34, 197, 94, 0.1); + --ok-muted: rgba(34, 197, 94, 0.75); + --ok-subtle: rgba(34, 197, 94, 0.12); --destructive: #ef4444; --destructive-foreground: #fafafa; - --warn: #eab308; - --warn-muted: rgba(234, 179, 8, 0.7); - --warn-subtle: rgba(234, 179, 8, 0.1); + --warn: #f59e0b; + --warn-muted: rgba(245, 158, 11, 0.75); + --warn-subtle: rgba(245, 158, 11, 0.12); --danger: #ef4444; - --danger-muted: rgba(239, 68, 68, 0.7); - --danger-subtle: rgba(239, 68, 68, 0.1); + --danger-muted: rgba(239, 68, 68, 0.75); + --danger-subtle: rgba(239, 68, 68, 0.12); --info: #3b82f6; - /* Focus */ - --focus: rgba(255, 77, 77, 0.2); + /* Focus - With glow */ + --focus: rgba(255, 92, 92, 0.25); --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow); /* Grid */ - --grid-line: rgba(255, 255, 255, 0.03); + --grid-line: rgba(255, 255, 255, 0.04); /* Theme transition */ --theme-switch-x: 50%; --theme-switch-y: 50%; - /* Typography */ + /* Typography - Space Grotesk for personality */ --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; - --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - /* Shadows - minimal */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + /* Shadows - Richer with subtle color */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); + --shadow-glow: 0 0 30px var(--accent-glow); - /* Radii - shadcn uses smaller radii */ - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - --radius-xl: 12px; + /* Radii - Slightly larger for friendlier feel */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; --radius-full: 9999px; - --radius: 6px; + --radius: 8px; - /* Transitions */ + /* Transitions - Snappy but smooth */ --ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); - --duration-fast: 150ms; + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --duration-fast: 120ms; --duration-normal: 200ms; - --duration-slow: 300ms; + --duration-slow: 350ms; color-scheme: dark; } -/* Light theme */ +/* Light theme - Clean with subtle warmth */ :root[data-theme="light"] { - --bg: #f8f8f7; - --bg-accent: #f3f2f0; + --bg: #fafafa; + --bg-accent: #f5f5f5; --bg-elevated: #ffffff; - --bg-hover: #eae8e6; - --bg-muted: #eae8e6; - --bg-content: #f0efed; + --bg-hover: #f0f0f0; + --bg-muted: #f0f0f0; + --bg-content: #f5f5f5; --card: #ffffff; - --card-foreground: #1c1917; - --card-highlight: rgba(0, 0, 0, 0.04); + --card-foreground: #18181b; + --card-highlight: rgba(0, 0, 0, 0.03); --popover: #ffffff; - --popover-foreground: #1c1917; + --popover-foreground: #18181b; - --panel: #f8f8f7; - --panel-strong: #f0efed; - --panel-hover: #e5e3e1; - --chrome: rgba(248, 248, 247, 0.95); - --chrome-strong: rgba(248, 248, 247, 0.98); + --panel: #fafafa; + --panel-strong: #f5f5f5; + --panel-hover: #ebebeb; + --chrome: rgba(250, 250, 250, 0.95); + --chrome-strong: rgba(250, 250, 250, 0.98); - --text: #44403c; - --text-strong: #292524; - --chat-text: #44403c; - --muted: #5c5856; - --muted-strong: #44403c; - --muted-foreground: #5c5856; + --text: #3f3f46; + --text-strong: #18181b; + --chat-text: #3f3f46; + --muted: #71717a; + --muted-strong: #52525b; + --muted-foreground: #71717a; - --border: #e0dedc; - --border-strong: #d6d3d1; - --border-hover: #a8a5a0; - --input: #e0dedc; + --border: #e4e4e7; + --border-strong: #d4d4d8; + --border-hover: #a1a1aa; + --input: #e4e4e7; - --accent: #b91c1c; - --accent-hover: #dc2626; - --accent-muted: #b91c1c; - --accent-subtle: rgba(185, 28, 28, 0.18); + --accent: #dc2626; + --accent-hover: #ef4444; + --accent-muted: #dc2626; + --accent-subtle: rgba(220, 38, 38, 0.12); --accent-foreground: #ffffff; - --primary: #b91c1c; + --accent-glow: rgba(220, 38, 38, 0.15); + --primary: #dc2626; --primary-foreground: #ffffff; - --secondary: #eae8e6; - --secondary-foreground: #44403c; + --secondary: #f4f4f5; + --secondary-foreground: #3f3f46; + --accent-2: #0d9488; + --accent-2-muted: rgba(13, 148, 136, 0.75); + --accent-2-subtle: rgba(13, 148, 136, 0.12); - --ok: #15803d; - --ok-muted: rgba(21, 128, 61, 0.75); - --ok-subtle: rgba(21, 128, 61, 0.12); - --destructive: #b91c1c; + --ok: #16a34a; + --ok-muted: rgba(22, 163, 74, 0.75); + --ok-subtle: rgba(22, 163, 74, 0.1); + --destructive: #dc2626; --destructive-foreground: #fafafa; - --warn: #a16207; - --warn-muted: rgba(161, 98, 7, 0.75); - --warn-subtle: rgba(161, 98, 7, 0.12); - --danger: #b91c1c; - --danger-muted: rgba(185, 28, 28, 0.75); - --danger-subtle: rgba(185, 28, 28, 0.12); - --info: #1d4ed8; + --warn: #d97706; + --warn-muted: rgba(217, 119, 6, 0.75); + --warn-subtle: rgba(217, 119, 6, 0.1); + --danger: #dc2626; + --danger-muted: rgba(220, 38, 38, 0.75); + --danger-subtle: rgba(220, 38, 38, 0.1); + --info: #2563eb; - --focus: rgba(185, 28, 28, 0.25); + --focus: rgba(220, 38, 38, 0.2); + --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow); - --grid-line: rgba(0, 0, 0, 0.06); + --grid-line: rgba(0, 0, 0, 0.05); + + /* Light shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04); + --shadow-glow: 0 0 24px var(--accent-glow); color-scheme: light; } @@ -181,8 +198,8 @@ body { body { margin: 0; - font: 400 14px/1.5 var(--font-body); - letter-spacing: -0.011em; + font: 400 14px/1.55 var(--font-body); + letter-spacing: -0.02em; background: var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; @@ -270,11 +287,11 @@ select { background: var(--border-strong); } -/* Animations */ +/* Animations - Polished with spring feel */ @keyframes rise { from { opacity: 0; - transform: translateY(4px); + transform: translateY(8px); } to { opacity: 1; @@ -294,7 +311,7 @@ select { @keyframes scale-in { from { opacity: 0; - transform: scale(0.96); + transform: scale(0.95); } to { opacity: 1; @@ -305,7 +322,7 @@ select { @keyframes dashboard-enter { from { opacity: 0; - transform: translateY(8px); + transform: translateY(12px); } to { opacity: 1; @@ -322,6 +339,32 @@ select { } } +@keyframes pulse-subtle { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +@keyframes glow-pulse { + 0%, 100% { + box-shadow: 0 0 0 rgba(255, 92, 92, 0); + } + 50% { + box-shadow: 0 0 20px var(--accent-glow); + } +} + +/* Stagger animation delays for grouped elements */ +.stagger-1 { animation-delay: 0ms; } +.stagger-2 { animation-delay: 50ms; } +.stagger-3 { animation-delay: 100ms; } +.stagger-4 { animation-delay: 150ms; } +.stagger-5 { animation-delay: 200ms; } +.stagger-6 { animation-delay: 250ms; } + /* Focus visible styles */ :focus-visible { outline: none; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index bdaca288f..9b253e231 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,63 +1,75 @@ @import './chat.css'; /* =========================================== - Cards + Cards - Refined with depth =========================================== */ .card { border: 1px solid var(--border); background: var(--card); border-radius: var(--radius-lg); - padding: 16px; - animation: rise 0.3s var(--ease-out); - transition: border-color var(--duration-fast) ease; - box-shadow: inset 0 1px 0 var(--card-highlight); + padding: 20px; + animation: rise 0.35s var(--ease-out) backwards; + transition: + border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); + box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight); } .card:hover { border-color: var(--border-strong); + box-shadow: var(--shadow-md), inset 0 1px 0 var(--card-highlight); } .card-title { - font-size: 14px; + font-size: 15px; font-weight: 600; + letter-spacing: -0.02em; color: var(--text-strong); } .card-sub { color: var(--muted); font-size: 13px; - margin-top: 4px; + margin-top: 6px; + line-height: 1.5; } /* =========================================== - Stats + Stats - Bold values, subtle labels =========================================== */ .stat { background: var(--card); border-radius: var(--radius-md); - padding: 12px 14px; + padding: 14px 16px; border: 1px solid var(--border); - transition: border-color var(--duration-fast) ease; + transition: + border-color var(--duration-normal) var(--ease-out), + box-shadow var(--duration-normal) var(--ease-out); box-shadow: inset 0 1px 0 var(--card-highlight); } .stat:hover { border-color: var(--border-strong); + box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight); } .stat-label { color: var(--muted); - font-size: 12px; + font-size: 11px; font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; } .stat-value { - font-size: 20px; - font-weight: 600; - margin-top: 4px; - letter-spacing: -0.02em; + font-size: 24px; + font-weight: 700; + margin-top: 6px; + letter-spacing: -0.03em; + line-height: 1.1; } .stat-value.ok { @@ -70,11 +82,12 @@ .stat-card { display: grid; - gap: 4px; + gap: 6px; } .note-title { font-weight: 600; + letter-spacing: -0.01em; } /* =========================================== @@ -262,7 +275,7 @@ } /* =========================================== - Status Dot + Status Dot - With glow for emphasis =========================================== */ .statusDot { @@ -270,14 +283,18 @@ height: 8px; border-radius: var(--radius-full); background: var(--danger); + box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); + animation: pulse-subtle 2s ease-in-out infinite; } .statusDot.ok { background: var(--ok); + box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); + animation: none; } /* =========================================== - Buttons + Buttons - Tactile with personality =========================================== */ .btn { @@ -287,23 +304,30 @@ gap: 8px; border: 1px solid var(--border); background: var(--bg-elevated); - padding: 8px 14px; + padding: 9px 16px; border-radius: var(--radius-md); font-size: 13px; font-weight: 500; + letter-spacing: -0.01em; cursor: pointer; transition: - border-color var(--duration-fast) ease, - background var(--duration-fast) ease; + border-color var(--duration-fast) var(--ease-out), + background var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); } .btn:hover { background: var(--bg-hover); border-color: var(--border-strong); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); } .btn:active { background: var(--secondary); + transform: translateY(0); + box-shadow: none; } .btn svg { @@ -321,11 +345,13 @@ border-color: var(--accent); background: var(--accent); color: var(--primary-foreground); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } .btn.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); + box-shadow: var(--shadow-md), 0 0 20px var(--accent-glow); } /* Keyboard shortcut badge (shadcn style) */ @@ -497,32 +523,34 @@ } /* =========================================== - Callouts + Callouts - Informative with subtle depth =========================================== */ .callout { - padding: 12px 14px; + padding: 14px 16px; border-radius: var(--radius-md); background: var(--secondary); border: 1px solid var(--border); font-size: 13px; + line-height: 1.5; + position: relative; } .callout.danger { - border-color: var(--danger-subtle); - background: var(--danger-subtle); + border-color: rgba(239, 68, 68, 0.25); + background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(239, 68, 68, 0.04) 100%); color: var(--danger); } .callout.info { - border-color: rgba(59, 130, 246, 0.2); - background: rgba(59, 130, 246, 0.1); + border-color: rgba(59, 130, 246, 0.25); + background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.04) 100%); color: var(--info); } .callout.success { - border-color: var(--ok-subtle); - background: var(--ok-subtle); + border-color: rgba(34, 197, 94, 0.25); + background: linear-gradient(135deg, rgba(34, 197, 94, 0.08) 0%, rgba(34, 197, 94, 0.04) 100%); color: var(--ok); } @@ -655,13 +683,13 @@ } /* =========================================== - Chips + Chips - Compact and punchy =========================================== */ .chip-row { display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; } .chip { @@ -669,10 +697,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 +717,14 @@ .chip-ok { color: var(--ok); - border-color: var(--ok-subtle); + border-color: rgba(34, 197, 94, 0.3); + background: var(--ok-subtle); } .chip-warn { color: var(--warn); - border-color: var(--warn-subtle); + border-color: rgba(245, 158, 11, 0.3); + background: var(--warn-subtle); } /* =========================================== diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 3648fc782..a3db5d737 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -132,18 +132,19 @@ } .brand-title { - font-size: 15px; - font-weight: 600; - letter-spacing: -0.02em; + font-size: 16px; + font-weight: 700; + letter-spacing: -0.03em; line-height: 1.1; color: var(--text-strong); } .brand-sub { - font-size: 11px; + font-size: 10px; font-weight: 500; color: var(--muted); - letter-spacing: 0.02em; + letter-spacing: 0.05em; + text-transform: uppercase; line-height: 1; } @@ -195,8 +196,8 @@ overflow-y: auto; overflow-x: hidden; padding: 16px 12px; - border-right: 1px solid var(--border); background: var(--bg); + scrollbar-width: none; /* Firefox */ transition: width var(--shell-focus-duration) var(--shell-focus-ease), padding var(--shell-focus-duration) var(--shell-focus-ease), @@ -204,6 +205,10 @@ min-height: 0; } +.nav::-webkit-scrollbar { + display: none; /* Chrome/Safari */ +} + .shell--chat-focus .nav { width: 0; padding: 0; @@ -412,10 +417,10 @@ .content { grid-area: content; - padding: 8px 8px 24px; + padding: 12px 16px 32px; display: flex; flex-direction: column; - gap: 20px; + gap: 24px; min-height: 0; overflow-y: auto; overflow-x: hidden; @@ -456,18 +461,19 @@ } .page-title { - font-size: 24px; - font-weight: 600; - letter-spacing: -0.02em; - line-height: 1.2; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.035em; + line-height: 1.15; color: var(--text-strong); } .page-sub { color: var(--muted); - font-size: 13px; + font-size: 14px; font-weight: 400; - margin-top: 4px; + margin-top: 6px; + letter-spacing: -0.01em; } .page-meta { @@ -501,7 +507,7 @@ .grid { display: grid; - gap: 16px; + gap: 20px; } .grid-cols-2 { @@ -514,19 +520,19 @@ .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; } From 0f5f7ec22a48a21c9c1266fd3bcfdaa3abe4ee91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 12:34:12 +0000 Subject: [PATCH 02/24] ci: stabilize pnpm setup --- .github/workflows/ci.yml | 52 ++++++++++++++++------------- .github/workflows/install-smoke.yml | 7 ++-- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be0d8926f..91090e297 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,20 +32,21 @@ jobs: node-version: 22.x check-latest: true + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + run_install: false + - 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 +109,12 @@ jobs: node-version: 22.x check-latest: true + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + run_install: false + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -118,16 +125,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 @@ -212,6 +214,12 @@ jobs: node-version: 22.x check-latest: true + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + run_install: false + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -222,16 +230,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 +282,21 @@ jobs: node-version: 22.x check-latest: true + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + run_install: false + - name: Runtime versions run: | node -v npm -v + pnpm -v - name: Capture node path run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" - - name: Enable corepack and pin pnpm - run: | - corepack enable - corepack prepare pnpm@10.23.0 --activate - pnpm -v - - name: Install dependencies env: CI: true diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 84d1b7f32..b380b1a9b 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -14,11 +14,10 @@ jobs: uses: actions/checkout@v4 - name: Setup pnpm - uses: pnpm/action-setup@v3 + uses: pnpm/action-setup@v4 with: - version: 10 - - name: Enable Corepack - run: corepack enable + version: 10.23.0 + run_install: false - name: Install pnpm deps (minimal) run: pnpm install --ignore-scripts --frozen-lockfile From 8f3da653b023f397ee55f40ebd9307654cbe7436 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 12:47:06 +0000 Subject: [PATCH 03/24] fix: allow control ui token auth without pairing --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 5 +- docs/gateway/security.md | 2 +- docs/web/control-ui.md | 4 +- src/gateway/server.auth.e2e.test.ts | 65 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 11 ++-- 6 files changed, 78 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52378f68..9cc7b5370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot - Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags ### Fixes +- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. - 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. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 020ca9c90..3b16be5b1 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2847,8 +2847,9 @@ Control UI base path: - `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served. - Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`. - Default: root (`/`) (unchanged). -- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity). - Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`. +- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips + device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS + (Tailscale Serve) or `127.0.0.1`. Related docs: - [Control UI](/web/control-ui) diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 48e3fa59c..ed0054411 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -58,7 +58,7 @@ When the audit prints findings, treat this as a priority order: The Control UI needs a **secure context** (HTTPS or localhost) to generate device identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back -to **token-only auth** on plain HTTP and skips device pairing. This is a security +to **token-only auth** and skips device pairing (even on HTTPS). This is a security downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`. `clawdbot security audit` warns when this setting is enabled. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index ede005259..188479679 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity. } ``` -This disables device identity + pairing for the Control UI. Use only if you -trust the network. +This disables device identity + pairing for the Control UI (even on HTTPS). Use +only if you trust the network. See [Tailscale](/gateway/tailscale) for HTTPS setup guidance. diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index b97a2d321..a2645a75d 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { PROTOCOL_VERSION } from "./protocol/index.js"; import { getHandshakeTimeoutMs } from "./server-constants.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; import { connectReq, getFreePort, @@ -286,6 +287,70 @@ describe("gateway server auth/connect", () => { } }); + test("allows control ui with device identity when insecure auth is enabled", async () => { + testState.gatewayControlUi = { allowInsecureAuth: true }; + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + trustedProxies: ["127.0.0.1"], + }, + } as any); + const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; + const port = await getFreePort(); + const server = await startGatewayServer(port); + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { "x-forwarded-for": "203.0.113.10" }, + }); + const challengePromise = onceMessage<{ payload?: unknown }>( + ws, + (o) => o.type === "event" && o.event === "connect.challenge", + ); + await new Promise((resolve) => ws.once("open", resolve)); + const challenge = await challengePromise; + const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + role: "operator", + scopes: [], + signedAtMs, + token: "secret", + nonce: String(nonce), + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce: String(nonce), + }; + const res = await connectReq(ws, { + token: "secret", + device, + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(true); + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + test("accepts device token auth for paired device", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 5bca2e5ed..5ef3f26e7 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -318,13 +318,13 @@ export function attachGatewayWsMessageHandler(params: { let devicePublicKey: string | null = null; const hasTokenAuth = Boolean(connectParams.auth?.token); const hasPasswordAuth = Boolean(connectParams.auth?.password); + const hasSharedAuth = hasTokenAuth || hasPasswordAuth; const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; + const allowInsecureControlUi = + isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; if (!device) { - const allowInsecureControlUi = - isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; - const canSkipDevice = - isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth; + const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth; if (isControlUi && !allowInsecureControlUi) { const errorMessage = "control ui requires HTTPS or localhost (secure context)"; @@ -569,7 +569,8 @@ export function attachGatewayWsMessageHandler(params: { return; } - if (device && devicePublicKey) { + const skipPairing = allowInsecureControlUi && hasSharedAuth; + if (device && devicePublicKey && !skipPairing) { const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ deviceId: device.id, From 003fff067aa80590ce532fb6ec719ff37e2ae02f Mon Sep 17 00:00:00 2001 From: Robby Date: Sun, 25 Jan 2026 11:30:56 +0000 Subject: [PATCH 04/24] fix: add text overflow ellipsis to config section titles Fixes #1728 Config section header titles were being truncated without visual indication. Added standard CSS truncation to BOTH title classes: - .config-section-hero__title (main section headers) - .config-section-card__title (card headers) Properties added: - white-space: nowrap - overflow: hidden - text-overflow: ellipsis --- ui/src/styles/config.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index 29ce19d8e..fcdeeb51f 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -413,6 +413,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 +589,9 @@ font-size: 17px; font-weight: 600; letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .config-section-card__desc { From 026def686e954319d69f3d70fa30ca8cc95a31c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 12:53:57 +0000 Subject: [PATCH 05/24] fix(matrix): decrypt E2EE media + size guard (#1744) Thanks @araa47. Co-authored-by: Akshay --- CHANGELOG.md | 1 + .../matrix/src/matrix/monitor/handler.ts | 10 +++-- .../matrix/src/matrix/monitor/media.test.ts | 42 +++++++++++++++++-- extensions/matrix/src/matrix/monitor/media.ts | 21 ++++++---- extensions/matrix/src/matrix/monitor/types.ts | 1 + 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc7b5370..f5659af14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot - Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags ### Fixes +- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47. - Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. - 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 diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 2ba7cbef0..4542e113a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -329,16 +329,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } - const contentType = - "info" in content && content.info && "mimetype" in content.info - ? (content.info as { mimetype?: string }).mimetype + const contentInfo = + "info" in content && content.info && typeof content.info === "object" + ? (content.info as { mimetype?: string; size?: number }) : undefined; + const contentType = contentInfo?.mimetype; + const contentSize = + typeof contentInfo?.size === "number" ? contentInfo.size : undefined; if (mediaUrl?.startsWith("mxc://")) { try { media = await downloadMatrixMedia({ client, mxcUrl: mediaUrl, contentType, + sizeBytes: contentSize, maxBytes: mediaMaxBytes, file: contentFile, }); diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index d8fd51888..10cbd8b47 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -25,10 +25,8 @@ describe("downloadMatrixMedia", () => { it("decrypts encrypted media when file payloads are present", async () => { const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted")); const client = { - downloadContent, crypto: { decryptMedia }, mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), } as unknown as import("matrix-bot-sdk").MatrixClient; @@ -55,7 +53,8 @@ describe("downloadMatrixMedia", () => { file, }); - expect(decryptMedia).toHaveBeenCalled(); + // decryptMedia should be called with just the file object (it handles download internally) + expect(decryptMedia).toHaveBeenCalledWith(file); expect(saveMediaBuffer).toHaveBeenCalledWith( Buffer.from("decrypted"), "image/png", @@ -64,4 +63,41 @@ describe("downloadMatrixMedia", () => { ); expect(result?.path).toBe("/tmp/media"); }); + + it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { + const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + + const client = { + crypto: { decryptMedia }, + mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), + } as unknown as import("matrix-bot-sdk").MatrixClient; + + const file = { + url: "mxc://example/file", + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }; + + await expect( + downloadMatrixMedia({ + client, + mxcUrl: "mxc://example/file", + contentType: "image/png", + sizeBytes: 2048, + maxBytes: 1024, + file, + }), + ).rejects.toThrow("Matrix media exceeds configured size limit"); + + expect(decryptMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index b60320e41..1ade1d19c 100644 --- a/extensions/matrix/src/matrix/monitor/media.ts +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -40,6 +40,7 @@ async function fetchMatrixMediaBuffer(params: { /** * Download and decrypt encrypted media from a Matrix room. + * Uses matrix-bot-sdk's decryptMedia which handles both download and decryption. */ async function fetchEncryptedMediaBuffer(params: { client: MatrixClient; @@ -50,18 +51,13 @@ async function fetchEncryptedMediaBuffer(params: { throw new Error("Cannot decrypt media: crypto not enabled"); } - // Download the encrypted content - const encryptedBuffer = await params.client.downloadContent(params.file.url); - if (encryptedBuffer.byteLength > params.maxBytes) { + // decryptMedia handles downloading and decrypting the encrypted content internally + const decrypted = await params.client.crypto.decryptMedia(params.file); + + if (decrypted.byteLength > params.maxBytes) { throw new Error("Matrix media exceeds configured size limit"); } - // Decrypt using matrix-bot-sdk crypto - const decrypted = await params.client.crypto.decryptMedia( - Buffer.from(encryptedBuffer), - params.file, - ); - return { buffer: decrypted }; } @@ -69,6 +65,7 @@ export async function downloadMatrixMedia(params: { client: MatrixClient; mxcUrl: string; contentType?: string; + sizeBytes?: number; maxBytes: number; file?: EncryptedFile; }): Promise<{ @@ -77,6 +74,12 @@ export async function downloadMatrixMedia(params: { placeholder: string; } | null> { let fetched: { buffer: Buffer; headerType?: string } | null; + if ( + typeof params.sizeBytes === "number" && + params.sizeBytes > params.maxBytes + ) { + throw new Error("Matrix media exceeds configured size limit"); + } if (params.file) { // Encrypted media diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts index d77bdac67..c77cf0282 100644 --- a/extensions/matrix/src/matrix/monitor/types.ts +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -29,6 +29,7 @@ export type RoomMessageEventContent = MessageEventContent & { file?: EncryptedFile; info?: { mimetype?: string; + size?: number; }; "m.relates_to"?: { rel_type?: string; From 7253bf398d8f0e3c001d78a2506aa2b89be8cd3c Mon Sep 17 00:00:00 2001 From: Senol Dogan Date: Sun, 25 Jan 2026 14:54:48 +0200 Subject: [PATCH 06/24] feat: audit fixes and documentation improvements (#1762) * feat: audit fixes and documentation improvements - Refactored model selection to drop legacy fallback and add warning - Improved heartbeat content validation - Added Skill Creation guide - Updated CONTRIBUTING.md with roadmap * style: fix formatting in model-selection.ts * style: fix formatting and improve model selection logic with tests --- CONTRIBUTING.md | 10 + docs/tools/creating-skills.md | 41 ++++ src/agents/model-selection.test.ts | 357 ++++++++++------------------- src/agents/model-selection.ts | 14 +- src/auto-reply/heartbeat.ts | 2 + 5 files changed, 187 insertions(+), 237 deletions(-) create mode 100644 docs/tools/creating-skills.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b37ea389..d6eb0532f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,3 +40,13 @@ Please include in your PR: - [ ] Confirm you understand what the code does AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. + +## Current Focus & Roadmap 🗺 + +We are currently prioritizing: +- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram). +- **UX**: Improving the onboarding wizard and error messages. +- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience. +- **Performance**: Optimizing token usage and compaction logic. + +Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels! diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md new file mode 100644 index 000000000..77e3415d2 --- /dev/null +++ b/docs/tools/creating-skills.md @@ -0,0 +1,41 @@ +# Creating Custom Skills 🛠 + +Clawdbot is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant. + +## What is a Skill? +A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources. + +## Step-by-Step: Your First Skill + +### 1. Create the Directory +Skills live in your workspace, usually `~/clawd/skills/`. Create a new folder for your skill: +```bash +mkdir -p ~/clawd/skills/hello-world +``` + +### 2. Define the `SKILL.md` +Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions. + +```markdown +--- +name: hello_world +description: A simple skill that says hello. +--- + +# Hello World Skill +When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!". +``` + +### 3. Add Tools (Optional) +You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`). + +### 4. Refresh Clawdbot +Ask your agent to "refresh skills" or restart the gateway. Clawdbot will discover the new directory and index the `SKILL.md`. + +## Best Practices +- **Be Concise**: Instruct the model on *what* to do, not how to be an AI. +- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input. +- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test. + +## Shared Skills +You can also browse and contribute skills to [ClawdHub](https://clawdhub.com). diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 391aee318..5e72ad396 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -1,252 +1,139 @@ -import { describe, expect, it } from "vitest"; - -import type { ClawdbotConfig } from "../config/config.js"; -import { DEFAULT_PROVIDER } from "./defaults.js"; +import { describe, it, expect, vi } from "vitest"; import { - buildAllowedModelSet, - modelKey, parseModelRef, - resolveAllowedModelRef, - resolveHooksGmailModel, + resolveModelRefFromString, + resolveConfiguredModelRef, + buildModelAliasIndex, + normalizeProviderId, + modelKey, } from "./model-selection.js"; +import type { ClawdbotConfig } from "../config/config.js"; -const catalog = [ - { - provider: "openai", - id: "gpt-4", - name: "GPT-4", - }, -]; - -describe("buildAllowedModelSet", () => { - it("always allows the configured default model", () => { - const cfg = { - agents: { - defaults: { - models: { - "openai/gpt-4": { alias: "gpt4" }, - }, - }, - }, - } as ClawdbotConfig; - - const allowed = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: "claude-cli", - defaultModel: "opus-4.5", - }); - - expect(allowed.allowAny).toBe(false); - expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true); - expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true); - }); - - it("includes the default model when no allowlist is set", () => { - const cfg = { - agents: { defaults: {} }, - } as ClawdbotConfig; - - const allowed = buildAllowedModelSet({ - cfg, - catalog, - defaultProvider: "claude-cli", - defaultModel: "opus-4.5", - }); - - expect(allowed.allowAny).toBe(true); - expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true); - expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true); - }); - - it("allows explicit custom providers from models.providers", () => { - const cfg = { - agents: { - defaults: { - models: { - "moonshot/kimi-k2-0905-preview": { alias: "kimi" }, - }, - }, - }, - models: { - mode: "merge", - providers: { - moonshot: { - baseUrl: "https://api.moonshot.ai/v1", - apiKey: "x", - api: "openai-completions", - models: [{ id: "kimi-k2-0905-preview", name: "Kimi" }], - }, - }, - }, - } as ClawdbotConfig; - - const allowed = buildAllowedModelSet({ - cfg, - catalog: [], - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-5", - }); - - expect(allowed.allowAny).toBe(false); - expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true); - }); -}); - -describe("parseModelRef", () => { - it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => { - const ref = parseModelRef("anthropic/opus-4.5", "anthropic"); - expect(ref).toEqual({ - provider: "anthropic", - model: "claude-opus-4-5", +describe("model-selection", () => { + describe("normalizeProviderId", () => { + it("should normalize provider names", () => { + expect(normalizeProviderId("Anthropic")).toBe("anthropic"); + expect(normalizeProviderId("Z.ai")).toBe("zai"); + expect(normalizeProviderId("z-ai")).toBe("zai"); + expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode"); + expect(normalizeProviderId("qwen")).toBe("qwen-portal"); }); }); - it("normalizes google gemini 3 models to preview ids", () => { - expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({ - provider: "google", - model: "gemini-3-pro-preview", - }); - expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({ - provider: "google", - model: "gemini-3-flash-preview", - }); - }); - - it("normalizes default-provider google models", () => { - expect(parseModelRef("gemini-3-pro", "google")).toEqual({ - provider: "google", - model: "gemini-3-pro-preview", - }); - }); -}); - -describe("resolveHooksGmailModel", () => { - it("returns null when hooks.gmail.model is not set", () => { - const cfg = {} satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - expect(result).toBeNull(); - }); - - it("returns null when hooks.gmail.model is empty", () => { - const cfg = { - hooks: { gmail: { model: "" } }, - } satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - expect(result).toBeNull(); - }); - - it("parses provider/model from hooks.gmail.model", () => { - const cfg = { - hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } }, - } satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - expect(result).toEqual({ - provider: "openrouter", - model: "meta-llama/llama-3.3-70b:free", - }); - }); - - it("resolves alias from agent.models", () => { - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, - }, - }, - }, - hooks: { gmail: { model: "Sonnet" } }, - } satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: DEFAULT_PROVIDER, - }); - expect(result).toEqual({ - provider: "anthropic", - model: "claude-sonnet-4-1", - }); - }); - - it("uses default provider when model omits provider", () => { - const cfg = { - hooks: { gmail: { model: "claude-haiku-3-5" } }, - } satisfies ClawdbotConfig; - const result = resolveHooksGmailModel({ - cfg, - defaultProvider: "anthropic", - }); - expect(result).toEqual({ - provider: "anthropic", - model: "claude-haiku-3-5", - }); - }); -}); - -describe("resolveAllowedModelRef", () => { - it("resolves aliases when allowed", () => { - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-sonnet-4-1": { alias: "Sonnet" }, - }, - }, - }, - } satisfies ClawdbotConfig; - const resolved = resolveAllowedModelRef({ - cfg, - catalog: [ - { - provider: "anthropic", - id: "claude-sonnet-4-1", - name: "Sonnet", - }, - ], - raw: "Sonnet", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-5", - }); - expect("error" in resolved).toBe(false); - if ("ref" in resolved) { - expect(resolved.ref).toEqual({ + describe("parseModelRef", () => { + it("should parse full model refs", () => { + expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({ provider: "anthropic", - model: "claude-sonnet-4-1", + model: "claude-3-5-sonnet", }); - } + }); + + it("should use default provider if none specified", () => { + expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({ + provider: "anthropic", + model: "claude-3-5-sonnet", + }); + }); + + it("should return null for empty strings", () => { + expect(parseModelRef("", "anthropic")).toBeNull(); + expect(parseModelRef(" ", "anthropic")).toBeNull(); + }); + + it("should handle invalid slash usage", () => { + expect(parseModelRef("/", "anthropic")).toBeNull(); + expect(parseModelRef("anthropic/", "anthropic")).toBeNull(); + expect(parseModelRef("/model", "anthropic")).toBeNull(); + }); }); - it("rejects disallowed models", () => { - const cfg = { - agents: { - defaults: { - models: { - "openai/gpt-4": { alias: "GPT4" }, + describe("buildModelAliasIndex", () => { + it("should build alias index from config", () => { + const cfg: Partial = { + agents: { + defaults: { + models: { + "anthropic/claude-3-5-sonnet": { alias: "fast" }, + "openai/gpt-4o": { alias: "smart" }, + }, }, }, - }, - } satisfies ClawdbotConfig; - const resolved = resolveAllowedModelRef({ - cfg, - catalog: [ - { provider: "openai", id: "gpt-4", name: "GPT-4" }, - { provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" }, - ], - raw: "anthropic/claude-sonnet-4-1", - defaultProvider: "openai", - defaultModel: "gpt-4", + }; + + const index = buildModelAliasIndex({ + cfg: cfg as ClawdbotConfig, + defaultProvider: "anthropic", + }); + + expect(index.byAlias.get("fast")?.ref).toEqual({ + provider: "anthropic", + model: "claude-3-5-sonnet", + }); + expect(index.byAlias.get("smart")?.ref).toEqual({ provider: "openai", model: "gpt-4o" }); + expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]); }); - expect(resolved).toEqual({ - error: "model not allowed: anthropic/claude-sonnet-4-1", + }); + + describe("resolveModelRefFromString", () => { + it("should resolve from string with alias", () => { + const index = { + byAlias: new Map([ + ["fast", { alias: "fast", ref: { provider: "anthropic", model: "sonnet" } }], + ]), + byKey: new Map(), + }; + + const resolved = resolveModelRefFromString({ + raw: "fast", + defaultProvider: "openai", + aliasIndex: index, + }); + + expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" }); + expect(resolved?.alias).toBe("fast"); + }); + + it("should resolve direct ref if no alias match", () => { + const resolved = resolveModelRefFromString({ + raw: "openai/gpt-4", + defaultProvider: "anthropic", + }); + expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" }); + }); + }); + + describe("resolveConfiguredModelRef", () => { + it("should fall back to anthropic and warn if provider is missing for non-alias", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const cfg: Partial = { + agents: { + defaults: { + model: "claude-3-5-sonnet", + }, + }, + }; + + const result = resolveConfiguredModelRef({ + cfg: cfg as ClawdbotConfig, + defaultProvider: "google", + defaultModel: "gemini-pro", + }); + + expect(result).toEqual({ provider: "anthropic", model: "claude-3-5-sonnet" }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Falling back to "anthropic/claude-3-5-sonnet"'), + ); + warnSpy.mockRestore(); + }); + + it("should use default provider/model if config is empty", () => { + const cfg: Partial = {}; + const result = resolveConfiguredModelRef({ + cfg: cfg as ClawdbotConfig, + defaultProvider: "openai", + defaultModel: "gpt-4", + }); + expect(result).toEqual({ provider: "openai", model: "gpt-4" }); }); }); }); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index a330423b3..e05370edd 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -131,14 +131,24 @@ export function resolveConfiguredModelRef(params: { cfg: params.cfg, defaultProvider: params.defaultProvider, }); + if (!trimmed.includes("/")) { + const aliasKey = normalizeAliasKey(trimmed); + const aliasMatch = aliasIndex.byAlias.get(aliasKey); + if (aliasMatch) return aliasMatch.ref; + + // Default to anthropic if no provider is specified, but warn as this is deprecated. + console.warn( + `[clawdbot] Model "${trimmed}" specified without provider. Falling back to "anthropic/${trimmed}". Please use "anthropic/${trimmed}" in your config.`, + ); + return { provider: "anthropic", model: trimmed }; + } + const resolved = resolveModelRefFromString({ raw: trimmed, defaultProvider: params.defaultProvider, aliasIndex, }); if (resolved) return resolved.ref; - // TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated. - return { provider: "anthropic", model: trimmed }; } return { provider: params.defaultProvider, model: params.defaultModel }; } diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 50567ad87..cca6c6ad1 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -32,6 +32,8 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | // This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content // (Those aren't valid markdown headers - ATX headers require space after #) if (/^#+(\s|$)/.test(trimmed)) continue; + // Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- " + if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue; // Found a non-empty, non-comment line - there's actionable content return false; } From 4e23b7f65403cc4d67ad08b178614988faecdc03 Mon Sep 17 00:00:00 2001 From: ymat19 Date: Sun, 25 Jan 2026 18:18:53 +0900 Subject: [PATCH 07/24] fix: use exact match for win32 platform detection The previous check used includes("win") which incorrectly matched "darwin" (macOS) because it contains "win". This caused cmd.exe to be used on macOS instead of /bin/sh. --- src/infra/node-shell.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infra/node-shell.ts b/src/infra/node-shell.ts index 26d5ff1d3..29ac52e15 100644 --- a/src/infra/node-shell.ts +++ b/src/infra/node-shell.ts @@ -2,7 +2,7 @@ export function buildNodeShellCommand(command: string, platform?: string | null) const normalized = String(platform ?? "") .trim() .toLowerCase(); - if (normalized.includes("win")) { + if (normalized === "win32") { return ["cmd.exe", "/d", "/s", "/c", command]; } return ["/bin/sh", "-lc", command]; From 7a5e103a6aeeedd969a7dd9bd12b472d9bf4ca0f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 12:56:02 +0000 Subject: [PATCH 08/24] fix: treat Windows platform labels as Windows for node shell (#1760) Thanks @ymat19. Co-authored-by: ymat19 <45934497+ymat19@users.noreply.github.com> --- CHANGELOG.md | 1 + src/infra/node-shell.test.ts | 40 ++++++++++++++++++++++++++++++++++++ src/infra/node-shell.ts | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/infra/node-shell.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5659af14..460cc3989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.clawd.bot - Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47. - Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. - Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. +- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19. - 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. diff --git a/src/infra/node-shell.test.ts b/src/infra/node-shell.test.ts new file mode 100644 index 000000000..8f95a29a8 --- /dev/null +++ b/src/infra/node-shell.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { buildNodeShellCommand } from "./node-shell.js"; + +describe("buildNodeShellCommand", () => { + it("uses cmd.exe for win32", () => { + expect(buildNodeShellCommand("echo hi", "win32")).toEqual([ + "cmd.exe", + "/d", + "/s", + "/c", + "echo hi", + ]); + }); + + it("uses cmd.exe for windows labels", () => { + expect(buildNodeShellCommand("echo hi", "windows")).toEqual([ + "cmd.exe", + "/d", + "/s", + "/c", + "echo hi", + ]); + expect(buildNodeShellCommand("echo hi", "Windows 11")).toEqual([ + "cmd.exe", + "/d", + "/s", + "/c", + "echo hi", + ]); + }); + + it("uses /bin/sh for darwin", () => { + expect(buildNodeShellCommand("echo hi", "darwin")).toEqual(["/bin/sh", "-lc", "echo hi"]); + }); + + it("uses /bin/sh when platform missing", () => { + expect(buildNodeShellCommand("echo hi")).toEqual(["/bin/sh", "-lc", "echo hi"]); + }); +}); diff --git a/src/infra/node-shell.ts b/src/infra/node-shell.ts index 29ac52e15..8a59fb524 100644 --- a/src/infra/node-shell.ts +++ b/src/infra/node-shell.ts @@ -2,7 +2,7 @@ export function buildNodeShellCommand(command: string, platform?: string | null) const normalized = String(platform ?? "") .trim() .toLowerCase(); - if (normalized === "win32") { + if (normalized.startsWith("win")) { return ["cmd.exe", "/d", "/s", "/c", command]; } return ["/bin/sh", "-lc", command]; From 6110514606c271d2fc4ef530949d2eb29629b1e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 12:58:00 +0000 Subject: [PATCH 09/24] docs: reorder 2026.1.24 changelog --- CHANGELOG.md | 59 ++++++++++++++++++++++++++-------------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 460cc3989..fd8dab3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,57 +9,56 @@ Docs: https://docs.clawd.bot - Venius (Venice AI): highlight provider guide + cross-links + expanded guidance. https://docs.clawd.bot/providers/venice ### 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 -- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47. -- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete. -- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep. -- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19. -- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles -- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. -- Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. - Web UI: 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. +- 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. +- 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. From 5a21722f32f3ad185c10cbad1e7f970ca9485977 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 13:00:52 +0000 Subject: [PATCH 10/24] docs: expand 2026.1.24 highlights --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8dab3ff..3ff3c9e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,11 @@ 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 - Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg. From 015c256984801b3281e7f78f7d0425f0b53f923d Mon Sep 17 00:00:00 2001 From: Yuanhai Date: Sun, 25 Jan 2026 01:54:19 -0500 Subject: [PATCH 11/24] docs: fix Slack API documentation URLs --- docs/channels/slack.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/channels/slack.md b/docs/channels/slack.md index bf7bcbf49..44bc84035 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -26,7 +26,7 @@ Minimal config: ``` ### Setup -1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. +1) Create a Slack app (From scratch) in https://api.slack.com/apps. 2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). 4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`). @@ -245,29 +245,29 @@ If you enable native commands, add one `slash_commands` entry per command you wa ## Scopes (current vs optional) Slack's Conversations API is type-scoped: you only need the scopes for the conversation types you actually touch (channels, groups, im, mpim). See -https://api.channels.slack.com/docs/conversations-api for the overview. +https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overview. ### Bot token scopes (required) - `chat:write` (send/update/delete messages via `chat.postMessage`) - https://api.channels.slack.com/methods/chat.postMessage + https://docs.slack.dev/reference/methods/chat.postMessage - `im:write` (open DMs via `conversations.open` for user DMs) - https://api.channels.slack.com/methods/conversations.open + https://docs.slack.dev/reference/methods/conversations.open - `channels:history`, `groups:history`, `im:history`, `mpim:history` - https://api.channels.slack.com/methods/conversations.history + https://docs.slack.dev/reference/methods/conversations.history - `channels:read`, `groups:read`, `im:read`, `mpim:read` - https://api.channels.slack.com/methods/conversations.info + https://docs.slack.dev/reference/methods/conversations.info - `users:read` (user lookup) - https://api.channels.slack.com/methods/users.info + https://docs.slack.dev/reference/methods/users.info - `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`) - https://api.channels.slack.com/methods/reactions.get - https://api.channels.slack.com/methods/reactions.add + https://docs.slack.dev/reference/methods/reactions.get + https://docs.slack.dev/reference/methods/reactions.add - `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`) - https://api.channels.slack.com/scopes/pins:read - https://api.channels.slack.com/scopes/pins:write + https://docs.slack.dev/reference/scopes/pins.read + https://docs.slack.dev/reference/scopes/pins.write - `emoji:read` (`emoji.list`) - https://api.channels.slack.com/scopes/emoji:read + https://docs.slack.dev/reference/scopes/emoji.read - `files:write` (uploads via `files.uploadV2`) - https://api.channels.slack.com/messaging/files/uploading + https://docs.slack.dev/messaging/working-with-files/#upload ### User token scopes (optional, read-only by default) Add these under **User Token Scopes** if you configure `channels.slack.userToken`. @@ -284,9 +284,9 @@ Add these under **User Token Scopes** if you configure `channels.slack.userToken - `mpim:write` (only if we add group-DM open/DM start via `conversations.open`) - `groups:write` (only if we add private-channel management: create/rename/invite/archive) - `chat:write.public` (only if we want to post to channels the bot isn't in) - https://api.channels.slack.com/scopes/chat:write.public + https://docs.slack.dev/reference/scopes/chat.write.public - `users:read.email` (only if we need email fields from `users.info`) - https://api.channels.slack.com/changelog/2017-04-narrowing-email-access + https://docs.slack.dev/changelog/2017-04-narrowing-email-access - `files:read` (only if we start listing/reading file metadata) ## Config From f61885976192819a07ac726cd1881577907af2a4 Mon Sep 17 00:00:00 2001 From: Ben Stein <31802821+benostein@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:07:19 +0100 Subject: [PATCH 12/24] fix(gemini-cli-auth): auto-extract OAuth credentials from installed Gemini CLI (#1773) Fixes #1765 - Extract client ID and secret from Gemini CLI's bundled oauth2.js - Cross-platform binary lookup (no shell commands) - Fallback to env vars for user override - Add tests for credential extraction --- extensions/google-gemini-cli-auth/README.md | 13 +- .../google-gemini-cli-auth/oauth.test.ts | 145 ++++++++++++++++++ extensions/google-gemini-cli-auth/oauth.ts | 102 ++++++++++-- 3 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 extensions/google-gemini-cli-auth/oauth.test.ts diff --git a/extensions/google-gemini-cli-auth/README.md b/extensions/google-gemini-cli-auth/README.md index 6e4bdbd2b..99eab057f 100644 --- a/extensions/google-gemini-cli-auth/README.md +++ b/extensions/google-gemini-cli-auth/README.md @@ -18,7 +18,18 @@ Restart the Gateway after enabling. clawdbot models auth login --provider google-gemini-cli --set-default ``` -## Env vars +## Requirements + +Requires the Gemini CLI to be installed (credentials are extracted automatically): + +```bash +brew install gemini-cli +# or: npm install -g @google/gemini-cli +``` + +## Env vars (optional) + +Override auto-detected credentials with: - `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID` - `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts new file mode 100644 index 000000000..9d186643a --- /dev/null +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { join } from "node:path"; + +// Mock fs module before importing the module under test +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +const mockRealpathSync = vi.fn(); +const mockReaddirSync = vi.fn(); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (...args: Parameters) => mockExistsSync(...args), + readFileSync: (...args: Parameters) => mockReadFileSync(...args), + realpathSync: (...args: Parameters) => mockRealpathSync(...args), + readdirSync: (...args: Parameters) => mockReaddirSync(...args), + }; +}); + +describe("extractGeminiCliCredentials", () => { + const 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) => { + if (p === fakeGeminiPath) return true; + if (p === 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) => p === 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) => { + if (p === fakeGeminiPath) return true; + if (p === 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) => { + if (p === fakeGeminiPath) return true; + if (p === fakeOauth2Path) return true; + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + + // First call + const result1 = extractGeminiCliCredentials(); + expect(result1).not.toBeNull(); + + // Second call should use cache (readFileSync not called again) + const readCount = mockReadFileSync.mock.calls.length; + const result2 = extractGeminiCliCredentials(); + expect(result2).toEqual(result1); + expect(mockReadFileSync.mock.calls.length).toBe(readCount); + }); +}); diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google-gemini-cli-auth/oauth.ts index 0fc68aa5a..405b94641 100644 --- a/extensions/google-gemini-cli-auth/oauth.ts +++ b/extensions/google-gemini-cli-auth/oauth.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; import { createServer } from "node:http"; +import { delimiter, dirname, join } from "node:path"; const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; const CLIENT_SECRET_KEYS = [ @@ -47,15 +48,98 @@ function resolveEnv(keys: string[]): string | undefined { return undefined; } -function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { - const clientId = resolveEnv(CLIENT_ID_KEYS); - if (!clientId) { - throw new Error( - "Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).", - ); +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +/** @internal */ +export function clearCredentialsCache(): void { + cachedGeminiCliCredentials = null; +} + +/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */ +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials; + + try { + const geminiPath = findInPath("gemini"); + if (!geminiPath) return null; + + const resolvedPath = realpathSync(geminiPath); + const geminiCliDir = dirname(dirname(resolvedPath)); + + const searchPaths = [ + join(geminiCliDir, "node_modules", "@google", "gemini-cli-core", "dist", "src", "code_assist", "oauth2.js"), + join(geminiCliDir, "node_modules", "@google", "gemini-cli-core", "dist", "code_assist", "oauth2.js"), + ]; + + let content: string | null = null; + for (const p of searchPaths) { + if (existsSync(p)) { + content = readFileSync(p, "utf8"); + break; + } + } + if (!content) { + const found = findFile(geminiCliDir, "oauth2.js", 10); + if (found) content = readFileSync(found, "utf8"); + } + if (!content) return null; + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + // Gemini CLI not installed or extraction failed } - const clientSecret = resolveEnv(CLIENT_SECRET_KEYS); - return { clientId, clientSecret }; + return null; +} + +function findInPath(name: string): string | null { + const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of exts) { + const p = join(dir, name + ext); + if (existsSync(p)) return p; + } + } + return null; +} + +function findFile(dir: string, name: string, depth: number): string | null { + if (depth <= 0) return null; + try { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isFile() && e.name === name) return p; + if (e.isDirectory() && !e.name.startsWith(".")) { + const found = findFile(p, name, depth - 1); + if (found) return found; + } + } + } catch {} + return null; +} + +function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { + // 1. Check env vars first (user override) + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + // 2. Try to extract from installed Gemini CLI + const extracted = extractGeminiCliCredentials(); + if (extracted) { + return extracted; + } + + // 3. No credentials available + throw new Error( + "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", + ); } function isWSL(): boolean { From abedc8bf7faf84bc3d034a56cf64ae5651ebb158 Mon Sep 17 00:00:00 2001 From: /noctivoro-x Date: Sun, 25 Jan 2026 06:10:48 -0700 Subject: [PATCH 13/24] fix: cron sessions inherit allowAgents from parent agent config (#1771) When a cron job runs in isolated mode, the sessions_spawn tool now correctly inherits the allowAgents permissions from the parent agent's config. The fix adds a requesterAgentIdOverride parameter that flows through the tool creation chain: - resolveEffectiveToolPolicy() extracts the correct agentId from the session key - This agentId is passed to sessions_spawn and agents_list tools - The tools use this override instead of re-parsing the session key This fixes #1767 --- src/agents/clawdbot-tools.ts | 8 +++++++- src/agents/pi-tools.ts | 1 + src/agents/tools/agents-list-tool.ts | 8 ++++++-- src/agents/tools/sessions-spawn-tool.ts | 4 +++- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 91de31937..b420cad6f 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -54,6 +54,8 @@ export function createClawdbotTools(options?: { hasRepliedRef?: { value: boolean }; /** If true, the model has native vision capability */ modelHasVision?: boolean; + /** Explicit agent ID override for cron/hook sessions. */ + requesterAgentIdOverride?: string; }): AnyAgentTool[] { const imageTool = options?.agentDir?.trim() ? createImageTool({ @@ -105,7 +107,10 @@ export function createClawdbotTools(options?: { agentSessionKey: options?.agentSessionKey, config: options?.config, }), - createAgentsListTool({ agentSessionKey: options?.agentSessionKey }), + createAgentsListTool({ + agentSessionKey: options?.agentSessionKey, + requesterAgentIdOverride: options?.requesterAgentIdOverride, + }), createSessionsListTool({ agentSessionKey: options?.agentSessionKey, sandboxed: options?.sandboxed, @@ -129,6 +134,7 @@ export function createClawdbotTools(options?: { agentGroupChannel: options?.agentGroupChannel, agentGroupSpace: options?.agentGroupSpace, sandboxed: options?.sandboxed, + requesterAgentIdOverride: options?.requesterAgentIdOverride, }), createSessionStatusTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index ba0fe13bc..bd745da03 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -313,6 +313,7 @@ export function createClawdbotCodingTools(options?: { replyToMode: options?.replyToMode, hasRepliedRef: options?.hasRepliedRef, modelHasVision: options?.modelHasVision, + requesterAgentIdOverride: agentId, }), ]; const coreToolNames = new Set( diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 40c7172cb..fe64e9558 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -19,7 +19,11 @@ type AgentListEntry = { configured: boolean; }; -export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAgentTool { +export function createAgentsListTool(opts?: { + agentSessionKey?: string; + /** Explicit agent ID override for cron/hook sessions. */ + requesterAgentIdOverride?: string; +}): AnyAgentTool { return { label: "Agents", name: "agents_list", @@ -37,7 +41,7 @@ export function createAgentsListTool(opts?: { agentSessionKey?: string }): AnyAg }) : alias; const requesterAgentId = normalizeAgentId( - parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID, + opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID, ); const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? []; diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index 838badfc3..e5e1391d1 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -67,6 +67,8 @@ export function createSessionsSpawnTool(opts?: { agentGroupChannel?: string | null; agentGroupSpace?: string | null; sandboxed?: boolean; + /** Explicit agent ID override for cron/hook sessions where session key parsing may not work. */ + requesterAgentIdOverride?: string; }): AnyAgentTool { return { label: "Sessions", @@ -129,7 +131,7 @@ export function createSessionsSpawnTool(opts?: { }); const requesterAgentId = normalizeAgentId( - parseAgentSessionKey(requesterInternalKey)?.agentId, + opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId, ); const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) From 8257ec6a1fd95b0bc402e5301754954294b4efca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 13:12:04 +0000 Subject: [PATCH 14/24] ci: harden pnpm setup --- .github/workflows/ci.yml | 74 +++++++++++++++++++++-------- .github/workflows/install-smoke.yml | 18 +++++-- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91090e297..9f944b361 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,11 +32,19 @@ jobs: node-version: 22.x check-latest: true - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.23.0 - run_install: false + - 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: | @@ -109,11 +117,19 @@ jobs: node-version: 22.x check-latest: true - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.23.0 - run_install: false + - 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 @@ -170,6 +186,8 @@ jobs: checks-windows: runs-on: blacksmith-4vcpu-windows-2025 + env: + NODE_OPTIONS: --max-old-space-size=4096 defaults: run: shell: bash @@ -214,11 +232,19 @@ jobs: node-version: 22.x check-latest: true - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.23.0 - run_install: false + - 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 @@ -282,11 +308,19 @@ jobs: node-version: 22.x check-latest: true - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.23.0 - run_install: false + - 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: | diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index b380b1a9b..16eba4eed 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -13,11 +13,19 @@ jobs: - name: Checkout CLI uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.23.0 - run_install: false + - 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 From 9bd5def32c730c0b7724ab30ac87d04d368fe7b3 Mon Sep 17 00:00:00 2001 From: Andre Foeken Date: Sun, 25 Jan 2026 14:18:41 +0100 Subject: [PATCH 15/24] fix(telegram): fall back to text when voice messages forbidden (#1725) * fix(telegram): fall back to text when voice messages forbidden When TTS auto mode is enabled, slash commands like /status would fail silently because sendVoice was rejected with VOICE_MESSAGES_FORBIDDEN. The entire reply would fail without any text being sent. This adds error handling to catch VOICE_MESSAGES_FORBIDDEN specifically and fall back to sending the text content as a regular message instead of failing completely. Co-Authored-By: Claude Opus 4.5 * fix: handle telegram voice fallback errors (#1725) (thanks @foeken) --------- Co-authored-by: Echo Co-authored-by: Claude Opus 4.5 Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/telegram/bot/delivery.test.ts | 107 ++++++++++++++++++++++++++++++ src/telegram/bot/delivery.ts | 41 +++++++++++- 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff3c9e9e..3b378a163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.clawd.bot - 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. diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index ca1b3f4cd..359ba12b3 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -164,4 +164,111 @@ describe("deliverReplies", () => { }), ); }); + + it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi + .fn() + .mockRejectedValue( + new Error( + "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", + ), + ); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 5, + chat: { id: "123" }, + }); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await deliverReplies({ + replies: [ + { mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true }, + ], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }); + + // Voice was attempted but failed + expect(sendVoice).toHaveBeenCalledTimes(1); + // Fallback to text succeeded + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.stringContaining("Hello there"), + expect.any(Object), + ); + }); + + it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi.fn().mockRejectedValue(new Error("Network error")); + const sendMessage = vi.fn(); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await expect( + deliverReplies({ + replies: [{ mediaUrl: "https://example.com/note.ogg", text: "Hello", audioAsVoice: true }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }), + ).rejects.toThrow("Network error"); + + expect(sendVoice).toHaveBeenCalledTimes(1); + // Text fallback should NOT be attempted for other errors + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendVoice = vi + .fn() + .mockRejectedValue( + new Error( + "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", + ), + ); + const sendMessage = vi.fn(); + const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("voice"), + contentType: "audio/ogg", + fileName: "note.ogg", + }); + + await expect( + deliverReplies({ + replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }), + ).rejects.toThrow("VOICE_MESSAGES_FORBIDDEN"); + + expect(sendVoice).toHaveBeenCalledTimes(1); + expect(sendMessage).not.toHaveBeenCalled(); + }); }); diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 2d117d748..c2b1afcf3 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -155,9 +155,44 @@ 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). + const errMsg = formatErrorMessage(voiceErr); + if (errMsg.includes("VOICE_MESSAGES_FORBIDDEN")) { + if (!reply.text?.trim()) { + throw voiceErr; + } + logVerbose( + "telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text", + ); + // Send the text content instead of the voice message. + if (reply.text) { + const chunks = chunkText(reply.text); + for (const chunk of chunks) { + await sendTelegramText(bot, chatId, chunk.html, runtime, { + replyToMessageId: + replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, + messageThreadId, + textMode: "html", + plainText: chunk.text, + linkPreview, + }); + if (replyToId && !hasReplied) { + hasReplied = true; + } + } + } + // 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, { From 97487a51a09aae2f4650a50afc1422abd29885d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 13:20:41 +0000 Subject: [PATCH 16/24] style: format agents list tool --- src/agents/tools/agents-list-tool.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index fe64e9558..28f136844 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -41,7 +41,9 @@ export function createAgentsListTool(opts?: { }) : alias; const requesterAgentId = normalizeAgentId( - opts?.requesterAgentIdOverride ?? parseAgentSessionKey(requesterInternalKey)?.agentId ?? DEFAULT_AGENT_ID, + opts?.requesterAgentIdOverride ?? + parseAgentSessionKey(requesterInternalKey)?.agentId ?? + DEFAULT_AGENT_ID, ); const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? []; From 22cf2b67666d4270dbcff4ef12e2f45dfb38ad06 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 13:20:37 +0000 Subject: [PATCH 17/24] fix: config/debug UI overflow (#1715) Thanks @saipreetham589. Co-authored-by: SaiPreetham --- CHANGELOG.md | 1 + ui/src/styles/components.css | 1 + ui/src/styles/config.css | 6 ++++-- ui/src/styles/layout.css | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b378a163..ebff8d858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.clawd.bot - Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido. ### Fixes +- 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. - Web UI: hide internal `message_id` hints in chat bubbles. diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 9b253e231..a78e0ef0a 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -593,6 +593,7 @@ border: 1px solid var(--border); max-height: 360px; overflow: auto; + max-width: 100%; } :root[data-theme="light"] .code-block, diff --git a/ui/src/styles/config.css b/ui/src/styles/config.css index fcdeeb51f..7d96ac13f 100644 --- a/ui/src/styles/config.css +++ b/ui/src/styles/config.css @@ -7,7 +7,7 @@ display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 0; - min-height: calc(100vh - 160px); + height: calc(100vh - 160px); margin: -16px; border-radius: var(--radius-xl); overflow: hidden; @@ -24,6 +24,8 @@ flex-direction: column; background: var(--bg-accent); border-right: 1px solid var(--border); + min-height: 0; + overflow: hidden; } :root[data-theme="light"] .config-sidebar { @@ -245,7 +247,7 @@ min-height: 0; min-width: 0; background: var(--panel); - overflow-y: auto; + overflow: hidden; } /* Actions Bar */ diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index a3db5d737..c2a5c6fe3 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -539,6 +539,7 @@ .stack { display: grid; gap: 12px; + grid-template-columns: minmax(0, 1fr); } .filters { From 03e9a076b8db919a878f6eb31d2110c344667d8e Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sat, 24 Jan 2026 22:06:22 -0800 Subject: [PATCH 18/24] Fix newline chunking: keep paragraphs/lists together --- src/auto-reply/chunk.test.ts | 26 +++++--- src/auto-reply/chunk.ts | 117 ++++++++++++++++++++++++++-------- src/infra/outbound/deliver.ts | 15 +++-- 3 files changed, 115 insertions(+), 43 deletions(-) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 01069d852..f2256c082 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -310,10 +310,16 @@ describe("chunkTextWithMode", () => { expect(chunks).toEqual(["Line one\nLine two"]); }); - it("uses newline-based chunking for newline mode", () => { + it("uses paragraph-based chunking for newline mode", () => { const text = "Line one\nLine two"; const chunks = chunkTextWithMode(text, 1000, "newline"); - expect(chunks).toEqual(["Line one", "Line two"]); + expect(chunks).toEqual(["Line one\nLine two"]); + }); + + it("splits on blank lines for newline mode", () => { + const text = "Para one\n\nPara two"; + const chunks = chunkTextWithMode(text, 1000, "newline"); + expect(chunks).toEqual(["Para one", "Para two"]); }); }); @@ -323,17 +329,19 @@ 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]); }); }); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index c77c0cd9f..8da1b9f9c 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -13,7 +13,9 @@ export type TextChunkProvider = ChannelId | typeof INTERNAL_MESSAGE_CHANNEL; /** * Chunking mode for outbound messages: * - "length": Split only when exceeding textChunkLimit (default) - * - "newline": Split on every newline, with fallback to length-based for long lines + * - "newline": Prefer breaking on "soft" boundaries. Historically this split on every + * newline; now it only breaks on paragraph boundaries (blank lines) unless the text + * exceeds the length limit. */ export type ChunkMode = "length" | "newline"; @@ -164,44 +166,105 @@ 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` + */ +export function chunkByParagraph(text: string, limit: number): string[] { + if (!text) return []; + if (limit <= 0) return [text]; + if (text.length <= limit) return [text]; + + // Normalize to \n so blank line detection is consistent. + const normalized = text.replace(/\r\n?/g, "\n"); + + const parts: string[] = []; + const seps: 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; + parts.push(normalized.slice(lastIndex, idx)); + seps.push(match[0]); + lastIndex = idx + match[0].length; + } + parts.push(normalized.slice(lastIndex)); + + const chunks: string[] = []; + let current = ""; + let pendingSep = ""; + + const flush = () => { + const out = current.trimEnd(); + if (out) chunks.push(out); + current = ""; + }; + + for (let i = 0; i < parts.length; i++) { + const paragraph = parts[i] ?? ""; + if (!paragraph.trim() && i === parts.length - 1) break; + + const prefix = pendingSep; + pendingSep = seps[i] ?? ""; + + const candidate = current + ? `${current}${prefix}${paragraph}` + : // Cap leading blank lines so we never exceed `limit` with just prefixes. + `${prefix.slice(0, Math.max(0, limit - 1))}${paragraph}`; + + if (candidate.length <= limit) { + current = candidate; + continue; + } + + // Can't fit this paragraph into the current chunk. + if (current) flush(); + + const paragraphWithPrefix = `${prefix}${paragraph}`; + if (paragraphWithPrefix.length <= limit) { + current = paragraphWithPrefix; + continue; + } + + // Paragraph itself is too long; split it by length (preferring newlines/whitespace). + const prefixCap = prefix.slice(0, Math.max(0, limit - 1)); + const remainingLimit = Math.max(1, limit - prefixCap.length); + const [first, ...rest] = chunkText(paragraph, remainingLimit); + if (first) chunks.push(prefixCap + first); + chunks.push(...rest); + } + + if (current.trim()) flush(); + 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); + const out: string[] = []; + for (const chunk of paragraphChunks) { + const nested = chunkMarkdownText(chunk, limit); + if (!nested.length && chunk) out.push(chunk); + else out.push(...nested); } - return chunks; + return out; } return chunkMarkdownText(text, limit); } diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index cd0ccd5ff..d246889e9 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,5 +1,5 @@ import { - chunkByNewline, + chunkByParagraph, chunkMarkdownTextWithMode, resolveChunkMode, resolveTextChunkLimit, @@ -239,14 +239,15 @@ export async function deliverOutboundPayloads(params: { } if (chunkMode === "newline") { const mode = handler.chunkerMode ?? "text"; - const lineChunks = + const blockChunks = mode === "markdown" ? chunkMarkdownTextWithMode(text, textLimit, "newline") - : chunkByNewline(text, textLimit, { splitLongLines: false }); - if (!lineChunks.length && text) lineChunks.push(text); - for (const lineChunk of lineChunks) { - const chunks = handler.chunker(lineChunk, textLimit); - if (!chunks.length && lineChunk) chunks.push(lineChunk); + : chunkByParagraph(text, textLimit); + + if (!blockChunks.length && text) blockChunks.push(text); + for (const blockChunk of blockChunks) { + const chunks = handler.chunker(blockChunk, textLimit); + if (!chunks.length && blockChunk) chunks.push(blockChunk); for (const chunk of chunks) { throwIfAborted(abortSignal); results.push(await handler.sendText(chunk)); From 46fa1c1301693454640f7afc59a102b1df4da6fc Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sat, 24 Jan 2026 23:18:58 -0800 Subject: [PATCH 19/24] Fix newline chunkMode block streaming to preserve single-newline paragraphs --- src/auto-reply/reply/block-streaming.ts | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 82fa85919..875920731 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -7,7 +7,7 @@ import { INTERNAL_MESSAGE_CHANNEL, listDeliverableMessageChannels, } from "../../utils/message-channel.js"; -import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; +import { resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; @@ -69,15 +69,11 @@ export function resolveBlockStreamingChunking( }); const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; - // When chunkMode is "newline", use newline-based streaming - const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); - if (channelChunkMode === "newline") { - // For newline mode: use very low minChars to flush quickly on newlines - const minChars = Math.max(1, Math.floor(chunkCfg?.minChars ?? 1)); - const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? textLimit)); - const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); - return { minChars, maxChars, breakPreference: "newline" }; - } + // Note: chunkMode="newline" used to imply splitting on each newline, but outbound + // delivery now treats it as paragraph-aware chunking (only split on blank lines). + // Block streaming should follow the same rule, so we do NOT special-case newline + // mode here. + // (chunkMode no longer alters block streaming behavior) const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX)); const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); @@ -103,11 +99,8 @@ export function resolveBlockStreamingCoalescing( ): BlockStreamingCoalescing | undefined { const providerKey = normalizeChunkProvider(provider); - // When chunkMode is "newline", disable coalescing to send each line immediately - const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId); - if (channelChunkMode === "newline") { - return undefined; - } + // Note: chunkMode="newline" is paragraph-aware in outbound delivery (blank-line splits), + // so block streaming should not disable coalescing or flush per single newline. const providerId = providerKey ? normalizeChannelId(providerKey) : null; const providerChunkLimit = providerId From 0975aa4a7c14079ec6e240144640178e38a0a306 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sat, 24 Jan 2026 23:30:14 -0800 Subject: [PATCH 20/24] Fix newline chunking: split on blank lines even under limit --- src/auto-reply/chunk.ts | 60 +++++++++++------------------------------ 1 file changed, 15 insertions(+), 45 deletions(-) diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 8da1b9f9c..340886c7e 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -177,69 +177,39 @@ export function chunkByNewline( export function chunkByParagraph(text: string, limit: number): string[] { if (!text) return []; if (limit <= 0) return [text]; - if (text.length <= limit) return [text]; // Normalize to \n so blank line detection is consistent. const normalized = text.replace(/\r\n?/g, "\n"); - const parts: string[] = []; - const seps: string[] = []; + // 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)) { + return normalized.length <= limit ? [normalized] : chunkText(normalized, limit); + } + 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; parts.push(normalized.slice(lastIndex, idx)); - seps.push(match[0]); lastIndex = idx + match[0].length; } parts.push(normalized.slice(lastIndex)); const chunks: string[] = []; - let current = ""; - let pendingSep = ""; - - const flush = () => { - const out = current.trimEnd(); - if (out) chunks.push(out); - current = ""; - }; - - for (let i = 0; i < parts.length; i++) { - const paragraph = parts[i] ?? ""; - if (!paragraph.trim() && i === parts.length - 1) break; - - const prefix = pendingSep; - pendingSep = seps[i] ?? ""; - - const candidate = current - ? `${current}${prefix}${paragraph}` - : // Cap leading blank lines so we never exceed `limit` with just prefixes. - `${prefix.slice(0, Math.max(0, limit - 1))}${paragraph}`; - - if (candidate.length <= limit) { - current = candidate; - continue; + for (const part of parts) { + const paragraph = part.replace(/\s+$/g, ""); + if (!paragraph.trim()) continue; + if (paragraph.length <= limit) { + chunks.push(paragraph); + } else { + chunks.push(...chunkText(paragraph, limit)); } - - // Can't fit this paragraph into the current chunk. - if (current) flush(); - - const paragraphWithPrefix = `${prefix}${paragraph}`; - if (paragraphWithPrefix.length <= limit) { - current = paragraphWithPrefix; - continue; - } - - // Paragraph itself is too long; split it by length (preferring newlines/whitespace). - const prefixCap = prefix.slice(0, Math.max(0, limit - 1)); - const remainingLimit = Math.max(1, limit - prefixCap.length); - const [first, ...rest] = chunkText(paragraph, remainingLimit); - if (first) chunks.push(prefixCap + first); - chunks.push(...rest); } - if (current.trim()) flush(); return chunks; } From c3f5b4c41667278a02354eb8de685315c6730904 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sat, 24 Jan 2026 23:39:09 -0800 Subject: [PATCH 21/24] Fix paragraph chunking to ignore blank lines inside code fences --- src/auto-reply/chunk.test.ts | 11 +++++++++++ src/auto-reply/chunk.ts | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index f2256c082..7007e0abc 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -343,6 +343,17 @@ describe("chunkMarkdownTextWithMode", () => { const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter"; expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual([text]); }); + + 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"]); + }); }); describe("resolveChunkMode", () => { diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 340886c7e..1615699b9 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -189,11 +189,19 @@ export function chunkByParagraph(text: string, limit: number): string[] { return normalized.length <= limit ? [normalized] : 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; } From 0130ecd8004bfe640446d0afd78d4cfe05942bf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 13:24:00 +0000 Subject: [PATCH 22/24] fix: paragraph-aware newline chunking (#1726) Thanks @tyler6204 Co-authored-by: Tyler Yust <64381258+tyler6204@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/channels/bluebubbles.md | 2 +- docs/channels/discord.md | 4 ++-- docs/channels/imessage.md | 4 ++-- docs/channels/matrix.md | 2 +- docs/channels/msteams.md | 2 +- docs/channels/nextcloud-talk.md | 2 +- docs/channels/signal.md | 4 ++-- docs/channels/slack.md | 2 +- docs/channels/telegram.md | 4 ++-- docs/channels/whatsapp.md | 2 +- docs/concepts/streaming.md | 2 +- docs/gateway/configuration.md | 2 +- src/auto-reply/chunk.test.ts | 5 +++++ src/auto-reply/chunk.ts | 16 +++++++++++++--- src/discord/chunk.test.ts | 2 +- src/infra/outbound/deliver.test.ts | 7 +++---- 17 files changed, 39 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebff8d858..2bc3ebae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Docs: https://docs.clawd.bot - 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. diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index 1dd8e560d..a1f4a0892 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -196,7 +196,7 @@ Provider options: - `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`). - `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`). - `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000). -- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming. +- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. - `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8). - `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables). - `channels.bluebubbles.dmHistoryLimit`: DM history limit. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index f63fd45c9..12dd28084 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -205,7 +205,7 @@ Notes: ## Capabilities & limits - DMs and guild text channels (threads are treated as separate channels; voice not supported). - Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17). -- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB). - Mention-gated guild replies by default to avoid noisy bots. - Reply context is injected when a message references another message (quoted content + ids). @@ -307,7 +307,7 @@ ack reaction after the bot replies. - `guilds..requireMention`: per-guild mention requirement (overridable per channel). - `guilds..reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`). - `textChunkLimit`: outbound text chunk size (chars). Default: 2000. -- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking. +- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking. - `maxLinesPerMessage`: soft max line count per message. Default: 17. - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables). diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 316822dc5..bae945e8c 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -219,7 +219,7 @@ This is useful when you want an isolated personality/model for a specific thread ## Limits - Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16). ## Addressing / delivery targets @@ -254,7 +254,7 @@ Provider options: - `channels.imessage.includeAttachments`: ingest attachments into context. - `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.imessage.textChunkLimit`: outbound chunk size (chars). -- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. Related global options: - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`). diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 77a2989d5..2d9025f51 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -215,7 +215,7 @@ Provider options: - `channels.matrix.initialSyncLimit`: initial sync limit. - `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). - `channels.matrix.textChunkLimit`: outbound text chunk size (chars). -- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible. - `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index de3b064b2..2f6ed5f83 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -415,7 +415,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) - `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. - `channels.msteams.textChunkLimit`: outbound text chunk size. -- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). - `channels.msteams.requireMention`: require @mention in channels/groups (default true). - `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)). diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md index 43c1595ed..abc696444 100644 --- a/docs/channels/nextcloud-talk.md +++ b/docs/channels/nextcloud-talk.md @@ -114,7 +114,7 @@ Provider options: - `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables). - `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit). - `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars). -- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel. - `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning. - `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB). diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 0ba89385d..c154b0591 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -111,7 +111,7 @@ Groups: ## Media + limits - Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Attachments supported (base64 fetched from `signal-cli`). - Default media cap: `channels.signal.mediaMaxMb` (default 8). - Use `channels.signal.ignoreAttachments` to skip downloading media. @@ -170,7 +170,7 @@ Provider options: - `channels.signal.historyLimit`: max group messages to include as context (0 disables). - `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms[""].historyLimit`. - `channels.signal.textChunkLimit`: outbound chunk size (chars). -- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB). Related global options: diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 44bc84035..5f768db0e 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -349,7 +349,7 @@ ack reaction after the bot replies. ## Limits - Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Media uploads are capped by `channels.slack.mediaMaxMb` (default 20). ## Reply threading diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index eb558cf74..e708e2e64 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -135,7 +135,7 @@ Notes: ## Limits - Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5). - Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs. - Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). @@ -524,7 +524,7 @@ Provider options: - `channels.telegram.accounts..capabilities.inlineButtons`: per-account override. - `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.textChunkLimit`: outbound chunk size (chars). -- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. +- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 517c71b93..4759cf4c9 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -271,7 +271,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately ## Limits - Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000). -- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on each line before length chunking. +- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. - Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB). - Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB). diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 8019e4cca..6f9609ca6 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -38,7 +38,7 @@ Legend: - `agents.defaults.blockStreamingChunk`: `{ minChars, maxChars, breakPreference? }`. - `agents.defaults.blockStreamingCoalesce`: `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send). - Channel hard cap: `*.textChunkLimit` (e.g., `channels.whatsapp.textChunkLimit`). -- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on each line before length chunking). +- Channel chunk mode: `*.chunkMode` (`length` default, `newline` splits on blank lines (paragraph boundaries) before length chunking). - Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping. **Boundary semantics:** diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3b16be5b1..868126101 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1131,7 +1131,7 @@ Reaction notification modes: - `own`: reactions on the bot's own messages (default). - `all`: all reactions on all messages. - `allowlist`: reactions from `guilds..users` on all messages (empty list disables). -Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on line boundaries before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars. +Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Set `channels.discord.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking. Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars. Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry). ### `channels.googlechat` (Chat API webhook) diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts index 7007e0abc..545899843 100644 --- a/src/auto-reply/chunk.test.ts +++ b/src/auto-reply/chunk.test.ts @@ -344,6 +344,11 @@ describe("chunkMarkdownTextWithMode", () => { 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]); diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 1615699b9..c4fd31ed8 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -173,10 +173,16 @@ export function chunkByNewline( * - 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): string[] { +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"); @@ -186,7 +192,9 @@ export function chunkByParagraph(text: string, limit: number): string[] { // boundaries, not only exceeding a length limit.) const paragraphRe = /\n[\t ]*\n+/; if (!paragraphRe.test(normalized)) { - return normalized.length <= limit ? [normalized] : chunkText(normalized, limit); + if (normalized.length <= limit) return [normalized]; + if (!splitLongParagraphs) return [normalized]; + return chunkText(normalized, limit); } const spans = parseFenceSpans(normalized); @@ -213,6 +221,8 @@ export function chunkByParagraph(text: string, limit: number): string[] { if (!paragraph.trim()) continue; if (paragraph.length <= limit) { chunks.push(paragraph); + } else if (!splitLongParagraphs) { + chunks.push(paragraph); } else { chunks.push(...chunkText(paragraph, limit)); } @@ -235,7 +245,7 @@ export function chunkMarkdownTextWithMode(text: string, limit: number, mode: Chu if (mode === "newline") { // 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); + const paragraphChunks = chunkByParagraph(text, limit, { splitLongParagraphs: false }); const out: string[] = []; for (const chunk of paragraphChunks) { const nested = chunkMarkdownText(chunk, limit); diff --git a/src/discord/chunk.test.ts b/src/discord/chunk.test.ts index f8e18e2b4..13ec1b8e7 100644 --- a/src/discord/chunk.test.ts +++ b/src/discord/chunk.test.ts @@ -58,7 +58,7 @@ describe("chunkDiscordText", () => { maxLines: 50, chunkMode: "newline", }); - expect(chunks).toEqual(["```js\nconst a = 1;\nconst b = 2;\n```", "After"]); + expect(chunks).toEqual([text]); }); it("reserves space for closing fences when chunking", () => { diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index d259366b4..a80a3f482 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -192,7 +192,7 @@ describe("deliverOutboundPayloads", () => { expect(sendWhatsApp).toHaveBeenNthCalledWith( 2, "+1555", - "\nLine two", + "Line two", expect.objectContaining({ verbose: false }), ); }); @@ -241,9 +241,8 @@ describe("deliverOutboundPayloads", () => { payloads: [{ text }], }); - expect(chunker).toHaveBeenCalledTimes(2); - expect(chunker).toHaveBeenNthCalledWith(1, "```js\nconst a = 1;\nconst b = 2;\n```", 4000); - expect(chunker).toHaveBeenNthCalledWith(2, "After", 4000); + expect(chunker).toHaveBeenCalledTimes(1); + expect(chunker).toHaveBeenNthCalledWith(1, text, 4000); }); it("uses iMessage media maxBytes from agent fallback", async () => { From 4c11fc0c09cf192b7148000253850cef0e528678 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 13:26:25 +0000 Subject: [PATCH 23/24] refactor: streamline telegram voice fallback --- src/telegram/bot/delivery.test.ts | 3 ++ src/telegram/bot/delivery.ts | 75 ++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 359ba12b3..404cc2fc2 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -17,6 +17,9 @@ vi.mock("grammy", () => ({ public fileName?: string, ) {} }, + GrammyError: class GrammyError extends Error { + description = ""; + }, })); describe("deliverReplies", () => { diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index c2b1afcf3..4edc91c8a 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -1,4 +1,4 @@ -import { type Bot, InputFile } from "grammy"; +import { type Bot, GrammyError, InputFile } from "grammy"; import { markdownToTelegramChunks, markdownToTelegramHtml, @@ -22,6 +22,7 @@ import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js" import type { TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; +const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -163,31 +164,26 @@ export async function deliverReplies(params: { // 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). - const errMsg = formatErrorMessage(voiceErr); - if (errMsg.includes("VOICE_MESSAGES_FORBIDDEN")) { - if (!reply.text?.trim()) { + 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", ); - // Send the text content instead of the voice message. - if (reply.text) { - const chunks = chunkText(reply.text); - for (const chunk of chunks) { - await sendTelegramText(bot, chatId, chunk.html, runtime, { - replyToMessageId: - replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, - messageThreadId, - textMode: "html", - plainText: chunk.text, - linkPreview, - }); - if (replyToId && !hasReplied) { - hasReplied = true; - } - } - } + hasReplied = await sendTelegramVoiceFallbackText({ + bot, + chatId, + runtime, + text: fallbackText, + chunkText, + replyToId, + replyToMode, + hasReplied, + messageThreadId, + linkPreview, + }); // Skip this media item; continue with next. continue; } @@ -263,6 +259,43 @@ export async function resolveMedia( return { path: saved.path, contentType: saved.contentType, placeholder }; } +function isVoiceMessagesForbidden(err: unknown): boolean { + if (err instanceof GrammyError) { + return VOICE_FORBIDDEN_RE.test(err.description); + } + return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err)); +} + +async function sendTelegramVoiceFallbackText(opts: { + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + text: string; + chunkText: (markdown: string) => ReturnType; + replyToId?: number; + replyToMode: ReplyToMode; + hasReplied: boolean; + messageThreadId?: number; + linkPreview?: boolean; +}): Promise { + const chunks = opts.chunkText(opts.text); + let hasReplied = opts.hasReplied; + for (const chunk of chunks) { + await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { + replyToMessageId: + opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined, + messageThreadId: opts.messageThreadId, + textMode: "html", + plainText: chunk.text, + linkPreview: opts.linkPreview, + }); + if (opts.replyToId && !hasReplied) { + hasReplied = true; + } + } + return hasReplied; +} + function buildTelegramSendParams(opts?: { replyToMessageId?: number; messageThreadId?: number; From a14ca1a3378a00bd253cd1ba6d45049574ae9d63 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 13:32:18 +0000 Subject: [PATCH 24/24] test: normalize gemini oauth paths --- .../google-gemini-cli-auth/oauth.test.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 9d186643a..d776d9be3 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -19,6 +19,7 @@ vi.mock("node:fs", async (importOriginal) => { }); 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 = ` @@ -57,8 +58,9 @@ describe("extractGeminiCliCredentials", () => { process.env.PATH = fakeBinDir; mockExistsSync.mockImplementation((p: string) => { - if (p === fakeGeminiPath) return true; - if (p === fakeOauth2Path) return true; + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) return true; + if (normalized === normalizePath(fakeOauth2Path)) return true; return false; }); mockRealpathSync.mockReturnValue(fakeResolvedPath); @@ -81,7 +83,9 @@ describe("extractGeminiCliCredentials", () => { process.env.PATH = fakeBinDir; - mockExistsSync.mockImplementation((p: string) => p === fakeGeminiPath); + mockExistsSync.mockImplementation( + (p: string) => normalizePath(p) === normalizePath(fakeGeminiPath), + ); mockRealpathSync.mockReturnValue(fakeResolvedPath); mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search @@ -100,8 +104,9 @@ describe("extractGeminiCliCredentials", () => { process.env.PATH = fakeBinDir; mockExistsSync.mockImplementation((p: string) => { - if (p === fakeGeminiPath) return true; - if (p === fakeOauth2Path) return true; + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) return true; + if (normalized === normalizePath(fakeOauth2Path)) return true; return false; }); mockRealpathSync.mockReturnValue(fakeResolvedPath); @@ -122,8 +127,9 @@ describe("extractGeminiCliCredentials", () => { process.env.PATH = fakeBinDir; mockExistsSync.mockImplementation((p: string) => { - if (p === fakeGeminiPath) return true; - if (p === fakeOauth2Path) return true; + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) return true; + if (normalized === normalizePath(fakeOauth2Path)) return true; return false; }); mockRealpathSync.mockReturnValue(fakeResolvedPath);