Compare commits

..
Author SHA1 Message Date
Jon Uleis b89efef749 feat(web_search): add freshness parameter for Brave time filtering
Adds support for Brave Search API's freshness parameter to filter results
by discovery time:
- 'pd' - Past 24 hours
- 'pw' - Past week
- 'pm' - Past month
- 'py' - Past year
- 'YYYY-MM-DDtoYYYY-MM-DD' - Custom date range

Useful for cron jobs and monitoring tasks that need recent results.

Note: Perplexity provider ignores this parameter (Brave only).

---
🤖 AI-assisted: This PR was created with Claude (Opus). Lightly tested via
build script. The change follows existing patterns for optional parameters
(country, search_lang, ui_lang).
2026-01-25 04:19:31 +00:00
Peter Steinberger 8a3b2eefa3 fix: validate web_search freshness (#1688) (thanks @JonUleis) 2026-01-25 04:17:42 +00:00
Peter Steinberger 50bb418fe7 fix: guard discord thread channel 2026-01-25 04:11:55 +00:00
Peter Steinberger 458e731f8b fix: newline chunking across channels 2026-01-25 04:11:36 +00:00
Peter Steinberger ca78ccf74c docs: add dedicated host faq 2026-01-25 04:11:08 +00:00
Peter Steinberger 580fd7abbd fix: guard discord forum thread access 2026-01-25 04:11:04 +00:00
Peter Steinberger 4a82c258c7 docs: add windows restart faq 2026-01-25 04:09:51 +00:00
Peter Steinberger 58c7c61e62 fix: add duplex for fetch uploads 2026-01-25 04:05:30 +00:00
Peter Steinberger 629ce4454d docs: add tips + clawd-to-clawd faq 2026-01-25 04:04:18 +00:00
Peter Steinbergerandvilkasdev 617d8a12d7 chore: update clawtributors
Co-authored-by: vilkasdev <vilkasdev@users.noreply.github.com>
2026-01-25 04:01:10 +00:00
Shadow cdceff2284 Discord: add forum parent context 2026-01-24 21:57:48 -06:00
Peter Steinberger cb52ffb842 fix: drop unused cli import 2026-01-25 03:42:32 +00:00
Peter Steinberger 3a35d313d9 fix: signal reactions 2026-01-25 03:24:44 +00:00
Tom McKenzieandClaude Sonnet 4.5 116fbb747f CLI: fix subcommand registration to work without --help/--version flags (#1683)
## Problem

The clawdbot-gateway systemd service was crash-looping on Linux (Fedora 42,
aarch64) with the error:

    error: unknown command '/usr/bin/node-22'

After ~20 seconds of runtime, the gateway would exit with status 1/FAILURE
and systemd would restart it, repeating the cycle indefinitely (80+ restarts
observed).

## Root Cause Analysis

### Investigation Steps

1. Examined systemd service logs via `journalctl --user -u clawdbot-gateway.service`
2. Found the error appeared consistently after the service had been running
   for 20-30 seconds
3. Added debug logging to trace argv at parseAsync() call
4. Discovered that argv was being passed to Commander.js with the node binary
   and script paths still present: `["/usr/bin/node-22", "/path/to/entry.js", "gateway", "--port", "18789"]`
5. Traced the issue to the lazy subcommand registration logic in runCli()

### The Bug

The lazy-loading logic for subcommands was gated behind `hasHelpOrVersion(parseArgv)`:

```typescript
if (hasHelpOrVersion(parseArgv)) {
  const primary = getPrimaryCommand(parseArgv);
  if (primary) {
    const { registerSubCliByName } = await import("./program/register.subclis.js");
    await registerSubCliByName(program, primary);
  }
}
```

This meant that when running `clawdbot gateway --port 18789` (without --help
or --version), the `gateway` subcommand was never registered before
`program.parseAsync(parseArgv)` was called. Commander.js would then try to
parse the arguments without knowing about the gateway command, leading to
parse errors.

The error message "unknown command '/usr/bin/node-22'" appeared because
Commander was treating the first positional argument as a command name due to
argv not being properly stripped on non-Windows platforms in some code paths.

## The Fix

Remove the `hasHelpOrVersion()` gate and always register the primary
subcommand when one is detected:

```typescript
// Register the primary subcommand if one exists (for lazy-loading)
const primary = getPrimaryCommand(parseArgv);
if (primary) {
  const { registerSubCliByName } = await import("./program/register.subclis.js");
  await registerSubCliByName(program, primary);
}
```

This ensures that subcommands like `gateway` are properly registered before
parsing begins, regardless of what flags are present.

## Environment

- OS: Fedora 42 (Linux 6.15.9-201.fc42.aarch64)
- Arch: aarch64
- Node: /usr/bin/node-22 (symlink to node-22)
- Deployment: systemd user service
- Runtime: Gateway started via `clawdbot gateway --port 18789`

## Why This Should Be Merged

1. **Critical Bug**: The gateway service cannot run reliably on Linux without
   this fix, making it a blocking issue for production deployments via systemd.

2. **Affects All Non-Help Invocations**: Any direct subcommand invocation
   (gateway, channels, etc.) without --help/--version is broken.

3. **Simple & Safe Fix**: The change removes an unnecessary condition that was
   preventing lazy-loading from working correctly. Subcommands should always be
   registered when detected, not just for help/version requests.

4. **No Regression Risk**: The fix maintains the lazy-loading behavior (only
   loads the requested subcommand), just ensures it works in all cases instead
   of only help/version scenarios.

5. **Tested**: Verified that the gateway service now runs stably for extended
   periods (45+ seconds continuous runtime with no crashes) after applying this
   fix.

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 03:17:02 +00:00
Peter Steinberger b1a555da13 fix: skip tailscale dns probe when off 2026-01-25 02:51:20 +00:00
Peter Steinberger c92aaca8b0 docs: answer local data storage faq 2026-01-25 02:48:28 +00:00
Peter Steinberger c3e777e3e1 fix: keep raw config edits scoped to config view (#1673) (thanks @Glucksberg) 2026-01-25 02:48:07 +00:00
Peter Steinberger 2e3b14187b fix: stabilize venice model discovery 2026-01-25 02:43:08 +00:00
Peter Steinberger f8a22521bd docs: clarify WSL2 recommendation 2026-01-25 02:30:09 +00:00
118 changed files with 1798 additions and 185 deletions

No files matched your search

+5 -2
View File
@@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot
### 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
- 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: update Fly.io guide notes.
@@ -21,20 +22,22 @@ Docs: https://docs.clawd.bot
- 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: keep raw config edits from toggling channel save state; enable save/apply on raw changes only. (#1673) 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.
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- 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.
- Gateway: honor trusted proxy client IPs for local pairing + HTTP checks. (#1654) Thanks @ndbroadbent.
- 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.
- 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)
- 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.
## 2026.1.23-1
+28 -27
View File
@@ -477,31 +477,32 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
<a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a>
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a>
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
<a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
<a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
<a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
<a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
<a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a>
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a>
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
<a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
<a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
<a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
<a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a>
<a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a>
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a>
<a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a>
<a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>
+2
View File
@@ -205,6 +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.
- 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).
@@ -306,6 +307,7 @@ ack reaction after the bot replies.
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline before length chunking.
- `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).
+2
View File
@@ -219,6 +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.
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
## Addressing / delivery targets
@@ -253,6 +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.
Related global options:
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
+2
View File
@@ -32,6 +32,8 @@ Text is supported everywhere; media and reactions vary by channel.
## Notes
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
stores more state on disk.
- Group behavior varies by channel; see [Groups](/concepts/groups).
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
- Telegram internals: [grammY notes](/channels/grammy).
+1
View File
@@ -215,6 +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.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).
+1
View File
@@ -415,6 +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.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)).
+1
View File
@@ -114,6 +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.blockStreaming`: disable block streaming for this channel.
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
+23
View File
@@ -95,6 +95,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.
- Attachments supported (base64 fetched from `signal-cli`).
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
- Use `channels.signal.ignoreAttachments` to skip downloading media.
@@ -105,8 +106,29 @@ Groups:
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
- Signal-cli does not expose read receipts for groups.
## Reactions (message tool)
- Use `message action=react` with `channel=signal`.
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
- `messageId` is the Signal timestamp for the message you’re reacting to.
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
Examples:
```
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
```
Config:
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
- `off`/`ack` disables agent reactions (message tool `react` will error).
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
## Delivery targets (CLI/cron)
- DMs: `signal:+15551234567` (or plain E.164).
- UUID DMs: `uuid:<id>` (or bare UUID).
- Groups: `signal:group:<groupId>`.
- Usernames: `username:<name>` (if supported by your Signal account).
@@ -131,6 +153,7 @@ Provider options:
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
Related global options:
+1
View File
@@ -349,6 +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.
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
## Reply threading
+2
View File
@@ -128,6 +128,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.
- 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).
@@ -516,6 +517,7 @@ Provider options:
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
+2 -1
View File
@@ -271,12 +271,13 @@ 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.
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
## Outbound send (text + media)
- Uses active web listener; error if gateway not running.
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`).
- Media:
- Image/video/audio/document supported.
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
+10 -2
View File
@@ -66,11 +66,12 @@ Name lookup:
- Discord only: `--poll-duration-hours`, `--message`
- `react`
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
- Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me`
- Signal group reactions: `--target-author` or `--target-author-uuid` required
- `reactions`
- Channels: Discord/Google Chat/Slack
@@ -213,6 +214,13 @@ clawdbot message react --channel slack \
--target C123 --message-id 456 --emoji "✅"
```
React in a Signal group:
```
clawdbot message react --channel signal \
--target signal:group:abc123 --message-id 1737630212345 \
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
```
Send Telegram inline buttons:
```
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
+1
View File
@@ -31,6 +31,7 @@ These files live under the workspace (`agents.defaults.workspace`, default
- Decisions, preferences, and durable facts go to `MEMORY.md`.
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`.
- If someone says "remember this," write it down (do not keep it in RAM).
- If you want something to stick, **ask the bot to write it** into memory.
## Automatic memory flush (pre-compaction ping)
+1
View File
@@ -38,6 +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).
- Discord soft cap: `channels.discord.maxLinesPerMessage` (default 17) splits tall replies to avoid UI clipping.
**Boundary semantics:**
+6 -2
View File
@@ -504,6 +504,7 @@ For groups, use `channels.whatsapp.groupPolicy` + `channels.whatsapp.groupAllowF
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "+447700900123"],
textChunkLimit: 4000, // optional outbound chunk size (chars)
chunkMode: "length", // optional chunking mode (length | newline)
mediaMaxMb: 50 // optional inbound media cap (MB)
}
}
@@ -1105,6 +1106,7 @@ Multi-account support lives under `channels.discord.accounts` (see the multi-acc
},
historyLimit: 20, // include last N guild messages as context
textChunkLimit: 2000, // optional outbound text chunk size (chars)
chunkMode: "length", // optional chunking mode (length | newline)
maxLinesPerMessage: 17, // soft max lines per message (Discord UI clipping)
retry: { // outbound retry policy
attempts: 3,
@@ -1125,7 +1127,7 @@ Reaction notification modes:
- `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). 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 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.
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
### `channels.googlechat` (Chat API webhook)
@@ -1218,6 +1220,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
ephemeral: true
},
textChunkLimit: 4000,
chunkMode: "length",
mediaMaxMb: 20
}
}
@@ -1267,7 +1270,8 @@ Mattermost requires a bot token plus the base URL for your server:
dmPolicy: "pairing",
chatmode: "oncall", // oncall | onmessage | onchar
oncharPrefixes: [">", "!"],
textChunkLimit: 4000
textChunkLimit: 4000,
chunkMode: "length"
}
}
}
+2
View File
@@ -29,6 +29,8 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh
- where the bot is allowed to act
- what the bot can touch
Start with the smallest access that still works, then widen it as you gain confidence.
### What the audit checks (high level)
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
+101
View File
@@ -48,6 +48,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
- [How important is it to run Clawdbot on a dedicated machine?](#how-important-is-it-to-run-clawdbot-on-a-dedicated-machine)
- [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What’s the value proposition?](#whats-the-value-proposition)
@@ -66,6 +67,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How does memory work?](#how-does-memory-work)
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
- [Where things live on disk](#where-things-live-on-disk)
- [Is all data used with Clawdbot saved locally?](#is-all-data-used-with-clawdbot-saved-locally)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
@@ -84,6 +86,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely)
- [Can two Clawdbots talk to each other (local + VPS)?](#can-two-clawdbots-talk-to-each-other-local--vps)
- [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps)
- [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
@@ -141,6 +144,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Logging and debugging](#logging-and-debugging)
- [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service)
- [I closed my terminal on Windows — how do I restart Clawdbot?](#i-closed-my-terminal-on-windows--how-do-i-restart-clawdbot)
- [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway)
- [ELI5: `clawdbot gateway restart` vs `clawdbot gateway`](#eli5-clawdbot-gateway-restart-vs-clawdbot-gateway)
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
@@ -229,6 +233,9 @@ This installs Clawdbot **from a git checkout**, so the agent can read the code +
reason about the exact version you are running. You can always switch back to stable later
by re-running the installer without `--install-method git`.
Tip: ask the agent to **plan and supervise** the fix (step-by-step), then execute only the
necessary commands. That keeps changes small and easier to audit.
If you discover a real bug or fix, please file a GitHub issue or send a PR:
https://github.com/clawdbot/clawdbot/issues
https://github.com/clawdbot/clawdbot/pulls
@@ -306,6 +313,9 @@ disk as enough for personal use, and note that a **Raspberry Pi 4 can run it**.
If you want extra headroom (logs, media, other services), **2GB is recommended**, but it’s
not a hard minimum.
Tip: a small Pi/VPS can host the Gateway, and you can pair **nodes** on your laptop/phone for
local screen/camera/canvas or command execution. See [Nodes](/nodes).
### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?
Yes. Copy the **state directory** and **workspace**, then run Doctor once. This
@@ -719,6 +729,15 @@ lowest friction and you’re okay with sleep/restarts, run it locally.
**Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser.
### How important is it to run Clawdbot on a dedicated machine?
Not required, but **recommended for reliability and isolation**.
- **Dedicated host (VPS/Mac mini/Pi):** always‑on, fewer sleep/reboot interruptions, cleaner permissions, easier to keep running.
- **Shared laptop/desktop:** totally fine for testing and active use, but expect pauses when the machine sleeps or updates.
If you want the best of both worlds, keep the Gateway on a dedicated host and pair your laptop as a **node** for local screen/camera/exec tools. See [Nodes](/nodes).
## What is Clawdbot?
### What is Clawdbot, in one paragraph?
@@ -918,6 +937,20 @@ models — see [Memory](/concepts/memory) for the setup details.
## Where things live on disk
### Is all data used with Clawdbot saved locally?
No — **Clawdbot’s state is local**, but **external services still see what you send them**.
- **Local by default:** sessions, memory files, config, and workspace live on the Gateway host
(`~/.clawdbot` + your workspace directory).
- **Remote by necessity:** messages you send to model providers (Anthropic/OpenAI/etc.) go to
their APIs, and chat platforms (WhatsApp/Telegram/Slack/etc.) store message data on their
servers.
- **You control the footprint:** using local models keeps prompts on your machine, but channel
traffic still goes through the channel’s servers.
Related: [Agent workspace](/concepts/agent-workspace), [Memory](/concepts/memory).
### Where does Clawdbot store its data?
Everything lives under `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`):
@@ -958,6 +991,9 @@ If the bot “forgets” after a restart, confirm the Gateway is using the same
workspace on every launch (and remember: remote mode uses the **gateway host’s**
workspace, not your local laptop).
Tip: if you want a durable behavior or preference, ask the bot to **write it into
AGENTS.md or MEMORY.md** rather than relying on chat history.
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
### What’s the recommended backup strategy?
@@ -1152,6 +1188,29 @@ pair devices you trust, and review [Security](/gateway/security).
Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
### Can two Clawdbots talk to each other (local + VPS)?
Yes. There is no built-in "bot-to-bot" bridge, but you can wire it up in a few
reliable ways:
**Simplest:** use a normal chat channel both bots can access (Telegram/Slack/WhatsApp).
Have Bot A send a message to Bot B, then let Bot B reply as usual.
**CLI bridge (generic):** run a script that calls the other Gateway with
`clawdbot agent --message ... --deliver`, targeting a chat where the other bot
listens. If one bot is on Railway/VPS, point your CLI at that remote Gateway
via SSH/Tailscale (see [Remote access](/gateway/remote)).
Example pattern (run from a machine that can reach the target Gateway):
```bash
clawdbot agent --message "Hello from local bot" --deliver --channel telegram --reply-to <chat-id>
```
Tip: add a guardrail so the two bots do not loop endlessly (mention-only, channel
allowlists, or a "do not reply to bot messages" rule).
Docs: [Remote access](/gateway/remote), [Agent CLI](/cli/agent), [Agent send](/tools/agent-send).
### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?
Yes — nodes are the first‑class way to reach your laptop from a remote Gateway, and they
@@ -1500,6 +1559,11 @@ Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`)
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
[Local models](/gateway/local-models).
Rule of thumb: use the **best model you can afford** for high-stakes work, and a cheaper
model for routine chat or summaries. You can route models per agent and use sub-agents to
parallelize long tasks (each sub-agent consumes tokens). See [Models](/concepts/models) and
[Sub-agents](/tools/subagents).
Strong warning: weaker/over-quantized models are more vulnerable to prompt
injection and unsafe behavior. See [Security](/gateway/security).
@@ -1983,6 +2047,43 @@ clawdbot gateway restart
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
### I closed my terminal on Windows — how do I restart Clawdbot?
There are **two Windows install modes**:
**1) WSL2 (recommended):** the Gateway runs inside Linux.
Open PowerShell, enter WSL, then restart:
```powershell
wsl
clawdbot gateway status
clawdbot gateway restart
```
If you never installed the service, start it in the foreground:
```bash
clawdbot gateway run
```
**2) Native Windows (not recommended):** the Gateway runs directly in Windows.
Open PowerShell and run:
```powershell
clawdbot gateway status
clawdbot gateway restart
```
If you run it manually (no service), use:
```powershell
clawdbot gateway run
```
Docs: [Windows (WSL2)](/platforms/windows), [Gateway service runbook](/gateway).
### How do I completely stop then start the Gateway?
If you installed the service:
+2 -1
View File
@@ -7,7 +7,8 @@ read_when:
# Windows (WSL2)
Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
CLI + Gateway run inside Linux, which keeps the runtime consistent and makes
tooling far more compatible (Node/Bun/pnpm, Linux binaries, skills). Native
Windows installs are untested and more problematic.
Native Windows companion apps are planned.
+1 -1
View File
@@ -45,7 +45,7 @@ run on host, set an explicit per-agent override:
See [Web tools](/tools/web).
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested, more problematic, and has poorer tool compatibility. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
## 1) Install the CLI (recommended)
+4
View File
@@ -26,6 +26,10 @@ Primary goals:
- Keep the tool surface hard to misuse: sub-agents do **not** get session tools by default.
- Avoid nested fan-out: sub-agents cannot spawn sub-agents.
Cost note: each sub-agent has its **own** context and token usage. For heavy or repetitive
tasks, set a cheaper model for sub-agents and keep your main agent on a higher-quality model.
You can configure this via `agents.defaults.subagents.model` or per-agent overrides.
## Tool
Use `sessions_spawn`:
+7
View File
@@ -174,6 +174,7 @@ Search the web using your configured provider.
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
- `ui_lang` (optional): ISO language code for UI elements
- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`)
**Examples:**
@@ -193,6 +194,12 @@ await web_search({
search_lang: "fr",
ui_lang: "fr"
});
// Recent results (past week)
await web_search({
query: "TMBG interview",
freshness: "pw"
});
```
## web_fetch
+1
View File
@@ -374,6 +374,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
deliveryMode: "direct",
chunker: (text, limit) =>
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
+13 -2
View File
@@ -684,6 +684,7 @@ async function processMessageWithPipeline(params: {
spaceId,
runtime,
core,
config,
statusSink,
typingMessageName,
});
@@ -725,10 +726,11 @@ async function deliverGoogleChatReply(params: {
spaceId: string;
runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime;
config: ClawdbotConfig;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
typingMessageName?: string;
}): Promise<void> {
const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
@@ -799,7 +801,16 @@ async function deliverGoogleChatReply(params: {
if (payload.text) {
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
const chunkMode = core.channel.text.resolveChunkMode(
config,
"googlechat",
account.accountId,
);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
payload.text,
chunkLimit,
chunkMode,
);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
+1
View File
@@ -186,6 +186,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
+1
View File
@@ -50,6 +50,7 @@ export const MatrixConfigSchema = z.object({
replyToMode: z.enum(["off", "first", "all"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),
@@ -16,10 +16,11 @@ export async function deliverMatrixReplies(params: {
tableMode?: MarkdownTableMode;
}): Promise<void> {
const core = getMatrixRuntime();
const cfg = core.config.loadConfig();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
cfg,
channel: "matrix",
accountId: params.accountId,
});
@@ -29,6 +30,7 @@ export async function deliverMatrixReplies(params: {
}
};
const chunkLimit = Math.min(params.textLimit, 4000);
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId);
let hasReplied = false;
for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
@@ -54,7 +56,11 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) {
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
text,
chunkLimit,
chunkMode,
)) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, {
@@ -42,7 +42,9 @@ const runtimeStub = {
channel: {
text: {
resolveTextChunkLimit: () => 4000,
resolveChunkMode: () => "length",
chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
+6 -1
View File
@@ -61,7 +61,12 @@ export async function sendMessageMatrix(
);
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
convertedMessage,
chunkLimit,
chunkMode,
);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId
? buildThreadRelation(threadId, opts.replyToId)
+1
View File
@@ -6,6 +6,7 @@ import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
export const matrixOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, deps, replyToId, threadId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;
+2
View File
@@ -69,6 +69,8 @@ export type MatrixConfig = {
threadReplies?: "off" | "inbound" | "always";
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Auto-join invites (always|allowlist|off). Default: always. */
+1
View File
@@ -158,6 +158,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
@@ -25,6 +25,7 @@ const MattermostAccountSchemaBase = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
})
@@ -738,7 +738,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
if (mediaUrls.length === 0) {
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
const chunkMode = core.channel.text.resolveChunkMode(
cfg,
"mattermost",
account.accountId,
);
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) continue;
await sendMessageMattermost(to, chunk, {
+2
View File
@@ -36,6 +36,8 @@ export type MattermostAccountConfig = {
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
+12 -9
View File
@@ -9,18 +9,21 @@ import {
} from "./messenger.js";
import { setMSTeamsRuntime } from "./runtime.js";
const chunkMarkdownText = (text: string, limit: number) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
for (let index = 0; index < text.length; index += limit) {
chunks.push(text.slice(index, index + limit));
}
return chunks;
};
const runtimeStub = {
channel: {
text: {
chunkMarkdownText: (text: string, limit: number) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
for (let index = 0; index < text.length; index += limit) {
chunks.push(text.slice(index, index + limit));
}
return chunks;
},
chunkMarkdownText,
chunkMarkdownTextWithMode: chunkMarkdownText,
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
+12 -4
View File
@@ -1,4 +1,5 @@
import {
type ChunkMode,
isSilentReplyText,
loadWebMedia,
type MarkdownTableMode,
@@ -63,6 +64,7 @@ export type MSTeamsReplyRenderOptions = {
chunkText?: boolean;
mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
};
/**
@@ -129,11 +131,16 @@ function pushTextMessages(
opts: {
chunkText: boolean;
chunkLimit: number;
chunkMode: ChunkMode;
},
) {
if (!text) return;
if (opts.chunkText) {
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) {
for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownTextWithMode(
text,
opts.chunkLimit,
opts.chunkMode,
)) {
const trimmed = chunk.trim();
if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue;
out.push({ text: trimmed });
@@ -197,6 +204,7 @@ export function renderReplyPayloadsToMessages(
const out: MSTeamsRenderedMessage[] = [];
const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false;
const chunkMode = options.chunkMode ?? "length";
const mediaMode = options.mediaMode ?? "split";
const tableMode =
options.tableMode ??
@@ -215,7 +223,7 @@ export function renderReplyPayloadsToMessages(
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
pushTextMessages(out, text, { chunkText, chunkLimit });
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
continue;
}
@@ -229,13 +237,13 @@ export function renderReplyPayloadsToMessages(
if (mediaList[i]) out.push({ mediaUrl: mediaList[i] });
}
} else {
pushTextMessages(out, text, { chunkText, chunkLimit });
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
}
continue;
}
// mediaMode === "split"
pushTextMessages(out, text, { chunkText, chunkLimit });
pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode });
for (const mediaUrl of mediaList) {
if (!mediaUrl) continue;
out.push({ mediaUrl });
+1
View File
@@ -7,6 +7,7 @@ import { sendMessageMSTeams, sendPollMSTeams } from "./send.js";
export const msteamsOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
pollMaxOptions: 12,
sendText: async ({ cfg, to, text, deps }) => {
@@ -59,6 +59,7 @@ export function createMSTeamsReplyDispatcher(params: {
cfg: params.cfg,
agentId: params.agentId,
});
const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams");
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
@@ -75,6 +76,7 @@ export function createMSTeamsReplyDispatcher(params: {
chunkText: true,
mediaMode: "split",
tableMode,
chunkMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
+1
View File
@@ -247,6 +247,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, replyToId }) => {
const result = await sendMessageNextcloudTalk(to, text, {
@@ -44,6 +44,7 @@ export const NextcloudTalkAccountSchemaBase = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
+2
View File
@@ -62,6 +62,8 @@ export type NextcloudTalkAccountConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
+12 -1
View File
@@ -18,12 +18,20 @@ import {
setAccountEnabledInConfigSection,
signalOnboardingAdapter,
SignalConfigSchema,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ResolvedSignalAccount,
} from "clawdbot/plugin-sdk";
import { getSignalRuntime } from "./runtime.js";
const signalMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) => getSignalRuntime().channel.signal.messageActions.listActions(ctx),
supportsAction: (ctx) => getSignalRuntime().channel.signal.messageActions.supportsAction?.(ctx),
handleAction: async (ctx) =>
await getSignalRuntime().channel.signal.messageActions.handleAction(ctx),
};
const meta = getChatChannelMeta("signal");
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
@@ -42,7 +50,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
},
actions: signalMessageActions,
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
@@ -115,7 +125,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
normalizeTarget: normalizeSignalMessagingTarget,
targetResolver: {
looksLikeId: looksLikeSignalTargetId,
hint: "<E.164|group:ID|signal:group:ID|signal:+E.164>",
hint: "<E.164|uuid:ID|group:ID|signal:group:ID|signal:+E.164>",
},
},
setup: {
@@ -197,6 +207,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
+1
View File
@@ -251,6 +251,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send =
+1
View File
@@ -276,6 +276,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
outbound: {
deliveryMode: "gateway",
chunker: (text, limit) => getWhatsAppRuntime().channel.text.chunkText(text, limit),
chunkerMode: "text",
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => {
+1
View File
@@ -288,6 +288,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
if (remaining.length) chunks.push(remaining);
return chunks;
},
chunkerMode: "text",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
+11 -2
View File
@@ -596,6 +596,8 @@ async function processMessageWithPipeline(params: {
chatId,
runtime,
core,
config,
accountId: account.accountId,
statusSink,
fetcher,
tableMode,
@@ -614,11 +616,13 @@ async function deliverZaloReply(params: {
chatId: string;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
config: ClawdbotConfig;
accountId?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
@@ -644,7 +648,12 @@ async function deliverZaloReply(params: {
}
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
ZALO_TEXT_LIMIT,
chunkMode,
);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
+1
View File
@@ -506,6 +506,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
if (remaining.length) chunks.push(remaining);
return chunks;
},
chunkerMode: "text",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
+12 -2
View File
@@ -332,6 +332,8 @@ async function processMessage(
isGroup,
runtime,
core,
config,
accountId: account.accountId,
statusSink,
tableMode: core.channel.text.resolveMarkdownTableMode({
cfg: config,
@@ -356,10 +358,13 @@ async function deliverZalouserReply(params: {
isGroup: boolean;
runtime: RuntimeEnv;
core: ZalouserCoreRuntime;
config: ClawdbotConfig;
accountId?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
@@ -390,7 +395,12 @@ async function deliverZalouserReply(params: {
}
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
const chunks = core.channel.text.chunkMarkdownTextWithMode(
text,
ZALOUSER_TEXT_LIMIT,
chunkMode,
);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
@@ -48,29 +48,19 @@ describe("models-config", () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
const previousVenice = process.env.VENICE_API_KEY;
const previousKimiCode = process.env.KIMICODE_API_KEY;
const previousKimiCodeAlt = process.env.KIMI_CODE_API_KEY;
const previousMinimax = process.env.MINIMAX_API_KEY;
const previousMoonshot = process.env.MOONSHOT_API_KEY;
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
const previousAwsAccessKey = process.env.AWS_ACCESS_KEY_ID;
const previousAwsSecretKey = process.env.AWS_SECRET_ACCESS_KEY;
const previousAwsProfile = process.env.AWS_PROFILE;
const previousAwsBearer = process.env.AWS_BEARER_TOKEN;
const previousVenice = process.env.VENICE_API_KEY;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.VENICE_API_KEY;
delete process.env.KIMICODE_API_KEY;
delete process.env.KIMI_CODE_API_KEY;
delete process.env.MINIMAX_API_KEY;
delete process.env.MOONSHOT_API_KEY;
delete process.env.SYNTHETIC_API_KEY;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_PROFILE;
delete process.env.AWS_BEARER_TOKEN;
delete process.env.VENICE_API_KEY;
try {
vi.resetModules();
@@ -93,26 +83,16 @@ describe("models-config", () => {
else process.env.GH_TOKEN = previousGh;
if (previousGithub === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = previousGithub;
if (previousVenice === undefined) delete process.env.VENICE_API_KEY;
else process.env.VENICE_API_KEY = previousVenice;
if (previousKimiCode === undefined) delete process.env.KIMICODE_API_KEY;
else process.env.KIMICODE_API_KEY = previousKimiCode;
if (previousKimiCodeAlt === undefined) delete process.env.KIMI_CODE_API_KEY;
else process.env.KIMI_CODE_API_KEY = previousKimiCodeAlt;
if (previousMinimax === undefined) delete process.env.MINIMAX_API_KEY;
else process.env.MINIMAX_API_KEY = previousMinimax;
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
else process.env.MOONSHOT_API_KEY = previousMoonshot;
if (previousSynthetic === undefined) delete process.env.SYNTHETIC_API_KEY;
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
if (previousAwsAccessKey === undefined) delete process.env.AWS_ACCESS_KEY_ID;
else process.env.AWS_ACCESS_KEY_ID = previousAwsAccessKey;
if (previousAwsSecretKey === undefined) delete process.env.AWS_SECRET_ACCESS_KEY;
else process.env.AWS_SECRET_ACCESS_KEY = previousAwsSecretKey;
if (previousAwsProfile === undefined) delete process.env.AWS_PROFILE;
else process.env.AWS_PROFILE = previousAwsProfile;
if (previousAwsBearer === undefined) delete process.env.AWS_BEARER_TOKEN;
else process.env.AWS_BEARER_TOKEN = previousAwsBearer;
if (previousVenice === undefined) delete process.env.VENICE_API_KEY;
else process.env.VENICE_API_KEY = previousVenice;
}
});
});
+25
View File
@@ -15,6 +15,8 @@ import { resolveChannelCapabilities } from "../../config/channel-capabilities.js
import type { ClawdbotConfig } from "../../config/config.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
@@ -255,6 +257,28 @@ export async function compactEmbeddedPiSessionDirect(
}
}
}
const reactionGuidance =
runtimeChannel && params.config
? (() => {
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
: undefined;
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
@@ -313,6 +337,7 @@ export async function compactEmbeddedPiSessionDirect(
ttsHint,
promptMode,
runtimeInfo,
reactionGuidance,
messageToolHints,
sandboxInfo,
tools,
+19 -7
View File
@@ -15,6 +15,7 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
@@ -255,14 +256,25 @@ export async function runEmbeddedAttempt(
}
}
const reactionGuidance =
runtimeChannel === "telegram" && params.config
runtimeChannel && params.config
? (() => {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
if (runtimeChannel === "telegram") {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
}
if (runtimeChannel === "signal") {
const resolved = resolveSignalReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Signal" } : undefined;
}
return undefined;
})()
: undefined;
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
+3
View File
@@ -94,6 +94,9 @@ function buildReactionSchema() {
messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
targetAuthor: Type.Optional(Type.String()),
targetAuthorUuid: Type.Optional(Type.String()),
groupId: Type.Optional(Type.String()),
};
}
+19 -1
View File
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
import { __testing } from "./web-search.js";
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl } = __testing;
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } =
__testing;
describe("web_search perplexity baseUrl defaults", () => {
it("detects a Perplexity key prefix", () => {
@@ -51,3 +52,20 @@ describe("web_search perplexity baseUrl defaults", () => {
);
});
});
describe("web_search freshness normalization", () => {
it("accepts Brave shortcut values", () => {
expect(normalizeFreshness("pd")).toBe("pd");
expect(normalizeFreshness("PW")).toBe("pw");
});
it("accepts valid date ranges", () => {
expect(normalizeFreshness("2024-01-01to2024-01-31")).toBe("2024-01-01to2024-01-31");
});
it("rejects invalid date ranges", () => {
expect(normalizeFreshness("2024-13-01to2024-01-31")).toBeUndefined();
expect(normalizeFreshness("2024-02-30to2024-03-01")).toBeUndefined();
expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined();
});
});
+65 -1
View File
@@ -29,6 +29,8 @@ const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
const WebSearchSchema = Type.Object({
query: Type.String({ description: "Search query string." }),
@@ -55,6 +57,12 @@ const WebSearchSchema = Type.Object({
description: "ISO language code for UI elements.",
}),
),
freshness: Type.Optional(
Type.String({
description:
"Filter results by discovery time (Brave only). Values: 'pd' (past 24h), 'pw' (past week), 'pm' (past month), 'py' (past year), or date range 'YYYY-MM-DDtoYYYY-MM-DD'.",
}),
),
});
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
@@ -219,6 +227,37 @@ function resolveSearchCount(value: unknown, fallback: number): number {
return clamped;
}
function normalizeFreshness(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const lower = trimmed.toLowerCase();
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) return lower;
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
if (!match) return undefined;
const [, start, end] = match;
if (!isValidIsoDate(start) || !isValidIsoDate(end)) return undefined;
if (start > end) return undefined;
return `${start}to${end}`;
}
function isValidIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return false;
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
);
}
function resolveSiteName(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
@@ -279,11 +318,14 @@ async function runWebSearch(params: {
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
perplexityBaseUrl?: string;
perplexityModel?: string;
}): Promise<Record<string, unknown>> {
const cacheKey = normalizeCacheKey(
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
params.provider === "brave"
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
: `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
);
const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true };
@@ -327,6 +369,9 @@ async function runWebSearch(params: {
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
const res = await fetch(url.toString(), {
method: "GET",
@@ -399,6 +444,23 @@ export function createWebSearchTool(options?: {
const country = readStringParam(params, "country");
const search_lang = readStringParam(params, "search_lang");
const ui_lang = readStringParam(params, "ui_lang");
const rawFreshness = readStringParam(params, "freshness");
if (rawFreshness && provider !== "brave") {
return jsonResult({
error: "unsupported_freshness",
message: "freshness is only supported by the Brave web_search provider.",
docs: "https://docs.clawd.bot/tools/web",
});
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined;
if (rawFreshness && !freshness) {
return jsonResult({
error: "invalid_freshness",
message:
"freshness must be one of pd, pw, pm, py, or a range like YYYY-MM-DDtoYYYY-MM-DD.",
docs: "https://docs.clawd.bot/tools/web",
});
}
const result = await runWebSearch({
query,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
@@ -409,6 +471,7 @@ export function createWebSearchTool(options?: {
country,
search_lang,
ui_lang,
freshness,
perplexityBaseUrl: resolvePerplexityBaseUrl(
perplexityConfig,
perplexityAuth?.source,
@@ -424,4 +487,5 @@ export function createWebSearchTool(options?: {
export const __testing = {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
normalizeFreshness,
} as const;
@@ -88,6 +88,40 @@ describe("web_search country and language parameters", () => {
const url = new URL(mockFetch.mock.calls[0][0] as string);
expect(url.searchParams.get("ui_lang")).toBe("de");
});
it("should pass freshness parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.(1, { query: "test", freshness: "pw" });
const url = new URL(mockFetch.mock.calls[0][0] as string);
expect(url.searchParams.get("freshness")).toBe("pw");
});
it("rejects invalid freshness values", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.(1, { query: "test", freshness: "yesterday" });
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "invalid_freshness" });
});
});
describe("web_search perplexity baseUrl defaults", () => {
@@ -120,6 +154,27 @@ describe("web_search perplexity baseUrl defaults", () => {
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://api.perplexity.ai/chat/completions");
});
it("rejects freshness for Perplexity provider", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
});
const result = await tool?.execute?.(1, { query: "test", freshness: "pw" });
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "unsupported_freshness" });
});
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
+54 -14
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
@@ -246,10 +247,10 @@ describe("chunkByNewline", () => {
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
});
it("filters empty lines", () => {
it("preserves blank lines by folding into the next chunk", () => {
const text = "Line one\n\n\nLine two\n\nLine three";
const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["Line one", "Line two", "Line three"]);
expect(chunks).toEqual(["Line one", "\n\nLine two", "\nLine three"]);
});
it("trims whitespace from lines", () => {
@@ -258,6 +259,12 @@ describe("chunkByNewline", () => {
expect(chunks).toEqual(["Line one", "Line two"]);
});
it("preserves leading blank lines on the first chunk", () => {
const text = "\n\nLine one\nLine two";
const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["\n\nLine one", "Line two"]);
});
it("falls back to length-based for long lines", () => {
const text = "Short line\n" + "a".repeat(50) + "\nAnother short";
const chunks = chunkByNewline(text, 20);
@@ -269,6 +276,12 @@ describe("chunkByNewline", () => {
expect(chunks[4]).toBe("Another short");
});
it("does not split long lines when splitLongLines is false", () => {
const text = "a".repeat(50);
const chunks = chunkByNewline(text, 20, { splitLongLines: false });
expect(chunks).toEqual([text]);
});
it("returns empty array for empty input", () => {
expect(chunkByNewline("", 100)).toEqual([]);
});
@@ -276,6 +289,18 @@ describe("chunkByNewline", () => {
it("returns empty array for whitespace-only input", () => {
expect(chunkByNewline(" \n\n ", 100)).toEqual([]);
});
it("preserves trailing blank lines on the last chunk", () => {
const text = "Line one\n\n";
const chunks = chunkByNewline(text, 1000);
expect(chunks).toEqual(["Line one\n\n"]);
});
it("keeps whitespace when trimLines is false", () => {
const text = " indented line \nNext";
const chunks = chunkByNewline(text, 1000, { trimLines: false });
expect(chunks).toEqual([" indented line ", "Next"]);
});
});
describe("chunkTextWithMode", () => {
@@ -292,6 +317,26 @@ describe("chunkTextWithMode", () => {
});
});
describe("chunkMarkdownTextWithMode", () => {
it("uses markdown-aware chunking for length mode", () => {
const text = "Line one\nLine two";
expect(chunkMarkdownTextWithMode(text, 1000, "length")).toEqual(chunkMarkdownText(text, 1000));
});
it("uses newline-based chunking for newline mode", () => {
const text = "Line one\nLine two";
expect(chunkMarkdownTextWithMode(text, 1000, "newline")).toEqual(["Line one", "Line two"]);
});
it("does not split inside code fences for 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",
]);
});
});
describe("resolveChunkMode", () => {
it("returns length as default", () => {
expect(resolveChunkMode(undefined, "telegram")).toBe("length");
@@ -304,16 +349,16 @@ describe("resolveChunkMode", () => {
expect(resolveChunkMode(cfg, "__internal__")).toBe("length");
});
it("supports provider-level overrides for bluebubbles", () => {
const cfg = { channels: { bluebubbles: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "bluebubbles")).toBe("newline");
it("supports provider-level overrides for slack", () => {
const cfg = { channels: { slack: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "slack")).toBe("newline");
expect(resolveChunkMode(cfg, "discord")).toBe("length");
});
it("supports account-level overrides for bluebubbles", () => {
it("supports account-level overrides for slack", () => {
const cfg = {
channels: {
bluebubbles: {
slack: {
chunkMode: "length" as const,
accounts: {
primary: { chunkMode: "newline" as const },
@@ -321,12 +366,7 @@ describe("resolveChunkMode", () => {
},
},
};
expect(resolveChunkMode(cfg, "bluebubbles", "primary")).toBe("newline");
expect(resolveChunkMode(cfg, "bluebubbles", "other")).toBe("length");
});
it("ignores chunkMode for non-bluebubbles providers", () => {
const cfg = { channels: { ["telegram" as string]: { chunkMode: "newline" as const } } };
expect(resolveChunkMode(cfg, "telegram")).toBe("length");
expect(resolveChunkMode(cfg, "slack", "primary")).toBe("newline");
expect(resolveChunkMode(cfg, "slack", "other")).toBe("length");
});
});
+92 -15
View File
@@ -101,8 +101,6 @@ export function resolveChunkMode(
accountId?: string | null,
): ChunkMode {
if (!provider || provider === INTERNAL_MESSAGE_CHANNEL) return DEFAULT_CHUNK_MODE;
// Chunk mode is only supported for BlueBubbles.
if (provider !== "bluebubbles") return DEFAULT_CHUNK_MODE;
const channelsConfig = cfg?.channels as Record<string, unknown> | undefined;
const providerConfig = (channelsConfig?.[provider] ??
(cfg as Record<string, unknown> | undefined)?.[provider]) as ProviderChunkConfig | undefined;
@@ -111,25 +109,56 @@ export function resolveChunkMode(
}
/**
* Split text on newlines, filtering empty lines.
* Lines exceeding maxLineLength are further split using length-based chunking.
* Split text on newlines, trimming line whitespace.
* Blank lines are folded into the next non-empty line as leading "\n" prefixes.
* Long lines can be split by length (default) or kept intact via splitLongLines:false.
*/
export function chunkByNewline(text: string, maxLineLength: number): string[] {
export function chunkByNewline(
text: string,
maxLineLength: number,
opts?: {
splitLongLines?: boolean;
trimLines?: boolean;
isSafeBreak?: (index: number) => boolean;
},
): string[] {
if (!text) return [];
const lines = text.split("\n");
if (maxLineLength <= 0) return text.trim() ? [text] : [];
const splitLongLines = opts?.splitLongLines !== false;
const trimLines = opts?.trimLines !== false;
const lines = splitByNewline(text, opts?.isSafeBreak);
const chunks: string[] = [];
let pendingBlankLines = 0;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue; // skip empty lines
if (trimmed.length <= maxLineLength) {
chunks.push(trimmed);
} else {
// Long line: fall back to length-based chunking
const subChunks = chunkText(trimmed, maxLineLength);
chunks.push(...subChunks);
if (!trimmed) {
pendingBlankLines += 1;
continue;
}
const maxPrefix = Math.max(0, maxLineLength - 1);
const cappedBlankLines = pendingBlankLines > 0 ? Math.min(pendingBlankLines, maxPrefix) : 0;
const prefix = cappedBlankLines > 0 ? "\n".repeat(cappedBlankLines) : "";
pendingBlankLines = 0;
const lineValue = trimLines ? trimmed : line;
if (!splitLongLines || lineValue.length + prefix.length <= maxLineLength) {
chunks.push(prefix + lineValue);
continue;
}
const firstLimit = Math.max(1, maxLineLength - prefix.length);
const first = lineValue.slice(0, firstLimit);
chunks.push(prefix + first);
const remaining = lineValue.slice(firstLimit);
if (remaining) {
chunks.push(...chunkText(remaining, maxLineLength));
}
}
if (pendingBlankLines > 0 && chunks.length > 0) {
chunks[chunks.length - 1] += "\n".repeat(pendingBlankLines);
}
return chunks;
@@ -140,11 +169,59 @@ export function chunkByNewline(text: string, maxLineLength: number): string[] {
*/
export function chunkTextWithMode(text: string, limit: number, mode: ChunkMode): string[] {
if (mode === "newline") {
return chunkByNewline(text, limit);
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 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);
}
return chunks;
}
return chunkMarkdownText(text, limit);
}
function splitByNewline(
text: string,
isSafeBreak: (index: number) => boolean = () => true,
): string[] {
const lines: string[] = [];
let start = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === "\n" && isSafeBreak(i)) {
lines.push(text.slice(start, i));
start = i + 1;
}
}
lines.push(text.slice(start));
return lines;
}
export function chunkText(text: string, limit: number): string[] {
if (!text) return [];
if (limit <= 0) return [text];
+2 -2
View File
@@ -69,7 +69,7 @@ export function resolveBlockStreamingChunking(
});
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
// BlueBubbles-only: if chunkMode is "newline", use newline-based streaming
// 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
@@ -103,7 +103,7 @@ export function resolveBlockStreamingCoalescing(
): BlockStreamingCoalescing | undefined {
const providerKey = normalizeChunkProvider(provider);
// BlueBubbles-only: when chunkMode is "newline", disable coalescing to send each line immediately
// When chunkMode is "newline", disable coalescing to send each line immediately
const channelChunkMode = resolveChunkMode(cfg, providerKey, accountId);
if (channelChunkMode === "newline") {
return undefined;
+151
View File
@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../../config/config.js";
import { signalMessageActions } from "./signal.js";
const sendReactionSignal = vi.fn(async () => ({ ok: true }));
const removeReactionSignal = vi.fn(async () => ({ ok: true }));
vi.mock("../../../signal/send-reactions.js", () => ({
sendReactionSignal: (...args: unknown[]) => sendReactionSignal(...args),
removeReactionSignal: (...args: unknown[]) => removeReactionSignal(...args),
}));
describe("signalMessageActions", () => {
it("returns no actions when no configured accounts exist", () => {
const cfg = {} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual([]);
});
it("hides react when reactions are disabled", () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual(["send"]);
});
it("enables react when at least one account allows reactions", () => {
const cfg = {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as ClawdbotConfig;
expect(signalMessageActions.listActions({ cfg })).toEqual(["send", "react"]);
});
it("skips send for plugin dispatch", () => {
expect(signalMessageActions.supportsAction?.({ action: "send" })).toBe(false);
expect(signalMessageActions.supportsAction?.({ action: "react" })).toBe(true);
});
it("blocks reactions when action gate is disabled", async () => {
const cfg = {
channels: { signal: { account: "+15550001111", actions: { reactions: false } } },
} as ClawdbotConfig;
await expect(
signalMessageActions.handleAction({
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "✅" },
cfg,
accountId: undefined,
}),
).rejects.toThrow(/actions\.reactions/);
});
it("uses account-level actions when enabled", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: {
signal: {
actions: { reactions: false },
accounts: {
work: { account: "+15550001111", actions: { reactions: true } },
},
},
},
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: { to: "+15550001111", messageId: "123", emoji: "👍" },
cfg,
accountId: "work",
});
expect(sendReactionSignal).toHaveBeenCalledWith("+15550001111", 123, "👍", {
accountId: "work",
});
});
it("normalizes uuid recipients", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: {
recipient: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "🔥",
},
cfg,
accountId: undefined,
});
expect(sendReactionSignal).toHaveBeenCalledWith(
"123e4567-e89b-12d3-a456-426614174000",
123,
"🔥",
{ accountId: undefined },
);
});
it("requires targetAuthor for group reactions", async () => {
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await expect(
signalMessageActions.handleAction({
action: "react",
params: { to: "signal:group:group-id", messageId: "123", emoji: "✅" },
cfg,
accountId: undefined,
}),
).rejects.toThrow(/targetAuthor/);
});
it("passes groupId and targetAuthor for group reactions", async () => {
sendReactionSignal.mockClear();
const cfg = {
channels: { signal: { account: "+15550001111" } },
} as ClawdbotConfig;
await signalMessageActions.handleAction({
action: "react",
params: {
to: "signal:group:group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
messageId: "123",
emoji: "✅",
},
cfg,
accountId: undefined,
});
expect(sendReactionSignal).toHaveBeenCalledWith("", 123, "✅", {
accountId: undefined,
groupId: "group-id",
targetAuthor: "uuid:123e4567-e89b-12d3-a456-426614174000",
targetAuthorUuid: undefined,
});
});
});
+130
View File
@@ -0,0 +1,130 @@
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
import { listEnabledSignalAccounts, resolveSignalAccount } from "../../../signal/accounts.js";
import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js";
import { sendReactionSignal, removeReactionSignal } from "../../../signal/send-reactions.js";
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
const providerId = "signal";
const GROUP_PREFIX = "group:";
function normalizeSignalReactionRecipient(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return trimmed;
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) return withoutSignal;
if (withoutSignal.toLowerCase().startsWith("uuid:")) {
return withoutSignal.slice("uuid:".length).trim();
}
return withoutSignal;
}
function resolveSignalReactionTarget(raw: string): { recipient?: string; groupId?: string } {
const trimmed = raw.trim();
if (!trimmed) return {};
const withoutSignal = trimmed.replace(/^signal:/i, "").trim();
if (!withoutSignal) return {};
if (withoutSignal.toLowerCase().startsWith(GROUP_PREFIX)) {
const groupId = withoutSignal.slice(GROUP_PREFIX.length).trim();
return groupId ? { groupId } : {};
}
return { recipient: normalizeSignalReactionRecipient(withoutSignal) };
}
export const signalMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledSignalAccounts(cfg);
if (accounts.length === 0) return [];
const configuredAccounts = accounts.filter((account) => account.configured);
if (configuredAccounts.length === 0) return [];
const actions = new Set<ChannelMessageActionName>(["send"]);
const reactionsEnabled = configuredAccounts.some((account) =>
createActionGate(account.config.actions)("reactions"),
);
if (reactionsEnabled) {
actions.add("react");
}
return Array.from(actions);
},
supportsAction: ({ action }) => action !== "send",
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") {
throw new Error("Send should be handled by outbound, not actions handler.");
}
if (action === "react") {
// Check reaction level first
const reactionLevelInfo = resolveSignalReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`Signal agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.signal.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
// Also check the action gate for backward compatibility
const actionConfig = resolveSignalAccount({ cfg, accountId }).config.actions;
const isActionEnabled = createActionGate(actionConfig);
if (!isActionEnabled("reactions")) {
throw new Error("Signal reactions are disabled via actions.reactions.");
}
const recipientRaw =
readStringParam(params, "recipient") ??
readStringParam(params, "to", {
required: true,
label: "recipient (UUID, phone number, or group)",
});
const target = resolveSignalReactionTarget(recipientRaw);
if (!target.recipient && !target.groupId) {
throw new Error("recipient or group required");
}
const messageId = readStringParam(params, "messageId", {
required: true,
label: "messageId (timestamp)",
});
const targetAuthor = readStringParam(params, "targetAuthor");
const targetAuthorUuid = readStringParam(params, "targetAuthorUuid");
if (target.groupId && !targetAuthor && !targetAuthorUuid) {
throw new Error("targetAuthor or targetAuthorUuid required for group reactions.");
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
const timestamp = parseInt(messageId, 10);
if (!Number.isFinite(timestamp)) {
throw new Error(`Invalid messageId: ${messageId}. Expected numeric timestamp.`);
}
if (remove) {
if (!emoji) throw new Error("Emoji required to remove reaction.");
await removeReactionSignal(target.recipient ?? "", timestamp, emoji, {
accountId: accountId ?? undefined,
groupId: target.groupId,
targetAuthor,
targetAuthorUuid,
});
return jsonResult({ ok: true, removed: emoji });
}
if (!emoji) throw new Error("Emoji required to add reaction.");
await sendReactionSignal(target.recipient ?? "", timestamp, emoji, {
accountId: accountId ?? undefined,
groupId: target.groupId,
targetAuthor,
targetAuthorUuid,
});
return jsonResult({ ok: true, added: emoji });
}
throw new Error(`Action ${action} not supported for ${providerId}.`);
},
};
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js";
describe("signal target normalization", () => {
it("normalizes uuid targets by stripping uuid:", () => {
expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("normalizes signal:uuid targets", () => {
expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe(
"123e4567-e89b-12d3-a456-426614174000",
);
});
it("accepts uuid prefixes for target detection", () => {
expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true);
});
it("accepts compact UUIDs for target detection", () => {
expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true);
expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true);
});
it("rejects invalid uuid prefixes", () => {
expect(looksLikeSignalTargetId("uuid:")).toBe(false);
expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false);
});
});
+18
View File
@@ -19,12 +19,30 @@ export function normalizeSignalMessagingTarget(raw: string): string | undefined
const id = normalized.slice("u:".length).trim();
return id ? `username:${id}`.toLowerCase() : undefined;
}
if (lower.startsWith("uuid:")) {
const id = normalized.slice("uuid:".length).trim();
return id ? id.toLowerCase() : undefined;
}
return normalized.toLowerCase();
}
// UUID pattern for signal-cli recipient IDs
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i;
export function looksLikeSignalTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(signal:)?(group:|username:|u:)/i.test(trimmed)) return true;
if (/^(signal:)?uuid:/i.test(trimmed)) {
const stripped = trimmed
.replace(/^signal:/i, "")
.replace(/^uuid:/i, "")
.trim();
if (!stripped) return false;
return UUID_PATTERN.test(stripped) || UUID_COMPACT_PATTERN.test(stripped);
}
// Accept UUIDs (used by signal-cli for reactions)
if (UUID_PATTERN.test(trimmed) || UUID_COMPACT_PATTERN.test(trimmed)) return true;
return /^\+?\d{3,}$/.test(trimmed);
}
@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../types.js";
export const imessageOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendIMessage ?? sendMessageIMessage;
+1
View File
@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../types.js";
export const signalOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? sendMessageSignal;
@@ -21,6 +21,7 @@ function parseThreadId(threadId?: string | number | null) {
export const telegramOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: markdownToTelegramHtmlChunks,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendTelegram ?? sendMessageTelegram;
@@ -8,6 +8,7 @@ import { missingTargetError } from "../../../infra/outbound/target-errors.js";
export const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "gateway",
chunker: chunkText,
chunkerMode: "text",
textChunkLimit: 4000,
pollMaxOptions: 12,
resolveTarget: ({ to, allowFrom, mode }) => {
+1
View File
@@ -84,6 +84,7 @@ export type ChannelOutboundContext = {
export type ChannelOutboundAdapter = {
deliveryMode: "direct" | "gateway" | "hybrid";
chunker?: ((text: string, limit: number) => string[]) | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
pollMaxOptions?: number;
resolveTarget?: (params: {
+22
View File
@@ -67,6 +67,28 @@ describe("cli program (smoke)", () => {
expect(messageCommand).toHaveBeenCalled();
});
it("runs message react with signal author fields", async () => {
const program = buildProgram();
await program.parseAsync(
[
"message",
"react",
"--channel",
"signal",
"--target",
"signal:group:abc123",
"--message-id",
"1737630212345",
"--emoji",
"✅",
"--target-author-uuid",
"123e4567-e89b-12d3-a456-426614174000",
],
{ from: "user" },
);
expect(messageCommand).toHaveBeenCalled();
});
it("runs status command", async () => {
const program = buildProgram();
await program.parseAsync(["status"], { from: "user" });
@@ -13,6 +13,8 @@ export function registerMessageReactionsCommands(message: Command, helpers: Mess
.option("--remove", "Remove reaction", false)
.option("--participant <id>", "WhatsApp reaction participant")
.option("--from-me", "WhatsApp reaction fromMe", false)
.option("--target-author <id>", "Signal reaction target author (uuid or phone)")
.option("--target-author-uuid <uuid>", "Signal reaction target author uuid")
.action(async (opts) => {
await helpers.runMessageAction("react", opts);
});
+6 -7
View File
@@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { getPrimaryCommand } from "./argv.js";
import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -50,12 +50,11 @@ export async function runCli(argv: string[] = process.argv) {
});
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
if (hasHelpOrVersion(parseArgv)) {
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
// Register the primary subcommand if one exists (for lazy-loading)
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
await program.parseAsync(parseArgv);
}
+1 -3
View File
@@ -353,9 +353,7 @@ export function applyVeniceProviderConfig(cfg: ClawdbotConfig): ClawdbotConfig {
const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition);
const mergedModels = [
...existingModels,
...veniceModels.filter(
(model) => !existingModels.some((existing) => existing.id === model.id),
),
...veniceModels.filter((model) => !existingModels.some((existing) => existing.id === model.id)),
];
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
string,
+1 -4
View File
@@ -2,10 +2,7 @@ export {
SYNTHETIC_DEFAULT_MODEL_ID,
SYNTHETIC_DEFAULT_MODEL_REF,
} from "../agents/synthetic-models.js";
export {
VENICE_DEFAULT_MODEL_ID,
VENICE_DEFAULT_MODEL_REF,
} from "../agents/venice-models.js";
export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js";
export {
applyAuthProfileConfig,
applyKimiCodeConfig,
+2
View File
@@ -108,6 +108,8 @@ export type DiscordAccountConfig = {
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 2000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
+2
View File
@@ -78,6 +78,8 @@ export type GoogleChatAccountConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
+2
View File
@@ -54,6 +54,8 @@ export type IMessageAccountConfig = {
mediaMaxMb?: number;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
+2
View File
@@ -72,6 +72,8 @@ export type MSTeamsConfig = {
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/**
+16
View File
@@ -8,6 +8,7 @@ import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js";
import type { DmConfig } from "./types.messages.js";
export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist";
export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive";
export type SignalAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
@@ -56,6 +57,8 @@ export type SignalAccountConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
@@ -64,6 +67,19 @@ export type SignalAccountConfig = {
reactionNotifications?: SignalReactionNotificationMode;
/** Allowlist for reaction notifications when mode is allowlist. */
reactionAllowlist?: Array<string | number>;
/** Action toggles for message tool capabilities. */
actions?: {
/** Enable/disable sending reactions via message tool (default: true). */
reactions?: boolean;
};
/**
* Controls agent reaction behavior:
* - "off": No reactions
* - "ack": Only automatic ack reactions (👀 when processing)
* - "minimal": Agent can react sparingly (default)
* - "extensive": Agent can react liberally
*/
reactionLevel?: SignalReactionLevel;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
};
+2
View File
@@ -116,6 +116,8 @@ export type SlackAccountConfig = {
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
+2
View File
@@ -80,6 +80,8 @@ export type TelegramAccountConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Chunking config for draft streaming in `streamMode: "block"`. */
+4
View File
@@ -55,6 +55,8 @@ export type WhatsAppConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Maximum media file size in MB. Default: 50. */
mediaMaxMb?: number;
/** Disable block streaming for this account. */
@@ -122,6 +124,8 @@ export type WhatsAppAccountConfig = {
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
mediaMaxMb?: number;
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
+14
View File
@@ -102,6 +102,7 @@ export const TelegramAccountSchemaBase = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
draftChunk: BlockStreamingChunkSchema.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
@@ -212,6 +213,7 @@ export const DiscordAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
maxLinesPerMessage: z.number().int().positive().optional(),
@@ -310,6 +312,7 @@ export const GoogleChatAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
@@ -401,6 +404,7 @@ export const SlackAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
@@ -494,11 +498,19 @@ export const SignalAccountSchemaBase = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().int().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
})
.strict();
@@ -547,6 +559,7 @@ export const IMessageAccountSchemaBase = z
includeAttachments: z.boolean().optional(),
mediaMaxMb: z.number().int().positive().optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z
@@ -705,6 +718,7 @@ export const MSTeamsConfigSchema = z
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaAllowHosts: z.array(z.string()).optional(),
requireMention: z.boolean().optional(),
@@ -30,6 +30,7 @@ export const WhatsAppAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
@@ -85,6 +86,7 @@ export const WhatsAppConfigSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
+11 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { chunkDiscordText } from "./chunk.js";
import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js";
function countLines(text: string) {
return text.split("\n").length;
@@ -51,6 +51,16 @@ describe("chunkDiscordText", () => {
expect(chunks.at(-1)).toContain("Done.");
});
it("keeps fenced blocks intact when chunkMode is newline", () => {
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
const chunks = chunkDiscordTextWithMode(text, {
maxChars: 2000,
maxLines: 50,
chunkMode: "newline",
});
expect(chunks).toEqual(["```js\nconst a = 1;\nconst b = 2;\n```", "After"]);
});
it("reserves space for closing fences when chunking", () => {
const body = "a".repeat(120);
const text = `\`\`\`txt\n${body}\n\`\`\``;
+27
View File
@@ -1,3 +1,5 @@
import { chunkMarkdownTextWithMode, type ChunkMode } from "../auto-reply/chunk.js";
export type ChunkDiscordTextOpts = {
/** Max characters per Discord message. Default: 2000. */
maxChars?: number;
@@ -178,6 +180,31 @@ export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}):
return rebalanceReasoningItalics(text, chunks);
}
export function chunkDiscordTextWithMode(
text: string,
opts: ChunkDiscordTextOpts & { chunkMode?: ChunkMode },
): string[] {
const chunkMode = opts.chunkMode ?? "length";
if (chunkMode !== "newline") {
return chunkDiscordText(text, opts);
}
const lineChunks = chunkMarkdownTextWithMode(
text,
Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS)),
"newline",
);
const chunks: string[] = [];
for (const line of lineChunks) {
const nested = chunkDiscordText(line, opts);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
}
// Keep italics intact for reasoning payloads that are wrapped once with `_…_`.
// When Discord chunking splits the message, we close italics at the end of
// each chunk and reopen at the start of the next so every chunk renders
@@ -1,3 +1,4 @@
import { ChannelType } from "@buape/carbon";
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
import {
removeAckReactionAfterReply,
@@ -21,6 +22,7 @@ import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-di
import type { ReplyPayload } from "../../auto-reply/types.js";
import { recordInboundSession } from "../../channels/session.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { resolveChunkMode } from "../../auto-reply/chunk.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
@@ -129,6 +131,15 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
senderDisplay && senderTag && senderDisplay !== senderTag
? `${senderDisplay} (${senderTag})`
: (senderDisplay ?? senderTag ?? author.id);
const isForumParent =
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
const forumParentSlug =
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const threadChannelId = threadChannel?.id;
const isForumStarter = Boolean(
threadChannelId && isForumParent && forumParentSlug && message.id === threadChannelId,
);
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
const channelDescription = channelInfo?.topic?.trim();
@@ -182,6 +193,9 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
if (replyContext) {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
if (forumContextLine) {
combinedBody = `${combinedBody}\n${forumContextLine}`;
}
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
@@ -335,6 +349,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
tableMode,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
replyReference.markSent();
},
+11 -5
View File
@@ -12,7 +12,7 @@ import {
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
@@ -40,7 +40,7 @@ import {
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { loadWebMedia } from "../../web/media.js";
import { chunkDiscordText } from "../chunk.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import {
allowListMatches,
@@ -767,6 +767,7 @@ async function dispatchDiscordCommandInteraction(params: {
}),
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
preferFollowUp: preferFollowUp || didReply,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
@@ -797,8 +798,9 @@ async function deliverDiscordInteractionReply(params: {
textLimit: number;
maxLinesPerMessage?: number;
preferFollowUp: boolean;
chunkMode: "length" | "newline";
}) {
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp } = params;
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
@@ -838,10 +840,12 @@ async function deliverDiscordInteractionReply(params: {
};
}),
);
const chunks = chunkDiscordText(text, {
const chunks = chunkDiscordTextWithMode(text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && text) chunks.push(text);
const caption = chunks[0] ?? "";
await sendMessage(caption, media);
for (const chunk of chunks.slice(1)) {
@@ -852,10 +856,12 @@ async function deliverDiscordInteractionReply(params: {
}
if (!text.trim()) return;
const chunks = chunkDiscordText(text, {
const chunks = chunkDiscordTextWithMode(text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && text) chunks.push(text);
for (const chunk of chunks) {
if (!chunk.trim()) continue;
await sendMessage(chunk);
+9 -3
View File
@@ -1,10 +1,11 @@
import type { RequestClient } from "@buape/carbon";
import type { ChunkMode } from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { MarkdownTableMode } from "../../config/types.base.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
import type { RuntimeEnv } from "../../runtime.js";
import { chunkDiscordText } from "../chunk.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { sendMessageDiscord } from "../send.js";
export async function deliverDiscordReply(params: {
@@ -18,6 +19,7 @@ export async function deliverDiscordReply(params: {
maxLinesPerMessage?: number;
replyToId?: string;
tableMode?: MarkdownTableMode;
chunkMode?: ChunkMode;
}) {
const chunkLimit = Math.min(params.textLimit, 2000);
for (const payload of params.replies) {
@@ -30,10 +32,14 @@ export async function deliverDiscordReply(params: {
if (mediaList.length === 0) {
let isFirstChunk = true;
for (const chunk of chunkDiscordText(text, {
const mode = params.chunkMode ?? "length";
const chunks = chunkDiscordTextWithMode(text, {
maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage,
})) {
chunkMode: mode,
});
if (!chunks.length && text) chunks.push(text);
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageDiscord(params.target, trimmed, {
+4
View File
@@ -1,5 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { resolveChunkMode } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
@@ -45,6 +46,7 @@ export async function sendMessageDiscord(
channel: "discord",
accountId: accountInfo.accountId,
});
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = parseRecipient(to);
@@ -61,6 +63,7 @@ export async function sendMessageDiscord(
request,
accountInfo.config.maxLinesPerMessage,
opts.embeds,
chunkMode,
);
} else {
result = await sendDiscordText(
@@ -71,6 +74,7 @@ export async function sendMessageDiscord(
request,
accountInfo.config.maxLinesPerMessage,
opts.embeds,
chunkMode,
);
}
} catch (err) {
+20 -4
View File
@@ -9,7 +9,8 @@ import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-polic
import { normalizePollDurationHours, normalizePollInput, type PollInput } from "../polls.js";
import { loadWebMedia } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { chunkDiscordText } from "./chunk.js";
import type { ChunkMode } from "../auto-reply/chunk.js";
import { chunkDiscordTextWithMode } from "./chunk.js";
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
import { DiscordSendError } from "./send.types.js";
import { parseDiscordTarget } from "./targets.js";
@@ -231,15 +232,18 @@ async function sendDiscordText(
request: DiscordRequest,
maxLinesPerMessage?: number,
embeds?: unknown[],
chunkMode?: ChunkMode,
) {
if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends");
}
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const chunks = chunkDiscordText(text, {
const chunks = chunkDiscordTextWithMode(text, {
maxChars: DISCORD_TEXT_LIMIT,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && text) chunks.push(text);
if (chunks.length === 1) {
const res = (await request(
() =>
@@ -285,14 +289,17 @@ async function sendDiscordMedia(
request: DiscordRequest,
maxLinesPerMessage?: number,
embeds?: unknown[],
chunkMode?: ChunkMode,
) {
const media = await loadWebMedia(mediaUrl);
const chunks = text
? chunkDiscordText(text, {
? chunkDiscordTextWithMode(text, {
maxChars: DISCORD_TEXT_LIMIT,
maxLines: maxLinesPerMessage,
chunkMode,
})
: [];
if (!chunks.length && text) chunks.push(text);
const caption = chunks[0] ?? "";
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
const res = (await request(
@@ -314,7 +321,16 @@ async function sendDiscordMedia(
)) as { id: string; channel_id: string };
for (const chunk of chunks.slice(1)) {
if (!chunk.trim()) continue;
await sendDiscordText(rest, channelId, chunk, undefined, request, maxLinesPerMessage);
await sendDiscordText(
rest,
channelId,
chunk,
undefined,
request,
maxLinesPerMessage,
undefined,
chunkMode,
);
}
return res;
}
+5 -1
View File
@@ -13,6 +13,7 @@ export async function startGatewayDiscovery(params: {
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
canvasPort?: number;
wideAreaDiscoveryEnabled: boolean;
tailscaleMode: "off" | "serve" | "funnel";
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
}) {
let bonjourStop: (() => Promise<void>) | null = null;
@@ -20,8 +21,11 @@ export async function startGatewayDiscovery(params: {
process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" &&
process.env.NODE_ENV !== "test" &&
!process.env.VITEST;
const tailscaleEnabled = params.tailscaleMode !== "off";
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
const tailnetDns = needsTailnetDns ? await resolveTailnetDnsHint() : undefined;
const tailnetDns = needsTailnetDns
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
: undefined;
const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim();
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const getTailnetHostname = vi.hoisted(() => vi.fn());
vi.mock("../infra/tailscale.js", () => ({ getTailnetHostname }));
import { resolveTailnetDnsHint } from "./server-discovery.js";
describe("resolveTailnetDnsHint", () => {
const prevTailnetDns = { value: undefined as string | undefined };
beforeEach(() => {
prevTailnetDns.value = process.env.CLAWDBOT_TAILNET_DNS;
delete process.env.CLAWDBOT_TAILNET_DNS;
getTailnetHostname.mockReset();
});
afterEach(() => {
if (prevTailnetDns.value === undefined) {
delete process.env.CLAWDBOT_TAILNET_DNS;
} else {
process.env.CLAWDBOT_TAILNET_DNS = prevTailnetDns.value;
}
});
test("returns env hint when disabled", async () => {
process.env.CLAWDBOT_TAILNET_DNS = "studio.tailnet.ts.net.";
const value = await resolveTailnetDnsHint({ enabled: false });
expect(value).toBe("studio.tailnet.ts.net");
expect(getTailnetHostname).not.toHaveBeenCalled();
});
test("skips tailscale lookup when disabled", async () => {
const value = await resolveTailnetDnsHint({ enabled: false });
expect(value).toBeUndefined();
expect(getTailnetHostname).not.toHaveBeenCalled();
});
test("uses tailscale lookup when enabled", async () => {
getTailnetHostname.mockResolvedValue("host.tailnet.ts.net");
const value = await resolveTailnetDnsHint({ enabled: true });
expect(value).toBe("host.tailnet.ts.net");
expect(getTailnetHostname).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -55,11 +55,13 @@ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}):
export async function resolveTailnetDnsHint(opts?: {
env?: NodeJS.ProcessEnv;
exec?: typeof runExec;
enabled?: boolean;
}): Promise<string | undefined> {
const env = opts?.env ?? process.env;
const envRaw = env.CLAWDBOT_TAILNET_DNS?.trim();
const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : "";
if (envValue) return envValue;
if (opts?.enabled === false) return undefined;
const exec =
opts?.exec ??
+1
View File
@@ -339,6 +339,7 @@ export async function startGatewayServer(
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
: undefined,
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
tailscaleMode,
logDiscovery,
});
bonjourStop = discovery.bonjourStop;
+3 -2
View File
@@ -1,4 +1,4 @@
import { chunkText } from "../../auto-reply/chunk.js";
import { chunkTextWithMode, resolveChunkMode } from "../../auto-reply/chunk.js";
import { loadConfig } from "../../config/config.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { convertMarkdownTables } from "../../markdown/tables.js";
@@ -23,13 +23,14 @@ export async function deliverReplies(params: {
channel: "imessage",
accountId,
});
const chunkMode = resolveChunkMode(cfg, "imessage", accountId);
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = payload.text ?? "";
const text = convertMarkdownTables(rawText, tableMode);
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, textLimit)) {
for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) {
await sendMessageIMessage(target, chunk, {
maxBytes,
client,
+14
View File
@@ -3,6 +3,20 @@ import { describe, expect, it, vi } from "vitest";
import { wrapFetchWithAbortSignal } from "./fetch.js";
describe("wrapFetchWithAbortSignal", () => {
it("adds duplex for requests with a body", async () => {
let seenInit: RequestInit | undefined;
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seenInit = init;
return {} as Response;
});
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
await wrapped("https://example.com", { method: "POST", body: "hi" });
expect(seenInit?.duplex).toBe("half");
});
it("converts foreign abort signals to native controllers", async () => {
let seenSignal: AbortSignal | undefined;
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+26 -6
View File
@@ -2,18 +2,38 @@ type FetchWithPreconnect = typeof fetch & {
preconnect: (url: string, init?: { credentials?: RequestCredentials }) => void;
};
type RequestInitWithDuplex = RequestInit & { duplex?: "half" };
function withDuplex(
init: RequestInit | undefined,
input: RequestInfo | URL,
): RequestInit | undefined {
const hasInitBody = init?.body != null;
const hasRequestBody =
!hasInitBody &&
typeof Request !== "undefined" &&
input instanceof Request &&
input.body != null;
if (!hasInitBody && !hasRequestBody) return init;
if (init && "duplex" in (init as Record<string, unknown>)) return init;
return init
? ({ ...init, duplex: "half" as const } as RequestInitWithDuplex)
: ({ duplex: "half" as const } as RequestInitWithDuplex);
}
export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch {
const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => {
const signal = init?.signal;
if (!signal) return fetchImpl(input, init);
const patchedInit = withDuplex(init, input);
const signal = patchedInit?.signal;
if (!signal) return fetchImpl(input, patchedInit);
if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) {
return fetchImpl(input, init);
return fetchImpl(input, patchedInit);
}
if (typeof AbortController === "undefined") {
return fetchImpl(input, init);
return fetchImpl(input, patchedInit);
}
if (typeof signal.addEventListener !== "function") {
return fetchImpl(input, init);
return fetchImpl(input, patchedInit);
}
const controller = new AbortController();
const onAbort = () => controller.abort();
@@ -22,7 +42,7 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
const response = fetchImpl(input, { ...init, signal: controller.signal });
const response = fetchImpl(input, { ...patchedInit, signal: controller.signal });
if (typeof signal.removeEventListener === "function") {
void response.finally(() => {
signal.removeEventListener("abort", onAbort);
+78
View File
@@ -168,6 +168,84 @@ describe("deliverOutboundPayloads", () => {
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
});
it("respects newline chunk mode for WhatsApp", async () => {
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
const cfg: ClawdbotConfig = {
channels: { whatsapp: { textChunkLimit: 4000, chunkMode: "newline" } },
};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "Line one\n\nLine two" }],
deps: { sendWhatsApp },
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
1,
"+1555",
"Line one",
expect.objectContaining({ verbose: false }),
);
expect(sendWhatsApp).toHaveBeenNthCalledWith(
2,
"+1555",
"\nLine two",
expect.objectContaining({ verbose: false }),
);
});
it("preserves fenced blocks for markdown chunkers in newline mode", async () => {
const chunker = vi.fn((text: string) => (text ? [text] : []));
const sendText = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
const sendMedia = vi.fn().mockImplementation(async ({ text }: { text: string }) => ({
channel: "matrix" as const,
messageId: text,
roomId: "r1",
}));
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
source: "test",
plugin: createOutboundTestPlugin({
id: "matrix",
outbound: {
deliveryMode: "direct",
chunker,
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText,
sendMedia,
},
}),
},
]),
);
const cfg: ClawdbotConfig = {
channels: { matrix: { textChunkLimit: 4000, chunkMode: "newline" } },
};
const text = "```js\nconst a = 1;\nconst b = 2;\n```\nAfter";
await deliverOutboundPayloads({
cfg,
channel: "matrix",
to: "!room",
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);
});
it("uses iMessage media maxBytes from agent fallback", async () => {
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "i1" });
setActivePluginRegistry(
+27 -1
View File
@@ -1,4 +1,9 @@
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import {
chunkByNewline,
chunkMarkdownTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "../../auto-reply/chunk.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits.js";
import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js";
@@ -62,6 +67,7 @@ type Chunker = (text: string, limit: number) => string[];
type ChannelHandler = {
chunker: Chunker | null;
chunkerMode?: "text" | "markdown";
textChunkLimit?: number;
sendText: (text: string) => Promise<OutboundDeliveryResult>;
sendMedia: (caption: string, mediaUrl: string) => Promise<OutboundDeliveryResult>;
@@ -121,8 +127,10 @@ function createPluginHandler(params: {
const sendText = outbound.sendText;
const sendMedia = outbound.sendMedia;
const chunker = outbound.chunker ?? null;
const chunkerMode = outbound.chunkerMode;
return {
chunker,
chunkerMode,
textChunkLimit: outbound.textChunkLimit,
sendText: async (text) =>
sendText({
@@ -192,6 +200,7 @@ export async function deliverOutboundPayloads(params: {
fallbackLimit: handler.textChunkLimit,
})
: undefined;
const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length";
const isSignalChannel = channel === "signal";
const signalTableMode = isSignalChannel
? resolveMarkdownTableMode({ cfg, channel: "signal", accountId })
@@ -212,6 +221,23 @@ export async function deliverOutboundPayloads(params: {
results.push(await handler.sendText(text));
return;
}
if (chunkMode === "newline") {
const mode = handler.chunkerMode ?? "text";
const lineChunks =
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);
for (const chunk of chunks) {
throwIfAborted(abortSignal);
results.push(await handler.sendText(chunk));
}
}
return;
}
const chunks = handler.chunker(text, textLimit);
for (const chunk of chunks) {
throwIfAborted(abortSignal);
+4
View File
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
import {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
@@ -40,6 +41,7 @@ import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/a
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
import { recordInboundSession } from "../../channels/session.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { signalMessageActions } from "../../channels/plugins/actions/signal.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
import { monitorWebChannel } from "../../channels/web/index.js";
@@ -169,6 +171,7 @@ export function createPluginRuntime(): PluginRuntime {
text: {
chunkByNewline,
chunkMarkdownText,
chunkMarkdownTextWithMode,
chunkText,
chunkTextWithMode,
resolveChunkMode,
@@ -269,6 +272,7 @@ export function createPluginRuntime(): PluginRuntime {
probeSignal,
sendMessageSignal,
monitorSignalProvider,
messageActions: signalMessageActions,
},
imessage: {
monitorIMessageProvider,
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more