Merge branch 'clawdbot:main' into main

This commit is contained in:
kastrah 2026-01-18 19:57:04 +01:00 committed by GitHub
commit 6d0cdac387
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
94 changed files with 1894 additions and 577 deletions

View File

@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps. (#1150) — thanks @shiv19.
### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.

View File

@ -474,25 +474,25 @@ Core contributors:
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></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/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/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/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></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/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/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/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/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=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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></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/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/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/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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=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/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/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/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/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/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/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/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/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/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>

View File

@ -9,7 +9,7 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**.
## Message envelopes (UTC)
## Message envelopes (UTC by default)
Inbound messages are wrapped in an envelope like:
@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like:
[Provider ... 2026-01-05T21:26Z] message text
```
The timestamp in the envelope is **always UTC**, with minutes precision.
The timestamp in the envelope is **UTC by default**, with minutes precision.
You can override this with:
```json5
{
agents: {
defaults: {
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
}
}
```
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
### Examples
**UTC (default):**
```
[Signal Alice +1555 2026-01-18T05:19Z] hello
```
**Fixed timezone:**
```
[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello
```
**Elapsed time:**
```
[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up
```
## Tool payloads (raw provider data + normalized fields)

View File

@ -7,10 +7,10 @@ read_when:
# Date & Time
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
We avoid rewriting provider timestamps so tools keep their native semantics.
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (UTC)
## Message envelopes (UTC by default)
Inbound messages are wrapped with a UTC timestamp (minute precision):
@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
[Provider ... 2026-01-05T21:26Z] message text
```
This envelope timestamp is **always UTC**, regardless of the host timezone.
This envelope timestamp is **UTC by default**, regardless of the host timezone.
You can override this behavior:
```json5
{
agents: {
defaults: {
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
}
}
```
- `envelopeTimezone: "local"` uses the host timezone.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
### Examples
**UTC (default):**
```
[WhatsApp +1555 2026-01-18T05:19Z] hello
```
**User timezone:**
```
[WhatsApp +1555 2026-01-18 00:19 CST] hello
```
**Elapsed time enabled:**
```
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
```
## System prompt: Current Date & Time

65
docs/debug/node-issue.md Normal file
View File

@ -0,0 +1,65 @@
# Node + tsx "__name is not a function" crash
## Summary
Running Clawdbot via Node with `tsx` fails at startup with:
```
[clawdbot] Failed to start CLI: TypeError: __name is not a function
at createSubsystemLogger (.../src/logging/subsystem.ts:203:25)
at .../src/agents/auth-profiles/constants.ts:25:20
```
This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun.
## Environment
- Node: v25.x (observed on v25.3.0)
- tsx: 4.21.0
- OS: macOS (repro also likely on other platforms that run Node 25)
## Repro (Node-only)
```bash
# in repo root
node --version
pnpm install
node --import tsx src/entry.ts status
```
## Minimal repro in repo
```bash
node --import tsx scripts/repro/tsx-name-repro.ts
```
## Node version check
- Node 25.3.0: fails
- Node 22.22.0 (Homebrew `node@22`): fails
- Node 24: not installed here yet; needs verification
## Notes / hypothesis
- `tsx` uses esbuild to transform TS/ESM. esbuilds `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`.
- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path.
- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten.
## Regression history
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
## Workarounds
- Use Bun for dev scripts (current temporary revert).
- Use Node + tsc watch, then run compiled output:
```bash
pnpm exec tsc --watch --preserveWatchOutput
node --watch dist/entry.js status
```
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25specific.
## References
- https://opennext.js.org/cloudflare/howtos/keep_names
- https://esbuild.github.io/api/#keep-names
- https://github.com/evanw/esbuild/issues/1031
## Next steps
- Repro on Node 22/24 to confirm Node 25 regression.
- Test `tsx` nightly or pin to earlier version if a known regression exists.
- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace.

View File

@ -836,10 +836,20 @@ async function processMessage(
const fromLabel = message.isGroup
? `group:${peerId}`
: message.senderName || `user:${message.senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "BlueBubbles",
from: fromLabel,
timestamp: message.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
let chatGuidForActions = chatGuid;

View File

@ -466,25 +466,34 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
isThreadRoot: event.isThreadRoot,
});
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix",
peer: {
kind: isDirectMessage ? "dm" : "channel",
id: isDirectMessage ? senderId : roomId,
},
});
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: event.getTs() ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix",
peer: {
kind: isDirectMessage ? "dm" : "channel",
id: isDirectMessage ? senderId : roomId,
},
});
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: bodyText,
@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
OriginatingTo: `room:${roomId}`,
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logger.warn(
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },

View File

@ -406,10 +406,20 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Teams",
from: envelopeFrom,
timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
let combinedBody = body;
@ -421,15 +431,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
}),
});
}
formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({
channel: "Teams",
from: conversationType,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
envelope: envelopeOptions,
}),
});
}
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload,
});
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void core.channel.session.recordSessionMetaFromInbound({
storePath,
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {

View File

@ -530,10 +530,20 @@ async function processMessageWithPipeline(params: {
}
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp: date ? date * 1000 : undefined,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
@ -560,9 +570,6 @@ async function processMessageWithPipeline(params: {
OriginatingTo: `zalo:${chatId}`,
});
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,

View File

@ -274,10 +274,20 @@ async function processMessage(
});
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
@ -301,9 +311,6 @@ async function processMessage(
OriginatingTo: `zalouser:${chatId}`,
});
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,

View File

@ -65,27 +65,27 @@
"dist/whatsapp/**"
],
"scripts": {
"dev": "bun src/entry.ts",
"dev": "node scripts/run-node.mjs",
"postinstall": "node scripts/postinstall.js",
"prepack": "pnpm build",
"docs:list": "bun scripts/docs-list.ts",
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
"docs:list": "node scripts/docs-list.js",
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
"build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts && bun scripts/copy-hook-metadata.ts && bun scripts/write-build-info.ts",
"plugins:sync": "bun scripts/sync-plugin-versions.ts",
"release:check": "bun scripts/release-check.ts",
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"release:check": "node --import tsx scripts/release-check.ts",
"ui:install": "node scripts/ui.js install",
"ui:dev": "node scripts/ui.js dev",
"ui:build": "node scripts/ui.js build",
"start": "bun src/entry.ts",
"clawdbot": "bun src/entry.ts",
"gateway:watch": "bun --watch src/entry.ts gateway --force",
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway",
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset",
"tui": "bun src/entry.ts tui",
"tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui",
"clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json",
"start": "node scripts/run-node.mjs",
"clawdbot": "node scripts/run-node.mjs",
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
"tui": "node scripts/run-node.mjs tui",
"tui:dev": "CLAWDBOT_PROFILE=dev node scripts/run-node.mjs tui",
"clawdbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
"ios:gen": "cd apps/ios && xcodegen generate",
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
@ -108,7 +108,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "pnpm --dir ui test",
"test:force": "bun scripts/test-force.ts",
"test:force": "node --import tsx scripts/test-force.ts",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
@ -126,11 +126,11 @@
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
"protocol:gen": "bun scripts/protocol-gen.ts",
"protocol:gen:swift": "bun scripts/protocol-gen-swift.ts",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500"
},
"keywords": [],
"author": "",

View File

@ -0,0 +1,15 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const binDir = path.join(root, "bin");
const scriptPath = path.join(root, "scripts", "docs-list.js");
const binPath = path.join(binDir, "docs-list");
fs.mkdirSync(binDir, { recursive: true });
const wrapper = `#!/usr/bin/env node\nimport { spawnSync } from "node:child_process";\nimport path from "node:path";\nimport { fileURLToPath } from "node:url";\n\nconst here = path.dirname(fileURLToPath(import.meta.url));\nconst script = path.join(here, "..", "scripts", "docs-list.js");\n\nconst result = spawnSync(process.execPath, [script], { stdio: "inherit" });\nprocess.exit(result.status ?? 1);\n`;
fs.writeFileSync(binPath, wrapper, { mode: 0o755 });

View File

@ -1,10 +1,10 @@
#!/usr/bin/env bun
#!/usr/bin/env node
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
process.stdout.on('error', (error) => {
if ((error as NodeJS.ErrnoException).code === 'EPIPE') {
if (error?.code === 'EPIPE') {
process.exit(0);
}
throw error;

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin/env -S node --import tsx
import { execSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";

View File

@ -0,0 +1,3 @@
import "../../src/logging/subsystem.js";
console.log("tsx-name-repro: loaded logging/subsystem");

38
scripts/run-node.mjs Normal file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import process from "node:process";
const args = process.argv.slice(2);
const env = { ...process.env };
const cwd = process.cwd();
const build = spawn("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
cwd,
env,
stdio: "inherit",
});
build.on("exit", (code, signal) => {
if (signal) {
process.exit(1);
return;
}
if (code !== 0 && code !== null) {
process.exit(code);
return;
}
const nodeProcess = spawn(process.execPath, ["dist/entry.js", ...args], {
cwd,
env,
stdio: "inherit",
});
nodeProcess.on("exit", (exitCode, exitSignal) => {
if (exitSignal) {
process.exit(1);
return;
}
process.exit(exitCode ?? 1);
});
});

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bun
#!/usr/bin/env -S node --import tsx
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";

View File

@ -45,19 +45,8 @@ function which(cmd) {
}
function resolveRunner() {
// CLAWDBOT_PREFER_PNPM=1 forces pnpm (useful in Docker on architectures where Bun fails)
const preferPnpm = process.env.CLAWDBOT_PREFER_PNPM === "1";
if (!preferPnpm) {
const bun = which("bun");
if (bun) return { cmd: bun, kind: "bun" };
}
const pnpm = which("pnpm");
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
if (preferPnpm) {
// Fallback to bun if pnpm not found even when preferring pnpm
const bun = which("bun");
if (bun) return { cmd: bun, kind: "bun" };
}
return null;
}
@ -107,9 +96,7 @@ if (!action) {
const runner = resolveRunner();
if (!runner) {
process.stderr.write(
"Missing UI runner: install bun or pnpm, then retry.\n",
);
process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
process.exit(1);
}
@ -129,32 +116,16 @@ if (action !== "install" && !script) {
process.exit(2);
}
if (runner.kind === "bun") {
if (action === "install") run(runner.cmd, ["install", ...rest]);
else {
if (!depsInstalled(action === "test" ? "test" : "build")) {
const installEnv =
action === "build"
? { ...process.env, NODE_ENV: "production" }
: process.env;
const installArgs =
action === "build" ? ["install", "--production"] : ["install"];
runSync(runner.cmd, installArgs, installEnv);
}
run(runner.cmd, ["run", script, ...rest]);
}
} else {
if (action === "install") run(runner.cmd, ["install", ...rest]);
else {
if (!depsInstalled(action === "test" ? "test" : "build")) {
const installEnv =
action === "build"
? { ...process.env, NODE_ENV: "production" }
: process.env;
const installArgs =
action === "build" ? ["install", "--prod"] : ["install"];
runSync(runner.cmd, installArgs, installEnv);
}
run(runner.cmd, ["run", script, ...rest]);
if (action === "install") run(runner.cmd, ["install", ...rest]);
else {
if (!depsInstalled(action === "test" ? "test" : "build")) {
const installEnv =
action === "build"
? { ...process.env, NODE_ENV: "production" }
: process.env;
const installArgs =
action === "build" ? ["install", "--prod"] : ["install"];
runSync(runner.cmd, installArgs, installEnv);
}
run(runner.cmd, ["run", script, ...rest]);
}

52
scripts/watch-node.mjs Normal file
View File

@ -0,0 +1,52 @@
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import process from "node:process";
const args = process.argv.slice(2);
const env = { ...process.env };
const cwd = process.cwd();
const initialBuild = spawnSync("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
cwd,
env,
stdio: "inherit",
});
if (initialBuild.status !== 0) {
process.exit(initialBuild.status ?? 1);
}
const tsc = spawn("pnpm", ["exec", "tsc", "--watch", "--preserveWatchOutput"], {
cwd,
env,
stdio: "inherit",
});
const nodeProcess = spawn(process.execPath, ["--watch", "dist/entry.js", ...args], {
cwd,
env,
stdio: "inherit",
});
let exiting = false;
function cleanup(code = 0) {
if (exiting) return;
exiting = true;
nodeProcess.kill("SIGTERM");
tsc.kill("SIGTERM");
process.exit(code);
}
process.on("SIGINT", () => cleanup(130));
process.on("SIGTERM", () => cleanup(143));
tsc.on("exit", (code) => {
if (exiting) return;
cleanup(code ?? 1);
});
nodeProcess.on("exit", (code, signal) => {
if (signal || exiting) return;
cleanup(code ?? 1);
});

View File

@ -5,7 +5,7 @@ import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-emb
describe("image dimension errors", () => {
it("parses anthropic image dimension errors", () => {
const raw =
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}";
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}';
const parsed = parseImageDimensionError(raw);
expect(parsed).not.toBeNull();
expect(parsed?.maxDimensionPx).toBe(2000);

View File

@ -25,7 +25,7 @@ describe("isCloudCodeAssistFormatError", () => {
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
expect(
isCloudCodeAssistFormatError(
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}',
),
).toBe(false);
});

View File

@ -89,7 +89,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
});

View File

@ -91,7 +91,6 @@ let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAg
beforeAll(async () => {
vi.useRealTimers();
vi.resetModules();
mockPiAi();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
}, 20_000);

View File

@ -41,7 +41,7 @@ describe("detectImageReferences", () => {
expect(refs[0]?.raw).toBe("~/Pictures/vacation.png");
expect(refs[0]?.type).toBe("path");
// Resolved path should expand ~
expect(refs[0]?.resolved).not.toContain("~");
expect(refs[0]?.resolved?.startsWith("~")).toBe(false);
});
it("detects multiple image references in a prompt", () => {

View File

@ -109,11 +109,7 @@ function buildMessagingSection(params: {
];
}
function buildDocsSection(params: {
docsPath?: string;
isMinimal: boolean;
readToolName: string;
}) {
function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) {
const docsPath = params.docsPath?.trim();
if (!docsPath || params.isMinimal) return [];
return [

View File

@ -58,7 +58,12 @@ async function resizeImageBase64IfNeeded(params: {
const height = meta?.height;
const overBytes = buf.byteLength > params.maxBytes;
const hasDimensions = typeof width === "number" && typeof height === "number";
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
if (
hasDimensions &&
!overBytes &&
width <= params.maxDimensionPx &&
height <= params.maxDimensionPx
) {
return {
base64: params.base64,
mimeType: params.mimeType,
@ -67,7 +72,10 @@ async function resizeImageBase64IfNeeded(params: {
height,
};
}
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) {
if (
hasDimensions &&
(width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)
) {
log.warn("Image exceeds limits; resizing", {
label: params.label,
width,

View File

@ -1,4 +1,8 @@
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import {
getChannelPlugin,
normalizeChannelId as normalizeAnyChannelId,
} from "../../channels/plugins/index.js";
import { normalizeChannelId as normalizeChatChannelId } from "../../channels/registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
@ -21,7 +25,8 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
const id = rest.join(":").trim();
if (!id) return null;
if (!channelRaw) return null;
const normalizedChannel = normalizeChannelId(channelRaw);
const normalizedChannel =
normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
const channel = normalizedChannel ?? channelRaw.toLowerCase();
const kindTarget = (() => {
if (!normalizedChannel) return id;

View File

@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "./envelope.js";
describe("formatAgentEnvelope", () => {
it("includes channel, from, ip, host, and timestamp", () => {
@ -38,6 +42,46 @@ describe("formatAgentEnvelope", () => {
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
});
it("formats timestamps in local timezone when configured", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (19:04 PST)
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
envelope: { timezone: "local" },
body: "hello",
});
process.env.TZ = originalTz;
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
});
it("formats timestamps in user timezone when configured", () => {
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z (04:04 CET)
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
envelope: { timezone: "user", userTimezone: "Europe/Vienna" },
body: "hello",
});
expect(body).toMatch(/\[WebChat 2025-01-02 04:04 [^\]]+\] hello/);
});
it("omits timestamps when configured", () => {
const ts = Date.UTC(2025, 0, 2, 3, 4);
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
envelope: { includeTimestamp: false },
body: "hello",
});
expect(body).toBe("[WebChat] hello");
});
it("handles missing optional fields", () => {
const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" });
expect(body).toBe("[Telegram] hi");
@ -77,4 +121,53 @@ describe("formatInboundEnvelope", () => {
});
expect(body).toBe("[iMessage +1555] hello");
});
it("includes elapsed time when previousTimestamp is provided", () => {
const now = Date.now();
const twoMinutesAgo = now - 2 * 60 * 1000;
const body = formatInboundEnvelope({
channel: "Telegram",
from: "Alice",
body: "follow-up message",
timestamp: now,
previousTimestamp: twoMinutesAgo,
chatType: "direct",
envelope: { includeTimestamp: false },
});
expect(body).toContain("Alice +2m");
expect(body).toContain("follow-up message");
});
it("omits elapsed time when disabled", () => {
const now = Date.now();
const body = formatInboundEnvelope({
channel: "Telegram",
from: "Alice",
body: "follow-up message",
timestamp: now,
previousTimestamp: now - 2 * 60 * 1000,
chatType: "direct",
envelope: { includeElapsed: false, includeTimestamp: false },
});
expect(body).toBe("[Telegram Alice] follow-up message");
});
it("resolves envelope options from config", () => {
const options = resolveEnvelopeFormatOptions({
agents: {
defaults: {
envelopeTimezone: "user",
envelopeTimestamp: "off",
envelopeElapsed: "off",
userTimezone: "Europe/Vienna",
},
},
});
expect(options).toEqual({
timezone: "user",
includeTimestamp: false,
includeElapsed: false,
userTimezone: "Europe/Vienna",
});
});
});

View File

@ -1,5 +1,7 @@
import { resolveUserTimezone } from "../agents/date-time.js";
import { normalizeChatType } from "../channels/chat-type.js";
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
import type { ClawdbotConfig } from "../config/config.js";
export type AgentEnvelopeParams = {
channel: string;
@ -8,31 +10,162 @@ export type AgentEnvelopeParams = {
host?: string;
ip?: string;
body: string;
previousTimestamp?: number | Date;
envelope?: EnvelopeFormatOptions;
};
function formatTimestamp(ts?: number | Date): string | undefined {
if (!ts) return undefined;
const date = ts instanceof Date ? ts : new Date(ts);
if (Number.isNaN(date.getTime())) return undefined;
export type EnvelopeFormatOptions = {
/**
* "utc" (default), "local", "user", or an explicit IANA timezone string.
*/
timezone?: string;
/**
* Include absolute timestamps in the envelope (default: true).
*/
includeTimestamp?: boolean;
/**
* Include elapsed time suffix when previousTimestamp is provided (default: true).
*/
includeElapsed?: boolean;
/**
* Optional user timezone used when timezone="user".
*/
userTimezone?: string;
};
type ResolvedEnvelopeTimezone =
| { mode: "utc" }
| { mode: "local" }
| { mode: "iana"; timeZone: string };
export function resolveEnvelopeFormatOptions(cfg?: ClawdbotConfig): EnvelopeFormatOptions {
const defaults = cfg?.agents?.defaults;
return {
timezone: defaults?.envelopeTimezone,
includeTimestamp: defaults?.envelopeTimestamp !== "off",
includeElapsed: defaults?.envelopeElapsed !== "off",
userTimezone: defaults?.userTimezone,
};
}
function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): Required<EnvelopeFormatOptions> {
const includeTimestamp = options?.includeTimestamp !== false;
const includeElapsed = options?.includeElapsed !== false;
return {
timezone: options?.timezone?.trim() || "utc",
includeTimestamp,
includeElapsed,
userTimezone: options?.userTimezone,
};
}
function resolveExplicitTimezone(value: string): string | undefined {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
}
function resolveEnvelopeTimezone(options: EnvelopeFormatOptions): ResolvedEnvelopeTimezone {
const trimmed = options.timezone?.trim();
if (!trimmed) return { mode: "utc" };
const lowered = trimmed.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
if (lowered === "local" || lowered === "host") return { mode: "local" };
if (lowered === "user") {
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
}
const explicit = resolveExplicitTimezone(trimmed);
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
}
function formatUtcTimestamp(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
// Compact ISO-like UTC timestamp with minutes precision.
// Example: 2025-01-02T03:04Z
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
}
function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const tz = [...parts]
.reverse()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min) return undefined;
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
}
function formatTimestamp(ts: number | Date | undefined, options?: EnvelopeFormatOptions): string | undefined {
if (!ts) return undefined;
const resolved = normalizeEnvelopeOptions(options);
if (!resolved.includeTimestamp) return undefined;
const date = ts instanceof Date ? ts : new Date(ts);
if (Number.isNaN(date.getTime())) return undefined;
const zone = resolveEnvelopeTimezone(resolved);
if (zone.mode === "utc") return formatUtcTimestamp(date);
if (zone.mode === "local") return formatZonedTimestamp(date);
return formatZonedTimestamp(date, zone.timeZone);
}
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
const elapsedMs = currentMs - previousMs;
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return undefined;
const seconds = Math.floor(elapsedMs / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const channel = params.channel?.trim() || "Channel";
const parts: string[] = [channel];
if (params.from?.trim()) parts.push(params.from.trim());
const resolved = normalizeEnvelopeOptions(params.envelope);
const elapsed =
resolved.includeElapsed && params.timestamp && params.previousTimestamp
? formatElapsedTime(
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp,
params.previousTimestamp instanceof Date
? params.previousTimestamp.getTime()
: params.previousTimestamp,
)
: undefined;
if (params.from?.trim()) {
const from = params.from.trim();
parts.push(elapsed ? `${from} +${elapsed}` : from);
} else if (elapsed) {
parts.push(`+${elapsed}`);
}
if (params.host?.trim()) parts.push(params.host.trim());
if (params.ip?.trim()) parts.push(params.ip.trim());
const ts = formatTimestamp(params.timestamp);
const ts = formatTimestamp(params.timestamp, resolved);
if (ts) parts.push(ts);
const header = `[${parts.join(" ")}]`;
return `${header} ${params.body}`;
@ -46,6 +179,8 @@ export function formatInboundEnvelope(params: {
chatType?: string;
senderLabel?: string;
sender?: SenderLabelParams;
previousTimestamp?: number | Date;
envelope?: EnvelopeFormatOptions;
}): string {
const chatType = normalizeChatType(params.chatType);
const isDirect = !chatType || chatType === "direct";
@ -55,6 +190,8 @@ export function formatInboundEnvelope(params: {
channel: params.channel,
from: params.from,
timestamp: params.timestamp,
previousTimestamp: params.previousTimestamp,
envelope: params.envelope,
body,
});
}
@ -85,11 +222,13 @@ export function formatThreadStarterEnvelope(params: {
author?: string;
timestamp?: number | Date;
body: string;
envelope?: EnvelopeFormatOptions;
}): string {
return formatAgentEnvelope({
channel: params.channel,
from: params.author,
timestamp: params.timestamp,
envelope: params.envelope,
body: params.body,
});
}

View File

@ -1,19 +1,17 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn(
async (method: string, _opts: unknown, params?: unknown) => {
if (method.endsWith(".get")) {
return {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
}
return { method, params };
},
);
const callGatewayFromCli = vi.fn(async (method: string, _opts: unknown, params?: unknown) => {
if (method.endsWith(".get")) {
return {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
}
return { method, params };
});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
@ -31,9 +29,7 @@ vi.mock("./gateway-rpc.js", () => ({
}));
vi.mock("./nodes-cli/rpc.js", async () => {
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
"./nodes-cli/rpc.js",
);
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>("./nodes-cli/rpc.js");
return {
...actual,
resolveNodeId: vi.fn(async () => "node-1"),
@ -57,11 +53,7 @@ describe("exec approvals CLI", () => {
await program.parseAsync(["approvals", "get"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.get",
expect.anything(),
{},
);
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
expect(runtimeErrors).toHaveLength(0);
});
@ -77,11 +69,9 @@ describe("exec approvals CLI", () => {
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.node.get",
expect.anything(),
{ nodeId: "node-1" },
);
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
nodeId: "node-1",
});
expect(runtimeErrors).toHaveLength(0);
});
});

View File

@ -151,15 +151,13 @@ export function registerExecApprovalsCli(program: Command) {
});
nodesCallOpts(setCmd);
const allowlist = approvals
.command("allowlist")
.description("Edit the per-agent allowlist");
const allowlist = approvals.command("allowlist").description("Edit the per-agent allowlist");
const allowlistAdd = allowlist
.command("add <pattern>")
.description("Add a glob pattern to an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.option("--agent <id>", 'Agent id (defaults to "default")')
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
@ -196,7 +194,7 @@ export function registerExecApprovalsCli(program: Command) {
.command("remove <pattern>")
.description("Remove a glob pattern from an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.option("--agent <id>", 'Agent id (defaults to "default")')
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {

View File

@ -87,19 +87,13 @@ describe("gateway SIGTERM", () => {
const out: string[] = [];
const err: string[] = [];
const bunBin = process.env.BUN_INSTALL
? path.join(process.env.BUN_INSTALL, "bin", "bun")
: "bun";
child = spawn(
process.execPath,
[
"--import",
"tsx",
"src/index.ts",
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
],
bunBin,
["src/entry.ts", "gateway", "--port", String(port), "--bind", "loopback", "--allow-unconfigured"],
{
cwd: process.cwd(),
env: {

View File

@ -156,9 +156,7 @@ export function registerMemoryCli(program: Command) {
for (const result of allResults) {
const { agentId, status, embeddingProbe, indexError } = result;
if (opts.index) {
const line = indexError
? `Memory index failed: ${indexError}`
: "Memory index complete.";
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
defaultRuntime.log(line);
}
const lines = [
@ -167,9 +165,7 @@ export function registerMemoryCli(program: Command) {
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
status.sources?.length
? `${label("Sources")} ${info(status.sources.join(", "))}`
: null,
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`,

View File

@ -116,12 +116,14 @@ export function registerNodesStatusCommands(nodes: Command) {
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
const versions = formatNodeVersions(obj as {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
});
const versions = formatNodeVersions(
obj as {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
},
);
const parts: string[] = ["Node:", displayName, nodeId];
if (ip) parts.push(ip);

View File

@ -11,10 +11,8 @@ import {
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
const CHANNELS: PairingChannel[] = listPairingChannels();
/** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown): PairingChannel {
function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel {
const value = (
typeof raw === "string"
? raw
@ -28,7 +26,7 @@ function parseChannel(raw: unknown): PairingChannel {
const normalized = normalizeChannelId(value);
if (normalized) {
if (!CHANNELS.includes(normalized as PairingChannel)) {
if (!channels.includes(normalized as PairingChannel)) {
throw new Error(`Channel ${normalized} does not support pairing`);
}
return normalized as PairingChannel;
@ -45,6 +43,7 @@ async function notifyApproved(channel: PairingChannel, id: string) {
}
export function registerPairingCli(program: Command) {
const channels = listPairingChannels();
const pairing = program
.command("pairing")
.description("Secure DM pairing (approve inbound requests)")
@ -57,17 +56,17 @@ export function registerPairingCli(program: Command) {
pairing
.command("list")
.description("List pending pairing requests")
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
.argument("[channel]", `Channel (${CHANNELS.join(", ")})`)
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
.argument("[channel]", `Channel (${channels.join(", ")})`)
.option("--json", "Print JSON", false)
.action(async (channelArg, opts) => {
const channelRaw = opts.channel ?? channelArg;
if (!channelRaw) {
throw new Error(
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${CHANNELS.join(", ")})`,
`Channel required. Use --channel <channel> or pass it as the first argument (expected one of: ${channels.join(", ")})`,
);
}
const channel = parseChannel(channelRaw);
const channel = parseChannel(channelRaw, channels);
const requests = await listChannelPairingRequests(channel);
if (opts.json) {
console.log(JSON.stringify({ channel, requests }, null, 2));
@ -87,7 +86,7 @@ export function registerPairingCli(program: Command) {
pairing
.command("approve")
.description("Approve a pairing code and allow that sender")
.option("--channel <channel>", `Channel (${CHANNELS.join(", ")})`)
.option("--channel <channel>", `Channel (${channels.join(", ")})`)
.argument("<codeOrChannel>", "Pairing code (or channel when using 2 args)")
.argument("[code]", "Pairing code (when channel is passed as the 1st arg)")
.option("--notify", "Notify the requester on the same channel", false)
@ -104,7 +103,7 @@ export function registerPairingCli(program: Command) {
`Too many arguments. Use: clawdbot pairing approve --channel <channel> <code>`,
);
}
const channel = parseChannel(channelRaw);
const channel = parseChannel(channelRaw, channels);
const approved = await approveChannelPairingCode({
channel,
code: String(resolvedCode),

View File

@ -13,6 +13,7 @@ import {
resolvePluginInstallDir,
} from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import type { PluginRecord } from "../plugins/registry.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
@ -79,6 +80,31 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
}
}
function applySlotSelectionForPlugin(
config: ClawdbotConfig,
pluginId: string,
): { config: ClawdbotConfig; warnings: string[] } {
const report = buildPluginStatusReport({ config });
const plugin = report.plugins.find((entry) => entry.id === pluginId);
if (!plugin) {
return { config, warnings: [] };
}
const result = applyExclusiveSlotSelection({
config,
selectedId: plugin.id,
selectedKind: plugin.kind,
registry: report,
});
return { config: result.config, warnings: result.warnings };
}
function logSlotWarnings(warnings: string[]) {
if (warnings.length === 0) return;
for (const warning of warnings) {
defaultRuntime.log(chalk.yellow(warning));
}
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@ -197,7 +223,7 @@ export function registerPluginsCli(program: Command) {
.argument("<id>", "Plugin id")
.action(async (id: string) => {
const cfg = loadConfig();
const next = {
let next: ClawdbotConfig = {
...cfg,
plugins: {
...cfg.plugins,
@ -210,7 +236,10 @@ export function registerPluginsCli(program: Command) {
},
},
};
const slotResult = applySlotSelectionForPlugin(next, id);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
});
@ -280,7 +309,10 @@ export function registerPluginsCli(program: Command) {
installPath: resolved,
version: probe.version,
});
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Linked plugin path: ${resolved}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
@ -319,7 +351,10 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir,
version: result.version,
});
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
@ -379,7 +414,10 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir,
version: result.version,
});
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
});

View File

@ -176,10 +176,7 @@ export async function applyAuthChoicePluginProvider(
if (result.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
await params.prompter.note(
`Default model set to ${result.defaultModel}`,
"Model configured",
);
await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
} else if (params.agentId) {
agentModelOverride = result.defaultModel;
await params.prompter.note(

View File

@ -417,6 +417,22 @@ describe("channels command", () => {
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
});
it("includes Telegram bot username from probe data", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {
telegram: [
{
accountId: "default",
enabled: true,
configured: true,
probe: { ok: true, bot: { username: "clawdbot_bot" } },
},
],
},
});
expect(lines.join("\n")).toMatch(/bot:@clawdbot_bot/);
});
it("surfaces Telegram group membership audit issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({
channelAccounts: {

View File

@ -51,6 +51,18 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);
}
const botUsername = (() => {
const bot = account.bot as { username?: string | null } | undefined;
const probeBot = (account.probe as { bot?: { username?: string | null } } | undefined)?.bot;
const raw = bot?.username ?? probeBot?.username ?? "";
if (typeof raw !== "string") return "";
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
})();
if (botUsername) {
bits.push(`bot:${botUsername}`);
}
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
bits.push(`dm:${account.dmPolicy}`);
}

View File

@ -1,6 +1,9 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
import { resolveGatewayLaunchAgentLabel, resolveNodeLaunchAgentLabel } from "../daemon/constants.js";
import {
resolveGatewayLaunchAgentLabel,
resolveNodeLaunchAgentLabel,
} from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import {
isLaunchAgentListed,
@ -44,10 +47,7 @@ async function maybeRepairLaunchAgentBootstrap(params: {
const plistExists = await launchAgentPlistExists(params.env);
if (!plistExists) return false;
note(
"LaunchAgent is listed but not loaded in launchd.",
`${params.title} LaunchAgent`,
);
note("LaunchAgent is listed but not loaded in launchd.", `${params.title} LaunchAgent`);
const shouldFix = await params.prompter.confirmSkipInNonInteractive({
message: `Repair ${params.title} LaunchAgent bootstrap now?`,

View File

@ -3,7 +3,7 @@ import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
@ -50,7 +50,6 @@ describe("onboard (non-interactive): remote gateway config", () => {
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
vi.resetModules();
const port = await getFreePort();
const token = "tok_remote_123";
@ -85,8 +84,8 @@ describe("onboard (non-interactive): remote gateway config", () => {
runtime,
);
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
const { resolveConfigPath } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
};

View File

@ -8,6 +8,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { allow: ["telegram"] },
},
env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true);
@ -21,6 +22,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { entries: { slack: { enabled: false } } },
},
env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false);
@ -39,6 +41,7 @@ describe("applyPluginAutoEnable", () => {
},
},
},
env: {},
});
expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true);
@ -50,6 +53,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } },
plugins: { enabled: false },
},
env: {},
});
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined();

View File

@ -45,10 +45,7 @@ function recordHasKeys(value: unknown): boolean {
return isRecord(value) && Object.keys(value).length > 0;
}
function accountsHaveKeys(
value: unknown,
keys: string[],
): boolean {
function accountsHaveKeys(value: unknown, keys: string[]): boolean {
if (!isRecord(value)) return false;
for (const account of Object.values(value)) {
if (!isRecord(account)) continue;
@ -59,7 +56,10 @@ function accountsHaveKeys(
return false;
}
function resolveChannelConfig(cfg: ClawdbotConfig, channelId: string): Record<string, unknown> | null {
function resolveChannelConfig(
cfg: ClawdbotConfig,
channelId: string,
): Record<string, unknown> | null {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId];
return isRecord(entry) ? entry : null;
@ -234,7 +234,10 @@ function isProviderConfigured(cfg: ClawdbotConfig, providerId: string): boolean
return false;
}
function resolveConfiguredPlugins(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): PluginEnableChange[] {
function resolveConfiguredPlugins(
cfg: ClawdbotConfig,
env: NodeJS.ProcessEnv,
): PluginEnableChange[] {
const changes: PluginEnableChange[] = [];
for (const channelId of CHANNEL_PLUGIN_IDS) {
if (isChannelConfigured(cfg, channelId, env)) {

View File

@ -172,6 +172,9 @@ const FIELD_LABELS: Record<string, string> = {
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace",
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
"agents.defaults.envelopeTimezone": "Envelope Timezone",
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
"agents.defaults.envelopeElapsed": "Envelope Elapsed",
"agents.defaults.memorySearch": "Memory Search",
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
"agents.defaults.memorySearch.sources": "Memory Search Sources",
@ -371,6 +374,12 @@ const FIELD_HELP: Record<string, string> = {
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
"agents.defaults.bootstrapMaxChars":
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
"agents.defaults.envelopeTimezone":
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
"agents.defaults.envelopeTimestamp":
'Include absolute timestamps in message envelopes ("on" or "off").',
"agents.defaults.envelopeElapsed":
'Include elapsed time in message envelopes ("on" or "off").',
"agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).",
"agents.defaults.memorySearch":
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",

View File

@ -155,6 +155,18 @@ export function loadSessionStore(
return structuredClone(store);
}
export function readSessionUpdatedAt(params: {
storePath: string;
sessionKey: string;
}): number | undefined {
try {
const store = loadSessionStore(params.storePath);
return store[params.sessionKey]?.updatedAt;
} catch {
return undefined;
}
}
async function saveSessionStoreUnlocked(
storePath: string,
store: Record<string, SessionEntry>,

View File

@ -105,6 +105,18 @@ export type AgentDefaultsConfig = {
userTimezone?: string;
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
timeFormat?: "auto" | "12" | "24";
/**
* Envelope timestamp timezone: "utc" (default), "local", "user", or an IANA timezone string.
*/
envelopeTimezone?: string;
/**
* Include absolute timestamps in message envelopes ("on" | "off", default: "on").
*/
envelopeTimestamp?: "on" | "off";
/**
* Include elapsed time in message envelopes ("on" | "off", default: "on").
*/
envelopeElapsed?: "on" | "off";
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */

View File

@ -20,7 +20,10 @@ export function parseClawdbotVersion(raw: string | null | undefined): ClawdbotVe
};
}
export function compareClawdbotVersions(a: string | null | undefined, b: string | null | undefined): number | null {
export function compareClawdbotVersions(
a: string | null | undefined,
b: string | null | undefined,
): number | null {
const parsedA = parseClawdbotVersion(a);
const parsedB = parseClawdbotVersion(b);
if (!parsedA || !parsedB) return null;

View File

@ -42,6 +42,9 @@ export const AgentDefaultsSchema = z
bootstrapMaxChars: z.number().int().positive().optional(),
userTimezone: z.string().optional(),
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
envelopeTimezone: z.string().optional(),
envelopeTimestamp: z.union([z.literal("on"), z.literal("off")]).optional(),
envelopeElapsed: z.union([z.literal("on"), z.literal("off")]).optional(),
contextTokens: z.number().int().positive().optional(),
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
memorySearch: MemorySearchSchema,

View File

@ -40,7 +40,7 @@ async function withLaunchctlStub(
' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");',
"}",
'if (args[0] === "list") {',
" const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || \"\";",
' const output = process.env.CLAWDBOT_TEST_LAUNCHCTL_LIST_OUTPUT || "";',
" process.stdout.write(output);",
"}",
"process.exit(0);",
@ -107,13 +107,10 @@ describe("launchd runtime parsing", () => {
describe("launchctl list detection", () => {
it("detects the resolved label in launchctl list", async () => {
await withLaunchctlStub(
{ listOutput: "123 0 com.clawdbot.gateway\n" },
async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(true);
},
);
await withLaunchctlStub({ listOutput: "123 0 com.clawdbot.gateway\n" }, async ({ env }) => {
const listed = await isLaunchAgentListed({ env });
expect(listed).toBe(true);
});
});
it("returns false when the label is missing", async () => {

View File

@ -176,9 +176,7 @@ export async function isLaunchAgentListed(args: {
const label = resolveLaunchAgentLabel({ env: args.env });
const res = await execLaunchctl(["list"]);
if (res.code !== 0) return false;
return res.stdout
.split(/\r?\n/)
.some((line) => line.trim().split(/\s+/).at(-1) === label);
return res.stdout.split(/\r?\n/).some((line) => line.trim().split(/\s+/).at(-1) === label);
}
export async function launchAgentPlistExists(

View File

@ -3,6 +3,8 @@ import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
const sendMock = vi.fn();
const reactMock = vi.fn();
const updateLastRouteMock = vi.fn();
@ -42,7 +44,7 @@ beforeEach(() => {
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
vi.resetModules();
__resetDiscordChannelInfoCacheForTest();
});
describe("discord tool result dispatch", () => {

View File

@ -8,7 +8,11 @@ import {
extractShortModelName,
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
import { formatInboundEnvelope, formatThreadStarterEnvelope } from "../../auto-reply/envelope.js";
import {
formatInboundEnvelope,
formatThreadStarterEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import {
buildPendingHistoryContextFromMap,
@ -18,6 +22,7 @@ import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.j
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
@ -137,6 +142,14 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
let combinedBody = formatInboundEnvelope({
channel: "Discord",
from: fromLabel,
@ -144,6 +157,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
body: text,
chatType: isDirectMessage ? "direct" : "channel",
senderLabel,
previousTimestamp,
envelope: envelopeOptions,
});
const shouldIncludeChannelHistory =
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
@ -161,10 +176,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
chatType: "channel",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
const replyContext = resolveReplyContext(message, resolveDiscordMessageText, {
envelope: envelopeOptions,
});
if (replyContext) {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
@ -186,6 +204,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
author: starter.author,
timestamp: starter.timestamp,
body: starter.text,
envelope: envelopeOptions,
});
threadStarterBody = starterEnvelope;
}
@ -268,9 +287,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,

View File

@ -44,6 +44,10 @@ const DISCORD_CHANNEL_INFO_CACHE = new Map<
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
export function __resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear();
}
export async function resolveDiscordChannelInfo(
client: Client,
channelId: string,

View File

@ -1,11 +1,12 @@
import type { Guild, Message, User } from "@buape/carbon";
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import { formatAgentEnvelope, type EnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { formatDiscordUserTag, resolveTimestampMs } from "./format.js";
export function resolveReplyContext(
message: Message,
resolveDiscordMessageText: (message: Message, options?: { includeForwarded?: boolean }) => string,
options?: { envelope?: EnvelopeFormatOptions },
): string | null {
const referenced = message.referencedMessage;
if (!referenced?.author) return null;
@ -20,6 +21,7 @@ export function resolveReplyContext(
from: fromLabel,
timestamp: resolveTimestampMs(referenced.timestamp),
body,
envelope: options?.envelope,
});
}

View File

@ -27,9 +27,10 @@ describe("runBootOnce", () => {
it("skips when BOOT.md is missing", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "skipped", reason: "missing" });
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
status: "skipped",
reason: "missing",
});
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
@ -37,9 +38,10 @@ describe("runBootOnce", () => {
it("skips when BOOT.md is empty", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "skipped", reason: "empty" });
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
status: "skipped",
reason: "empty",
});
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
@ -50,9 +52,9 @@ describe("runBootOnce", () => {
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
agentCommand.mockResolvedValue(undefined);
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "ran" });
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
status: "ran",
});
expect(agentCommand).toHaveBeenCalledTimes(1);
const call = agentCommand.mock.calls[0]?.[0];

View File

@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
@ -141,7 +141,6 @@ describe("gateway wizard (e2e)", () => {
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
vi.resetModules();
const wizardToken = `wiz-${randomUUID()}`;
const port = await getFreeGatewayPort();
@ -187,8 +186,8 @@ describe("gateway wizard (e2e)", () => {
expect(didSendToken).toBe(true);
expect(next.status).toBe("done");
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
const parsed = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"));
const { resolveConfigPath } = await import("../config/config.js");
const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
const token = (parsed as Record<string, unknown>)?.gateway as
| Record<string, unknown>
| undefined;

View File

@ -4,6 +4,7 @@ import path from "node:path";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
@ -300,10 +301,20 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const envelopedMessage = formatInboundEnvelope({
channel: "WebChat",
from: p.sessionKey,
timestamp: now,
body: parsedMessage,
chatType: "direct",
previousTimestamp: entry?.updatedAt,
envelope: envelopeOptions,
});
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
message: envelopedMessage,
images: parsedImages.length > 0 ? parsedImages : undefined,
sessionId,
sessionKey: p.sessionKey,

View File

@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import {
agentCommand,
connectOk,
@ -14,6 +16,33 @@ import {
installGatewayTestHooks();
const registryState = vi.hoisted(() => ({
registry: {
plugins: [],
tools: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
} as PluginRegistry,
}));
vi.mock("./server-plugins.js", async () => {
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
return {
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
setActivePluginRegistry(registryState.registry);
return {
pluginRegistry: registryState.registry,
gatewayMethods: params.baseMethods ?? [],
};
},
};
});
const BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
@ -22,8 +51,96 @@ function expectChannels(call: Record<string, unknown>, channel: string) {
expect(call.messageChannel).toBe(channel);
}
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const createStubChannelPlugin = (params: {
id: ChannelPlugin["id"];
label: string;
resolveAllowFrom?: (cfg: Record<string, unknown>) => string[];
}): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
resolveAllowFrom: params.resolveAllowFrom
? ({ cfg }) => params.resolveAllowFrom?.(cfg as Record<string, unknown>) ?? []
: undefined,
},
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to, allowFrom }) => {
const trimmed = to?.trim() ?? "";
if (trimmed) return { ok: true, to: trimmed };
const first = allowFrom?.[0];
if (first) return { ok: true, to: String(first) };
return {
ok: false,
error: new Error(`missing target for ${params.id}`),
};
},
sendText: async () => ({ channel: params.id, messageId: "msg-test" }),
sendMedia: async () => ({ channel: params.id, messageId: "msg-test" }),
},
});
const defaultRegistry = createRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: createStubChannelPlugin({
id: "whatsapp",
label: "WhatsApp",
resolveAllowFrom: (cfg) => {
const channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.whatsapp as Record<string, unknown> | undefined;
const allow = entry?.allowFrom;
return Array.isArray(allow) ? allow.map((value) => String(value)) : [];
},
}),
},
{
pluginId: "telegram",
source: "test",
plugin: createStubChannelPlugin({ id: "telegram", label: "Telegram" }),
},
{
pluginId: "discord",
source: "test",
plugin: createStubChannelPlugin({ id: "discord", label: "Discord" }),
},
{
pluginId: "slack",
source: "test",
plugin: createStubChannelPlugin({ id: "slack", label: "Slack" }),
},
{
pluginId: "signal",
source: "test",
plugin: createStubChannelPlugin({ id: "signal", label: "Signal" }),
},
]);
describe("gateway server agent", () => {
test("agent marks implicit delivery when lastTo is stale", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+436769770569"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@ -63,6 +180,7 @@ describe("gateway server agent", () => {
});
test("agent forwards sessionKey to agentCommand", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@ -97,6 +215,7 @@ describe("gateway server agent", () => {
});
test("agent forwards accountId to agentCommand", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@ -136,6 +255,7 @@ describe("gateway server agent", () => {
});
test("agent avoids lastAccountId when explicit to is provided", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@ -175,6 +295,7 @@ describe("gateway server agent", () => {
});
test("agent keeps explicit accountId when explicit to is provided", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@ -215,6 +336,7 @@ describe("gateway server agent", () => {
});
test("agent falls back to lastAccountId for implicit delivery", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@ -253,6 +375,7 @@ describe("gateway server agent", () => {
});
test("agent forwards image attachments as images[]", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@ -299,6 +422,7 @@ describe("gateway server agent", () => {
});
test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
@ -335,6 +459,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel whatsapp", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@ -374,6 +499,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel telegram", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@ -412,6 +538,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel discord", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@ -450,6 +577,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel slack", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@ -488,6 +616,7 @@ describe("gateway server agent", () => {
});
test("agent routes main last-channel signal", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({

View File

@ -1,4 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import {
connectOk,
installGatewayTestHooks,
@ -10,6 +12,125 @@ const loadConfigHelpers = async () => await import("../config/config.js");
installGatewayTestHooks();
const registryState = vi.hoisted(() => ({
registry: {
plugins: [],
tools: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
} as PluginRegistry,
}));
vi.mock("./server-plugins.js", async () => {
const { setActivePluginRegistry } = await import("../plugins/runtime.js");
return {
loadGatewayPlugins: (params: { baseMethods: string[] }) => {
setActivePluginRegistry(registryState.registry);
return {
pluginRegistry: registryState.registry,
gatewayMethods: params.baseMethods ?? [],
};
},
};
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const createStubChannelPlugin = (params: {
id: ChannelPlugin["id"];
label: string;
summary?: Record<string, unknown>;
logoutCleared?: boolean;
}): ChannelPlugin => ({
id: params.id,
meta: {
id: params.id,
label: params.label,
selectionLabel: params.label,
docsPath: `/channels/${params.id}`,
blurb: "test stub.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isConfigured: async () => false,
},
status: {
buildChannelSummary: async () => ({
configured: false,
...params.summary,
}),
},
gateway: {
logoutAccount: async () => ({
cleared: params.logoutCleared ?? false,
envToken: false,
}),
},
});
const telegramPlugin: ChannelPlugin = {
...createStubChannelPlugin({
id: "telegram",
label: "Telegram",
summary: { tokenSource: "none", lastProbeAt: null },
logoutCleared: true,
}),
gateway: {
logoutAccount: async ({ cfg }) => {
const { writeConfigFile } = await import("../config/config.js");
const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : {};
delete nextTelegram.botToken;
await writeConfigFile({
...cfg,
channels: {
...cfg.channels,
telegram: nextTelegram,
},
});
return { cleared: true, envToken: false, loggedOut: true };
},
},
};
const defaultRegistry = createRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: createStubChannelPlugin({ id: "whatsapp", label: "WhatsApp" }),
},
{
pluginId: "telegram",
source: "test",
plugin: telegramPlugin,
},
{
pluginId: "signal",
source: "test",
plugin: createStubChannelPlugin({
id: "signal",
label: "Signal",
summary: { lastProbeAt: null },
}),
},
]);
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
afterEach(async () => {
@ -28,6 +149,7 @@ afterEach(async () => {
describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
registryState.registry = defaultRegistry;
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
@ -59,6 +181,7 @@ describe("gateway server channels", () => {
});
test("channels.logout reports no session when missing", async () => {
registryState.registry = defaultRegistry;
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
@ -74,6 +197,7 @@ describe("gateway server channels", () => {
test("channels.logout clears telegram bot token from config", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
registryState.registry = defaultRegistry;
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({
channels: {

View File

@ -1,7 +1,12 @@
import { createServer } from "node:net";
import { describe, expect, test } from "vitest";
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import type { ChannelOutboundAdapter } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js";
import {
connectOk,
getFreePort,
@ -16,6 +21,49 @@ import {
installGatewayTestHooks();
const whatsappOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
sendText: async ({ deps, to, text }) => {
if (!deps?.sendWhatsApp) {
throw new Error("Missing sendWhatsApp dep");
}
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, {})) };
},
sendMedia: async ({ deps, to, text, mediaUrl }) => {
if (!deps?.sendWhatsApp) {
throw new Error("Missing sendWhatsApp dep");
}
return { channel: "whatsapp", ...(await deps.sendWhatsApp(to, text, { mediaUrl })) };
},
};
const whatsappPlugin = createOutboundTestPlugin({
id: "whatsapp",
outbound: whatsappOutbound,
label: "WhatsApp",
});
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
diagnostics: [],
});
const whatsappRegistry = createRegistry([
{
pluginId: "whatsapp",
source: "test",
plugin: whatsappPlugin,
},
]);
const emptyRegistry = createRegistry([]);
describe("gateway server misc", () => {
test("hello-ok advertises the gateway port for canvas host", async () => {
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
@ -47,31 +95,38 @@ describe("gateway server misc", () => {
});
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const prevRegistry = getActivePluginRegistry() ?? emptyRegistry;
try {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
setActivePluginRegistry(whatsappRegistry);
expect(getChannelPlugin("whatsapp")).toBeDefined();
const idem = "same-key";
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
const sendReq = (id: string) =>
ws.send(
JSON.stringify({
type: "req",
id,
method: "send",
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
}),
);
sendReq("a1");
sendReq("a2");
const idem = "same-key";
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
const sendReq = (id: string) =>
ws.send(
JSON.stringify({
type: "req",
id,
method: "send",
params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
}),
);
sendReq("a1");
sendReq("a2");
const res1 = await res1P;
const res2 = await res2P;
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
expect(res1.payload).toEqual(res2.payload);
ws.close();
await server.close();
const res1 = await res1P;
const res2 = await res2P;
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
expect(res1.payload).toEqual(res2.payload);
ws.close();
await server.close();
} finally {
setActivePluginRegistry(prevRegistry);
}
});
test("refuses to start when port already bound", async () => {

View File

@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
export type BridgeClientInfo = {
nodeId: string;
@ -91,6 +92,7 @@ export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined,
agentsConfig: undefined as Record<string, unknown> | undefined,
bindingsConfig: undefined as Array<Record<string, unknown>> | undefined,
channelsConfig: undefined as Record<string, unknown> | undefined,
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | undefined,
allowFrom: undefined as string[] | undefined,
@ -259,49 +261,63 @@ vi.mock("../config/config.js", async () => {
config: testState.migrationConfig ?? (raw as Record<string, unknown>),
changes: testState.migrationChanges,
}),
loadConfig: () => ({
agents: (() => {
const defaults = {
model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
};
if (testState.agentsConfig) {
return { ...testState.agentsConfig, defaults };
}
return { defaults };
})(),
bindings: testState.bindingsConfig,
channels: {
whatsapp: {
allowFrom: testState.allowFrom,
loadConfig: () => {
const base = {
agents: (() => {
const defaults = {
model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
};
if (testState.agentsConfig) {
return { ...testState.agentsConfig, defaults };
}
return { defaults };
})(),
bindings: testState.bindingsConfig,
channels: (() => {
const baseChannels =
testState.channelsConfig && typeof testState.channelsConfig === "object"
? { ...testState.channelsConfig }
: {};
const existing = baseChannels.whatsapp;
const mergedWhatsApp: Record<string, unknown> =
existing && typeof existing === "object" && !Array.isArray(existing)
? { ...existing }
: {};
if (testState.allowFrom !== undefined) {
mergedWhatsApp.allowFrom = testState.allowFrom;
}
baseChannels.whatsapp = mergedWhatsApp;
return baseChannels;
})(),
session: {
mainKey: "main",
store: testState.sessionStorePath,
...testState.sessionConfig,
},
},
session: {
mainKey: "main",
store: testState.sessionStorePath,
...testState.sessionConfig,
},
gateway: (() => {
const gateway: Record<string, unknown> = {};
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
return Object.keys(gateway).length > 0 ? gateway : undefined;
})(),
canvasHost: (() => {
const canvasHost: Record<string, unknown> = {};
if (typeof testState.canvasHostPort === "number")
canvasHost.port = testState.canvasHostPort;
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
})(),
hooks: testState.hooksConfig,
cron: (() => {
const cron: Record<string, unknown> = {};
if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
return Object.keys(cron).length > 0 ? cron : undefined;
})(),
}),
gateway: (() => {
const gateway: Record<string, unknown> = {};
if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
return Object.keys(gateway).length > 0 ? gateway : undefined;
})(),
canvasHost: (() => {
const canvasHost: Record<string, unknown> = {};
if (typeof testState.canvasHostPort === "number")
canvasHost.port = testState.canvasHostPort;
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
})(),
hooks: testState.hooksConfig,
cron: (() => {
const cron: Record<string, unknown> = {};
if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
return Object.keys(cron).length > 0 ? cron : undefined;
})(),
} as ReturnType<typeof actual.loadConfig>;
return applyPluginAutoEnable({ config: base }).config;
},
parseConfigJson5: (raw: string) => {
try {
return { ok: true, parsed: JSON.parse(raw) as unknown };

View File

@ -99,6 +99,7 @@ export function installGatewayTestHooks() {
testState.agentConfig = undefined;
testState.agentsConfig = undefined;
testState.bindingsConfig = undefined;
testState.channelsConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();

View File

@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import * as tar from "tar";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = [];
@ -15,22 +15,6 @@ function makeTempDir() {
return dir;
}
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
const prev = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = stateDir;
vi.resetModules();
try {
return await fn();
} finally {
if (prev === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = prev;
}
vi.resetModules();
}
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
@ -72,10 +56,9 @@ describe("installHooksFromArchive", () => {
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(archivePath, buffer);
const result = await withStateDir(stateDir, async () => {
const { installHooksFromArchive } = await import("./install.js");
return await installHooksFromArchive({ archivePath });
});
const hooksDir = path.join(stateDir, "hooks");
const { installHooksFromArchive } = await import("./install.js");
const result = await installHooksFromArchive({ archivePath, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@ -121,10 +104,9 @@ describe("installHooksFromArchive", () => {
);
await tar.c({ cwd: workDir, file: archivePath }, ["package"]);
const result = await withStateDir(stateDir, async () => {
const { installHooksFromArchive } = await import("./install.js");
return await installHooksFromArchive({ archivePath });
});
const hooksDir = path.join(stateDir, "hooks");
const { installHooksFromArchive } = await import("./install.js");
const result = await installHooksFromArchive({ archivePath, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@ -155,10 +137,9 @@ describe("installHooksFromPath", () => {
);
fs.writeFileSync(path.join(hookDir, "handler.ts"), "export default async () => {};\n");
const result = await withStateDir(stateDir, async () => {
const { installHooksFromPath } = await import("./install.js");
return await installHooksFromPath({ path: hookDir });
});
const hooksDir = path.join(stateDir, "hooks");
const { installHooksFromPath } = await import("./install.js");
const result = await installHooksFromPath({ path: hookDir, hooksDir });
expect(result.ok).toBe(true);
if (!result.ok) return;

View File

@ -11,7 +11,11 @@ import {
} from "../../auto-reply/reply/response-prefix-template.js";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js";
import {
formatInboundEnvelope,
formatInboundFromLabel,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
@ -33,6 +37,7 @@ import {
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
@ -401,6 +406,14 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
directLabel: senderNormalized,
directId: sender,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "iMessage",
from: fromLabel,
@ -408,6 +421,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
body: bodyText,
chatType: isGroup ? "group" : "direct",
sender: { name: senderNormalized, id: sender },
previousTimestamp,
envelope: envelopeOptions,
});
let combinedBody = body;
if (isGroup && historyKey && historyLimit > 0) {
@ -424,6 +439,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
@ -461,9 +477,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
OriginatingTo: imessageTo,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,

View File

@ -1,6 +1,8 @@
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as logging from "../logging.js";
const createService = vi.fn();
const shutdown = vi.fn();
@ -23,14 +25,6 @@ vi.mock("../logger.js", () => {
};
});
vi.mock("../logging.js", async () => {
const actual = await vi.importActual<typeof import("../logging.js")>("../logging.js");
return {
...actual,
getLogger: () => ({ info: (...args: unknown[]) => getLoggerInfo(...args) }),
};
});
vi.mock("@homebridge/ciao", () => {
return {
Protocol: { TCP: "tcp" },
@ -60,6 +54,12 @@ describe("gateway bonjour advertiser", () => {
const prevEnv = { ...process.env };
beforeEach(() => {
vi.spyOn(logging, "getLogger").mockReturnValue({
info: (...args: unknown[]) => getLoggerInfo(...args),
});
});
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) delete process.env[key];

View File

@ -90,7 +90,7 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "bin", "tool");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
const res = resolveCommandResolution("\"./bin/tool\" --version", cwd, undefined);
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
});

View File

@ -86,7 +86,12 @@ export async function requestExecHostViaSocket(params: {
idx = buffer.indexOf("\n");
if (!line) continue;
try {
const msg = JSON.parse(line) as { type?: string; ok?: boolean; payload?: unknown; error?: unknown };
const msg = JSON.parse(line) as {
type?: string;
ok?: boolean;
payload?: unknown;
error?: unknown;
};
if (msg?.type === "exec-res") {
clearTimeout(timer);
if (msg.ok === true && msg.payload) {

View File

@ -1,4 +1,31 @@
export * from "./logging/console.js";
export {
enableConsoleCapture,
getConsoleSettings,
getResolvedConsoleSettings,
routeLogsToStderr,
setConsoleSubsystemFilter,
setConsoleTimestampPrefix,
shouldLogSubsystemToConsole,
} from "./logging/console.js";
export type { ConsoleLoggerSettings, ConsoleStyle } from "./logging/console.js";
export type { LogLevel } from "./logging/levels.js";
export * from "./logging/logger.js";
export * from "./logging/subsystem.js";
export { ALLOWED_LOG_LEVELS, levelToMinLevel, normalizeLogLevel } from "./logging/levels.js";
export {
DEFAULT_LOG_DIR,
DEFAULT_LOG_FILE,
getChildLogger,
getLogger,
getResolvedLoggerSettings,
isFileLogLevelEnabled,
resetLogger,
setLoggerOverride,
toPinoLikeLogger,
} from "./logging/logger.js";
export type { LoggerResolvedSettings, LoggerSettings, PinoLikeLogger } from "./logging/logger.js";
export {
createSubsystemLogger,
createSubsystemRuntime,
runtimeForLogger,
stripRedundantSubsystemPrefixForConsole,
} from "./logging/subsystem.js";
export type { SubsystemLogger } from "./logging/subsystem.js";

View File

@ -1,21 +1,60 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { isPathWithinBase } from "../../test/helpers/paths.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
describe("media store", () => {
let store: typeof import("./store.js");
let home = "";
const envSnapshot: Record<string, string | undefined> = {};
const snapshotEnv = () => {
for (const key of ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "CLAWDBOT_STATE_DIR"]) {
envSnapshot[key] = process.env[key];
}
};
const restoreEnv = () => {
for (const [key, value] of Object.entries(envSnapshot)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
};
beforeAll(async () => {
snapshotEnv();
home = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-home-"));
process.env.HOME = home;
process.env.USERPROFILE = home;
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
if (process.platform === "win32") {
const match = home.match(/^([A-Za-z]:)(.*)$/);
if (match) {
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
}
await fs.mkdir(path.join(home, ".clawdbot"), { recursive: true });
store = await import("./store.js");
});
afterAll(async () => {
restoreEnv();
try {
await fs.rm(home, { recursive: true, force: true });
} catch {
// ignore cleanup failures in tests
}
});
async function withTempStore<T>(
fn: (store: typeof import("./store.js"), home: string) => Promise<T>,
): Promise<T> {
return await withTempHome(async (home) => {
vi.resetModules();
const store = await import("./store.js");
return await fn(store, home);
});
return await fn(store, home);
}
it("creates and returns media directory", async () => {

View File

@ -4,29 +4,30 @@ import fs from "node:fs/promises";
import { request } from "node:https";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import { CONFIG_DIR } from "../utils.js";
import { resolveConfigDir } from "../utils.js";
import { detectMime, extensionForMime } from "./mime.js";
const MEDIA_DIR = path.join(CONFIG_DIR, "media");
const resolveMediaDir = () => path.join(resolveConfigDir(), "media");
const MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
export function getMediaDir() {
return MEDIA_DIR;
return resolveMediaDir();
}
export async function ensureMediaDir() {
await fs.mkdir(MEDIA_DIR, { recursive: true });
return MEDIA_DIR;
const mediaDir = resolveMediaDir();
await fs.mkdir(mediaDir, { recursive: true });
return mediaDir;
}
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
await ensureMediaDir();
const entries = await fs.readdir(MEDIA_DIR).catch(() => []);
const mediaDir = await ensureMediaDir();
const entries = await fs.readdir(mediaDir).catch(() => []);
const now = Date.now();
await Promise.all(
entries.map(async (file) => {
const full = path.join(MEDIA_DIR, file);
const full = path.join(mediaDir, file);
const stat = await fs.stat(full).catch(() => null);
if (!stat) return;
if (now - stat.mtimeMs > ttlMs) {
@ -110,7 +111,8 @@ export async function saveMediaSource(
headers?: Record<string, string>,
subdir = "",
): Promise<SavedMedia> {
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
const baseDir = resolveMediaDir();
const dir = subdir ? path.join(baseDir, subdir) : baseDir;
await fs.mkdir(dir, { recursive: true });
await cleanOldMedia();
const baseId = crypto.randomUUID();
@ -154,7 +156,7 @@ export async function saveMediaBuffer(
if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`);
}
const dir = path.join(MEDIA_DIR, subdir);
const dir = path.join(resolveMediaDir(), subdir);
await fs.mkdir(dir, { recursive: true });
const baseId = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);

View File

@ -183,7 +183,9 @@ async function fetchGeminiBatchStatus(params: {
batchName: string;
}): Promise<GeminiBatchStatus> {
const baseUrl = getGeminiBaseUrl(params.gemini);
const name = params.batchName.startsWith("batches/") ? params.batchName : `batches/${params.batchName}`;
const name = params.batchName.startsWith("batches/")
? params.batchName
: `batches/${params.batchName}`;
const statusUrl = `${baseUrl}/${name}`;
debugLog("memory embeddings: gemini batch status", { statusUrl });
const res = await fetch(statusUrl, {
@ -328,7 +330,11 @@ export async function runGeminiEmbeddingBatches(params: {
requests: group.length,
});
if (!params.wait && batchInfo.state && !["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)) {
if (
!params.wait &&
batchInfo.state &&
!["SUCCEEDED", "COMPLETED", "DONE"].includes(batchInfo.state)
) {
throw new Error(
`gemini batch ${batchName} submitted; enable remote.batch.wait to await completion`,
);
@ -376,8 +382,7 @@ export async function runGeminiEmbeddingBatches(params: {
errors.push(`${customId}: ${line.response.error.message}`);
continue;
}
const embedding =
line.embedding?.values ?? line.response?.embedding?.values ?? [];
const embedding = line.embedding?.values ?? line.response?.embedding?.values ?? [];
if (embedding.length === 0) {
errors.push(`${customId}: empty embedding`);
continue;

View File

@ -3,14 +3,8 @@ import fsSync from "node:fs";
import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import {
createGeminiEmbeddingProvider,
type GeminiEmbeddingClient,
} from "./embeddings-gemini.js";
import {
createOpenAiEmbeddingProvider,
type OpenAiEmbeddingClient,
} from "./embeddings-openai.js";
import { createGeminiEmbeddingProvider, type GeminiEmbeddingClient } from "./embeddings-gemini.js";
import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js";
import { importNodeLlamaCpp } from "./node-llama.js";
export type { GeminiEmbeddingClient } from "./embeddings-gemini.js";
@ -68,7 +62,6 @@ function isMissingApiKeyError(err: unknown): boolean {
return message.includes("No API key found for provider");
}
async function createLocalEmbeddingProvider(
options: EmbeddingProviderOptions,
): Promise<EmbeddingProvider> {
@ -188,9 +181,7 @@ export async function createEmbeddingProvider(
fallbackReason: reason,
};
} catch (fallbackErr) {
throw new Error(
`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`,
);
throw new Error(`${reason}\n\nFallback to ${fallback} failed: ${formatError(fallbackErr)}`);
}
}
throw new Error(reason);

View File

@ -697,9 +697,7 @@ export class MemoryIndexManager {
private async removeIndexFiles(basePath: string): Promise<void> {
const suffixes = ["", "-wal", "-shm"];
await Promise.all(
suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })),
);
await Promise.all(suffixes.map((suffix) => fs.rm(`${basePath}${suffix}`, { force: true })));
}
private ensureSchema() {
@ -1064,8 +1062,8 @@ export class MemoryIndexManager {
const batch = this.settings.remote?.batch;
const enabled = Boolean(
batch?.enabled &&
((this.openAi && this.provider.id === "openai") ||
(this.gemini && this.provider.id === "gemini")),
((this.openAi && this.provider.id === "openai") ||
(this.gemini && this.provider.id === "gemini")),
);
return {
enabled,

View File

@ -63,7 +63,11 @@ describe("memory vector dedupe", () => {
if (!result.manager) throw new Error("manager missing");
manager = result.manager;
const db = (manager as unknown as { db: { exec: (sql: string) => void; prepare: (sql: string) => unknown } }).db;
const db = (
manager as unknown as {
db: { exec: (sql: string) => void; prepare: (sql: string) => unknown };
}
).db;
db.exec("CREATE TABLE IF NOT EXISTS chunks_vec (id TEXT PRIMARY KEY, embedding BLOB)");
const sqlSeen: string[] = [];
@ -75,16 +79,20 @@ describe("memory vector dedupe", () => {
return originalPrepare(sql);
};
(manager as unknown as { ensureVectorReady: (dims?: number) => Promise<boolean> }).ensureVectorReady =
async () => true;
(
manager as unknown as { ensureVectorReady: (dims?: number) => Promise<boolean> }
).ensureVectorReady = async () => true;
const entry = await buildFileEntry(path.join(workspaceDir, "MEMORY.md"), workspaceDir);
await (manager as unknown as { indexFile: (entry: unknown, options: { source: "memory" }) => Promise<void> }).indexFile(
entry,
{ source: "memory" },
);
await (
manager as unknown as {
indexFile: (entry: unknown, options: { source: "memory" }) => Promise<void>;
}
).indexFile(entry, { source: "memory" });
const deleteIndex = sqlSeen.findIndex((sql) => sql.includes("DELETE FROM chunks_vec WHERE id = ?"));
const deleteIndex = sqlSeen.findIndex((sql) =>
sql.includes("DELETE FROM chunks_vec WHERE id = ?"),
);
const insertIndex = sqlSeen.findIndex((sql) => sql.includes("INSERT INTO chunks_vec"));
expect(deleteIndex).toBeGreaterThan(-1);
expect(insertIndex).toBeGreaterThan(-1);

View File

@ -576,7 +576,8 @@ async function handleInvoke(
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
const useMacAppExec = process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
const useMacAppExec =
process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
if (useMacAppExec) {
const execRequest: ExecHostRequest = {
command: argv,

View File

@ -4,7 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = [];
@ -77,22 +77,6 @@ function packToArchive({
return dest;
}
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
const prev = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = stateDir;
vi.resetModules();
try {
return await fn();
} finally {
if (prev === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = prev;
}
vi.resetModules();
}
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
@ -126,10 +110,9 @@ describe("installPluginFromArchive", () => {
outName: "plugin.tgz",
});
const result = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath });
});
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.pluginId).toBe("voice-call");
@ -160,12 +143,10 @@ describe("installPluginFromArchive", () => {
outName: "plugin.tgz",
});
const { first, second } = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({ archivePath });
const second = await installPluginFromArchive({ archivePath });
return { first, second };
});
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({ archivePath, extensionsDir });
const second = await installPluginFromArchive({ archivePath, extensionsDir });
expect(first.ok).toBe(true);
expect(second.ok).toBe(false);
@ -191,10 +172,9 @@ describe("installPluginFromArchive", () => {
const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(archivePath, buffer);
const result = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath });
});
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(true);
if (!result.ok) return;
@ -243,18 +223,23 @@ describe("installPluginFromArchive", () => {
});
})();
const result = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({ archivePath: archiveV1 });
const second = await installPluginFromArchive({ archivePath: archiveV2, mode: "update" });
return { first, second };
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({
archivePath: archiveV1,
extensionsDir,
});
const second = await installPluginFromArchive({
archivePath: archiveV2,
extensionsDir,
mode: "update",
});
expect(result.first.ok).toBe(true);
expect(result.second.ok).toBe(true);
if (!result.second.ok) return;
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
if (!second.ok) return;
const manifest = JSON.parse(
fs.readFileSync(path.join(result.second.targetDir, "package.json"), "utf-8"),
fs.readFileSync(path.join(second.targetDir, "package.json"), "utf-8"),
) as { version?: string };
expect(manifest.version).toBe("0.0.2");
});
@ -276,10 +261,9 @@ describe("installPluginFromArchive", () => {
outName: "bad.tgz",
});
const result = await withStateDir(stateDir, async () => {
const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath });
});
const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js");
const result = await installPluginFromArchive({ archivePath, extensionsDir });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain("clawdbot.extensions");

View File

@ -12,6 +12,7 @@ import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js";
import { defaultSlotIdForKey } from "./slots.js";
import type {
ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition,
@ -92,7 +93,7 @@ const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedP
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
memory: memorySlot ?? "memory-core",
memory: memorySlot ?? defaultSlotIdForKey("memory"),
},
entries: normalizePluginEntries(config?.entries),
};

View File

@ -11,7 +11,11 @@ import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
@ -33,12 +37,16 @@ import {
import { resolveStateDir } from "../../config/paths.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { auditDiscordChannelPermissions } from "../../discord/audit.js";
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "../../discord/directory-live.js";
import {
listDiscordDirectoryGroupsLive,
listDiscordDirectoryPeersLive,
} from "../../discord/directory-live.js";
import { monitorDiscordProvider } from "../../discord/monitor.js";
import { probeDiscord } from "../../discord/probe.js";
import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js";
@ -68,7 +76,10 @@ import { monitorSignalProvider } from "../../signal/index.js";
import { probeSignal } from "../../signal/probe.js";
import { sendMessageSignal } from "../../signal/send.js";
import { monitorSlackProvider } from "../../slack/index.js";
import { listSlackDirectoryGroupsLive, listSlackDirectoryPeersLive } from "../../slack/directory-live.js";
import {
listSlackDirectoryGroupsLive,
listSlackDirectoryPeersLive,
} from "../../slack/directory-live.js";
import { probeSlack } from "../../slack/probe.js";
import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
@ -137,12 +148,12 @@ export function createPluginRuntime(): PluginRuntime {
registerMemoryCli,
},
channel: {
text: {
chunkMarkdownText,
chunkText,
resolveTextChunkLimit,
hasControlCommand,
},
text: {
chunkMarkdownText,
chunkText,
resolveTextChunkLimit,
hasControlCommand,
},
reply: {
dispatchReplyWithBufferedBlockDispatcher,
createReplyDispatcherWithTyping,
@ -151,6 +162,8 @@ export function createPluginRuntime(): PluginRuntime {
dispatchReplyFromConfig,
finalizeInboundContext,
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
},
routing: {
resolveAgentRoute,
@ -166,6 +179,7 @@ export function createPluginRuntime(): PluginRuntime {
},
session: {
resolveStorePath,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
updateLastRoute,
},
@ -181,12 +195,12 @@ export function createPluginRuntime(): PluginRuntime {
createInboundDebouncer,
resolveInboundDebounceMs,
},
commands: {
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
commands: {
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage,
shouldComputeCommandAuthorized,
shouldHandleTextCommands,
},
discord: {
messageActions: discordMessageActions,
auditChannelPermissions: auditDiscordChannelPermissions,

View File

@ -44,10 +44,14 @@ type DispatchReplyFromConfig =
type FinalizeInboundContext =
typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext;
type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope;
type FormatInboundEnvelope = typeof import("../../auto-reply/envelope.js").formatInboundEnvelope;
type ResolveEnvelopeFormatOptions =
typeof import("../../auto-reply/envelope.js").resolveEnvelopeFormatOptions;
type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir;
type RecordSessionMetaFromInbound =
typeof import("../../config/sessions.js").recordSessionMetaFromInbound;
type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath;
type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessionUpdatedAt;
type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute;
type LoadConfig = typeof import("../../config/config.js").loadConfig;
type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile;
@ -59,8 +63,7 @@ type MediaKindFromMime = typeof import("../../media/constants.js").mediaKindFrom
type IsVoiceCompatibleAudio = typeof import("../../media/audio.js").isVoiceCompatibleAudio;
type GetImageMetadata = typeof import("../../media/image-ops.js").getImageMetadata;
type ResizeToJpeg = typeof import("../../media/image-ops.js").resizeToJpeg;
type CreateMemoryGetTool =
typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
type CreateMemoryGetTool = typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
type CreateMemorySearchTool =
typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool;
type RegisterMemoryCli = typeof import("../../cli/memory-cli.js").registerMemoryCli;
@ -170,6 +173,8 @@ export type PluginRuntime = {
dispatchReplyFromConfig: DispatchReplyFromConfig;
finalizeInboundContext: FinalizeInboundContext;
formatAgentEnvelope: FormatAgentEnvelope;
formatInboundEnvelope: FormatInboundEnvelope;
resolveEnvelopeFormatOptions: ResolveEnvelopeFormatOptions;
};
routing: {
resolveAgentRoute: ResolveAgentRoute;
@ -185,6 +190,7 @@ export type PluginRuntime = {
};
session: {
resolveStorePath: ResolveStorePath;
readSessionUpdatedAt: ReadSessionUpdatedAt;
recordSessionMetaFromInbound: RecordSessionMetaFromInbound;
updateLastRoute: UpdateLastRoute;
};

96
src/plugins/slots.test.ts Normal file
View File

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { applyExclusiveSlotSelection } from "./slots.js";
describe("applyExclusiveSlotSelection", () => {
it("selects the slot and disables other entries for the same kind", () => {
const config: ClawdbotConfig = {
plugins: {
slots: { memory: "memory-core" },
entries: {
"memory-core": { enabled: true },
memory: { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "memory",
selectedKind: "memory",
registry: {
plugins: [
{ id: "memory-core", kind: "memory" },
{ id: "memory", kind: "memory" },
],
},
});
expect(result.changed).toBe(true);
expect(result.config.plugins?.slots?.memory).toBe("memory");
expect(result.config.plugins?.entries?.["memory-core"]?.enabled).toBe(false);
expect(result.warnings).toContain(
'Exclusive slot "memory" switched from "memory-core" to "memory".',
);
expect(result.warnings).toContain(
'Disabled other "memory" slot plugins: memory-core.',
);
});
it("does nothing when the slot already matches", () => {
const config: ClawdbotConfig = {
plugins: {
slots: { memory: "memory" },
entries: {
memory: { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "memory",
selectedKind: "memory",
registry: { plugins: [{ id: "memory", kind: "memory" }] },
});
expect(result.changed).toBe(false);
expect(result.warnings).toHaveLength(0);
expect(result.config).toBe(config);
});
it("warns when the slot falls back to a default", () => {
const config: ClawdbotConfig = {
plugins: {
entries: {
memory: { enabled: true },
},
},
};
const result = applyExclusiveSlotSelection({
config,
selectedId: "memory",
selectedKind: "memory",
registry: { plugins: [{ id: "memory", kind: "memory" }] },
});
expect(result.changed).toBe(true);
expect(result.warnings).toContain(
'Exclusive slot "memory" switched from "memory-core" to "memory".',
);
});
it("skips changes when no exclusive slot applies", () => {
const config: ClawdbotConfig = {};
const result = applyExclusiveSlotSelection({
config,
selectedId: "custom",
});
expect(result.changed).toBe(false);
expect(result.warnings).toHaveLength(0);
expect(result.config).toBe(config);
});
});

102
src/plugins/slots.ts Normal file
View File

@ -0,0 +1,102 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { PluginSlotsConfig } from "../config/types.plugins.js";
import type { PluginKind } from "./types.js";
export type PluginSlotKey = keyof PluginSlotsConfig;
type SlotPluginRecord = {
id: string;
kind?: PluginKind;
};
const SLOT_BY_KIND: Record<PluginKind, PluginSlotKey> = {
memory: "memory",
};
const DEFAULT_SLOT_BY_KEY: Record<PluginSlotKey, string> = {
memory: "memory-core",
};
export function slotKeyForPluginKind(kind?: PluginKind): PluginSlotKey | null {
if (!kind) return null;
return SLOT_BY_KIND[kind] ?? null;
}
export function defaultSlotIdForKey(slotKey: PluginSlotKey): string {
return DEFAULT_SLOT_BY_KEY[slotKey];
}
export type SlotSelectionResult = {
config: ClawdbotConfig;
warnings: string[];
changed: boolean;
};
export function applyExclusiveSlotSelection(params: {
config: ClawdbotConfig;
selectedId: string;
selectedKind?: PluginKind;
registry?: { plugins: SlotPluginRecord[] };
}): SlotSelectionResult {
const slotKey = slotKeyForPluginKind(params.selectedKind);
if (!slotKey) {
return { config: params.config, warnings: [], changed: false };
}
const warnings: string[] = [];
const pluginsConfig = params.config.plugins ?? {};
const prevSlot = pluginsConfig.slots?.[slotKey];
const slots = {
...pluginsConfig.slots,
[slotKey]: params.selectedId,
};
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
warnings.push(
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
);
}
const entries = { ...pluginsConfig.entries };
const disabledIds: string[] = [];
if (params.registry) {
for (const plugin of params.registry.plugins) {
if (plugin.id === params.selectedId) continue;
if (plugin.kind !== params.selectedKind) continue;
const entry = entries[plugin.id];
if (!entry || entry.enabled !== false) {
entries[plugin.id] = {
...entry,
enabled: false,
};
disabledIds.push(plugin.id);
}
}
}
if (disabledIds.length > 0) {
warnings.push(
`Disabled other "${slotKey}" slot plugins: ${disabledIds.sort().join(", ")}.`,
);
}
const changed = prevSlot !== params.selectedId || disabledIds.length > 0;
if (!changed) {
return { config: params.config, warnings: [], changed: false };
}
return {
config: {
...params.config,
plugins: {
...pluginsConfig,
slots,
entries,
},
},
warnings,
changed: true,
};
}

View File

@ -8,7 +8,11 @@ import {
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { formatInboundEnvelope, formatInboundFromLabel } from "../../auto-reply/envelope.js";
import {
formatInboundEnvelope,
formatInboundFromLabel,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
@ -21,6 +25,7 @@ import {
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
@ -77,6 +82,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
directLabel: entry.senderName,
directId: entry.senderDisplay,
});
const route = resolveAgentRoute({
cfg: deps.cfg,
channel: "signal",
accountId: deps.accountId,
peer: {
kind: entry.isGroup ? "group" : "dm",
id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId,
},
});
const storePath = resolveStorePath(deps.cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(deps.cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "Signal",
from: fromLabel,
@ -84,6 +106,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
body: entry.bodyText,
chatType: entry.isGroup ? "group" : "direct",
sender: { name: entry.senderName, id: entry.senderDisplay },
previousTimestamp,
envelope: envelopeOptions,
});
let combinedBody = body;
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
@ -103,19 +127,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
}`,
chatType: "group",
senderLabel: historyEntry.sender,
envelope: envelopeOptions,
}),
});
}
const route = resolveAgentRoute({
cfg: deps.cfg,
channel: "signal",
accountId: deps.accountId,
peer: {
kind: entry.isGroup ? "group" : "dm",
id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId,
},
});
const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`;
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
@ -144,9 +159,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
OriginatingTo: signalTo,
});
const storePath = resolveStorePath(deps.cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,

View File

@ -5,6 +5,7 @@ import type { FinalizedMsgContext } from "../../../auto-reply/templating.js";
import {
formatInboundEnvelope,
formatThreadStarterEnvelope,
resolveEnvelopeFormatOptions,
} from "../../../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
@ -21,7 +22,11 @@ import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js";
import { resolveConversationLabel } from "../../../channels/conversation-label.js";
import { resolveControlCommandGate } from "../../../channels/command-gating.js";
import { recordSessionMetaFromInbound, resolveStorePath } from "../../../config/sessions.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
} from "../../../config/sessions.js";
import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
@ -372,6 +377,14 @@ export async function prepareSlackMessage(params: {
From: slackFrom,
}) ?? (isDirectMessage ? senderName : roomLabel);
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
const storePath = resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "Slack",
from: envelopeFrom,
@ -379,6 +392,8 @@ export async function prepareSlackMessage(params: {
body: textWithId,
chatType: isDirectMessage ? "direct" : "channel",
sender: { name: senderName, id: senderId },
previousTimestamp,
envelope: envelopeOptions,
});
let combinedBody = body;
@ -389,17 +404,18 @@ export async function prepareSlackMessage(params: {
limit: ctx.historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatInboundEnvelope({
channel: "Slack",
from: roomLabel,
timestamp: entry.timestamp,
body: `${entry.body}${
entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : ""
}`,
chatType: "channel",
senderLabel: entry.sender,
}),
});
formatInboundEnvelope({
channel: "Slack",
from: roomLabel,
timestamp: entry.timestamp,
body: `${entry.body}${
entry.messageId ? ` [id:${entry.messageId} channel:${message.channel}]` : ""
}`,
chatType: "channel",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`;
@ -433,6 +449,7 @@ export async function prepareSlackMessage(params: {
author: starterName,
timestamp: starter.ts ? Math.round(Number(starter.ts) * 1000) : undefined,
body: starterWithId,
envelope: envelopeOptions,
});
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
@ -472,9 +489,6 @@ export async function prepareSlackMessage(params: {
OriginatingTo: slackTo,
}) satisfies FinalizedMsgContext;
const storePath = resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: sessionKey,

View File

@ -3,7 +3,7 @@ import type { Bot } from "grammy";
import { resolveAckReaction } from "../agents/identity.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
import { formatInboundEnvelope } from "../auto-reply/envelope.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntry,
@ -13,6 +13,7 @@ import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
import { formatLocationText, toLocationContext } from "../channels/location.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
@ -417,6 +418,14 @@ export const buildTelegramMessageContext = async ({
const conversationLabel = isGroup
? (groupLabel ?? `group:${chatId}`)
: buildSenderLabel(msg, senderId || chatId);
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "Telegram",
from: conversationLabel,
@ -428,6 +437,8 @@ export const buildTelegramMessageContext = async ({
username: senderUsername || undefined,
id: senderId || undefined,
},
previousTimestamp,
envelope: envelopeOptions,
});
let combinedBody = body;
if (isGroup && historyKey && historyLimit > 0) {
@ -444,6 +455,7 @@ export const buildTelegramMessageContext = async ({
body: `${entry.body} [id:${entry.messageId ?? "unknown"} chat:${chatId}]`,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
@ -504,9 +516,6 @@ export const buildTelegramMessageContext = async ({
OriginatingTo: `telegram:${chatId}`,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,

View File

@ -176,7 +176,7 @@ describe("createTelegramBot", () => {
expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
});
it("keeps group envelope headers stable (sender identity is separate)", async () => {
onSpy.mockReset();
@ -217,7 +217,7 @@ describe("createTelegramBot", () => {
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset();

View File

@ -330,7 +330,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/,
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
);
expect(payload.Body).toContain("hello world");
} finally {

View File

@ -452,7 +452,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 2025-01-09T00:00Z\]/,
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
);
expect(payload.Body).toContain("hello world");
} finally {
@ -586,7 +586,7 @@ describe("createTelegramBot", () => {
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
});
@ -628,7 +628,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");

View File

@ -328,9 +328,13 @@ describe("web auto-reply", () => {
expect(resolver).toHaveBeenCalledTimes(2);
const firstArgs = resolver.mock.calls[0][0];
const secondArgs = resolver.mock.calls[1][0];
expect(firstArgs.Body).toContain("[WhatsApp +1 2025-01-01T00:00Z] [clawdbot] first");
expect(firstArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/,
);
expect(firstArgs.Body).not.toContain("second");
expect(secondArgs.Body).toContain("[WhatsApp +1 2025-01-01T01:00Z] [clawdbot] second");
expect(secondArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/,
);
expect(secondArgs.Body).not.toContain("first");
// Max listeners bumped to avoid warnings in multi-instance test runs

View File

@ -1,5 +1,8 @@
import { resolveMessagePrefix } from "../../../agents/identity.js";
import { formatInboundEnvelope } from "../../../auto-reply/envelope.js";
import {
formatInboundEnvelope,
type EnvelopeFormatOptions,
} from "../../../auto-reply/envelope.js";
import type { loadConfig } from "../../../config/config.js";
import type { WebInboundMsg } from "../types.js";
@ -14,8 +17,10 @@ export function buildInboundLine(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
agentId: string;
previousTimestamp?: number;
envelope?: EnvelopeFormatOptions;
}) {
const { cfg, msg, agentId } = params;
const { cfg, msg, agentId, previousTimestamp, envelope } = params;
// WhatsApp inbound prefix: channels.whatsapp.messagePrefix > legacy messages.messagePrefix > identity/defaults
const messagePrefix = resolveMessagePrefix(cfg, agentId, {
configured: cfg.channels?.whatsapp?.messagePrefix,
@ -37,5 +42,7 @@ export function buildInboundLine(params: {
e164: msg.senderE164,
id: msg.senderJid,
},
previousTimestamp,
envelope,
});
}

View File

@ -8,7 +8,10 @@ import {
type ResponsePrefixContext,
} from "../../../auto-reply/reply/response-prefix-template.js";
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
import { formatInboundEnvelope } from "../../../auto-reply/envelope.js";
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "../../../auto-reply/envelope.js";
import {
buildHistoryContextFromEntries,
type HistoryEntry,
@ -20,7 +23,11 @@ import { shouldComputeCommandAuthorized } from "../../../auto-reply/command-dete
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
import { toLocationContext } from "../../../channels/location.js";
import type { loadConfig } from "../../../config/config.js";
import { recordSessionMetaFromInbound, resolveStorePath } from "../../../config/sessions.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveStorePath,
} from "../../../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import type { getChildLogger } from "../../../logging.js";
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
@ -121,10 +128,20 @@ export async function processMessage(params: {
suppressGroupHistoryClear?: boolean;
}) {
const conversationId = params.msg.conversationId ?? params.msg.from;
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(params.cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: params.route.sessionKey,
});
let combinedBody = buildInboundLine({
cfg: params.cfg,
msg: params.msg,
agentId: params.route.agentId,
previousTimestamp,
envelope: envelopeOptions,
});
let shouldClearGroupHistory = false;
@ -152,6 +169,7 @@ export async function processMessage(params: {
body: bodyWithId,
chatType: "group",
senderLabel: entry.sender,
envelope: envelopeOptions,
});
},
});
@ -288,9 +306,6 @@ export async function processMessage(params: {
});
}
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const metaTask = recordSessionMetaFromInbound({
storePath,
sessionKey: params.route.sessionKey,

View File

@ -23,23 +23,23 @@ describe("web logout", () => {
it("deletes cached credentials when present", { timeout: 60_000 }, async () => {
await withTempHome(async (home) => {
vi.resetModules();
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
const { logoutWeb } = await import("./session.js");
const { resolveDefaultWebAuthDir } = await import("./auth-store.js");
const authDir = resolveDefaultWebAuthDir();
expect(isPathWithinBase(home, WA_WEB_AUTH_DIR)).toBe(true);
expect(isPathWithinBase(home, authDir)).toBe(true);
fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true });
fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}");
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, "creds.json"), "{}");
const result = await logoutWeb({ runtime: runtime as never });
expect(result).toBe(true);
expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false);
expect(fs.existsSync(authDir)).toBe(false);
});
});
it("no-ops when nothing to delete", { timeout: 60_000 }, async () => {
await withTempHome(async () => {
vi.resetModules();
const { logoutWeb } = await import("./session.js");
const result = await logoutWeb({ runtime: runtime as never });
expect(result).toBe(false);
@ -49,7 +49,6 @@ describe("web logout", () => {
it("keeps shared oauth.json when using legacy auth dir", async () => {
await withTempHome(async () => {
vi.resetModules();
const { logoutWeb } = await import("./session.js");
const { resolveOAuthDir } = await import("../config/paths.js");

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { stripEnvelope } from "./message-extract";
describe("stripEnvelope", () => {
it("strips UTC envelope", () => {
const text = "[WebChat agent:main:main 2026-01-18T05:19Z] hello world";
expect(stripEnvelope(text)).toBe("hello world");
});
it("strips local-time envelope", () => {
const text = "[Telegram Ada Lovelace (@ada) id:1234 2026-01-18 19:29 GMT+1] test";
expect(stripEnvelope(text)).toBe("test");
});
it("strips envelopes without timestamps for known channels", () => {
const text = "[WhatsApp +1234567890] hi there";
expect(stripEnvelope(text)).toBe("hi there");
});
it("handles multi-line messages", () => {
const text = "[Slack #general 2026-01-18T05:19Z] first line\nsecond line";
expect(stripEnvelope(text)).toBe("first line\nsecond line");
});
it("returns text as-is when no envelope present", () => {
const text = "just a regular message";
expect(stripEnvelope(text)).toBe("just a regular message");
});
it("does not strip non-envelope brackets", () => {
expect(stripEnvelope("[OK] hello")).toBe("[OK] hello");
expect(stripEnvelope("[1/2] step one")).toBe("[1/2] step one");
});
});

View File

@ -1,11 +1,42 @@
import { stripThinkingTags } from "../format";
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
const ENVELOPE_CHANNELS = [
"WebChat",
"WhatsApp",
"Telegram",
"Signal",
"Slack",
"Discord",
"iMessage",
"Teams",
"Matrix",
"Zalo",
"Zalo Personal",
"BlueBubbles",
];
function looksLikeEnvelopeHeader(header: string): boolean {
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
}
export function stripEnvelope(text: string): string {
const match = text.match(ENVELOPE_PREFIX);
if (!match) return text;
const header = match[1] ?? "";
if (!looksLikeEnvelopeHeader(header)) return text;
return text.slice(match[0].length);
}
export function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "";
const content = m.content;
if (typeof content === "string") {
return role === "assistant" ? stripThinkingTags(content) : content;
const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content);
return processed;
}
if (Array.isArray(content)) {
const parts = content
@ -17,11 +48,13 @@ export function extractText(message: unknown): string | null {
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) {
const joined = parts.join("\n");
return role === "assistant" ? stripThinkingTags(joined) : joined;
const processed = role === "assistant" ? stripThinkingTags(joined) : stripEnvelope(joined);
return processed;
}
}
if (typeof m.text === "string") {
return role === "assistant" ? stripThinkingTags(m.text) : m.text;
const processed = role === "assistant" ? stripThinkingTags(m.text) : stripEnvelope(m.text);
return processed;
}
return null;
}
@ -83,4 +116,3 @@ export function formatReasoningMarkdown(text: string): string {
.map((line) => `_${line}_`);
return lines.length ? ["_Reasoning:_", ...lines].join("\n") : "";
}

View File

@ -1,5 +1,5 @@
import type { GatewayBrowserClient } from "../gateway";
import { stripThinkingTags } from "../format";
import { extractText } from "../chat/message-extract";
import { generateUUID } from "../uuid";
export type ChatState = {
@ -142,29 +142,3 @@ export function handleChatEvent(
}
return payload.state;
}
function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "";
const content = m.content;
if (typeof content === "string") {
return role === "assistant" ? stripThinkingTags(content) : content;
}
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) {
const joined = parts.join("\n");
return role === "assistant" ? stripThinkingTags(joined) : joined;
}
}
if (typeof m.text === "string") {
return role === "assistant" ? stripThinkingTags(m.text) : m.text;
}
return null;
}