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 ### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - 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 ### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x. - 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: Thanks to all clawtributors:
<p align="left"> <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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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=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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></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> </p>

View File

@ -9,7 +9,7 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**. 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: 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 [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) ## Tool payloads (raw provider data + normalized fields)

View File

@ -7,10 +7,10 @@ read_when:
# Date & Time # Date & Time
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**. Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
We avoid rewriting provider timestamps so tools keep their native semantics. 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): 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 [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 ## 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 const fromLabel = message.isGroup
? `group:${peerId}` ? `group:${peerId}`
: message.senderName || `user:${message.senderId}`; : 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({ const body = core.channel.reply.formatAgentEnvelope({
channel: "BlueBubbles", channel: "BlueBubbles",
from: fromLabel, from: fromLabel,
timestamp: message.timestamp, timestamp: message.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody, body: rawBody,
}); });
let chatGuidForActions = chatGuid; let chatGuidForActions = chatGuid;

View File

@ -466,25 +466,34 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
isThreadRoot: event.isThreadRoot, 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 envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${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({ const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix", channel: "Matrix",
from: envelopeFrom, from: envelopeFrom,
timestamp: event.getTs() ?? undefined, timestamp: event.getTs() ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId, body: textWithId,
}); });
const route = core.channel.routing.resolveAgentRoute({ const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
cfg,
channel: "matrix",
peer: {
kind: isDirectMessage ? "dm" : "channel",
id: isDirectMessage ? senderId : roomId,
},
});
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body, Body: body,
RawBody: bodyText, RawBody: bodyText,
@ -517,13 +526,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
OriginatingTo: `room:${roomId}`, OriginatingTo: `room:${roomId}`,
}); });
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { void core.channel.session.recordSessionMetaFromInbound({
agentId: route.agentId, storePath,
}); sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
void core.channel.session.recordSessionMetaFromInbound({ ctx: ctxPayload,
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => { }).catch((err) => {
logger.warn( logger.warn(
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, { 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 mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType; 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({ const body = core.channel.reply.formatAgentEnvelope({
channel: "Teams", channel: "Teams",
from: envelopeFrom, from: envelopeFrom,
timestamp, timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody, body: rawBody,
}); });
let combinedBody = body; let combinedBody = body;
@ -421,15 +431,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
historyKey, historyKey,
limit: historyLimit, limit: historyLimit,
currentMessage: combinedBody, currentMessage: combinedBody,
formatEntry: (entry) => formatEntry: (entry) =>
core.channel.reply.formatAgentEnvelope({ core.channel.reply.formatAgentEnvelope({
channel: "Teams", channel: "Teams",
from: conversationType, from: conversationType,
timestamp: entry.timestamp, timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
}), envelope: envelopeOptions,
}); }),
} });
}
const ctxPayload = core.channel.reply.finalizeInboundContext({ const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
@ -455,11 +466,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload, ...mediaPayload,
}); });
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { void core.channel.session.recordSessionMetaFromInbound({
agentId: route.agentId, storePath,
});
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
}).catch((err) => { }).catch((err) => {

View File

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

View File

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

View File

@ -65,27 +65,27 @@
"dist/whatsapp/**" "dist/whatsapp/**"
], ],
"scripts": { "scripts": {
"dev": "bun src/entry.ts", "dev": "node scripts/run-node.mjs",
"postinstall": "node scripts/postinstall.js", "postinstall": "node scripts/postinstall.js",
"prepack": "pnpm build", "prepack": "pnpm build",
"docs:list": "bun scripts/docs-list.ts", "docs:list": "node scripts/docs-list.js",
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list", "docs:bin": "node scripts/build-docs-list.mjs",
"docs:dev": "cd docs && mint dev", "docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links", "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", "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": "bun scripts/sync-plugin-versions.ts", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"release:check": "bun scripts/release-check.ts", "release:check": "node --import tsx scripts/release-check.ts",
"ui:install": "node scripts/ui.js install", "ui:install": "node scripts/ui.js install",
"ui:dev": "node scripts/ui.js dev", "ui:dev": "node scripts/ui.js dev",
"ui:build": "node scripts/ui.js build", "ui:build": "node scripts/ui.js build",
"start": "bun src/entry.ts", "start": "node scripts/run-node.mjs",
"clawdbot": "bun src/entry.ts", "clawdbot": "node scripts/run-node.mjs",
"gateway:watch": "bun --watch src/entry.ts gateway --force", "gateway:watch": "node scripts/watch-node.mjs gateway --force",
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway", "gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset", "gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
"tui": "bun src/entry.ts tui", "tui": "node scripts/run-node.mjs tui",
"tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui", "tui:dev": "CLAWDBOT_PROFILE=dev node scripts/run-node.mjs tui",
"clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json", "clawdbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
"ios:gen": "cd apps/ios && xcodegen generate", "ios:gen": "cd apps/ios && xcodegen generate",
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj", "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'", "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": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ui": "pnpm --dir ui test", "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:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.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: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: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", "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": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "bun scripts/protocol-gen-swift.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", "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", "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": [], "keywords": [],
"author": "", "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 { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
process.stdout.on('error', (error) => { process.stdout.on('error', (error) => {
if ((error as NodeJS.ErrnoException).code === 'EPIPE') { if (error?.code === 'EPIPE') {
process.exit(0); process.exit(0);
} }
throw error; 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 { execSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs"; 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 os from "node:os";
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";

View File

@ -45,19 +45,8 @@ function which(cmd) {
} }
function resolveRunner() { 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"); const pnpm = which("pnpm");
if (pnpm) return { cmd: pnpm, kind: "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; return null;
} }
@ -107,9 +96,7 @@ if (!action) {
const runner = resolveRunner(); const runner = resolveRunner();
if (!runner) { if (!runner) {
process.stderr.write( process.stderr.write("Missing UI runner: install pnpm, then retry.\n");
"Missing UI runner: install bun or pnpm, then retry.\n",
);
process.exit(1); process.exit(1);
} }
@ -129,32 +116,16 @@ if (action !== "install" && !script) {
process.exit(2); process.exit(2);
} }
if (runner.kind === "bun") { if (action === "install") run(runner.cmd, ["install", ...rest]);
if (action === "install") run(runner.cmd, ["install", ...rest]); else {
else { if (!depsInstalled(action === "test" ? "test" : "build")) {
if (!depsInstalled(action === "test" ? "test" : "build")) { const installEnv =
const installEnv = action === "build"
action === "build" ? { ...process.env, NODE_ENV: "production" }
? { ...process.env, NODE_ENV: "production" } : process.env;
: process.env; const installArgs =
const installArgs = action === "build" ? ["install", "--prod"] : ["install"];
action === "build" ? ["install", "--production"] : ["install"]; runSync(runner.cmd, installArgs, installEnv);
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]);
} }
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", () => { describe("image dimension errors", () => {
it("parses anthropic image dimension errors", () => { it("parses anthropic image dimension errors", () => {
const raw = 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); const parsed = parseImageDimensionError(raw);
expect(parsed).not.toBeNull(); expect(parsed).not.toBeNull();
expect(parsed?.maxDimensionPx).toBe(2000); expect(parsed?.maxDimensionPx).toBe(2000);

View File

@ -25,7 +25,7 @@ describe("isCloudCodeAssistFormatError", () => {
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
expect( expect(
isCloudCodeAssistFormatError( 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); ).toBe(false);
}); });

View File

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

View File

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

View File

@ -41,7 +41,7 @@ describe("detectImageReferences", () => {
expect(refs[0]?.raw).toBe("~/Pictures/vacation.png"); expect(refs[0]?.raw).toBe("~/Pictures/vacation.png");
expect(refs[0]?.type).toBe("path"); expect(refs[0]?.type).toBe("path");
// Resolved path should expand ~ // 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", () => { it("detects multiple image references in a prompt", () => {

View File

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

View File

@ -58,7 +58,12 @@ async function resizeImageBase64IfNeeded(params: {
const height = meta?.height; const height = meta?.height;
const overBytes = buf.byteLength > params.maxBytes; const overBytes = buf.byteLength > params.maxBytes;
const hasDimensions = typeof width === "number" && typeof height === "number"; 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 { return {
base64: params.base64, base64: params.base64,
mimeType: params.mimeType, mimeType: params.mimeType,
@ -67,7 +72,10 @@ async function resizeImageBase64IfNeeded(params: {
height, 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", { log.warn("Image exceeds limits; resizing", {
label: params.label, label: params.label,
width, 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"; import type { ClawdbotConfig } from "../../config/config.js";
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP"; const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
@ -21,7 +25,8 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
const id = rest.join(":").trim(); const id = rest.join(":").trim();
if (!id) return null; if (!id) return null;
if (!channelRaw) return null; if (!channelRaw) return null;
const normalizedChannel = normalizeChannelId(channelRaw); const normalizedChannel =
normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
const channel = normalizedChannel ?? channelRaw.toLowerCase(); const channel = normalizedChannel ?? channelRaw.toLowerCase();
const kindTarget = (() => { const kindTarget = (() => {
if (!normalizedChannel) return id; if (!normalizedChannel) return id;

View File

@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatAgentEnvelope, formatInboundEnvelope } from "./envelope.js"; import {
formatAgentEnvelope,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "./envelope.js";
describe("formatAgentEnvelope", () => { describe("formatAgentEnvelope", () => {
it("includes channel, from, ip, host, and timestamp", () => { it("includes channel, from, ip, host, and timestamp", () => {
@ -38,6 +42,46 @@ describe("formatAgentEnvelope", () => {
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello"); 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", () => { it("handles missing optional fields", () => {
const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" }); const body = formatAgentEnvelope({ channel: "Telegram", body: "hi" });
expect(body).toBe("[Telegram] hi"); expect(body).toBe("[Telegram] hi");
@ -77,4 +121,53 @@ describe("formatInboundEnvelope", () => {
}); });
expect(body).toBe("[iMessage +1555] hello"); 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 { normalizeChatType } from "../channels/chat-type.js";
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js"; import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
import type { ClawdbotConfig } from "../config/config.js";
export type AgentEnvelopeParams = { export type AgentEnvelopeParams = {
channel: string; channel: string;
@ -8,31 +10,162 @@ export type AgentEnvelopeParams = {
host?: string; host?: string;
ip?: string; ip?: string;
body: string; body: string;
previousTimestamp?: number | Date;
envelope?: EnvelopeFormatOptions;
}; };
function formatTimestamp(ts?: number | Date): string | undefined { export type EnvelopeFormatOptions = {
if (!ts) return undefined; /**
const date = ts instanceof Date ? ts : new Date(ts); * "utc" (default), "local", "user", or an explicit IANA timezone string.
if (Number.isNaN(date.getTime())) return undefined; */
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 yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0"); const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0"); const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).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`; 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 { export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const channel = params.channel?.trim() || "Channel"; const channel = params.channel?.trim() || "Channel";
const parts: string[] = [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.host?.trim()) parts.push(params.host.trim());
if (params.ip?.trim()) parts.push(params.ip.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); if (ts) parts.push(ts);
const header = `[${parts.join(" ")}]`; const header = `[${parts.join(" ")}]`;
return `${header} ${params.body}`; return `${header} ${params.body}`;
@ -46,6 +179,8 @@ export function formatInboundEnvelope(params: {
chatType?: string; chatType?: string;
senderLabel?: string; senderLabel?: string;
sender?: SenderLabelParams; sender?: SenderLabelParams;
previousTimestamp?: number | Date;
envelope?: EnvelopeFormatOptions;
}): string { }): string {
const chatType = normalizeChatType(params.chatType); const chatType = normalizeChatType(params.chatType);
const isDirect = !chatType || chatType === "direct"; const isDirect = !chatType || chatType === "direct";
@ -55,6 +190,8 @@ export function formatInboundEnvelope(params: {
channel: params.channel, channel: params.channel,
from: params.from, from: params.from,
timestamp: params.timestamp, timestamp: params.timestamp,
previousTimestamp: params.previousTimestamp,
envelope: params.envelope,
body, body,
}); });
} }
@ -85,11 +222,13 @@ export function formatThreadStarterEnvelope(params: {
author?: string; author?: string;
timestamp?: number | Date; timestamp?: number | Date;
body: string; body: string;
envelope?: EnvelopeFormatOptions;
}): string { }): string {
return formatAgentEnvelope({ return formatAgentEnvelope({
channel: params.channel, channel: params.channel,
from: params.author, from: params.author,
timestamp: params.timestamp, timestamp: params.timestamp,
envelope: params.envelope,
body: params.body, body: params.body,
}); });
} }

View File

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

View File

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

View File

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

View File

@ -156,9 +156,7 @@ export function registerMemoryCli(program: Command) {
for (const result of allResults) { for (const result of allResults) {
const { agentId, status, embeddingProbe, indexError } = result; const { agentId, status, embeddingProbe, indexError } = result;
if (opts.index) { if (opts.index) {
const line = indexError const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
? `Memory index failed: ${indexError}`
: "Memory index complete.";
defaultRuntime.log(line); defaultRuntime.log(line);
} }
const lines = [ const lines = [
@ -167,9 +165,7 @@ export function registerMemoryCli(program: Command) {
`(requested: ${status.requestedProvider})`, `(requested: ${status.requestedProvider})`,
)}`, )}`,
`${label("Model")} ${info(status.model)}`, `${label("Model")} ${info(status.model)}`,
status.sources?.length status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
? `${label("Sources")} ${info(status.sources.join(", "))}`
: null,
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`, `${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`, `${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 family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null; const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null; const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
const versions = formatNodeVersions(obj as { const versions = formatNodeVersions(
platform?: string; obj as {
version?: string; platform?: string;
coreVersion?: string; version?: string;
uiVersion?: string; coreVersion?: string;
}); uiVersion?: string;
},
);
const parts: string[] = ["Node:", displayName, nodeId]; const parts: string[] = ["Node:", displayName, nodeId];
if (ip) parts.push(ip); if (ip) parts.push(ip);

View File

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

View File

@ -13,6 +13,7 @@ import {
resolvePluginInstallDir, resolvePluginInstallDir,
} from "../plugins/install.js"; } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js"; import { recordPluginInstall } from "../plugins/installs.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import type { PluginRecord } from "../plugins/registry.js"; import type { PluginRecord } from "../plugins/registry.js";
import { buildPluginStatusReport } from "../plugins/status.js"; import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.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) { export function registerPluginsCli(program: Command) {
const plugins = program const plugins = program
.command("plugins") .command("plugins")
@ -197,7 +223,7 @@ export function registerPluginsCli(program: Command) {
.argument("<id>", "Plugin id") .argument("<id>", "Plugin id")
.action(async (id: string) => { .action(async (id: string) => {
const cfg = loadConfig(); const cfg = loadConfig();
const next = { let next: ClawdbotConfig = {
...cfg, ...cfg,
plugins: { plugins: {
...cfg.plugins, ...cfg.plugins,
@ -210,7 +236,10 @@ export function registerPluginsCli(program: Command) {
}, },
}, },
}; };
const slotResult = applySlotSelectionForPlugin(next, id);
next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`); defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
}); });
@ -280,7 +309,10 @@ export function registerPluginsCli(program: Command) {
installPath: resolved, installPath: resolved,
version: probe.version, version: probe.version,
}); });
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Linked plugin path: ${resolved}`); defaultRuntime.log(`Linked plugin path: ${resolved}`);
defaultRuntime.log(`Restart the gateway to load plugins.`); defaultRuntime.log(`Restart the gateway to load plugins.`);
return; return;
@ -319,7 +351,10 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir, installPath: result.targetDir,
version: result.version, version: result.version,
}); });
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`); defaultRuntime.log(`Restart the gateway to load plugins.`);
return; return;
@ -379,7 +414,10 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir, installPath: result.targetDir,
version: result.version, version: result.version,
}); });
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`); defaultRuntime.log(`Restart the gateway to load plugins.`);
}); });

View File

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

View File

@ -417,6 +417,22 @@ describe("channels command", () => {
expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i); 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", () => { it("surfaces Telegram group membership audit issues in channels status output", () => {
const lines = formatGatewayChannelsStatusLines({ const lines = formatGatewayChannelsStatusLines({
channelAccounts: { channelAccounts: {

View File

@ -51,6 +51,18 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
if (typeof account.mode === "string" && account.mode.length > 0) { if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`); 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) { if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
bits.push(`dm:${account.dmPolicy}`); bits.push(`dm:${account.dmPolicy}`);
} }

View File

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

View File

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

View File

@ -8,6 +8,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } }, channels: { slack: { botToken: "x" } },
plugins: { allow: ["telegram"] }, plugins: { allow: ["telegram"] },
}, },
env: {},
}); });
expect(result.config.plugins?.entries?.slack?.enabled).toBe(true); expect(result.config.plugins?.entries?.slack?.enabled).toBe(true);
@ -21,6 +22,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } }, channels: { slack: { botToken: "x" } },
plugins: { entries: { slack: { enabled: false } } }, plugins: { entries: { slack: { enabled: false } } },
}, },
env: {},
}); });
expect(result.config.plugins?.entries?.slack?.enabled).toBe(false); 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); expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true);
@ -50,6 +53,7 @@ describe("applyPluginAutoEnable", () => {
channels: { slack: { botToken: "x" } }, channels: { slack: { botToken: "x" } },
plugins: { enabled: false }, plugins: { enabled: false },
}, },
env: {},
}); });
expect(result.config.plugins?.entries?.slack?.enabled).toBeUndefined(); 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; return isRecord(value) && Object.keys(value).length > 0;
} }
function accountsHaveKeys( function accountsHaveKeys(value: unknown, keys: string[]): boolean {
value: unknown,
keys: string[],
): boolean {
if (!isRecord(value)) return false; if (!isRecord(value)) return false;
for (const account of Object.values(value)) { for (const account of Object.values(value)) {
if (!isRecord(account)) continue; if (!isRecord(account)) continue;
@ -59,7 +56,10 @@ function accountsHaveKeys(
return false; 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 channels = cfg.channels as Record<string, unknown> | undefined;
const entry = channels?.[channelId]; const entry = channels?.[channelId];
return isRecord(entry) ? entry : null; return isRecord(entry) ? entry : null;
@ -234,7 +234,10 @@ function isProviderConfigured(cfg: ClawdbotConfig, providerId: string): boolean
return false; return false;
} }
function resolveConfiguredPlugins(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): PluginEnableChange[] { function resolveConfiguredPlugins(
cfg: ClawdbotConfig,
env: NodeJS.ProcessEnv,
): PluginEnableChange[] {
const changes: PluginEnableChange[] = []; const changes: PluginEnableChange[] = [];
for (const channelId of CHANNEL_PLUGIN_IDS) { for (const channelId of CHANNEL_PLUGIN_IDS) {
if (isChannelConfigured(cfg, channelId, env)) { if (isChannelConfigured(cfg, channelId, env)) {

View File

@ -172,6 +172,9 @@ const FIELD_LABELS: Record<string, string> = {
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace", "agents.defaults.workspace": "Workspace",
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "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": "Memory Search",
"agents.defaults.memorySearch.enabled": "Enable Memory Search", "agents.defaults.memorySearch.enabled": "Enable Memory Search",
"agents.defaults.memorySearch.sources": "Memory Search Sources", "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).", "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
"agents.defaults.bootstrapMaxChars": "agents.defaults.bootstrapMaxChars":
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "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.models": "Configured model catalog (keys are full provider/model IDs).",
"agents.defaults.memorySearch": "agents.defaults.memorySearch":
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",

View File

@ -155,6 +155,18 @@ export function loadSessionStore(
return structuredClone(store); 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( async function saveSessionStoreUnlocked(
storePath: string, storePath: string,
store: Record<string, SessionEntry>, store: Record<string, SessionEntry>,

View File

@ -105,6 +105,18 @@ export type AgentDefaultsConfig = {
userTimezone?: string; userTimezone?: string;
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */ /** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
timeFormat?: "auto" | "12" | "24"; 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). */ /** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number; contextTokens?: number;
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */ /** 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 parsedA = parseClawdbotVersion(a);
const parsedB = parseClawdbotVersion(b); const parsedB = parseClawdbotVersion(b);
if (!parsedA || !parsedB) return null; if (!parsedA || !parsedB) return null;

View File

@ -42,6 +42,9 @@ export const AgentDefaultsSchema = z
bootstrapMaxChars: z.number().int().positive().optional(), bootstrapMaxChars: z.number().int().positive().optional(),
userTimezone: z.string().optional(), userTimezone: z.string().optional(),
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).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(), contextTokens: z.number().int().positive().optional(),
cliBackends: z.record(z.string(), CliBackendSchema).optional(), cliBackends: z.record(z.string(), CliBackendSchema).optional(),
memorySearch: MemorySearchSchema, memorySearch: MemorySearchSchema,

View File

@ -40,7 +40,7 @@ async function withLaunchctlStub(
' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");', ' fs.appendFileSync(logPath, JSON.stringify(args) + "\\n", "utf8");',
"}", "}",
'if (args[0] === "list") {', '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.stdout.write(output);",
"}", "}",
"process.exit(0);", "process.exit(0);",
@ -107,13 +107,10 @@ describe("launchd runtime parsing", () => {
describe("launchctl list detection", () => { describe("launchctl list detection", () => {
it("detects the resolved label in launchctl list", async () => { it("detects the resolved label in launchctl list", async () => {
await withLaunchctlStub( await withLaunchctlStub({ listOutput: "123 0 com.clawdbot.gateway\n" }, async ({ env }) => {
{ listOutput: "123 0 com.clawdbot.gateway\n" }, const listed = await isLaunchAgentListed({ env });
async ({ env }) => { expect(listed).toBe(true);
const listed = await isLaunchAgentListed({ env }); });
expect(listed).toBe(true);
},
);
}); });
it("returns false when the label is missing", async () => { 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 label = resolveLaunchAgentLabel({ env: args.env });
const res = await execLaunchctl(["list"]); const res = await execLaunchctl(["list"]);
if (res.code !== 0) return false; if (res.code !== 0) return false;
return res.stdout return res.stdout.split(/\r?\n/).some((line) => line.trim().split(/\s+/).at(-1) === label);
.split(/\r?\n/)
.some((line) => line.trim().split(/\s+/).at(-1) === label);
} }
export async function launchAgentPlistExists( export async function launchAgentPlistExists(

View File

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

View File

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

View File

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

View File

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

View File

@ -27,9 +27,10 @@ describe("runBootOnce", () => {
it("skips when BOOT.md is missing", async () => { it("skips when BOOT.md is missing", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await expect( await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }), status: "skipped",
).resolves.toEqual({ status: "skipped", reason: "missing" }); reason: "missing",
});
expect(agentCommand).not.toHaveBeenCalled(); expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true });
}); });
@ -37,9 +38,10 @@ describe("runBootOnce", () => {
it("skips when BOOT.md is empty", async () => { it("skips when BOOT.md is empty", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8"); await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
await expect( await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }), status: "skipped",
).resolves.toEqual({ status: "skipped", reason: "empty" }); reason: "empty",
});
expect(agentCommand).not.toHaveBeenCalled(); expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true }); 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"); await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
agentCommand.mockResolvedValue(undefined); agentCommand.mockResolvedValue(undefined);
await expect( await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }), status: "ran",
).resolves.toEqual({ status: "ran" }); });
expect(agentCommand).toHaveBeenCalledTimes(1); expect(agentCommand).toHaveBeenCalledTimes(1);
const call = agentCommand.mock.calls[0]?.[0]; 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 os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
@ -141,7 +141,6 @@ describe("gateway wizard (e2e)", () => {
process.env.HOME = tempHome; process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR; delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH; delete process.env.CLAWDBOT_CONFIG_PATH;
vi.resetModules();
const wizardToken = `wiz-${randomUUID()}`; const wizardToken = `wiz-${randomUUID()}`;
const port = await getFreeGatewayPort(); const port = await getFreeGatewayPort();
@ -187,8 +186,8 @@ describe("gateway wizard (e2e)", () => {
expect(didSendToken).toBe(true); expect(didSendToken).toBe(true);
expect(next.status).toBe("done"); expect(next.status).toBe("done");
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js"); const { resolveConfigPath } = await import("../config/config.js");
const parsed = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")); const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
const token = (parsed as Record<string, unknown>)?.gateway as const token = (parsed as Record<string, unknown>)?.gateway as
| Record<string, unknown> | Record<string, unknown>
| undefined; | undefined;

View File

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

View File

@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { import {
agentCommand, agentCommand,
connectOk, connectOk,
@ -14,6 +16,33 @@ import {
installGatewayTestHooks(); 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 = const BASE_IMAGE_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII="; "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
@ -22,8 +51,96 @@ function expectChannels(call: Record<string, unknown>, channel: string) {
expect(call.messageChannel).toBe(channel); 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", () => { describe("gateway server agent", () => {
test("agent marks implicit delivery when lastTo is stale", async () => { test("agent marks implicit delivery when lastTo is stale", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+436769770569"]; testState.allowFrom = ["+436769770569"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@ -63,6 +180,7 @@ describe("gateway server agent", () => {
}); });
test("agent forwards sessionKey to agentCommand", async () => { test("agent forwards sessionKey to agentCommand", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@ -97,6 +215,7 @@ describe("gateway server agent", () => {
}); });
test("agent forwards accountId to agentCommand", async () => { test("agent forwards accountId to agentCommand", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); 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 () => { test("agent avoids lastAccountId when explicit to is provided", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); 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 () => { test("agent keeps explicit accountId when explicit to is provided", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); 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 () => { test("agent falls back to lastAccountId for implicit delivery", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@ -253,6 +375,7 @@ describe("gateway server agent", () => {
}); });
test("agent forwards image attachments as images[]", async () => { test("agent forwards image attachments as images[]", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ 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 () => { test("agent falls back to whatsapp when delivery requested and no last channel exists", async () => {
registryState.registry = defaultRegistry;
testState.allowFrom = ["+1555"]; testState.allowFrom = ["+1555"];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
@ -335,6 +459,7 @@ describe("gateway server agent", () => {
}); });
test("agent routes main last-channel whatsapp", async () => { test("agent routes main last-channel whatsapp", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@ -374,6 +499,7 @@ describe("gateway server agent", () => {
}); });
test("agent routes main last-channel telegram", async () => { test("agent routes main last-channel telegram", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@ -412,6 +538,7 @@ describe("gateway server agent", () => {
}); });
test("agent routes main last-channel discord", async () => { test("agent routes main last-channel discord", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@ -450,6 +577,7 @@ describe("gateway server agent", () => {
}); });
test("agent routes main last-channel slack", async () => { test("agent routes main last-channel slack", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({
@ -488,6 +616,7 @@ describe("gateway server agent", () => {
}); });
test("agent routes main last-channel signal", async () => { test("agent routes main last-channel signal", async () => {
registryState.registry = defaultRegistry;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ await writeSessionStore({

View File

@ -1,4 +1,6 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { import {
connectOk, connectOk,
installGatewayTestHooks, installGatewayTestHooks,
@ -10,6 +12,125 @@ const loadConfigHelpers = async () => await import("../config/config.js");
installGatewayTestHooks(); 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>>> = []; const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
afterEach(async () => { afterEach(async () => {
@ -28,6 +149,7 @@ afterEach(async () => {
describe("gateway server channels", () => { describe("gateway server channels", () => {
test("channels.status returns snapshot without probe", async () => { test("channels.status returns snapshot without probe", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined); vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
registryState.registry = defaultRegistry;
const result = await startServerWithClient(); const result = await startServerWithClient();
servers.push(result); servers.push(result);
const { ws } = result; const { ws } = result;
@ -59,6 +181,7 @@ describe("gateway server channels", () => {
}); });
test("channels.logout reports no session when missing", async () => { test("channels.logout reports no session when missing", async () => {
registryState.registry = defaultRegistry;
const result = await startServerWithClient(); const result = await startServerWithClient();
servers.push(result); servers.push(result);
const { ws } = result; const { ws } = result;
@ -74,6 +197,7 @@ describe("gateway server channels", () => {
test("channels.logout clears telegram bot token from config", async () => { test("channels.logout clears telegram bot token from config", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined); vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
registryState.registry = defaultRegistry;
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers(); const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({ await writeConfigFile({
channels: { channels: {

View File

@ -1,7 +1,12 @@
import { createServer } from "node:net"; import { createServer } from "node:net";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js"; import { resolveCanvasHostUrl } from "../infra/canvas-host-url.js";
import { getChannelPlugin } from "../channels/plugins/index.js";
import { GatewayLockError } from "../infra/gateway-lock.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 { import {
connectOk, connectOk,
getFreePort, getFreePort,
@ -16,6 +21,49 @@ import {
installGatewayTestHooks(); 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", () => { describe("gateway server misc", () => {
test("hello-ok advertises the gateway port for canvas host", async () => { test("hello-ok advertises the gateway port for canvas host", async () => {
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
@ -47,31 +95,38 @@ describe("gateway server misc", () => {
}); });
test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => { test("send dedupes by idempotencyKey", { timeout: 60_000 }, async () => {
const { server, ws } = await startServerWithClient(); const prevRegistry = getActivePluginRegistry() ?? emptyRegistry;
await connectOk(ws); try {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
setActivePluginRegistry(whatsappRegistry);
expect(getChannelPlugin("whatsapp")).toBeDefined();
const idem = "same-key"; const idem = "same-key";
const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1"); const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2"); const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
const sendReq = (id: string) => const sendReq = (id: string) =>
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "req", type: "req",
id, id,
method: "send", method: "send",
params: { to: "+15550000000", message: "hi", idempotencyKey: idem }, params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
}), }),
); );
sendReq("a1"); sendReq("a1");
sendReq("a2"); sendReq("a2");
const res1 = await res1P; const res1 = await res1P;
const res2 = await res2P; const res2 = await res2P;
expect(res1.ok).toBe(true); expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true); expect(res2.ok).toBe(true);
expect(res1.payload).toEqual(res2.payload); expect(res1.payload).toEqual(res2.payload);
ws.close(); ws.close();
await server.close(); await server.close();
} finally {
setActivePluginRegistry(prevRegistry);
}
}); });
test("refuses to start when port already bound", async () => { 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 os from "node:os";
import path from "node:path"; import path from "node:path";
import { vi } from "vitest"; import { vi } from "vitest";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
export type BridgeClientInfo = { export type BridgeClientInfo = {
nodeId: string; nodeId: string;
@ -91,6 +92,7 @@ export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined, agentConfig: undefined as Record<string, unknown> | undefined,
agentsConfig: undefined as Record<string, unknown> | undefined, agentsConfig: undefined as Record<string, unknown> | undefined,
bindingsConfig: undefined as Array<Record<string, unknown>> | undefined, bindingsConfig: undefined as Array<Record<string, unknown>> | undefined,
channelsConfig: undefined as Record<string, unknown> | undefined,
sessionStorePath: undefined as string | undefined, sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | undefined, sessionConfig: undefined as Record<string, unknown> | undefined,
allowFrom: undefined as string[] | undefined, allowFrom: undefined as string[] | undefined,
@ -259,49 +261,63 @@ vi.mock("../config/config.js", async () => {
config: testState.migrationConfig ?? (raw as Record<string, unknown>), config: testState.migrationConfig ?? (raw as Record<string, unknown>),
changes: testState.migrationChanges, changes: testState.migrationChanges,
}), }),
loadConfig: () => ({ loadConfig: () => {
agents: (() => { const base = {
const defaults = { agents: (() => {
model: "anthropic/claude-opus-4-5", const defaults = {
workspace: path.join(os.tmpdir(), "clawd-gateway-test"), model: "anthropic/claude-opus-4-5",
...testState.agentConfig, workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
}; ...testState.agentConfig,
if (testState.agentsConfig) { };
return { ...testState.agentsConfig, defaults }; if (testState.agentsConfig) {
} return { ...testState.agentsConfig, defaults };
return { defaults }; }
})(), return { defaults };
bindings: testState.bindingsConfig, })(),
channels: { bindings: testState.bindingsConfig,
whatsapp: { channels: (() => {
allowFrom: testState.allowFrom, 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,
}, },
}, gateway: (() => {
session: { const gateway: Record<string, unknown> = {};
mainKey: "main", if (testState.gatewayBind) gateway.bind = testState.gatewayBind;
store: testState.sessionStorePath, if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth;
...testState.sessionConfig, return Object.keys(gateway).length > 0 ? gateway : undefined;
}, })(),
gateway: (() => { canvasHost: (() => {
const gateway: Record<string, unknown> = {}; const canvasHost: Record<string, unknown> = {};
if (testState.gatewayBind) gateway.bind = testState.gatewayBind; if (typeof testState.canvasHostPort === "number")
if (testState.gatewayAuth) gateway.auth = testState.gatewayAuth; canvasHost.port = testState.canvasHostPort;
return Object.keys(gateway).length > 0 ? gateway : undefined; return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
})(), })(),
canvasHost: (() => { hooks: testState.hooksConfig,
const canvasHost: Record<string, unknown> = {}; cron: (() => {
if (typeof testState.canvasHostPort === "number") const cron: Record<string, unknown> = {};
canvasHost.port = testState.canvasHostPort; if (typeof testState.cronEnabled === "boolean") cron.enabled = testState.cronEnabled;
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined; if (typeof testState.cronStorePath === "string") cron.store = testState.cronStorePath;
})(), return Object.keys(cron).length > 0 ? cron : undefined;
hooks: testState.hooksConfig, })(),
cron: (() => { } as ReturnType<typeof actual.loadConfig>;
const cron: Record<string, unknown> = {}; return applyPluginAutoEnable({ config: base }).config;
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;
})(),
}),
parseConfigJson5: (raw: string) => { parseConfigJson5: (raw: string) => {
try { try {
return { ok: true, parsed: JSON.parse(raw) as unknown }; return { ok: true, parsed: JSON.parse(raw) as unknown };

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import os from "node:os"; 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 createService = vi.fn();
const shutdown = 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", () => { vi.mock("@homebridge/ciao", () => {
return { return {
Protocol: { TCP: "tcp" }, Protocol: { TCP: "tcp" },
@ -60,6 +54,12 @@ describe("gateway bonjour advertiser", () => {
const prevEnv = { ...process.env }; const prevEnv = { ...process.env };
beforeEach(() => {
vi.spyOn(logging, "getLogger").mockReturnValue({
info: (...args: unknown[]) => getLoggerInfo(...args),
});
});
afterEach(() => { afterEach(() => {
for (const key of Object.keys(process.env)) { for (const key of Object.keys(process.env)) {
if (!(key in prevEnv)) delete process.env[key]; 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"); const script = path.join(cwd, "bin", "tool");
fs.mkdirSync(path.dirname(script), { recursive: true }); fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, ""); fs.writeFileSync(script, "");
const res = resolveCommandResolution("\"./bin/tool\" --version", cwd, undefined); const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
expect(res?.resolvedPath).toBe(script); expect(res?.resolvedPath).toBe(script);
}); });
}); });

View File

@ -86,7 +86,12 @@ export async function requestExecHostViaSocket(params: {
idx = buffer.indexOf("\n"); idx = buffer.indexOf("\n");
if (!line) continue; if (!line) continue;
try { 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") { if (msg?.type === "exec-res") {
clearTimeout(timer); clearTimeout(timer);
if (msg.ok === true && msg.payload) { 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 type { LogLevel } from "./logging/levels.js";
export * from "./logging/logger.js"; export { ALLOWED_LOG_LEVELS, levelToMinLevel, normalizeLogLevel } from "./logging/levels.js";
export * from "./logging/subsystem.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 fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import JSZip from "jszip"; import JSZip from "jszip";
import sharp from "sharp"; 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 { isPathWithinBase } from "../../test/helpers/paths.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
describe("media store", () => { 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>( async function withTempStore<T>(
fn: (store: typeof import("./store.js"), home: string) => Promise<T>, fn: (store: typeof import("./store.js"), home: string) => Promise<T>,
): Promise<T> { ): Promise<T> {
return await withTempHome(async (home) => { return await fn(store, home);
vi.resetModules();
const store = await import("./store.js");
return await fn(store, home);
});
} }
it("creates and returns media directory", async () => { 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 { request } from "node:https";
import path from "node:path"; import path from "node:path";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import { CONFIG_DIR } from "../utils.js"; import { resolveConfigDir } from "../utils.js";
import { detectMime, extensionForMime } from "./mime.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 MAX_BYTES = 5 * 1024 * 1024; // 5MB default
const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes const DEFAULT_TTL_MS = 2 * 60 * 1000; // 2 minutes
export function getMediaDir() { export function getMediaDir() {
return MEDIA_DIR; return resolveMediaDir();
} }
export async function ensureMediaDir() { export async function ensureMediaDir() {
await fs.mkdir(MEDIA_DIR, { recursive: true }); const mediaDir = resolveMediaDir();
return MEDIA_DIR; await fs.mkdir(mediaDir, { recursive: true });
return mediaDir;
} }
export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) { export async function cleanOldMedia(ttlMs = DEFAULT_TTL_MS) {
await ensureMediaDir(); const mediaDir = await ensureMediaDir();
const entries = await fs.readdir(MEDIA_DIR).catch(() => []); const entries = await fs.readdir(mediaDir).catch(() => []);
const now = Date.now(); const now = Date.now();
await Promise.all( await Promise.all(
entries.map(async (file) => { 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); const stat = await fs.stat(full).catch(() => null);
if (!stat) return; if (!stat) return;
if (now - stat.mtimeMs > ttlMs) { if (now - stat.mtimeMs > ttlMs) {
@ -110,7 +111,8 @@ export async function saveMediaSource(
headers?: Record<string, string>, headers?: Record<string, string>,
subdir = "", subdir = "",
): Promise<SavedMedia> { ): 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 fs.mkdir(dir, { recursive: true });
await cleanOldMedia(); await cleanOldMedia();
const baseId = crypto.randomUUID(); const baseId = crypto.randomUUID();
@ -154,7 +156,7 @@ export async function saveMediaBuffer(
if (buffer.byteLength > maxBytes) { if (buffer.byteLength > maxBytes) {
throw new Error(`Media exceeds ${(maxBytes / (1024 * 1024)).toFixed(0)}MB limit`); 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 }); await fs.mkdir(dir, { recursive: true });
const baseId = crypto.randomUUID(); const baseId = crypto.randomUUID();
const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined); const headerExt = extensionForMime(contentType?.split(";")[0]?.trim() ?? undefined);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import JSZip from "jszip"; import JSZip from "jszip";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
const tempDirs: string[] = []; const tempDirs: string[] = [];
@ -77,22 +77,6 @@ function packToArchive({
return dest; 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(() => { afterEach(() => {
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
try { try {
@ -126,10 +110,9 @@ describe("installPluginFromArchive", () => {
outName: "plugin.tgz", outName: "plugin.tgz",
}); });
const result = await withStateDir(stateDir, async () => { const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js"); const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath }); const result = await installPluginFromArchive({ archivePath, extensionsDir });
});
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; if (!result.ok) return;
expect(result.pluginId).toBe("voice-call"); expect(result.pluginId).toBe("voice-call");
@ -160,12 +143,10 @@ describe("installPluginFromArchive", () => {
outName: "plugin.tgz", outName: "plugin.tgz",
}); });
const { first, second } = await withStateDir(stateDir, async () => { const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js"); const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({ archivePath }); const first = await installPluginFromArchive({ archivePath, extensionsDir });
const second = await installPluginFromArchive({ archivePath }); const second = await installPluginFromArchive({ archivePath, extensionsDir });
return { first, second };
});
expect(first.ok).toBe(true); expect(first.ok).toBe(true);
expect(second.ok).toBe(false); expect(second.ok).toBe(false);
@ -191,10 +172,9 @@ describe("installPluginFromArchive", () => {
const buffer = await zip.generateAsync({ type: "nodebuffer" }); const buffer = await zip.generateAsync({ type: "nodebuffer" });
fs.writeFileSync(archivePath, buffer); fs.writeFileSync(archivePath, buffer);
const result = await withStateDir(stateDir, async () => { const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js"); const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath }); const result = await installPluginFromArchive({ archivePath, extensionsDir });
});
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; if (!result.ok) return;
@ -243,18 +223,23 @@ describe("installPluginFromArchive", () => {
}); });
})(); })();
const result = await withStateDir(stateDir, async () => { const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js"); const { installPluginFromArchive } = await import("./install.js");
const first = await installPluginFromArchive({ archivePath: archiveV1 }); const first = await installPluginFromArchive({
const second = await installPluginFromArchive({ archivePath: archiveV2, mode: "update" }); archivePath: archiveV1,
return { first, second }; extensionsDir,
});
const second = await installPluginFromArchive({
archivePath: archiveV2,
extensionsDir,
mode: "update",
}); });
expect(result.first.ok).toBe(true); expect(first.ok).toBe(true);
expect(result.second.ok).toBe(true); expect(second.ok).toBe(true);
if (!result.second.ok) return; if (!second.ok) return;
const manifest = JSON.parse( 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 }; ) as { version?: string };
expect(manifest.version).toBe("0.0.2"); expect(manifest.version).toBe("0.0.2");
}); });
@ -276,10 +261,9 @@ describe("installPluginFromArchive", () => {
outName: "bad.tgz", outName: "bad.tgz",
}); });
const result = await withStateDir(stateDir, async () => { const extensionsDir = path.join(stateDir, "extensions");
const { installPluginFromArchive } = await import("./install.js"); const { installPluginFromArchive } = await import("./install.js");
return await installPluginFromArchive({ archivePath }); const result = await installPluginFromArchive({ archivePath, extensionsDir });
});
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
if (result.ok) return; if (result.ok) return;
expect(result.error).toContain("clawdbot.extensions"); 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 { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js"; import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js"; import { setActivePluginRegistry } from "./runtime.js";
import { defaultSlotIdForKey } from "./slots.js";
import type { import type {
ClawdbotPluginConfigSchema, ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition, ClawdbotPluginDefinition,
@ -92,7 +93,7 @@ const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedP
deny: normalizeList(config?.deny), deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths), loadPaths: normalizeList(config?.load?.paths),
slots: { slots: {
memory: memorySlot ?? "memory-core", memory: memorySlot ?? defaultSlotIdForKey("memory"),
}, },
entries: normalizePluginEntries(config?.entries), entries: normalizePluginEntries(config?.entries),
}; };

View File

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

View File

@ -44,10 +44,14 @@ type DispatchReplyFromConfig =
type FinalizeInboundContext = type FinalizeInboundContext =
typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext; typeof import("../../auto-reply/reply/inbound-context.js").finalizeInboundContext;
type FormatAgentEnvelope = typeof import("../../auto-reply/envelope.js").formatAgentEnvelope; 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 ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir;
type RecordSessionMetaFromInbound = type RecordSessionMetaFromInbound =
typeof import("../../config/sessions.js").recordSessionMetaFromInbound; typeof import("../../config/sessions.js").recordSessionMetaFromInbound;
type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath; 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 UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute;
type LoadConfig = typeof import("../../config/config.js").loadConfig; type LoadConfig = typeof import("../../config/config.js").loadConfig;
type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile; 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 IsVoiceCompatibleAudio = typeof import("../../media/audio.js").isVoiceCompatibleAudio;
type GetImageMetadata = typeof import("../../media/image-ops.js").getImageMetadata; type GetImageMetadata = typeof import("../../media/image-ops.js").getImageMetadata;
type ResizeToJpeg = typeof import("../../media/image-ops.js").resizeToJpeg; type ResizeToJpeg = typeof import("../../media/image-ops.js").resizeToJpeg;
type CreateMemoryGetTool = type CreateMemoryGetTool = typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
typeof import("../../agents/tools/memory-tool.js").createMemoryGetTool;
type CreateMemorySearchTool = type CreateMemorySearchTool =
typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool; typeof import("../../agents/tools/memory-tool.js").createMemorySearchTool;
type RegisterMemoryCli = typeof import("../../cli/memory-cli.js").registerMemoryCli; type RegisterMemoryCli = typeof import("../../cli/memory-cli.js").registerMemoryCli;
@ -170,6 +173,8 @@ export type PluginRuntime = {
dispatchReplyFromConfig: DispatchReplyFromConfig; dispatchReplyFromConfig: DispatchReplyFromConfig;
finalizeInboundContext: FinalizeInboundContext; finalizeInboundContext: FinalizeInboundContext;
formatAgentEnvelope: FormatAgentEnvelope; formatAgentEnvelope: FormatAgentEnvelope;
formatInboundEnvelope: FormatInboundEnvelope;
resolveEnvelopeFormatOptions: ResolveEnvelopeFormatOptions;
}; };
routing: { routing: {
resolveAgentRoute: ResolveAgentRoute; resolveAgentRoute: ResolveAgentRoute;
@ -185,6 +190,7 @@ export type PluginRuntime = {
}; };
session: { session: {
resolveStorePath: ResolveStorePath; resolveStorePath: ResolveStorePath;
readSessionUpdatedAt: ReadSessionUpdatedAt;
recordSessionMetaFromInbound: RecordSessionMetaFromInbound; recordSessionMetaFromInbound: RecordSessionMetaFromInbound;
updateLastRoute: UpdateLastRoute; 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, type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js"; } from "../../auto-reply/reply/response-prefix-template.js";
import { hasControlCommand } from "../../auto-reply/command-detection.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 { import {
createInboundDebouncer, createInboundDebouncer,
resolveInboundDebounceMs, resolveInboundDebounceMs,
@ -21,6 +25,7 @@ import {
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import { import {
readSessionUpdatedAt,
recordSessionMetaFromInbound, recordSessionMetaFromInbound,
resolveStorePath, resolveStorePath,
updateLastRoute, updateLastRoute,
@ -77,6 +82,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
directLabel: entry.senderName, directLabel: entry.senderName,
directId: entry.senderDisplay, 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({ const body = formatInboundEnvelope({
channel: "Signal", channel: "Signal",
from: fromLabel, from: fromLabel,
@ -84,6 +106,8 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
body: entry.bodyText, body: entry.bodyText,
chatType: entry.isGroup ? "group" : "direct", chatType: entry.isGroup ? "group" : "direct",
sender: { name: entry.senderName, id: entry.senderDisplay }, sender: { name: entry.senderName, id: entry.senderDisplay },
previousTimestamp,
envelope: envelopeOptions,
}); });
let combinedBody = body; let combinedBody = body;
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined; const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
@ -103,19 +127,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
}`, }`,
chatType: "group", chatType: "group",
senderLabel: historyEntry.sender, 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 signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`;
const ctxPayload = finalizeInboundContext({ const ctxPayload = finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
@ -144,9 +159,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
OriginatingTo: signalTo, OriginatingTo: signalTo,
}); });
const storePath = resolveStorePath(deps.cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({ void recordSessionMetaFromInbound({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,

View File

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

View File

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

View File

@ -176,7 +176,7 @@ describe("createTelegramBot", () => {
expect(payload.WasMentioned).toBe(true); expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada"); expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9"); 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 () => { it("keeps group envelope headers stable (sender identity is separate)", async () => {
onSpy.mockReset(); onSpy.mockReset();
@ -217,7 +217,7 @@ describe("createTelegramBot", () => {
expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99"); expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada"); 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 () => { it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset(); onSpy.mockReset();

View File

@ -330,7 +330,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch( 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"); expect(payload.Body).toContain("hello world");
} finally { } finally {

View File

@ -452,7 +452,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch( 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"); expect(payload.Body).toContain("hello world");
} finally { } finally {
@ -586,7 +586,7 @@ describe("createTelegramBot", () => {
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload); expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true); 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.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9"); expect(payload.SenderId).toBe("9");
}); });
@ -628,7 +628,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload); 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.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99"); expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada"); expect(payload.SenderUsername).toBe("ada");

View File

@ -328,9 +328,13 @@ describe("web auto-reply", () => {
expect(resolver).toHaveBeenCalledTimes(2); expect(resolver).toHaveBeenCalledTimes(2);
const firstArgs = resolver.mock.calls[0][0]; const firstArgs = resolver.mock.calls[0][0];
const secondArgs = resolver.mock.calls[1][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(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"); expect(secondArgs.Body).not.toContain("first");
// Max listeners bumped to avoid warnings in multi-instance test runs // Max listeners bumped to avoid warnings in multi-instance test runs

View File

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

View File

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

View File

@ -23,23 +23,23 @@ describe("web logout", () => {
it("deletes cached credentials when present", { timeout: 60_000 }, async () => { it("deletes cached credentials when present", { timeout: 60_000 }, async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.resetModules(); const { logoutWeb } = await import("./session.js");
const { logoutWeb, WA_WEB_AUTH_DIR } = 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.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); fs.writeFileSync(path.join(authDir, "creds.json"), "{}");
const result = await logoutWeb({ runtime: runtime as never }); const result = await logoutWeb({ runtime: runtime as never });
expect(result).toBe(true); 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 () => { it("no-ops when nothing to delete", { timeout: 60_000 }, async () => {
await withTempHome(async () => { await withTempHome(async () => {
vi.resetModules();
const { logoutWeb } = await import("./session.js"); const { logoutWeb } = await import("./session.js");
const result = await logoutWeb({ runtime: runtime as never }); const result = await logoutWeb({ runtime: runtime as never });
expect(result).toBe(false); expect(result).toBe(false);
@ -49,7 +49,6 @@ describe("web logout", () => {
it("keeps shared oauth.json when using legacy auth dir", async () => { it("keeps shared oauth.json when using legacy auth dir", async () => {
await withTempHome(async () => { await withTempHome(async () => {
vi.resetModules();
const { logoutWeb } = await import("./session.js"); const { logoutWeb } = await import("./session.js");
const { resolveOAuthDir } = await import("../config/paths.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"; 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 { export function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : ""; const role = typeof m.role === "string" ? m.role : "";
const content = m.content; const content = m.content;
if (typeof content === "string") { if (typeof content === "string") {
return role === "assistant" ? stripThinkingTags(content) : content; const processed = role === "assistant" ? stripThinkingTags(content) : stripEnvelope(content);
return processed;
} }
if (Array.isArray(content)) { if (Array.isArray(content)) {
const parts = content const parts = content
@ -17,11 +48,13 @@ export function extractText(message: unknown): string | null {
.filter((v): v is string => typeof v === "string"); .filter((v): v is string => typeof v === "string");
if (parts.length > 0) { if (parts.length > 0) {
const joined = parts.join("\n"); 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") { 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; return null;
} }
@ -83,4 +116,3 @@ export function formatReasoningMarkdown(text: string): string {
.map((line) => `_${line}_`); .map((line) => `_${line}_`);
return lines.length ? ["_Reasoning:_", ...lines].join("\n") : ""; return lines.length ? ["_Reasoning:_", ...lines].join("\n") : "";
} }

View File

@ -1,5 +1,5 @@
import type { GatewayBrowserClient } from "../gateway"; import type { GatewayBrowserClient } from "../gateway";
import { stripThinkingTags } from "../format"; import { extractText } from "../chat/message-extract";
import { generateUUID } from "../uuid"; import { generateUUID } from "../uuid";
export type ChatState = { export type ChatState = {
@ -142,29 +142,3 @@ export function handleChatEvent(
} }
return payload.state; 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;
}