Compare commits
35 Commits
feat/plugi
...
cs/teams_f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b5b74854 | ||
|
|
9c9e2ee6be | ||
|
|
8ea8801d06 | ||
|
|
c97bf23a4a | ||
|
|
3fff943ba1 | ||
|
|
90685ef814 | ||
|
|
a8f2ac5411 | ||
|
|
dea96a2c3d | ||
|
|
90ae2f541c | ||
|
|
d9a467fe3b | ||
|
|
aef88cd9f1 | ||
|
|
104d977d12 | ||
|
|
4b24753be7 | ||
|
|
df09e583aa | ||
|
|
46e6546bb9 | ||
|
|
5428c97685 | ||
|
|
202d7af855 | ||
|
|
72020b37c3 | ||
|
|
b051621bd4 | ||
|
|
ff52aec38e | ||
|
|
15620b1092 | ||
|
|
ad7fc4964a | ||
|
|
8f4426052c | ||
|
|
6a60d47c53 | ||
|
|
b1482957f5 | ||
|
|
4d2e9e8113 | ||
|
|
72d62a54c6 | ||
|
|
ae48066d28 | ||
|
|
f56f799990 | ||
|
|
7e498ab94a | ||
|
|
6bd6ae41b1 | ||
|
|
f648aae440 | ||
|
|
b56587f26e | ||
|
|
4ee808dbcb | ||
|
|
66eec295b8 |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -5,6 +5,8 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.23 (Unreleased)
|
||||
|
||||
### Changes
|
||||
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
|
||||
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
|
||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
@@ -16,9 +18,15 @@ Docs: https://docs.clawd.bot
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||
- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.
|
||||
|
||||
### Fixes
|
||||
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
@@ -53,7 +61,9 @@ Docs: https://docs.clawd.bot
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
|
||||
- MS Teams (plugin): remove `.default` suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.
|
||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
|
||||
49
README.md
49
README.md
@@ -479,28 +479,29 @@ Thanks to all clawtributors:
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
|
||||
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
|
||||
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
|
||||
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a>
|
||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a>
|
||||
<a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
|
||||
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
||||
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
|
||||
<a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
|
||||
<a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
|
||||
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
|
||||
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
|
||||
<a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a>
|
||||
<a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/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/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a>
|
||||
<a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
||||
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a>
|
||||
<a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
|
||||
<a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
|
||||
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
|
||||
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||
<a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
|
||||
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
|
||||
<a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
||||
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||
<a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
|
||||
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
|
||||
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a>
|
||||
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
|
||||
<a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
|
||||
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
|
||||
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a>
|
||||
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
||||
<a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
|
||||
<a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
||||
<a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
|
||||
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
|
||||
<a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
|
||||
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
|
||||
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
@@ -66,12 +66,12 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
|
||||
|
||||
## Time handling
|
||||
|
||||
The system prompt includes a dedicated **Current Date & Time** section when user
|
||||
time or timezone is known. It is explicit about:
|
||||
The system prompt includes a dedicated **Current Date & Time** section when the
|
||||
user timezone is known. To keep the prompt cache-stable, it now only includes
|
||||
the **time zone** (no dynamic clock or time format).
|
||||
|
||||
- The user’s **local time** (already converted).
|
||||
- The **time zone** used for the conversion.
|
||||
- The **time format** (12-hour / 24-hour).
|
||||
Use `session_status` when the agent needs the current time; the status card
|
||||
includes a timestamp line.
|
||||
|
||||
Configure with:
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics.
|
||||
Clawdbot defaults to **host-local time for transport timestamps** and **user timezone only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics (current time is available via `session_status`).
|
||||
|
||||
## Message envelopes (local by default)
|
||||
|
||||
@@ -63,16 +63,16 @@ You can override this behavior:
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
If the user timezone or local time is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section:
|
||||
If the user timezone is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section with the **time zone only** (no clock/time format)
|
||||
to keep prompt caching stable:
|
||||
|
||||
```
|
||||
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
|
||||
Time format: 12-hour
|
||||
Time zone: America/Chicago
|
||||
```
|
||||
|
||||
If only the timezone is known, we still include the section and instruct the model
|
||||
to assume UTC for unknown time references.
|
||||
When the agent needs the current time, use the `session_status` tool; the status
|
||||
card includes a timestamp line.
|
||||
|
||||
## System event lines (local by default)
|
||||
|
||||
|
||||
@@ -1045,6 +1045,7 @@
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
"platforms/linux",
|
||||
"platforms/fly",
|
||||
"platforms/hetzner",
|
||||
"platforms/exe-dev"
|
||||
]
|
||||
|
||||
@@ -1446,6 +1446,44 @@ active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
|
||||
`removeAckAfterReply` removes the bot’s ack reaction after a reply is sent
|
||||
(Slack/Discord/Telegram only). Default: `false`.
|
||||
|
||||
#### `messages.tts`
|
||||
|
||||
Enable text-to-speech for outbound replies. When on, Clawdbot generates audio
|
||||
using ElevenLabs or OpenAI and attaches it to responses. Telegram uses Opus
|
||||
voice notes; other channels send MP3 audio.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
mode: "final", // final | all (include tool/block replies)
|
||||
provider: "elevenlabs",
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.clawdbot/settings/tts.json",
|
||||
elevenlabs: {
|
||||
apiKey: "elevenlabs_api_key",
|
||||
voiceId: "voice_id",
|
||||
modelId: "eleven_multilingual_v2"
|
||||
},
|
||||
openai: {
|
||||
apiKey: "openai_api_key",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts_on`, `/tts_off`).
|
||||
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
||||
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||
- `/tts_limit` and `/tts_summary` control per-user summarization settings.
|
||||
- `apiKey` values fall back to `ELEVENLABS_API_KEY`/`XI_API_KEY` and `OPENAI_API_KEY`.
|
||||
|
||||
### `talk`
|
||||
|
||||
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
|
||||
|
||||
267
docs/platforms/fly.md
Normal file
267
docs/platforms/fly.md
Normal file
@@ -0,0 +1,267 @@
|
||||
---
|
||||
title: Fly.io
|
||||
description: Deploy Clawdbot on Fly.io
|
||||
---
|
||||
|
||||
# Fly.io Deployment
|
||||
|
||||
**Goal:** Clawdbot Gateway running on a [Fly.io](https://fly.io) machine with persistent storage, automatic HTTPS, and Discord/channel access.
|
||||
|
||||
## What you need
|
||||
|
||||
- [flyctl CLI](https://fly.io/docs/hands-on/install-flyctl/) installed
|
||||
- Fly.io account (free tier works)
|
||||
- Model auth: Anthropic API key (or other provider keys)
|
||||
- Channel credentials: Discord bot token, Telegram token, etc.
|
||||
|
||||
## Beginner quick path
|
||||
|
||||
1. Clone repo → customize `fly.toml`
|
||||
2. Create app + volume → set secrets
|
||||
3. Deploy with `fly deploy`
|
||||
4. SSH in to create config or use Control UI
|
||||
|
||||
## 1) Create the Fly app
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
|
||||
# Create a new Fly app (pick your own name)
|
||||
fly apps create my-clawdbot
|
||||
|
||||
# Create a persistent volume (1GB is usually enough)
|
||||
fly volumes create clawdbot_data --size 1 --region lhr
|
||||
```
|
||||
|
||||
**Tip:** Choose a region close to you. Common options: `lhr` (London), `iad` (Virginia), `sjc` (San Jose).
|
||||
|
||||
## 2) Configure fly.toml
|
||||
|
||||
Edit `fly.toml` to match your app name and requirements:
|
||||
|
||||
```toml
|
||||
app = "my-clawdbot" # Your app name
|
||||
primary_region = "lhr"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
NODE_ENV = "production"
|
||||
CLAWDBOT_PREFER_PNPM = "1"
|
||||
CLAWDBOT_STATE_DIR = "/data"
|
||||
NODE_OPTIONS = "--max-old-space-size=1536"
|
||||
|
||||
[processes]
|
||||
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = false
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ["app"]
|
||||
|
||||
[[vm]]
|
||||
size = "shared-cpu-2x"
|
||||
memory = "2048mb"
|
||||
|
||||
[mounts]
|
||||
source = "clawdbot_data"
|
||||
destination = "/data"
|
||||
```
|
||||
|
||||
**Key settings:**
|
||||
|
||||
| Setting | Why |
|
||||
|---------|-----|
|
||||
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
|
||||
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
|
||||
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
|
||||
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
|
||||
|
||||
## 3) Set secrets
|
||||
|
||||
```bash
|
||||
# Required: Gateway token (for non-loopback binding)
|
||||
fly secrets set CLAWDBOT_GATEWAY_TOKEN=$(openssl rand -hex 32)
|
||||
|
||||
# Model provider API keys
|
||||
fly secrets set ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# Optional: Other providers
|
||||
fly secrets set OPENAI_API_KEY=sk-...
|
||||
fly secrets set GOOGLE_API_KEY=...
|
||||
|
||||
# Channel tokens
|
||||
fly secrets set DISCORD_BOT_TOKEN=MTQ...
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Non-loopback binds (`--bind lan`) require `CLAWDBOT_GATEWAY_TOKEN` for security.
|
||||
- Treat these tokens like passwords.
|
||||
|
||||
## 4) Deploy
|
||||
|
||||
```bash
|
||||
fly deploy
|
||||
```
|
||||
|
||||
First deploy builds the Docker image (~2-3 minutes). Subsequent deploys are faster.
|
||||
|
||||
After deployment, verify:
|
||||
```bash
|
||||
fly status
|
||||
fly logs
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
[gateway] listening on ws://0.0.0.0:3000 (PID xxx)
|
||||
[discord] logged in to discord as xxx
|
||||
```
|
||||
|
||||
## 5) Create config file
|
||||
|
||||
SSH into the machine to create a proper config:
|
||||
|
||||
```bash
|
||||
fly ssh console
|
||||
```
|
||||
|
||||
Create the config directory and file:
|
||||
```bash
|
||||
mkdir -p /data/.clawdbot
|
||||
cat > /data/.clawdbot/clawdbot.json << 'EOF'
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-opus-4-5"
|
||||
},
|
||||
"models": {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"anthropic/claude-sonnet-4-5": {}
|
||||
},
|
||||
"maxConcurrent": 4
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Restart to apply:
|
||||
```bash
|
||||
exit
|
||||
fly machine restart <machine-id>
|
||||
```
|
||||
|
||||
## 6) Access the Gateway
|
||||
|
||||
### Control UI
|
||||
|
||||
Open in browser:
|
||||
```bash
|
||||
fly open
|
||||
```
|
||||
|
||||
Or visit `https://my-clawdbot.fly.dev/`
|
||||
|
||||
Paste your gateway token (the one from `CLAWDBOT_GATEWAY_TOKEN`) to authenticate.
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
fly logs # Live logs
|
||||
fly logs --no-tail # Recent logs
|
||||
```
|
||||
|
||||
### SSH Console
|
||||
|
||||
```bash
|
||||
fly ssh console
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "App is not listening on expected address"
|
||||
|
||||
The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
|
||||
|
||||
**Fix:** Add `--bind lan` to your process command in `fly.toml`.
|
||||
|
||||
### OOM / Memory Issues
|
||||
|
||||
Container keeps restarting or getting killed.
|
||||
|
||||
**Fix:** Increase memory in `fly.toml`:
|
||||
```toml
|
||||
[[vm]]
|
||||
memory = "2048mb"
|
||||
```
|
||||
|
||||
### Gateway Lock Issues
|
||||
|
||||
Gateway refuses to start with "already running" errors.
|
||||
|
||||
This happens when the container restarts but the PID lock file persists on the volume.
|
||||
|
||||
**Fix:** Delete the lock file:
|
||||
```bash
|
||||
fly ssh console
|
||||
rm /data/.clawdbot/run/gateway.*.lock
|
||||
exit
|
||||
fly machine restart <machine-id>
|
||||
```
|
||||
|
||||
### Config Not Being Read
|
||||
|
||||
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
|
||||
|
||||
Verify the config exists:
|
||||
```bash
|
||||
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Redeploy
|
||||
fly deploy
|
||||
|
||||
# Check health
|
||||
fly status
|
||||
fly logs
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Fly.io uses **x86 architecture** (not ARM)
|
||||
- The Dockerfile is compatible with both architectures
|
||||
- For WhatsApp/Telegram onboarding, use `fly ssh console`
|
||||
- Persistent data lives on the volume at `/data`
|
||||
|
||||
## Cost
|
||||
|
||||
With the recommended config (`shared-cpu-2x`, 2GB RAM):
|
||||
- ~$10-15/month depending on usage
|
||||
- Free tier includes some allowance
|
||||
|
||||
See [Fly.io pricing](https://fly.io/docs/about/pricing/) for details.
|
||||
@@ -23,6 +23,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
|
||||
## VPS & hosting
|
||||
|
||||
- Fly.io: [Fly.io](/platforms/fly)
|
||||
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ Plugins can register:
|
||||
- Background services
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
@@ -494,6 +495,65 @@ export default function (api) {
|
||||
}
|
||||
```
|
||||
|
||||
### Register auto-reply commands
|
||||
|
||||
Plugins can register custom slash commands that execute **without invoking the
|
||||
AI agent**. This is useful for toggle commands, status checks, or quick actions
|
||||
that don't need LLM processing.
|
||||
|
||||
```ts
|
||||
export default function (api) {
|
||||
api.registerCommand({
|
||||
name: "mystatus",
|
||||
description: "Show plugin status",
|
||||
handler: (ctx) => ({
|
||||
text: `Plugin is running! Channel: ${ctx.channel}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Command handler context:
|
||||
|
||||
- `senderId`: The sender's ID (if available)
|
||||
- `channel`: The channel where the command was sent
|
||||
- `isAuthorizedSender`: Whether the sender is an authorized user
|
||||
- `args`: Arguments passed after the command (if `acceptsArgs: true`)
|
||||
- `commandBody`: The full command text
|
||||
- `config`: The current Clawdbot config
|
||||
|
||||
Command options:
|
||||
|
||||
- `name`: Command name (without the leading `/`)
|
||||
- `description`: Help text shown in command lists
|
||||
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
|
||||
- `requireAuth`: Whether to require authorized sender (default: true)
|
||||
- `handler`: Function that returns `{ text: string }` (can be async)
|
||||
|
||||
Example with authorization and arguments:
|
||||
|
||||
```ts
|
||||
api.registerCommand({
|
||||
name: "setmode",
|
||||
description: "Set plugin mode",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx) => {
|
||||
const mode = ctx.args?.trim() || "default";
|
||||
await saveMode(mode);
|
||||
return { text: `Mode set to: ${mode}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Plugin commands are processed **before** built-in commands and the AI agent
|
||||
- Commands are registered globally and work across all channels
|
||||
- Command names are case-insensitive (`/MyStatus` matches `/mystatus`)
|
||||
- Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
|
||||
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
|
||||
- Duplicate command registration across plugins will fail with a diagnostic error
|
||||
|
||||
### Register background services
|
||||
|
||||
```ts
|
||||
|
||||
@@ -48,6 +48,7 @@ Implementation:
|
||||
|
||||
**OpenAI / OpenAI Codex**
|
||||
- Image sanitization only.
|
||||
- On model switch into OpenAI Responses/Codex, drop orphaned reasoning signatures (standalone reasoning items without a following content block).
|
||||
- No tool call id sanitization.
|
||||
- No tool result pairing repair.
|
||||
- No turn validation or reordering.
|
||||
|
||||
@@ -25,6 +25,7 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
|
||||
Notes:
|
||||
- Matching is case-insensitive.
|
||||
- `*` wildcards are supported (`"*"` means all tools).
|
||||
- If `tools.allow` only references unknown or unloaded plugin tool names, Clawdbot logs a warning and ignores the allowlist so core tools stay available.
|
||||
|
||||
## Tool profiles (base allowlist)
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ Text + native (when enabled):
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/tts_on` (enable TTS replies)
|
||||
- `/tts_off` (disable TTS replies)
|
||||
- `/tts_provider [openai|elevenlabs]` (set or show TTS provider)
|
||||
- `/tts_limit <chars>` (max chars before TTS summarization)
|
||||
- `/tts_summary on|off` (toggle TTS auto-summary)
|
||||
- `/tts_status` (show TTS status)
|
||||
- `/audio <text>` (convert text to a TTS audio reply)
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
||||
try {
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
|
||||
await tokenProvider.getAccessToken("https://api.botframework.com");
|
||||
let graph:
|
||||
| {
|
||||
ok: boolean;
|
||||
|
||||
28
fly.toml
Normal file
28
fly.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
# Clawdbot Fly.io deployment configuration
|
||||
# See https://fly.io/docs/reference/configuration/
|
||||
|
||||
app = "clawdbot"
|
||||
primary_region = "lhr" # London
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
NODE_ENV = "production"
|
||||
# Fly uses x86, but keep this for consistency
|
||||
CLAWDBOT_PREFER_PNPM = "1"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = false # Keep running for persistent connections
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
|
||||
[[vm]]
|
||||
size = "shared-cpu-1x"
|
||||
memory = "512mb"
|
||||
|
||||
[mounts]
|
||||
source = "clawdbot_data"
|
||||
destination = "/data"
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -393,6 +393,8 @@ importers:
|
||||
|
||||
extensions/telegram: {}
|
||||
|
||||
extensions/telegram-tts: {}
|
||||
|
||||
extensions/tlon:
|
||||
dependencies:
|
||||
'@urbit/aura':
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: bird
|
||||
description: X/Twitter CLI for reading, searching, posting, and engagement via cookies.
|
||||
homepage: https://bird.fast
|
||||
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)"},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
|
||||
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)","os":["darwin"]},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
|
||||
---
|
||||
|
||||
# bird 🐦
|
||||
|
||||
215
src/agents/anthropic-payload-log.ts
Normal file
215
src/agents/anthropic-payload-log.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
|
||||
type PayloadLogStage = "request" | "usage";
|
||||
|
||||
type PayloadLogEvent = {
|
||||
ts: string;
|
||||
stage: PayloadLogStage;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string | null;
|
||||
workspaceDir?: string;
|
||||
payload?: unknown;
|
||||
usage?: Record<string, unknown>;
|
||||
error?: string;
|
||||
payloadDigest?: string;
|
||||
};
|
||||
|
||||
type PayloadLogConfig = {
|
||||
enabled: boolean;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
type PayloadLogWriter = {
|
||||
filePath: string;
|
||||
write: (line: string) => void;
|
||||
};
|
||||
|
||||
const writers = new Map<string, PayloadLogWriter>();
|
||||
const log = createSubsystemLogger("agent/anthropic-payload");
|
||||
|
||||
function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig {
|
||||
const enabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG) ?? false;
|
||||
const fileOverride = env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
|
||||
const filePath = fileOverride
|
||||
? resolveUserPath(fileOverride)
|
||||
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
|
||||
return { enabled, filePath };
|
||||
}
|
||||
|
||||
function getWriter(filePath: string): PayloadLogWriter {
|
||||
const existing = writers.get(filePath);
|
||||
if (existing) return existing;
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const writer: PayloadLogWriter = {
|
||||
filePath,
|
||||
write: (line: string) => {
|
||||
queue = queue
|
||||
.then(() => ready)
|
||||
.then(() => fs.appendFile(filePath, line, "utf8"))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
};
|
||||
|
||||
writers.set(filePath, writer);
|
||||
return writer;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string | null {
|
||||
try {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (typeof val === "bigint") return val.toString();
|
||||
if (typeof val === "function") return "[Function]";
|
||||
if (val instanceof Error) {
|
||||
return { name: val.name, message: val.message, stack: val.stack };
|
||||
}
|
||||
if (val instanceof Uint8Array) {
|
||||
return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
|
||||
}
|
||||
return val;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
|
||||
return String(error);
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
return safeJsonStringify(error) ?? "unknown error";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string | undefined {
|
||||
const serialized = safeJsonStringify(value);
|
||||
if (!serialized) return undefined;
|
||||
return crypto.createHash("sha256").update(serialized).digest("hex");
|
||||
}
|
||||
|
||||
function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
|
||||
return (model as { api?: unknown })?.api === "anthropic-messages";
|
||||
}
|
||||
|
||||
function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknown> | null {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const msg = messages[i] as { role?: unknown; usage?: unknown };
|
||||
if (msg?.role === "assistant" && msg.usage && typeof msg.usage === "object") {
|
||||
return msg.usage as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AnthropicPayloadLogger = {
|
||||
enabled: true;
|
||||
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
|
||||
recordUsage: (messages: AgentMessage[], error?: unknown) => void;
|
||||
};
|
||||
|
||||
export function createAnthropicPayloadLogger(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string | null;
|
||||
workspaceDir?: string;
|
||||
}): AnthropicPayloadLogger | null {
|
||||
const env = params.env ?? process.env;
|
||||
const cfg = resolvePayloadLogConfig(env);
|
||||
if (!cfg.enabled) return null;
|
||||
|
||||
const writer = getWriter(cfg.filePath);
|
||||
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
workspaceDir: params.workspaceDir,
|
||||
};
|
||||
|
||||
const record = (event: PayloadLogEvent) => {
|
||||
const line = safeJsonStringify(event);
|
||||
if (!line) return;
|
||||
writer.write(`${line}\n`);
|
||||
};
|
||||
|
||||
const wrapStreamFn: AnthropicPayloadLogger["wrapStreamFn"] = (streamFn) => {
|
||||
const wrapped: StreamFn = (model, context, options) => {
|
||||
if (!isAnthropicModel(model as Model<Api>)) {
|
||||
return streamFn(model, context, options);
|
||||
}
|
||||
const nextOnPayload = (payload: unknown) => {
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "request",
|
||||
payload,
|
||||
payloadDigest: digest(payload),
|
||||
});
|
||||
options?.onPayload?.(payload);
|
||||
};
|
||||
return streamFn(model, context, {
|
||||
...options,
|
||||
onPayload: nextOnPayload,
|
||||
});
|
||||
};
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
|
||||
const usage = findLastAssistantUsage(messages);
|
||||
const errorMessage = formatError(error);
|
||||
if (!usage) {
|
||||
if (errorMessage) {
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "usage",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "usage",
|
||||
usage,
|
||||
error: errorMessage,
|
||||
});
|
||||
log.info("anthropic usage", {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
usage,
|
||||
});
|
||||
};
|
||||
|
||||
log.info("anthropic payload logger enabled", { filePath: writer.filePath });
|
||||
return { enabled: true, wrapStreamFn, recordUsage };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||
import { createTtsTool } from "./tools/tts-tool.js";
|
||||
|
||||
export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
@@ -96,6 +97,10 @@ export function createClawdbotTools(options?: {
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
}),
|
||||
createTtsTool({
|
||||
agentChannel: options?.agentChannel,
|
||||
config: options?.config,
|
||||
}),
|
||||
createGatewayTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js";
|
||||
|
||||
describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
it("keeps reasoning signatures when followed by content", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal reasoning",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
|
||||
});
|
||||
|
||||
it("drops orphaned reasoning blocks without following content", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "next" },
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([
|
||||
{ role: "user", content: "next" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops object-form orphaned signatures", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinkingSignature: { id: "rs_obj", type: "reasoning" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps non-reasoning thinking signatures", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "t",
|
||||
thinkingSignature: "reasoning_content",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,8 @@ export {
|
||||
parseImageDimensionError,
|
||||
} from "./pi-embedded-helpers/errors.js";
|
||||
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
|
||||
|
||||
export { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers/openai.js";
|
||||
export {
|
||||
isEmptyAssistantMessageContent,
|
||||
sanitizeSessionMessagesImages,
|
||||
|
||||
118
src/agents/pi-embedded-helpers/openai.ts
Normal file
118
src/agents/pi-embedded-helpers/openai.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
type OpenAIThinkingBlock = {
|
||||
type?: unknown;
|
||||
thinking?: unknown;
|
||||
thinkingSignature?: unknown;
|
||||
};
|
||||
|
||||
type OpenAIReasoningSignature = {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
|
||||
if (!value) return null;
|
||||
let candidate: { id?: unknown; type?: unknown } | null = null;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||
try {
|
||||
candidate = JSON.parse(trimmed) as { id?: unknown; type?: unknown };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
candidate = value as { id?: unknown; type?: unknown };
|
||||
}
|
||||
if (!candidate) return null;
|
||||
const id = typeof candidate.id === "string" ? candidate.id : "";
|
||||
const type = typeof candidate.type === "string" ? candidate.type : "";
|
||||
if (!id.startsWith("rs_")) return null;
|
||||
if (type === "reasoning" || type.startsWith("reasoning.")) {
|
||||
return { id, type };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFollowingNonThinkingBlock(
|
||||
content: Extract<AgentMessage, { role: "assistant" }>["content"],
|
||||
index: number,
|
||||
): boolean {
|
||||
for (let i = index + 1; i < content.length; i++) {
|
||||
const block = content[i];
|
||||
if (!block || typeof block !== "object") return true;
|
||||
if ((block as { type?: unknown }).type !== "thinking") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
|
||||
* without the required following item.
|
||||
*
|
||||
* Clawdbot persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata
|
||||
* is incomplete, drop the block to keep history usable.
|
||||
*/
|
||||
export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||
const out: AgentMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = (msg as { role?: unknown }).role;
|
||||
if (role !== "assistant") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||
if (!Array.isArray(assistantMsg.content)) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
type AssistantContentBlock = (typeof assistantMsg.content)[number];
|
||||
|
||||
const nextContent: AssistantContentBlock[] = [];
|
||||
for (let i = 0; i < assistantMsg.content.length; i++) {
|
||||
const block = assistantMsg.content[i];
|
||||
if (!block || typeof block !== "object") {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
const record = block as OpenAIThinkingBlock;
|
||||
if (record.type !== "thinking") {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
const signature = parseOpenAIReasoningSignature(record.thinkingSignature);
|
||||
if (!signature) {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextContent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -161,4 +161,92 @@ describe("sanitizeSessionHistory", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("does not downgrade openai reasoning when the model has not changed", async () => {
|
||||
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||
{
|
||||
type: "custom",
|
||||
customType: "model-snapshot",
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.2-codex",
|
||||
},
|
||||
},
|
||||
];
|
||||
const sessionManager = {
|
||||
getEntries: vi.fn(() => sessionEntries),
|
||||
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||
sessionEntries.push({ type: "custom", customType, data });
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_test", type: "reasoning" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(result).toEqual(messages);
|
||||
});
|
||||
|
||||
it("downgrades openai reasoning only when the model changes", async () => {
|
||||
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||
{
|
||||
type: "custom",
|
||||
customType: "model-snapshot",
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
provider: "anthropic",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "claude-3-7",
|
||||
},
|
||||
},
|
||||
];
|
||||
const sessionManager = {
|
||||
getEntries: vi.fn(() => sessionEntries),
|
||||
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||
sessionEntries.push({ type: "custom", customType, data });
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning",
|
||||
thinkingSignature: { id: "rs_test", type: "reasoning" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||
import {
|
||||
downgradeOpenAIReasoningBlocks,
|
||||
isCompactionFailureError,
|
||||
isGoogleModelApi,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
@@ -211,7 +212,50 @@ registerUnhandledRejectionHandler((reason) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
type CustomEntryLike = { type?: unknown; customType?: unknown };
|
||||
type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown };
|
||||
|
||||
type ModelSnapshotEntry = {
|
||||
timestamp: number;
|
||||
provider?: string;
|
||||
modelApi?: string | null;
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot";
|
||||
|
||||
function readLastModelSnapshot(sessionManager: SessionManager): ModelSnapshotEntry | null {
|
||||
try {
|
||||
const entries = sessionManager.getEntries();
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i] as CustomEntryLike;
|
||||
if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) continue;
|
||||
const data = entry?.data as ModelSnapshotEntry | undefined;
|
||||
if (data && typeof data === "object") {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendModelSnapshot(sessionManager: SessionManager, data: ModelSnapshotEntry): void {
|
||||
try {
|
||||
sessionManager.appendCustomEntry(MODEL_SNAPSHOT_CUSTOM_TYPE, data);
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
function isSameModelSnapshot(a: ModelSnapshotEntry, b: ModelSnapshotEntry): boolean {
|
||||
const normalize = (value?: string | null) => value ?? "";
|
||||
return (
|
||||
normalize(a.provider) === normalize(b.provider) &&
|
||||
normalize(a.modelApi) === normalize(b.modelApi) &&
|
||||
normalize(a.modelId) === normalize(b.modelId)
|
||||
);
|
||||
}
|
||||
|
||||
function hasGoogleTurnOrderingMarker(sessionManager: SessionManager): boolean {
|
||||
try {
|
||||
@@ -292,12 +336,38 @@ export async function sanitizeSessionHistory(params: {
|
||||
? sanitizeToolUseResultPairing(sanitizedThinking)
|
||||
: sanitizedThinking;
|
||||
|
||||
const isOpenAIResponsesApi =
|
||||
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
|
||||
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
|
||||
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
|
||||
const modelChanged = priorSnapshot
|
||||
? !isSameModelSnapshot(priorSnapshot, {
|
||||
timestamp: 0,
|
||||
provider: params.provider,
|
||||
modelApi: params.modelApi,
|
||||
modelId: params.modelId,
|
||||
})
|
||||
: false;
|
||||
const sanitizedOpenAI =
|
||||
isOpenAIResponsesApi && modelChanged
|
||||
? downgradeOpenAIReasoningBlocks(repairedTools)
|
||||
: repairedTools;
|
||||
|
||||
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
|
||||
appendModelSnapshot(params.sessionManager, {
|
||||
timestamp: Date.now(),
|
||||
provider: params.provider,
|
||||
modelApi: params.modelApi,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!policy.applyGoogleTurnOrdering) {
|
||||
return repairedTools;
|
||||
return sanitizedOpenAI;
|
||||
}
|
||||
|
||||
return applyGoogleTurnOrderingFix({
|
||||
messages: repairedTools,
|
||||
messages: sanitizedOpenAI,
|
||||
modelApi: params.modelApi,
|
||||
sessionManager: params.sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
import { createCacheTrace } from "../../cache-trace.js";
|
||||
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
|
||||
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||
@@ -458,6 +459,16 @@ export async function runEmbeddedAttempt(
|
||||
modelApi: params.model.api,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const anthropicPayloadLogger = createAnthropicPayloadLogger({
|
||||
env: process.env,
|
||||
runId: params.runId,
|
||||
sessionId: activeSession.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.model.api,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
|
||||
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
|
||||
activeSession.agent.streamFn = streamSimple;
|
||||
@@ -478,6 +489,11 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn);
|
||||
}
|
||||
if (anthropicPayloadLogger) {
|
||||
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
|
||||
activeSession.agent.streamFn,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const prior = await sanitizeSessionHistory({
|
||||
@@ -772,6 +788,7 @@ export async function runEmbeddedAttempt(
|
||||
messages: messagesSnapshot,
|
||||
note: promptError ? "prompt error" : undefined,
|
||||
});
|
||||
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError);
|
||||
|
||||
// Run agent_end hooks to allow plugins to analyze the conversation
|
||||
// This is fire-and-forget, so we don't await
|
||||
|
||||
@@ -148,6 +148,35 @@ describe("buildEmbeddedRunPayloads", () => {
|
||||
expect(payloads[0]?.text).toBe("All good");
|
||||
});
|
||||
|
||||
it("adds tool error fallback when the assistant only invoked tools", () => {
|
||||
const payloads = buildEmbeddedRunPayloads({
|
||||
assistantTexts: [],
|
||||
toolMetas: [],
|
||||
lastAssistant: {
|
||||
stopReason: "toolUse",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "toolu_01",
|
||||
name: "exec",
|
||||
arguments: { command: "echo hi" },
|
||||
},
|
||||
],
|
||||
} as AssistantMessage,
|
||||
lastToolError: { toolName: "exec", error: "Command exited with code 1" },
|
||||
sessionKey: "session:telegram",
|
||||
inlineToolResultsAllowed: false,
|
||||
verboseLevel: "off",
|
||||
reasoningLevel: "off",
|
||||
toolResultFormat: "plain",
|
||||
});
|
||||
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]?.isError).toBe(true);
|
||||
expect(payloads[0]?.text).toContain("Exec");
|
||||
expect(payloads[0]?.text).toContain("code 1");
|
||||
});
|
||||
|
||||
it("suppresses recoverable tool errors containing 'required'", () => {
|
||||
const payloads = buildEmbeddedRunPayloads({
|
||||
assistantTexts: [],
|
||||
|
||||
@@ -169,7 +169,16 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
}
|
||||
|
||||
if (params.lastToolError) {
|
||||
const hasUserFacingReply = replyItems.length > 0;
|
||||
const lastAssistantHasToolCalls =
|
||||
Array.isArray(params.lastAssistant?.content) &&
|
||||
params.lastAssistant?.content.some((block) =>
|
||||
block && typeof block === "object"
|
||||
? (block as { type?: unknown }).type === "toolCall"
|
||||
: false,
|
||||
);
|
||||
const lastAssistantWasToolUse = params.lastAssistant?.stopReason === "toolUse";
|
||||
const hasUserFacingReply =
|
||||
replyItems.length > 0 && !lastAssistantHasToolCalls && !lastAssistantWasToolUse;
|
||||
// Check if this is a recoverable/internal tool error that shouldn't be shown to users
|
||||
// when there's already a user-facing reply (the model should have retried).
|
||||
const errorLower = (params.lastToolError.error ?? "").toLowerCase();
|
||||
|
||||
@@ -44,10 +44,12 @@ import {
|
||||
buildPluginToolGroups,
|
||||
collectExplicitAllowlist,
|
||||
expandPolicyWithPluginGroups,
|
||||
normalizeToolName,
|
||||
resolveToolProfilePolicy,
|
||||
stripPluginOnlyAllowlist,
|
||||
} from "./tool-policy.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
|
||||
function isOpenAIProvider(provider?: string) {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
@@ -253,11 +255,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const bashTool = {
|
||||
...(execTool as unknown as AnyAgentTool),
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
} satisfies AnyAgentTool;
|
||||
const processTool = createProcessTool({
|
||||
cleanupMs: cleanupMsOverride ?? execConfig.cleanupMs,
|
||||
scopeKey,
|
||||
@@ -278,7 +275,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
: []),
|
||||
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
|
||||
execTool as unknown as AnyAgentTool,
|
||||
bashTool,
|
||||
processTool as unknown as AnyAgentTool,
|
||||
// Channel docking: include channel-defined agent tools (login, etc.).
|
||||
...listChannelAgentTools({ cfg: options?.config }),
|
||||
@@ -319,38 +315,46 @@ export function createClawdbotCodingTools(options?: {
|
||||
modelHasVision: options?.modelHasVision,
|
||||
}),
|
||||
];
|
||||
const coreToolNames = new Set(
|
||||
tools
|
||||
.filter((tool) => !getPluginToolMeta(tool as AnyAgentTool))
|
||||
.map((tool) => normalizeToolName(tool.name))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const pluginGroups = buildPluginToolGroups({
|
||||
tools,
|
||||
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
|
||||
});
|
||||
const profilePolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(profilePolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
const resolvePolicy = (policy: typeof profilePolicy, label: string) => {
|
||||
const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames);
|
||||
if (resolved.unknownAllowlist.length > 0) {
|
||||
const entries = resolved.unknownAllowlist.join(", ");
|
||||
const suffix = resolved.strippedAllowlist
|
||||
? "Ignoring allowlist so core tools remain available."
|
||||
: "These entries won't match any tool unless the plugin is enabled.";
|
||||
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
|
||||
}
|
||||
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
|
||||
};
|
||||
const profilePolicyExpanded = resolvePolicy(
|
||||
profilePolicy,
|
||||
profile ? `tools.profile (${profile})` : "tools.profile",
|
||||
);
|
||||
const providerProfileExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(providerProfilePolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
const providerProfileExpanded = resolvePolicy(
|
||||
providerProfilePolicy,
|
||||
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
|
||||
);
|
||||
const globalPolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(globalPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
|
||||
const globalProviderExpanded = resolvePolicy(globalProviderPolicy, "tools.byProvider.allow");
|
||||
const agentPolicyExpanded = resolvePolicy(
|
||||
agentPolicy,
|
||||
agentId ? `agents.${agentId}.tools.allow` : "agent tools.allow",
|
||||
);
|
||||
const globalProviderExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(globalProviderPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const agentPolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(agentPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const agentProviderExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const groupPolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(groupPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
const agentProviderExpanded = resolvePolicy(
|
||||
agentProviderPolicy,
|
||||
agentId ? `agents.${agentId}.tools.byProvider.allow` : "agent tools.byProvider.allow",
|
||||
);
|
||||
const groupPolicyExpanded = resolvePolicy(groupPolicy, "group tools.allow");
|
||||
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
||||
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
|
||||
});
|
||||
|
||||
it("includes user time when provided (12-hour)", () => {
|
||||
it("includes user timezone when provided (12-hour)", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
@@ -133,11 +133,10 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)");
|
||||
expect(prompt).toContain("Time format: 12-hour");
|
||||
expect(prompt).toContain("Time zone: America/Chicago");
|
||||
});
|
||||
|
||||
it("includes user time when provided (24-hour)", () => {
|
||||
it("includes user timezone when provided (24-hour)", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
@@ -146,11 +145,10 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)");
|
||||
expect(prompt).toContain("Time format: 24-hour");
|
||||
expect(prompt).toContain("Time zone: America/Chicago");
|
||||
});
|
||||
|
||||
it("shows UTC fallback when only timezone is provided", () => {
|
||||
it("shows timezone when only timezone is provided", () => {
|
||||
const prompt = buildAgentSystemPrompt({
|
||||
workspaceDir: "/tmp/clawd",
|
||||
userTimezone: "America/Chicago",
|
||||
@@ -158,9 +156,7 @@ describe("buildAgentSystemPrompt", () => {
|
||||
});
|
||||
|
||||
expect(prompt).toContain("## Current Date & Time");
|
||||
expect(prompt).toContain(
|
||||
"Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.",
|
||||
);
|
||||
expect(prompt).toContain("Time zone: America/Chicago");
|
||||
});
|
||||
|
||||
it("includes model alias guidance when aliases are provided", () => {
|
||||
|
||||
@@ -49,22 +49,9 @@ function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: bool
|
||||
return ["## User Identity", ownerLine, ""];
|
||||
}
|
||||
|
||||
function buildTimeSection(params: {
|
||||
userTimezone?: string;
|
||||
userTime?: string;
|
||||
userTimeFormat?: ResolvedTimeFormat;
|
||||
}) {
|
||||
if (!params.userTimezone && !params.userTime) return [];
|
||||
return [
|
||||
"## Current Date & Time",
|
||||
params.userTime
|
||||
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
|
||||
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
|
||||
params.userTimeFormat
|
||||
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
|
||||
: "",
|
||||
"",
|
||||
];
|
||||
function buildTimeSection(params: { userTimezone?: string }) {
|
||||
if (!params.userTimezone) return [];
|
||||
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
|
||||
}
|
||||
|
||||
function buildReplyTagsSection(isMinimal: boolean) {
|
||||
@@ -212,7 +199,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
sessions_send: "Send a message to another session/sub-agent",
|
||||
sessions_spawn: "Spawn a sub-agent session",
|
||||
session_status:
|
||||
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
|
||||
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
|
||||
image: "Analyze an image with the configured image model",
|
||||
};
|
||||
|
||||
@@ -302,7 +289,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
: undefined;
|
||||
const reasoningLevel = params.reasoningLevel ?? "off";
|
||||
const userTimezone = params.userTimezone?.trim();
|
||||
const userTime = params.userTime?.trim();
|
||||
const skillsPrompt = params.skillsPrompt?.trim();
|
||||
const heartbeatPrompt = params.heartbeatPrompt?.trim();
|
||||
const heartbeatPromptLine = heartbeatPrompt
|
||||
@@ -465,8 +451,6 @@ export function buildAgentSystemPrompt(params: {
|
||||
...buildUserIdentitySection(ownerLine, isMinimal),
|
||||
...buildTimeSection({
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat: params.userTimeFormat,
|
||||
}),
|
||||
"## Workspace Files (injected)",
|
||||
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
"title": "Exec",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"bash": {
|
||||
"emoji": "🛠️",
|
||||
"title": "Exec",
|
||||
"label": "exec",
|
||||
"detailKeys": ["command"]
|
||||
},
|
||||
"process": {
|
||||
"emoji": "🧰",
|
||||
"title": "Process",
|
||||
|
||||
@@ -6,20 +6,46 @@ const pluginGroups: PluginToolGroups = {
|
||||
all: ["lobster", "workflow_tool"],
|
||||
byPlugin: new Map([["lobster", ["lobster", "workflow_tool"]]]),
|
||||
};
|
||||
const coreTools = new Set(["read", "write", "exec", "session_status"]);
|
||||
|
||||
describe("stripPluginOnlyAllowlist", () => {
|
||||
it("strips allowlist when it only targets plugin tools", () => {
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups);
|
||||
expect(policy?.allow).toBeUndefined();
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups, coreTools);
|
||||
expect(policy.policy?.allow).toBeUndefined();
|
||||
expect(policy.unknownAllowlist).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips allowlist when it only targets plugin groups", () => {
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups);
|
||||
expect(policy?.allow).toBeUndefined();
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups, coreTools);
|
||||
expect(policy.policy?.allow).toBeUndefined();
|
||||
expect(policy.unknownAllowlist).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps allowlist when it mixes plugin and core entries", () => {
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster", "read"] }, pluginGroups);
|
||||
expect(policy?.allow).toEqual(["lobster", "read"]);
|
||||
const policy = stripPluginOnlyAllowlist(
|
||||
{ allow: ["lobster", "read"] },
|
||||
pluginGroups,
|
||||
coreTools,
|
||||
);
|
||||
expect(policy.policy?.allow).toEqual(["lobster", "read"]);
|
||||
expect(policy.unknownAllowlist).toEqual([]);
|
||||
});
|
||||
|
||||
it("strips allowlist with unknown entries when no core tools match", () => {
|
||||
const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() };
|
||||
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, emptyPlugins, coreTools);
|
||||
expect(policy.policy?.allow).toBeUndefined();
|
||||
expect(policy.unknownAllowlist).toEqual(["lobster"]);
|
||||
});
|
||||
|
||||
it("keeps allowlist with core tools and reports unknown entries", () => {
|
||||
const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() };
|
||||
const policy = stripPluginOnlyAllowlist(
|
||||
{ allow: ["read", "lobster"] },
|
||||
emptyPlugins,
|
||||
coreTools,
|
||||
);
|
||||
expect(policy.policy?.allow).toEqual(["read", "lobster"]);
|
||||
expect(policy.unknownAllowlist).toEqual(["lobster"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@ describe("tool-policy", () => {
|
||||
const expanded = expandToolGroups(["group:runtime", "BASH", "apply-patch", "group:fs"]);
|
||||
const set = new Set(expanded);
|
||||
expect(set.has("exec")).toBe(true);
|
||||
expect(set.has("bash")).toBe(true);
|
||||
expect(set.has("process")).toBe(true);
|
||||
expect(set.has("bash")).toBe(false);
|
||||
expect(set.has("apply_patch")).toBe(true);
|
||||
expect(set.has("read")).toBe(true);
|
||||
expect(set.has("write")).toBe(true);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
||||
// Basic workspace/file tools
|
||||
"group:fs": ["read", "write", "edit", "apply_patch"],
|
||||
// Host/runtime execution tools
|
||||
"group:runtime": ["exec", "bash", "process"],
|
||||
"group:runtime": ["exec", "process"],
|
||||
// Session management tools
|
||||
"group:sessions": [
|
||||
"sessions_list",
|
||||
@@ -95,6 +95,12 @@ export type PluginToolGroups = {
|
||||
byPlugin: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export type AllowlistResolution = {
|
||||
policy: ToolPolicyLike | undefined;
|
||||
unknownAllowlist: string[];
|
||||
strippedAllowlist: boolean;
|
||||
};
|
||||
|
||||
export function expandToolGroups(list?: string[]) {
|
||||
const normalized = normalizeToolList(list);
|
||||
const expanded: string[] = [];
|
||||
@@ -181,17 +187,33 @@ export function expandPolicyWithPluginGroups(
|
||||
export function stripPluginOnlyAllowlist(
|
||||
policy: ToolPolicyLike | undefined,
|
||||
groups: PluginToolGroups,
|
||||
): ToolPolicyLike | undefined {
|
||||
if (!policy?.allow || policy.allow.length === 0) return policy;
|
||||
coreTools: Set<string>,
|
||||
): AllowlistResolution {
|
||||
if (!policy?.allow || policy.allow.length === 0) {
|
||||
return { policy, unknownAllowlist: [], strippedAllowlist: false };
|
||||
}
|
||||
const normalized = normalizeToolList(policy.allow);
|
||||
if (normalized.length === 0) return policy;
|
||||
if (normalized.length === 0) {
|
||||
return { policy, unknownAllowlist: [], strippedAllowlist: false };
|
||||
}
|
||||
const pluginIds = new Set(groups.byPlugin.keys());
|
||||
const pluginTools = new Set(groups.all);
|
||||
const isPluginEntry = (entry: string) =>
|
||||
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
|
||||
const isPluginOnly = normalized.every((entry) => isPluginEntry(entry));
|
||||
if (!isPluginOnly) return policy;
|
||||
return { ...policy, allow: undefined };
|
||||
const unknownAllowlist: string[] = [];
|
||||
let hasCoreEntry = false;
|
||||
for (const entry of normalized) {
|
||||
const isPluginEntry =
|
||||
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
|
||||
const expanded = expandToolGroups([entry]);
|
||||
const isCoreEntry = expanded.some((tool) => coreTools.has(tool));
|
||||
if (isCoreEntry) hasCoreEntry = true;
|
||||
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
|
||||
}
|
||||
const strippedAllowlist = !hasCoreEntry;
|
||||
return {
|
||||
policy: strippedAllowlist ? { ...policy, allow: undefined } : policy,
|
||||
unknownAllowlist: Array.from(new Set(unknownAllowlist)),
|
||||
strippedAllowlist,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveDefaultModelForAgent,
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
|
||||
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
|
||||
import { buildStatusMessage } from "../../auto-reply/status.js";
|
||||
@@ -215,7 +216,7 @@ export function createSessionStatusTool(opts?: {
|
||||
label: "Session Status",
|
||||
name: "session_status",
|
||||
description:
|
||||
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
|
||||
"Show a /status-equivalent session status card (usage + time + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
|
||||
parameters: SessionStatusToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
@@ -324,6 +325,13 @@ export function createSessionStatusTool(opts?: {
|
||||
resolved.entry.queueDebounceMs ?? resolved.entry.queueCap ?? resolved.entry.queueDrop,
|
||||
);
|
||||
|
||||
const userTimezone = resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(cfg.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const timeLine = userTime
|
||||
? `🕒 Time: ${userTime} (${userTimezone})`
|
||||
: `🕒 Time zone: ${userTimezone}`;
|
||||
|
||||
const agentDefaults = cfg.agents?.defaults ?? {};
|
||||
const defaultLabel = `${configured.provider}/${configured.model}`;
|
||||
const agentModel =
|
||||
@@ -346,6 +354,7 @@ export function createSessionStatusTool(opts?: {
|
||||
agentDir,
|
||||
}),
|
||||
usageLine,
|
||||
timeLine,
|
||||
queue: {
|
||||
mode: queueSettings.mode,
|
||||
depth: queueDepth,
|
||||
|
||||
60
src/agents/tools/tts-tool.ts
Normal file
60
src/agents/tools/tts-tool.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import { textToSpeech } from "../../tts/tts.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
|
||||
const TtsToolSchema = Type.Object({
|
||||
text: Type.String({ description: "Text to convert to speech." }),
|
||||
channel: Type.Optional(
|
||||
Type.String({ description: "Optional channel id to pick output format (e.g. telegram)." }),
|
||||
),
|
||||
});
|
||||
|
||||
export function createTtsTool(opts?: {
|
||||
config?: ClawdbotConfig;
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
label: "TTS",
|
||||
name: "tts",
|
||||
description:
|
||||
"Convert text to speech and return a MEDIA: path. Use when the user requests audio or TTS is enabled. Copy the MEDIA line exactly.",
|
||||
parameters: TtsToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
const text = readStringParam(params, "text", { required: true });
|
||||
const channel = readStringParam(params, "channel");
|
||||
const cfg = opts?.config ?? loadConfig();
|
||||
const result = await textToSpeech({
|
||||
text,
|
||||
cfg,
|
||||
channel: channel ?? opts?.agentChannel,
|
||||
});
|
||||
|
||||
if (result.success && result.audioPath) {
|
||||
const lines: string[] = [];
|
||||
// Tag Telegram Opus output as a voice bubble instead of a file attachment.
|
||||
if (result.voiceCompatible) lines.push("[[audio_as_voice]]");
|
||||
lines.push(`MEDIA:${result.audioPath}`);
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { audioPath: result.audioPath, provider: result.provider },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: result.error ?? "TTS conversion failed",
|
||||
},
|
||||
],
|
||||
details: { error: result.error },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -272,6 +272,81 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "audio",
|
||||
nativeName: "audio",
|
||||
description: "Convert text to a TTS audio reply.",
|
||||
textAlias: "/audio",
|
||||
args: [
|
||||
{
|
||||
name: "text",
|
||||
description: "Text to speak",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts_on",
|
||||
nativeName: "tts_on",
|
||||
description: "Enable text-to-speech for replies.",
|
||||
textAlias: "/tts_on",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts_off",
|
||||
nativeName: "tts_off",
|
||||
description: "Disable text-to-speech for replies.",
|
||||
textAlias: "/tts_off",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts_provider",
|
||||
nativeName: "tts_provider",
|
||||
description: "Set or show the TTS provider.",
|
||||
textAlias: "/tts_provider",
|
||||
args: [
|
||||
{
|
||||
name: "provider",
|
||||
description: "openai or elevenlabs",
|
||||
type: "string",
|
||||
choices: ["openai", "elevenlabs"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts_limit",
|
||||
nativeName: "tts_limit",
|
||||
description: "Set or show the max TTS text length.",
|
||||
textAlias: "/tts_limit",
|
||||
args: [
|
||||
{
|
||||
name: "maxLength",
|
||||
description: "Max chars before summarizing",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts_summary",
|
||||
nativeName: "tts_summary",
|
||||
description: "Enable or disable TTS auto-summary.",
|
||||
textAlias: "/tts_summary",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["on", "off"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "tts_status",
|
||||
nativeName: "tts_status",
|
||||
description: "Show TTS status and last attempt.",
|
||||
textAlias: "/tts_status",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "stop",
|
||||
nativeName: "stop",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||
import { handleModelsCommand } from "./commands-models.js";
|
||||
import { handleTtsCommands } from "./commands-tts.js";
|
||||
import {
|
||||
handleAbortTrigger,
|
||||
handleActivationCommand,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
handleStopCommand,
|
||||
handleUsageCommand,
|
||||
} from "./commands-session.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
import type {
|
||||
CommandHandler,
|
||||
CommandHandlerResult,
|
||||
@@ -31,11 +33,14 @@ import type {
|
||||
} from "./commands-types.js";
|
||||
|
||||
const HANDLERS: CommandHandler[] = [
|
||||
// Plugin commands are processed first, before built-in commands
|
||||
handlePluginCommand,
|
||||
handleBashCommand,
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
handleUsageCommand,
|
||||
handleRestartCommand,
|
||||
handleTtsCommands,
|
||||
handleHelpCommand,
|
||||
handleCommandsListCommand,
|
||||
handleStatusCommand,
|
||||
|
||||
41
src/auto-reply/reply/commands-plugin.ts
Normal file
41
src/auto-reply/reply/commands-plugin.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Plugin Command Handler
|
||||
*
|
||||
* Handles commands registered by plugins, bypassing the LLM agent.
|
||||
* This handler is called before built-in command handlers.
|
||||
*/
|
||||
|
||||
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
|
||||
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
|
||||
|
||||
/**
|
||||
* Handle plugin-registered commands.
|
||||
* Returns a result if a plugin command was matched and executed,
|
||||
* or null to continue to the next handler.
|
||||
*/
|
||||
export const handlePluginCommand: CommandHandler = async (
|
||||
params,
|
||||
_allowTextCommands,
|
||||
): Promise<CommandHandlerResult | null> => {
|
||||
const { command, cfg } = params;
|
||||
|
||||
// Try to match a plugin command
|
||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||
if (!match) return null;
|
||||
|
||||
// Execute the plugin command (always returns a result)
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
args: match.args,
|
||||
senderId: command.senderId,
|
||||
channel: command.channel,
|
||||
isAuthorizedSender: command.isAuthorizedSender,
|
||||
commandBody: command.commandBodyNormalized,
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: result.text },
|
||||
};
|
||||
};
|
||||
214
src/auto-reply/reply/commands-tts.ts
Normal file
214
src/auto-reply/reply/commands-tts.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import {
|
||||
getLastTtsAttempt,
|
||||
getTtsMaxLength,
|
||||
getTtsProvider,
|
||||
isSummarizationEnabled,
|
||||
isTtsEnabled,
|
||||
resolveTtsApiKey,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
setLastTtsAttempt,
|
||||
setSummarizationEnabled,
|
||||
setTtsEnabled,
|
||||
setTtsMaxLength,
|
||||
setTtsProvider,
|
||||
textToSpeech,
|
||||
} from "../../tts/tts.js";
|
||||
|
||||
function parseCommandArg(normalized: string, command: string): string | null {
|
||||
if (normalized === command) return "";
|
||||
if (normalized.startsWith(`${command} `)) return normalized.slice(command.length).trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export const handleTtsCommands: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
if (
|
||||
!normalized.startsWith("/tts_") &&
|
||||
normalized !== "/audio" &&
|
||||
!normalized.startsWith("/audio ")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring TTS command from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const config = resolveTtsConfig(params.cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
|
||||
if (normalized === "/tts_on") {
|
||||
setTtsEnabled(prefsPath, true);
|
||||
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
|
||||
}
|
||||
|
||||
if (normalized === "/tts_off") {
|
||||
setTtsEnabled(prefsPath, false);
|
||||
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
|
||||
}
|
||||
|
||||
const audioArg = parseCommandArg(normalized, "/audio");
|
||||
if (audioArg !== null) {
|
||||
if (!audioArg.trim()) {
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Usage: /audio <text>" } };
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const result = await textToSpeech({
|
||||
text: audioArg,
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
prefsPath,
|
||||
});
|
||||
|
||||
if (result.success && result.audioPath) {
|
||||
setLastTtsAttempt({
|
||||
timestamp: Date.now(),
|
||||
success: true,
|
||||
textLength: audioArg.length,
|
||||
summarized: false,
|
||||
provider: result.provider,
|
||||
latencyMs: result.latencyMs,
|
||||
});
|
||||
const payload: ReplyPayload = {
|
||||
mediaUrl: result.audioPath,
|
||||
audioAsVoice: result.voiceCompatible === true,
|
||||
};
|
||||
return { shouldContinue: false, reply: payload };
|
||||
}
|
||||
|
||||
setLastTtsAttempt({
|
||||
timestamp: Date.now(),
|
||||
success: false,
|
||||
textLength: audioArg.length,
|
||||
summarized: false,
|
||||
error: result.error,
|
||||
latencyMs: Date.now() - start,
|
||||
});
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Error generating audio: ${result.error ?? "unknown error"}` },
|
||||
};
|
||||
}
|
||||
|
||||
const providerArg = parseCommandArg(normalized, "/tts_provider");
|
||||
if (providerArg !== null) {
|
||||
const currentProvider = getTtsProvider(config, prefsPath);
|
||||
if (!providerArg.trim()) {
|
||||
const fallback = currentProvider === "openai" ? "elevenlabs" : "openai";
|
||||
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
|
||||
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
`🎙️ TTS provider\n` +
|
||||
`Primary: ${currentProvider}\n` +
|
||||
`Fallback: ${fallback}\n` +
|
||||
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
|
||||
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
|
||||
`Usage: /tts_provider openai | elevenlabs`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const requested = providerArg.trim().toLowerCase();
|
||||
if (requested !== "openai" && requested !== "elevenlabs") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /tts_provider openai | elevenlabs" },
|
||||
};
|
||||
}
|
||||
|
||||
setTtsProvider(prefsPath, requested);
|
||||
const fallback = requested === "openai" ? "elevenlabs" : "openai";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ TTS provider set to ${requested} (fallback: ${fallback}).` },
|
||||
};
|
||||
}
|
||||
|
||||
const limitArg = parseCommandArg(normalized, "/tts_limit");
|
||||
if (limitArg !== null) {
|
||||
if (!limitArg.trim()) {
|
||||
const currentLimit = getTtsMaxLength(prefsPath);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
|
||||
};
|
||||
}
|
||||
const next = Number.parseInt(limitArg.trim(), 10);
|
||||
if (!Number.isFinite(next) || next < 100 || next > 10_000) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /tts_limit <100-10000>" },
|
||||
};
|
||||
}
|
||||
setTtsMaxLength(prefsPath, next);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ TTS limit set to ${next} characters.` },
|
||||
};
|
||||
}
|
||||
|
||||
const summaryArg = parseCommandArg(normalized, "/tts_summary");
|
||||
if (summaryArg !== null) {
|
||||
if (!summaryArg.trim()) {
|
||||
const enabled = isSummarizationEnabled(prefsPath);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
|
||||
};
|
||||
}
|
||||
const requested = summaryArg.trim().toLowerCase();
|
||||
if (requested !== "on" && requested !== "off") {
|
||||
return { shouldContinue: false, reply: { text: "⚙️ Usage: /tts_summary on|off" } };
|
||||
}
|
||||
setSummarizationEnabled(prefsPath, requested === "on");
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: requested === "on" ? "✅ TTS auto-summary enabled." : "❌ TTS auto-summary disabled.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized === "/tts_status") {
|
||||
const enabled = isTtsEnabled(config, prefsPath);
|
||||
const provider = getTtsProvider(config, prefsPath);
|
||||
const hasKey = Boolean(resolveTtsApiKey(config, provider));
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath);
|
||||
const last = getLastTtsAttempt();
|
||||
const lines = [
|
||||
"📊 TTS status",
|
||||
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
|
||||
`Provider: ${provider} (${hasKey ? "✅ key" : "❌ no key"})`,
|
||||
`Text limit: ${maxLength} chars`,
|
||||
`Auto-summary: ${summarize ? "on" : "off"}`,
|
||||
];
|
||||
if (last) {
|
||||
const timeAgo = Math.round((Date.now() - last.timestamp) / 1000);
|
||||
lines.push("");
|
||||
lines.push(`Last attempt (${timeAgo}s ago): ${last.success ? "✅" : "❌"}`);
|
||||
lines.push(`Text: ${last.textLength} chars${last.summarized ? " (summarized)" : ""}`);
|
||||
if (last.success) {
|
||||
lines.push(`Provider: ${last.provider ?? "unknown"}`);
|
||||
lines.push(`Latency: ${last.latencyMs ?? 0}ms`);
|
||||
} else if (last.error) {
|
||||
lines.push(`Error: ${last.error}`);
|
||||
}
|
||||
}
|
||||
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
||||
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||
import { maybeApplyTtsToPayload } from "../../tts/tts.js";
|
||||
|
||||
export type DispatchFromConfigResult = {
|
||||
queuedFinal: boolean;
|
||||
@@ -91,6 +92,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase();
|
||||
const shouldRouteToOriginating =
|
||||
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface;
|
||||
const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface;
|
||||
|
||||
/**
|
||||
* Helper to send a payload via route-reply (async).
|
||||
@@ -164,22 +166,36 @@ export async function dispatchReplyFromConfig(params: {
|
||||
{
|
||||
...params.replyOptions,
|
||||
onToolResult: (payload: ReplyPayload) => {
|
||||
if (shouldRouteToOriginating) {
|
||||
// Fire-and-forget for streaming tool results when routing.
|
||||
void sendPayloadAsync(payload);
|
||||
} else {
|
||||
// Synchronous dispatch to preserve callback timing.
|
||||
dispatcher.sendToolResult(payload);
|
||||
}
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "tool",
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload);
|
||||
} else {
|
||||
dispatcher.sendToolResult(ttsPayload);
|
||||
}
|
||||
};
|
||||
return run();
|
||||
},
|
||||
onBlockReply: (payload: ReplyPayload, context) => {
|
||||
if (shouldRouteToOriginating) {
|
||||
// Await routed sends so upstream can enforce ordering/timeouts.
|
||||
return sendPayloadAsync(payload, context?.abortSignal);
|
||||
} else {
|
||||
// Synchronous dispatch to preserve callback timing.
|
||||
dispatcher.sendBlockReply(payload);
|
||||
}
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
payload,
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "block",
|
||||
});
|
||||
if (shouldRouteToOriginating) {
|
||||
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
||||
} else {
|
||||
dispatcher.sendBlockReply(ttsPayload);
|
||||
}
|
||||
};
|
||||
return run();
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
@@ -190,10 +206,16 @@ export async function dispatchReplyFromConfig(params: {
|
||||
let queuedFinal = false;
|
||||
let routedFinalCount = 0;
|
||||
for (const reply of replies) {
|
||||
const ttsReply = await maybeApplyTtsToPayload({
|
||||
payload: reply,
|
||||
cfg,
|
||||
channel: ttsChannel,
|
||||
kind: "final",
|
||||
});
|
||||
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
|
||||
// Route final reply to originating channel.
|
||||
const result = await routeReply({
|
||||
payload: reply,
|
||||
payload: ttsReply,
|
||||
channel: originatingChannel,
|
||||
to: originatingTo,
|
||||
sessionKey: ctx.SessionKey,
|
||||
@@ -209,7 +231,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
queuedFinal = result.ok || queuedFinal;
|
||||
if (result.ok) routedFinalCount += 1;
|
||||
} else {
|
||||
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
|
||||
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
|
||||
}
|
||||
}
|
||||
await dispatcher.waitForIdle();
|
||||
|
||||
@@ -72,8 +72,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
||||
});
|
||||
if (!normalized) return { ok: true };
|
||||
|
||||
const text = normalized.text ?? "";
|
||||
const mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
|
||||
let text = normalized.text ?? "";
|
||||
let mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
|
||||
? (normalized.mediaUrls?.filter(Boolean) as string[])
|
||||
: normalized.mediaUrl
|
||||
? [normalized.mediaUrl]
|
||||
|
||||
@@ -52,6 +52,7 @@ type StatusArgs = {
|
||||
resolvedElevated?: ElevatedLevel;
|
||||
modelAuth?: string;
|
||||
usageLine?: string;
|
||||
timeLine?: string;
|
||||
queue?: QueueStatus;
|
||||
mediaDecisions?: MediaUnderstandingDecision[];
|
||||
subagentsLine?: string;
|
||||
@@ -381,6 +382,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
|
||||
return [
|
||||
versionLine,
|
||||
args.timeLine,
|
||||
modelLine,
|
||||
usageCostLine,
|
||||
`📚 ${contextLine}`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -82,13 +83,16 @@ function stripWindowsNodeExec(argv: string[]): string[] {
|
||||
const execBase = path.basename(execPath).toLowerCase();
|
||||
const isExecPath = (value: string | undefined): boolean => {
|
||||
if (!value) return false;
|
||||
const lower = normalizeCandidate(value).toLowerCase();
|
||||
const normalized = normalizeCandidate(value);
|
||||
if (!normalized) return false;
|
||||
const lower = normalized.toLowerCase();
|
||||
return (
|
||||
lower === execPathLower ||
|
||||
path.basename(lower) === execBase ||
|
||||
lower.endsWith("\\node.exe") ||
|
||||
lower.endsWith("/node.exe") ||
|
||||
lower.includes("node.exe")
|
||||
lower.includes("node.exe") ||
|
||||
(path.basename(lower) === "node.exe" && fs.existsSync(normalized))
|
||||
);
|
||||
};
|
||||
const filtered = argv.filter((arg, index) => index === 0 || !isExecPath(arg));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { QueueDropPolicy, QueueMode, QueueModeByProvider } from "./types.queue.js";
|
||||
import type { TtsConfig } from "./types.tts.js";
|
||||
|
||||
export type GroupChatConfig = {
|
||||
mentionPatterns?: string[];
|
||||
@@ -81,6 +82,8 @@ export type MessagesConfig = {
|
||||
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
|
||||
/** Remove ack reaction after reply is sent (default: false). */
|
||||
removeAckAfterReply?: boolean;
|
||||
/** Text-to-speech settings for outbound replies. */
|
||||
tts?: TtsConfig;
|
||||
};
|
||||
|
||||
export type NativeCommandsSetting = boolean | "auto";
|
||||
|
||||
@@ -23,5 +23,6 @@ export * from "./types.signal.js";
|
||||
export * from "./types.skills.js";
|
||||
export * from "./types.slack.js";
|
||||
export * from "./types.telegram.js";
|
||||
export * from "./types.tts.js";
|
||||
export * from "./types.tools.js";
|
||||
export * from "./types.whatsapp.js";
|
||||
|
||||
30
src/config/types.tts.ts
Normal file
30
src/config/types.tts.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type TtsProvider = "elevenlabs" | "openai";
|
||||
|
||||
export type TtsMode = "final" | "all";
|
||||
|
||||
export type TtsConfig = {
|
||||
/** Enable auto-TTS (can be overridden by local prefs). */
|
||||
enabled?: boolean;
|
||||
/** Apply TTS to final replies only or to all replies (tool/block/final). */
|
||||
mode?: TtsMode;
|
||||
/** Primary TTS provider (fallbacks are automatic). */
|
||||
provider?: TtsProvider;
|
||||
/** ElevenLabs configuration. */
|
||||
elevenlabs?: {
|
||||
apiKey?: string;
|
||||
voiceId?: string;
|
||||
modelId?: string;
|
||||
};
|
||||
/** OpenAI configuration. */
|
||||
openai?: {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
voice?: string;
|
||||
};
|
||||
/** Optional path for local TTS user preferences JSON. */
|
||||
prefsPath?: string;
|
||||
/** Hard cap for text sent to TTS (chars). */
|
||||
maxTextLength?: number;
|
||||
/** API request timeout (ms). */
|
||||
timeoutMs?: number;
|
||||
};
|
||||
@@ -155,6 +155,36 @@ export const MarkdownConfigSchema = z
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const TtsProviderSchema = z.enum(["elevenlabs", "openai"]);
|
||||
export const TtsModeSchema = z.enum(["final", "all"]);
|
||||
export const TtsConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
mode: TtsModeSchema.optional(),
|
||||
provider: TtsProviderSchema.optional(),
|
||||
elevenlabs: z
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
voiceId: z.string().optional(),
|
||||
modelId: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
openai: z
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
voice: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
prefsPath: z.string().optional(),
|
||||
maxTextLength: z.number().int().min(1).optional(),
|
||||
timeoutMs: z.number().int().min(1000).max(120000).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const HumanDelaySchema = z
|
||||
.object({
|
||||
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
InboundDebounceSchema,
|
||||
NativeCommandsSettingSchema,
|
||||
QueueSchema,
|
||||
TtsConfigSchema,
|
||||
} from "./zod-schema.core.js";
|
||||
|
||||
const SessionResetConfigSchema = z
|
||||
@@ -90,6 +91,7 @@ export const MessagesSchema = z
|
||||
ackReaction: z.string().optional(),
|
||||
ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(),
|
||||
removeAckAfterReply: z.boolean().optional(),
|
||||
tts: TtsConfigSchema,
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
@@ -125,6 +125,43 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("appends current time after the cron header line", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = await writeSessionStore(home);
|
||||
const deps: CliDeps = {
|
||||
sendMessageWhatsApp: vi.fn(),
|
||||
sendMessageTelegram: vi.fn(),
|
||||
sendMessageDiscord: vi.fn(),
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendMessageIMessage: vi.fn(),
|
||||
};
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 5,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
|
||||
await runCronIsolatedAgentTurn({
|
||||
cfg: makeCfg(home, storePath),
|
||||
deps,
|
||||
job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }),
|
||||
message: "do it",
|
||||
sessionKey: "cron:job-1",
|
||||
lane: "cron",
|
||||
});
|
||||
|
||||
const call = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0] as {
|
||||
prompt?: string;
|
||||
};
|
||||
const lines = call?.prompt?.split("\n") ?? [];
|
||||
expect(lines[0]).toContain("[cron:job-1");
|
||||
expect(lines[0]).toContain("do it");
|
||||
expect(lines[1]).toMatch(/^Current time: .+ \(.+\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses agentId for workspace, session key, and store paths", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const deps: CliDeps = {
|
||||
|
||||
@@ -25,6 +25,11 @@ import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import { ensureAgentWorkspace } from "../../agents/workspace.js";
|
||||
import {
|
||||
formatUserTime,
|
||||
resolveUserTimeFormat,
|
||||
resolveUserTimezone,
|
||||
} from "../../agents/date-time.js";
|
||||
import {
|
||||
formatXHighModelHint,
|
||||
normalizeThinkLevel,
|
||||
@@ -226,7 +231,12 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
});
|
||||
|
||||
const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
|
||||
const commandBody = base;
|
||||
const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.cfg.agents?.defaults?.timeFormat);
|
||||
const formattedTime =
|
||||
formatUserTime(new Date(now), userTimezone, userTimeFormat) ?? new Date(now).toISOString();
|
||||
const timeLine = `Current time: ${formattedTime} (${userTimezone})`;
|
||||
const commandBody = `${base}\n${timeLine}`.trim();
|
||||
|
||||
const existingSnapshot = cronSession.sessionEntry.skillsSnapshot;
|
||||
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
|
||||
|
||||
@@ -8,6 +8,12 @@ const BASE_METHODS = [
|
||||
"status",
|
||||
"usage.status",
|
||||
"usage.cost",
|
||||
"tts.status",
|
||||
"tts.providers",
|
||||
"tts.enable",
|
||||
"tts.disable",
|
||||
"tts.convert",
|
||||
"tts.setProvider",
|
||||
"config.get",
|
||||
"config.set",
|
||||
"config.apply",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { sessionsHandlers } from "./server-methods/sessions.js";
|
||||
import { skillsHandlers } from "./server-methods/skills.js";
|
||||
import { systemHandlers } from "./server-methods/system.js";
|
||||
import { talkHandlers } from "./server-methods/talk.js";
|
||||
import { ttsHandlers } from "./server-methods/tts.js";
|
||||
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
|
||||
import { updateHandlers } from "./server-methods/update.js";
|
||||
import { usageHandlers } from "./server-methods/usage.js";
|
||||
@@ -53,6 +54,8 @@ const READ_METHODS = new Set([
|
||||
"status",
|
||||
"usage.status",
|
||||
"usage.cost",
|
||||
"tts.status",
|
||||
"tts.providers",
|
||||
"models.list",
|
||||
"agents.list",
|
||||
"agent.identity.get",
|
||||
@@ -75,6 +78,10 @@ const WRITE_METHODS = new Set([
|
||||
"agent.wait",
|
||||
"wake",
|
||||
"talk.mode",
|
||||
"tts.enable",
|
||||
"tts.disable",
|
||||
"tts.convert",
|
||||
"tts.setProvider",
|
||||
"voicewake.set",
|
||||
"node.invoke",
|
||||
"chat.send",
|
||||
@@ -151,6 +158,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...configHandlers,
|
||||
...wizardHandlers,
|
||||
...talkHandlers,
|
||||
...ttsHandlers,
|
||||
...skillsHandlers,
|
||||
...sessionsHandlers,
|
||||
...systemHandlers,
|
||||
|
||||
138
src/gateway/server-methods/tts.ts
Normal file
138
src/gateway/server-methods/tts.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
OPENAI_TTS_MODELS,
|
||||
OPENAI_TTS_VOICES,
|
||||
getTtsProvider,
|
||||
isTtsEnabled,
|
||||
resolveTtsApiKey,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
setTtsEnabled,
|
||||
setTtsProvider,
|
||||
textToSpeech,
|
||||
} from "../../tts/tts.js";
|
||||
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const ttsHandlers: GatewayRequestHandlers = {
|
||||
"tts.status": async ({ respond }) => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const config = resolveTtsConfig(cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
const provider = getTtsProvider(config, prefsPath);
|
||||
respond(true, {
|
||||
enabled: isTtsEnabled(config, prefsPath),
|
||||
provider,
|
||||
fallbackProvider: provider === "openai" ? "elevenlabs" : "openai",
|
||||
prefsPath,
|
||||
hasOpenAIKey: Boolean(resolveTtsApiKey(config, "openai")),
|
||||
hasElevenLabsKey: Boolean(resolveTtsApiKey(config, "elevenlabs")),
|
||||
});
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
}
|
||||
},
|
||||
"tts.enable": async ({ respond }) => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const config = resolveTtsConfig(cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
setTtsEnabled(prefsPath, true);
|
||||
respond(true, { enabled: true });
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
}
|
||||
},
|
||||
"tts.disable": async ({ respond }) => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const config = resolveTtsConfig(cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
setTtsEnabled(prefsPath, false);
|
||||
respond(true, { enabled: false });
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
}
|
||||
},
|
||||
"tts.convert": async ({ params, respond }) => {
|
||||
const text = typeof params.text === "string" ? params.text.trim() : "";
|
||||
if (!text) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "tts.convert requires text"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const channel = typeof params.channel === "string" ? params.channel.trim() : undefined;
|
||||
const result = await textToSpeech({ text, cfg, channel });
|
||||
if (result.success && result.audioPath) {
|
||||
respond(true, {
|
||||
audioPath: result.audioPath,
|
||||
provider: result.provider,
|
||||
outputFormat: result.outputFormat,
|
||||
voiceCompatible: result.voiceCompatible,
|
||||
});
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, result.error ?? "TTS conversion failed"),
|
||||
);
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
}
|
||||
},
|
||||
"tts.setProvider": async ({ params, respond }) => {
|
||||
const provider = typeof params.provider === "string" ? params.provider.trim() : "";
|
||||
if (provider !== "openai" && provider !== "elevenlabs") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "Invalid provider. Use openai or elevenlabs."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const config = resolveTtsConfig(cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
setTtsProvider(prefsPath, provider);
|
||||
respond(true, { provider });
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
}
|
||||
},
|
||||
"tts.providers": async ({ respond }) => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const config = resolveTtsConfig(cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
respond(true, {
|
||||
providers: [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
configured: Boolean(resolveTtsApiKey(config, "openai")),
|
||||
models: [...OPENAI_TTS_MODELS],
|
||||
voices: [...OPENAI_TTS_VOICES],
|
||||
},
|
||||
{
|
||||
id: "elevenlabs",
|
||||
name: "ElevenLabs",
|
||||
configured: Boolean(resolveTtsApiKey(config, "elevenlabs")),
|
||||
models: ["eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_monolingual_v1"],
|
||||
},
|
||||
],
|
||||
active: getTtsProvider(config, prefsPath),
|
||||
});
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
const merged = { ...base, ...overrides };
|
||||
|
||||
@@ -140,6 +140,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
|
||||
async function makeEnv() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-lock-"));
|
||||
@@ -22,6 +25,41 @@ async function makeEnv() {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLockPath(env: NodeJS.ProcessEnv) {
|
||||
const stateDir = resolveStateDir(env);
|
||||
const configPath = resolveConfigPath(env, stateDir);
|
||||
const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);
|
||||
return { lockPath: path.join(stateDir, `gateway.${hash}.lock`), configPath };
|
||||
}
|
||||
|
||||
function makeProcStat(pid: number, startTime: number) {
|
||||
const fields = [
|
||||
"R",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
"1",
|
||||
String(startTime),
|
||||
"1",
|
||||
"1",
|
||||
];
|
||||
return `${pid} (node) ${fields.join(" ")}`;
|
||||
}
|
||||
|
||||
describe("gateway lock", () => {
|
||||
it("blocks concurrent acquisition until release", async () => {
|
||||
const { env, cleanup } = await makeEnv();
|
||||
@@ -52,4 +90,98 @@ describe("gateway lock", () => {
|
||||
await lock2?.release();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("treats recycled linux pid as stale when start time mismatches", async () => {
|
||||
const { env, cleanup } = await makeEnv();
|
||||
const { lockPath, configPath } = resolveLockPath(env);
|
||||
const payload = {
|
||||
pid: process.pid,
|
||||
createdAt: new Date().toISOString(),
|
||||
configPath,
|
||||
startTime: 111,
|
||||
};
|
||||
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
|
||||
|
||||
const readFileSync = fsSync.readFileSync;
|
||||
const statValue = makeProcStat(process.pid, 222);
|
||||
const spy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
|
||||
if (filePath === `/proc/${process.pid}/stat`) {
|
||||
return statValue;
|
||||
}
|
||||
return readFileSync(filePath as never, encoding as never) as never;
|
||||
});
|
||||
|
||||
const lock = await acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 20,
|
||||
platform: "linux",
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
|
||||
await lock?.release();
|
||||
spy.mockRestore();
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("keeps lock on linux when proc access fails unless stale", async () => {
|
||||
const { env, cleanup } = await makeEnv();
|
||||
const { lockPath, configPath } = resolveLockPath(env);
|
||||
const payload = {
|
||||
pid: process.pid,
|
||||
createdAt: new Date().toISOString(),
|
||||
configPath,
|
||||
startTime: 111,
|
||||
};
|
||||
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
|
||||
|
||||
const readFileSync = fsSync.readFileSync;
|
||||
const spy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
|
||||
if (filePath === `/proc/${process.pid}/stat`) {
|
||||
throw new Error("EACCES");
|
||||
}
|
||||
return readFileSync(filePath as never, encoding as never) as never;
|
||||
});
|
||||
|
||||
await expect(
|
||||
acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 120,
|
||||
pollIntervalMs: 20,
|
||||
staleMs: 10_000,
|
||||
platform: "linux",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(GatewayLockError);
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
const stalePayload = {
|
||||
...payload,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
};
|
||||
await fs.writeFile(lockPath, JSON.stringify(stalePayload), "utf8");
|
||||
|
||||
const staleSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
|
||||
if (filePath === `/proc/${process.pid}/stat`) {
|
||||
throw new Error("EACCES");
|
||||
}
|
||||
return readFileSync(filePath as never, encoding as never) as never;
|
||||
});
|
||||
|
||||
const lock = await acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 20,
|
||||
staleMs: 1,
|
||||
platform: "linux",
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
|
||||
await lock?.release();
|
||||
staleSpy.mockRestore();
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
@@ -12,6 +13,7 @@ type LockPayload = {
|
||||
pid: number;
|
||||
createdAt: string;
|
||||
configPath: string;
|
||||
startTime?: number;
|
||||
};
|
||||
|
||||
export type GatewayLockHandle = {
|
||||
@@ -26,6 +28,7 @@ export type GatewayLockOptions = {
|
||||
pollIntervalMs?: number;
|
||||
staleMs?: number;
|
||||
allowInTests?: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
|
||||
export class GatewayLockError extends Error {
|
||||
@@ -38,6 +41,8 @@ export class GatewayLockError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type LockOwnerStatus = "alive" | "dead" | "unknown";
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||
try {
|
||||
@@ -48,6 +53,80 @@ function isAlive(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProcArg(arg: string): string {
|
||||
return arg.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
|
||||
function parseProcCmdline(raw: string): string[] {
|
||||
return raw
|
||||
.split("\0")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isGatewayArgv(args: string[]): boolean {
|
||||
const normalized = args.map(normalizeProcArg);
|
||||
if (!normalized.includes("gateway")) return false;
|
||||
|
||||
const entryCandidates = [
|
||||
"dist/index.js",
|
||||
"dist/index.mjs",
|
||||
"dist/entry.js",
|
||||
"dist/entry.mjs",
|
||||
"scripts/run-node.mjs",
|
||||
"src/index.ts",
|
||||
];
|
||||
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exe = normalized[0] ?? "";
|
||||
return exe.endsWith("/clawdbot") || exe === "clawdbot";
|
||||
}
|
||||
|
||||
function readLinuxCmdline(pid: number): string[] | null {
|
||||
try {
|
||||
const raw = fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
||||
return parseProcCmdline(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readLinuxStartTime(pid: number): number | null {
|
||||
try {
|
||||
const raw = fsSync.readFileSync(`/proc/${pid}/stat`, "utf8").trim();
|
||||
const closeParen = raw.lastIndexOf(")");
|
||||
if (closeParen < 0) return null;
|
||||
const rest = raw.slice(closeParen + 1).trim();
|
||||
const fields = rest.split(/\s+/);
|
||||
const startTime = Number.parseInt(fields[19] ?? "", 10);
|
||||
return Number.isFinite(startTime) ? startTime : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayOwnerStatus(
|
||||
pid: number,
|
||||
payload: LockPayload | null,
|
||||
platform: NodeJS.Platform,
|
||||
): LockOwnerStatus {
|
||||
if (!isAlive(pid)) return "dead";
|
||||
if (platform !== "linux") return "alive";
|
||||
|
||||
const payloadStartTime = payload?.startTime;
|
||||
if (Number.isFinite(payloadStartTime)) {
|
||||
const currentStartTime = readLinuxStartTime(pid);
|
||||
if (currentStartTime == null) return "unknown";
|
||||
return currentStartTime === payloadStartTime ? "alive" : "dead";
|
||||
}
|
||||
|
||||
const args = readLinuxCmdline(pid);
|
||||
if (!args) return "unknown";
|
||||
return isGatewayArgv(args) ? "alive" : "dead";
|
||||
}
|
||||
|
||||
async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
@@ -55,10 +134,12 @@ async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
|
||||
if (typeof parsed.pid !== "number") return null;
|
||||
if (typeof parsed.createdAt !== "string") return null;
|
||||
if (typeof parsed.configPath !== "string") return null;
|
||||
const startTime = typeof parsed.startTime === "number" ? parsed.startTime : undefined;
|
||||
return {
|
||||
pid: parsed.pid,
|
||||
createdAt: parsed.createdAt,
|
||||
configPath: parsed.configPath,
|
||||
startTime,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -88,6 +169,7 @@ export async function acquireGatewayLock(
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const { lockPath, configPath } = resolveGatewayLockPath(env);
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
|
||||
@@ -97,11 +179,15 @@ export async function acquireGatewayLock(
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const handle = await fs.open(lockPath, "wx");
|
||||
const startTime = platform === "linux" ? readLinuxStartTime(process.pid) : null;
|
||||
const payload: LockPayload = {
|
||||
pid: process.pid,
|
||||
createdAt: new Date().toISOString(),
|
||||
configPath,
|
||||
};
|
||||
if (typeof startTime === "number" && Number.isFinite(startTime)) {
|
||||
payload.startTime = startTime;
|
||||
}
|
||||
await handle.writeFile(JSON.stringify(payload), "utf8");
|
||||
return {
|
||||
lockPath,
|
||||
@@ -119,12 +205,14 @@ export async function acquireGatewayLock(
|
||||
|
||||
lastPayload = await readLockPayload(lockPath);
|
||||
const ownerPid = lastPayload?.pid;
|
||||
const ownerAlive = ownerPid ? isAlive(ownerPid) : false;
|
||||
if (!ownerAlive && ownerPid) {
|
||||
const ownerStatus = ownerPid
|
||||
? resolveGatewayOwnerStatus(ownerPid, lastPayload, platform)
|
||||
: "unknown";
|
||||
if (ownerStatus === "dead" && ownerPid) {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
continue;
|
||||
}
|
||||
if (!ownerAlive) {
|
||||
if (ownerStatus !== "alive") {
|
||||
let stale = false;
|
||||
if (lastPayload?.createdAt) {
|
||||
const createdAt = Date.parse(lastPayload.createdAt);
|
||||
|
||||
290
src/plugins/commands.ts
Normal file
290
src/plugins/commands.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Plugin Command Registry
|
||||
*
|
||||
* Manages commands registered by plugins that bypass the LLM agent.
|
||||
* These commands are processed before built-in commands and before agent invocation.
|
||||
*/
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
|
||||
type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
// Registry of plugin commands
|
||||
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
|
||||
|
||||
// Lock to prevent modifications during command execution
|
||||
let registryLocked = false;
|
||||
|
||||
// Maximum allowed length for command arguments (defense in depth)
|
||||
const MAX_ARGS_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* Reserved command names that plugins cannot override.
|
||||
* These are built-in commands from commands-registry.data.ts.
|
||||
*/
|
||||
const RESERVED_COMMANDS = new Set([
|
||||
// Core commands
|
||||
"help",
|
||||
"commands",
|
||||
"status",
|
||||
"whoami",
|
||||
"context",
|
||||
// Session management
|
||||
"stop",
|
||||
"restart",
|
||||
"reset",
|
||||
"new",
|
||||
"compact",
|
||||
// Configuration
|
||||
"config",
|
||||
"debug",
|
||||
"allowlist",
|
||||
"activation",
|
||||
// Agent control
|
||||
"skill",
|
||||
"subagents",
|
||||
"model",
|
||||
"models",
|
||||
"queue",
|
||||
// Messaging
|
||||
"send",
|
||||
// Execution
|
||||
"bash",
|
||||
"exec",
|
||||
// Mode toggles
|
||||
"think",
|
||||
"verbose",
|
||||
"reasoning",
|
||||
"elevated",
|
||||
// Billing
|
||||
"usage",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate a command name.
|
||||
* Returns an error message if invalid, or null if valid.
|
||||
*/
|
||||
export function validateCommandName(name: string): string | null {
|
||||
const trimmed = name.trim().toLowerCase();
|
||||
|
||||
if (!trimmed) {
|
||||
return "Command name cannot be empty";
|
||||
}
|
||||
|
||||
// Must start with a letter, contain only letters, numbers, hyphens, underscores
|
||||
// Note: trimmed is already lowercased, so no need for /i flag
|
||||
if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) {
|
||||
return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores";
|
||||
}
|
||||
|
||||
// Check reserved commands
|
||||
if (RESERVED_COMMANDS.has(trimmed)) {
|
||||
return `Command name "${trimmed}" is reserved by a built-in command`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type CommandRegistrationResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a plugin command.
|
||||
* Returns an error if the command name is invalid or reserved.
|
||||
*/
|
||||
export function registerPluginCommand(
|
||||
pluginId: string,
|
||||
command: ClawdbotPluginCommandDefinition,
|
||||
): CommandRegistrationResult {
|
||||
// Prevent registration while commands are being processed
|
||||
if (registryLocked) {
|
||||
return { ok: false, error: "Cannot register commands while processing is in progress" };
|
||||
}
|
||||
|
||||
// Validate handler is a function
|
||||
if (typeof command.handler !== "function") {
|
||||
return { ok: false, error: "Command handler must be a function" };
|
||||
}
|
||||
|
||||
const validationError = validateCommandName(command.name);
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError };
|
||||
}
|
||||
|
||||
const key = `/${command.name.toLowerCase()}`;
|
||||
|
||||
// Check for duplicate registration
|
||||
if (pluginCommands.has(key)) {
|
||||
const existing = pluginCommands.get(key)!;
|
||||
return {
|
||||
ok: false,
|
||||
error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"`,
|
||||
};
|
||||
}
|
||||
|
||||
pluginCommands.set(key, { ...command, pluginId });
|
||||
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered plugin commands.
|
||||
* Called during plugin reload.
|
||||
*/
|
||||
export function clearPluginCommands(): void {
|
||||
pluginCommands.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear plugin commands for a specific plugin.
|
||||
*/
|
||||
export function clearPluginCommandsForPlugin(pluginId: string): void {
|
||||
for (const [key, cmd] of pluginCommands.entries()) {
|
||||
if (cmd.pluginId === pluginId) {
|
||||
pluginCommands.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command body matches a registered plugin command.
|
||||
* Returns the command definition and parsed args if matched.
|
||||
*
|
||||
* Note: If a command has `acceptsArgs: false` and the user provides arguments,
|
||||
* the command will not match. This allows the message to fall through to
|
||||
* built-in handlers or the agent. Document this behavior to plugin authors.
|
||||
*/
|
||||
export function matchPluginCommand(
|
||||
commandBody: string,
|
||||
): { command: RegisteredPluginCommand; args?: string } | null {
|
||||
const trimmed = commandBody.trim();
|
||||
if (!trimmed.startsWith("/")) return null;
|
||||
|
||||
// Extract command name and args
|
||||
const spaceIndex = trimmed.indexOf(" ");
|
||||
const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
|
||||
const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim();
|
||||
|
||||
const key = commandName.toLowerCase();
|
||||
const command = pluginCommands.get(key);
|
||||
|
||||
if (!command) return null;
|
||||
|
||||
// If command doesn't accept args but args were provided, don't match
|
||||
if (args && !command.acceptsArgs) return null;
|
||||
|
||||
return { command, args: args || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize command arguments to prevent injection attacks.
|
||||
* Removes control characters and enforces length limits.
|
||||
*/
|
||||
function sanitizeArgs(args: string | undefined): string | undefined {
|
||||
if (!args) return undefined;
|
||||
|
||||
// Enforce length limit
|
||||
if (args.length > MAX_ARGS_LENGTH) {
|
||||
return args.slice(0, MAX_ARGS_LENGTH);
|
||||
}
|
||||
|
||||
// Remove control characters (except newlines and tabs which may be intentional)
|
||||
let sanitized = "";
|
||||
for (const char of args) {
|
||||
const code = char.charCodeAt(0);
|
||||
const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f;
|
||||
if (!isControl) sanitized += char;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a plugin command handler.
|
||||
*
|
||||
* Note: Plugin authors should still validate and sanitize ctx.args for their
|
||||
* specific use case. This function provides basic defense-in-depth sanitization.
|
||||
*/
|
||||
export async function executePluginCommand(params: {
|
||||
command: RegisteredPluginCommand;
|
||||
args?: string;
|
||||
senderId?: string;
|
||||
channel: string;
|
||||
isAuthorizedSender: boolean;
|
||||
commandBody: string;
|
||||
config: ClawdbotConfig;
|
||||
}): Promise<{ text: string }> {
|
||||
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
|
||||
|
||||
// Check authorization
|
||||
const requireAuth = command.requireAuth !== false; // Default to true
|
||||
if (requireAuth && !isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Plugin command /${command.name} blocked: unauthorized sender ${senderId || "<unknown>"}`,
|
||||
);
|
||||
return { text: "⚠️ This command requires authorization." };
|
||||
}
|
||||
|
||||
// Sanitize args before passing to handler
|
||||
const sanitizedArgs = sanitizeArgs(args);
|
||||
|
||||
const ctx: PluginCommandContext = {
|
||||
senderId,
|
||||
channel,
|
||||
isAuthorizedSender,
|
||||
args: sanitizedArgs,
|
||||
commandBody,
|
||||
config,
|
||||
};
|
||||
|
||||
// Lock registry during execution to prevent concurrent modifications
|
||||
registryLocked = true;
|
||||
try {
|
||||
const result = await command.handler(ctx);
|
||||
logVerbose(
|
||||
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
|
||||
);
|
||||
return { text: result.text };
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logVerbose(`Plugin command /${command.name} error: ${error.message}`);
|
||||
// Don't leak internal error details - return a safe generic message
|
||||
return { text: "⚠️ Command failed. Please try again later." };
|
||||
} finally {
|
||||
registryLocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all registered plugin commands.
|
||||
* Used for /help and /commands output.
|
||||
*/
|
||||
export function listPluginCommands(): Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
pluginId: string;
|
||||
}> {
|
||||
return Array.from(pluginCommands.values()).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
pluginId: cmd.pluginId,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin command specs for native command registration (e.g., Telegram).
|
||||
*/
|
||||
export function getPluginCommandSpecs(): Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
}> {
|
||||
return Array.from(pluginCommands.values()).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type NormalizedPluginsConfig,
|
||||
} from "./config-state.js";
|
||||
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
||||
import { clearPluginCommands } from "./commands.js";
|
||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||
import { createPluginRuntime } from "./runtime/index.js";
|
||||
import { setActivePluginRegistry } from "./runtime.js";
|
||||
@@ -147,6 +148,7 @@ function createPluginRecord(params: {
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpHandlers: 0,
|
||||
hookCount: 0,
|
||||
configSchema: params.configSchema,
|
||||
@@ -177,6 +179,9 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
}
|
||||
|
||||
// Clear previously registered plugin commands before reloading
|
||||
clearPluginCommands();
|
||||
|
||||
const runtime = createPluginRuntime();
|
||||
const { registry, createApi } = createPluginRegistry({
|
||||
logger,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ClawdbotPluginApi,
|
||||
ClawdbotPluginChannelRegistration,
|
||||
ClawdbotPluginCliRegistrar,
|
||||
ClawdbotPluginCommandDefinition,
|
||||
ClawdbotPluginHttpHandler,
|
||||
ClawdbotPluginHookOptions,
|
||||
ProviderPlugin,
|
||||
@@ -26,6 +27,7 @@ import type {
|
||||
PluginHookHandlerMap,
|
||||
PluginHookRegistration as TypedPluginHookRegistration,
|
||||
} from "./types.js";
|
||||
import { registerPluginCommand } from "./commands.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import path from "node:path";
|
||||
@@ -77,6 +79,12 @@ export type PluginServiceRegistration = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginCommandRegistration = {
|
||||
pluginId: string;
|
||||
command: ClawdbotPluginCommandDefinition;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -96,6 +104,7 @@ export type PluginRecord = {
|
||||
gatewayMethods: string[];
|
||||
cliCommands: string[];
|
||||
services: string[];
|
||||
commands: string[];
|
||||
httpHandlers: number;
|
||||
hookCount: number;
|
||||
configSchema: boolean;
|
||||
@@ -114,6 +123,7 @@ export type PluginRegistry = {
|
||||
httpHandlers: PluginHttpRegistration[];
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
commands: PluginCommandRegistration[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
@@ -135,6 +145,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
|
||||
@@ -352,6 +363,38 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
});
|
||||
};
|
||||
|
||||
const registerCommand = (record: PluginRecord, command: ClawdbotPluginCommandDefinition) => {
|
||||
const name = command.name.trim();
|
||||
if (!name) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: "command registration missing name",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Register with the plugin command system (validates name and checks for duplicates)
|
||||
const result = registerPluginCommand(record.id, command);
|
||||
if (!result.ok) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `command registration failed: ${result.error}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
record.commands.push(name);
|
||||
registry.commands.push({
|
||||
pluginId: record.id,
|
||||
command,
|
||||
source: record.source,
|
||||
});
|
||||
};
|
||||
|
||||
const registerTypedHook = <K extends PluginHookName>(
|
||||
record: PluginRecord,
|
||||
hookName: K,
|
||||
@@ -401,6 +444,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerService: (service) => registerService(record, service),
|
||||
registerCommand: (command) => registerCommand(record, command),
|
||||
resolvePath: (input: string) => resolveUserPath(input),
|
||||
on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts),
|
||||
};
|
||||
@@ -416,6 +460,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerGatewayMethod,
|
||||
registerCli,
|
||||
registerService,
|
||||
registerCommand,
|
||||
registerHook,
|
||||
registerTypedHook,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,59 @@ export type ClawdbotPluginGatewayMethod = {
|
||||
handler: GatewayRequestHandler;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Commands
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Context passed to plugin command handlers.
|
||||
*/
|
||||
export type PluginCommandContext = {
|
||||
/** The sender's identifier (e.g., Telegram user ID) */
|
||||
senderId?: string;
|
||||
/** The channel/surface (e.g., "telegram", "discord") */
|
||||
channel: string;
|
||||
/** Whether the sender is on the allowlist */
|
||||
isAuthorizedSender: boolean;
|
||||
/** Raw command arguments after the command name */
|
||||
args?: string;
|
||||
/** The full normalized command body */
|
||||
commandBody: string;
|
||||
/** Current clawdbot configuration */
|
||||
config: ClawdbotConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result returned by a plugin command handler.
|
||||
*/
|
||||
export type PluginCommandResult = {
|
||||
/** Text response to send back to the user */
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler function for plugin commands.
|
||||
*/
|
||||
export type PluginCommandHandler = (
|
||||
ctx: PluginCommandContext,
|
||||
) => PluginCommandResult | Promise<PluginCommandResult>;
|
||||
|
||||
/**
|
||||
* Definition for a plugin-registered command.
|
||||
*/
|
||||
export type ClawdbotPluginCommandDefinition = {
|
||||
/** Command name without leading slash (e.g., "tts_on") */
|
||||
name: string;
|
||||
/** Description shown in /help and command menus */
|
||||
description: string;
|
||||
/** Whether this command accepts arguments */
|
||||
acceptsArgs?: boolean;
|
||||
/** Whether only authorized senders can use this command (default: true) */
|
||||
requireAuth?: boolean;
|
||||
/** The handler function */
|
||||
handler: PluginCommandHandler;
|
||||
};
|
||||
|
||||
export type ClawdbotPluginHttpHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -201,6 +254,12 @@ export type ClawdbotPluginApi = {
|
||||
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||
registerService: (service: ClawdbotPluginService) => void;
|
||||
registerProvider: (provider: ProviderPlugin) => void;
|
||||
/**
|
||||
* Register a custom command that bypasses the LLM agent.
|
||||
* Plugin commands are processed before built-in commands and before agent invocation.
|
||||
* Use this for simple state-toggling or status commands that don't need AI reasoning.
|
||||
*/
|
||||
registerCommand: (command: ClawdbotPluginCommandDefinition) => void;
|
||||
resolvePath: (input: string) => string;
|
||||
/** Register a lifecycle hook handler */
|
||||
on: <K extends PluginHookName>(
|
||||
|
||||
@@ -60,3 +60,61 @@ describe("resolveSlackSystemEventSessionKey", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isChannelAllowed with groupPolicy and channelsConfig", () => {
|
||||
it("allows unlisted channels when groupPolicy is open even with channelsConfig entries", () => {
|
||||
// Bug fix: when groupPolicy="open" and channels has some entries,
|
||||
// unlisted channels should still be allowed (not blocked)
|
||||
const ctx = createSlackMonitorContext({
|
||||
...baseParams(),
|
||||
groupPolicy: "open",
|
||||
channelsConfig: {
|
||||
C_LISTED: { requireMention: true },
|
||||
},
|
||||
});
|
||||
// Listed channel should be allowed
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true);
|
||||
// Unlisted channel should ALSO be allowed when policy is "open"
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks unlisted channels when groupPolicy is allowlist", () => {
|
||||
const ctx = createSlackMonitorContext({
|
||||
...baseParams(),
|
||||
groupPolicy: "allowlist",
|
||||
channelsConfig: {
|
||||
C_LISTED: { requireMention: true },
|
||||
},
|
||||
});
|
||||
// Listed channel should be allowed
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true);
|
||||
// Unlisted channel should be blocked when policy is "allowlist"
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks explicitly denied channels even when groupPolicy is open", () => {
|
||||
const ctx = createSlackMonitorContext({
|
||||
...baseParams(),
|
||||
groupPolicy: "open",
|
||||
channelsConfig: {
|
||||
C_ALLOWED: { allow: true },
|
||||
C_DENIED: { allow: false },
|
||||
},
|
||||
});
|
||||
// Explicitly allowed channel
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_ALLOWED", channelType: "channel" })).toBe(true);
|
||||
// Explicitly denied channel should be blocked even with open policy
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_DENIED", channelType: "channel" })).toBe(false);
|
||||
// Unlisted channel should be allowed with open policy
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true);
|
||||
});
|
||||
|
||||
it("allows all channels when groupPolicy is open and channelsConfig is empty", () => {
|
||||
const ctx = createSlackMonitorContext({
|
||||
...baseParams(),
|
||||
groupPolicy: "open",
|
||||
channelsConfig: undefined,
|
||||
});
|
||||
expect(ctx.isChannelAllowed({ channelId: "C_ANY", channelType: "channel" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -327,7 +327,11 @@ export function createSlackMonitorContext(params: {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!channelAllowed) {
|
||||
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
|
||||
// (i.e., have a matching config entry with allow:false). Channels not in the
|
||||
// config (matchSource undefined) should be allowed under open policy.
|
||||
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
|
||||
if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) {
|
||||
logVerbose(`slack: drop channel ${p.channelId} (${channelMatchMeta})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
186
src/slack/monitor/slash.policy.test.ts
Normal file
186
src/slack/monitor/slash.policy.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { registerSlackMonitorSlashCommands } from "./slash.js";
|
||||
|
||||
const dispatchMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const upsertPairingRequestMock = vi.fn();
|
||||
const resolveAgentRouteMock = vi.fn();
|
||||
|
||||
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../routing/resolve-route.js", () => ({
|
||||
resolveAgentRoute: (...args: unknown[]) => resolveAgentRouteMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/identity.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/identity.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveEffectiveMessagesConfig: () => ({ responsePrefix: "" }),
|
||||
};
|
||||
});
|
||||
|
||||
function createHarness(overrides?: {
|
||||
groupPolicy?: "open" | "allowlist";
|
||||
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
}) {
|
||||
const commands = new Map<unknown, (args: unknown) => Promise<void>>();
|
||||
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
|
||||
const app = {
|
||||
client: { chat: { postEphemeral } },
|
||||
command: (name: unknown, handler: (args: unknown) => Promise<void>) => {
|
||||
commands.set(name, handler);
|
||||
},
|
||||
};
|
||||
|
||||
const channelId = overrides?.channelId ?? "C_UNLISTED";
|
||||
const channelName = overrides?.channelName ?? "unlisted";
|
||||
|
||||
const ctx = {
|
||||
cfg: { commands: { native: false } },
|
||||
runtime: {},
|
||||
botToken: "bot-token",
|
||||
botUserId: "bot",
|
||||
teamId: "T1",
|
||||
allowFrom: ["*"],
|
||||
dmEnabled: true,
|
||||
dmPolicy: "open",
|
||||
groupDmEnabled: false,
|
||||
groupDmChannels: [],
|
||||
defaultRequireMention: true,
|
||||
groupPolicy: overrides?.groupPolicy ?? "open",
|
||||
useAccessGroups: true,
|
||||
channelsConfig: overrides?.channelsConfig,
|
||||
slashCommand: { enabled: true, name: "clawd", ephemeral: true, sessionPrefix: "slack:slash" },
|
||||
textLimit: 4000,
|
||||
app,
|
||||
isChannelAllowed: () => true,
|
||||
resolveChannelName: async () => ({ name: channelName, type: "channel" }),
|
||||
resolveUserName: async () => ({ name: "Ada" }),
|
||||
} as unknown;
|
||||
|
||||
const account = { accountId: "acct", config: { commands: { native: false } } } as unknown;
|
||||
|
||||
return { commands, ctx, account, postEphemeral, channelId, channelName };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dispatchMock.mockReset().mockResolvedValue({ counts: { final: 1, tool: 0, block: 0 } });
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
resolveAgentRouteMock.mockReset().mockReturnValue({
|
||||
agentId: "main",
|
||||
sessionKey: "session:1",
|
||||
accountId: "acct",
|
||||
});
|
||||
});
|
||||
|
||||
describe("slack slash commands channel policy", () => {
|
||||
it("allows unlisted channels when groupPolicy is open", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||
groupPolicy: "open",
|
||||
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||
channelId: "C_UNLISTED",
|
||||
channelName: "unlisted",
|
||||
});
|
||||
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||
|
||||
const handler = [...commands.values()][0];
|
||||
if (!handler) throw new Error("Missing slash handler");
|
||||
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler({
|
||||
command: {
|
||||
user_id: "U1",
|
||||
user_name: "Ada",
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
text: "hello",
|
||||
trigger_id: "t1",
|
||||
},
|
||||
ack: vi.fn().mockResolvedValue(undefined),
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
expect(respond).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "This channel is not allowed." }),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks explicitly denied channels when groupPolicy is open", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||
groupPolicy: "open",
|
||||
channelsConfig: { C_DENIED: { allow: false } },
|
||||
channelId: "C_DENIED",
|
||||
channelName: "denied",
|
||||
});
|
||||
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||
|
||||
const handler = [...commands.values()][0];
|
||||
if (!handler) throw new Error("Missing slash handler");
|
||||
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler({
|
||||
command: {
|
||||
user_id: "U1",
|
||||
user_name: "Ada",
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
text: "hello",
|
||||
trigger_id: "t1",
|
||||
},
|
||||
ack: vi.fn().mockResolvedValue(undefined),
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
|
||||
const { commands, ctx, account, channelId, channelName } = createHarness({
|
||||
groupPolicy: "allowlist",
|
||||
channelsConfig: { C_LISTED: { requireMention: true } },
|
||||
channelId: "C_UNLISTED",
|
||||
channelName: "unlisted",
|
||||
});
|
||||
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||
|
||||
const handler = [...commands.values()][0];
|
||||
if (!handler) throw new Error("Missing slash handler");
|
||||
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler({
|
||||
command: {
|
||||
user_id: "U1",
|
||||
user_name: "Ada",
|
||||
channel_id: channelId,
|
||||
channel_name: channelName,
|
||||
text: "hello",
|
||||
trigger_id: "t1",
|
||||
},
|
||||
ack: vi.fn().mockResolvedValue(undefined),
|
||||
respond,
|
||||
});
|
||||
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -262,8 +262,7 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
groupPolicy: ctx.groupPolicy,
|
||||
channelAllowlistConfigured,
|
||||
channelAllowed,
|
||||
}) ||
|
||||
!channelAllowed
|
||||
})
|
||||
) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
@@ -271,13 +270,17 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ctx.useAccessGroups && channelConfig?.allowed === false) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
|
||||
// (i.e., have a matching config entry with allow:false). Channels not in the
|
||||
// config (matchSource undefined) should be allowed under open policy.
|
||||
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
|
||||
if (!channelAllowed && (ctx.groupPolicy !== "open" || hasExplicitConfig)) {
|
||||
await respond({
|
||||
text: "This channel is not allowed.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
234
src/tts/tts.test.ts
Normal file
234
src/tts/tts.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { _test } from "./tts.js";
|
||||
|
||||
const {
|
||||
isValidVoiceId,
|
||||
isValidOpenAIVoice,
|
||||
isValidOpenAIModel,
|
||||
OPENAI_TTS_MODELS,
|
||||
OPENAI_TTS_VOICES,
|
||||
summarizeText,
|
||||
resolveOutputFormat,
|
||||
} = _test;
|
||||
|
||||
describe("tts", () => {
|
||||
describe("isValidVoiceId", () => {
|
||||
it("accepts valid ElevenLabs voice IDs", () => {
|
||||
expect(isValidVoiceId("pMsXgVXv3BLzUgSXRplE")).toBe(true);
|
||||
expect(isValidVoiceId("21m00Tcm4TlvDq8ikWAM")).toBe(true);
|
||||
expect(isValidVoiceId("EXAVITQu4vr4xnSDxMaL")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts voice IDs of varying valid lengths", () => {
|
||||
expect(isValidVoiceId("a1b2c3d4e5")).toBe(true);
|
||||
expect(isValidVoiceId("a".repeat(40))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects too short voice IDs", () => {
|
||||
expect(isValidVoiceId("")).toBe(false);
|
||||
expect(isValidVoiceId("abc")).toBe(false);
|
||||
expect(isValidVoiceId("123456789")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects too long voice IDs", () => {
|
||||
expect(isValidVoiceId("a".repeat(41))).toBe(false);
|
||||
expect(isValidVoiceId("a".repeat(100))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects voice IDs with invalid characters", () => {
|
||||
expect(isValidVoiceId("pMsXgVXv3BLz-gSXRplE")).toBe(false);
|
||||
expect(isValidVoiceId("pMsXgVXv3BLz_gSXRplE")).toBe(false);
|
||||
expect(isValidVoiceId("pMsXgVXv3BLz gSXRplE")).toBe(false);
|
||||
expect(isValidVoiceId("../../../etc/passwd")).toBe(false);
|
||||
expect(isValidVoiceId("voice?param=value")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidOpenAIVoice", () => {
|
||||
it("accepts all valid OpenAI voices", () => {
|
||||
for (const voice of OPENAI_TTS_VOICES) {
|
||||
expect(isValidOpenAIVoice(voice)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid voice names", () => {
|
||||
expect(isValidOpenAIVoice("invalid")).toBe(false);
|
||||
expect(isValidOpenAIVoice("")).toBe(false);
|
||||
expect(isValidOpenAIVoice("ALLOY")).toBe(false);
|
||||
expect(isValidOpenAIVoice("alloy ")).toBe(false);
|
||||
expect(isValidOpenAIVoice(" alloy")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidOpenAIModel", () => {
|
||||
it("accepts gpt-4o-mini-tts model", () => {
|
||||
expect(isValidOpenAIModel("gpt-4o-mini-tts")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other models", () => {
|
||||
expect(isValidOpenAIModel("tts-1")).toBe(false);
|
||||
expect(isValidOpenAIModel("tts-1-hd")).toBe(false);
|
||||
expect(isValidOpenAIModel("invalid")).toBe(false);
|
||||
expect(isValidOpenAIModel("")).toBe(false);
|
||||
expect(isValidOpenAIModel("gpt-4")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OPENAI_TTS_MODELS", () => {
|
||||
it("contains only gpt-4o-mini-tts", () => {
|
||||
expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts");
|
||||
expect(OPENAI_TTS_MODELS).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is a non-empty array", () => {
|
||||
expect(Array.isArray(OPENAI_TTS_MODELS)).toBe(true);
|
||||
expect(OPENAI_TTS_MODELS.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOutputFormat", () => {
|
||||
it("uses Opus for Telegram", () => {
|
||||
const output = resolveOutputFormat("telegram");
|
||||
expect(output.openai).toBe("opus");
|
||||
expect(output.elevenlabs).toBe("opus_48000_64");
|
||||
expect(output.extension).toBe(".opus");
|
||||
expect(output.voiceCompatible).toBe(true);
|
||||
});
|
||||
|
||||
it("uses MP3 for other channels", () => {
|
||||
const output = resolveOutputFormat("discord");
|
||||
expect(output.openai).toBe("mp3");
|
||||
expect(output.elevenlabs).toBe("mp3_44100_128");
|
||||
expect(output.extension).toBe(".mp3");
|
||||
expect(output.voiceCompatible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeText", () => {
|
||||
const mockApiKey = "test-api-key";
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("summarizes text and returns result with metrics", async () => {
|
||||
const mockSummary = "This is a summarized version of the text.";
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: mockSummary } }],
|
||||
}),
|
||||
});
|
||||
|
||||
const longText = "A".repeat(2000);
|
||||
const result = await summarizeText(longText, 1500, mockApiKey, 30_000);
|
||||
|
||||
expect(result.summary).toBe(mockSummary);
|
||||
expect(result.inputLength).toBe(2000);
|
||||
expect(result.outputLength).toBe(mockSummary.length);
|
||||
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls OpenAI API with correct parameters", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "Summary" } }],
|
||||
}),
|
||||
});
|
||||
|
||||
await summarizeText("Long text to summarize", 500, mockApiKey, 30_000);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${mockApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const callArgs = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(callArgs[1].body);
|
||||
expect(body.model).toBe("gpt-4o-mini");
|
||||
expect(body.temperature).toBe(0.3);
|
||||
expect(body.max_tokens).toBe(250);
|
||||
});
|
||||
|
||||
it("rejects targetLength below minimum (100)", async () => {
|
||||
await expect(summarizeText("text", 99, mockApiKey, 30_000)).rejects.toThrow(
|
||||
"Invalid targetLength: 99",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects targetLength above maximum (10000)", async () => {
|
||||
await expect(summarizeText("text", 10001, mockApiKey, 30_000)).rejects.toThrow(
|
||||
"Invalid targetLength: 10001",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts targetLength at boundaries", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "Summary" } }],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(summarizeText("text", 100, mockApiKey, 30_000)).resolves.toBeDefined();
|
||||
await expect(summarizeText("text", 10000, mockApiKey, 30_000)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("throws error when API returns non-ok response", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
});
|
||||
|
||||
await expect(summarizeText("text", 500, mockApiKey, 30_000)).rejects.toThrow(
|
||||
"Summarization service unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws error when no summary is returned", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(summarizeText("text", 500, mockApiKey, 30_000)).rejects.toThrow(
|
||||
"No summary returned",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws error when summary content is empty", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: " " } }],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(summarizeText("text", 500, mockApiKey, 30_000)).rejects.toThrow(
|
||||
"No summary returned",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
630
src/tts/tts.ts
Normal file
630
src/tts/tts.ts
Normal file
@@ -0,0 +1,630 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { TtsConfig, TtsMode, TtsProvider } from "../config/types.tts.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_TTS_MAX_LENGTH = 1500;
|
||||
const DEFAULT_TTS_SUMMARIZE = true;
|
||||
const DEFAULT_MAX_TEXT_LENGTH = 4000;
|
||||
const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
const DEFAULT_ELEVENLABS_VOICE_ID = "pMsXgVXv3BLzUgSXRplE";
|
||||
const DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2";
|
||||
const DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts";
|
||||
const DEFAULT_OPENAI_VOICE = "alloy";
|
||||
|
||||
const TELEGRAM_OUTPUT = {
|
||||
openai: "opus" as const,
|
||||
// ElevenLabs output formats use codec_sample_rate_bitrate naming.
|
||||
// Opus @ 48kHz/64kbps is a good voice-note tradeoff for Telegram.
|
||||
elevenlabs: "opus_48000_64",
|
||||
extension: ".opus",
|
||||
voiceCompatible: true,
|
||||
};
|
||||
|
||||
const DEFAULT_OUTPUT = {
|
||||
openai: "mp3" as const,
|
||||
elevenlabs: "mp3_44100_128",
|
||||
extension: ".mp3",
|
||||
voiceCompatible: false,
|
||||
};
|
||||
|
||||
export type ResolvedTtsConfig = {
|
||||
enabled: boolean;
|
||||
mode: TtsMode;
|
||||
provider: TtsProvider;
|
||||
elevenlabs: {
|
||||
apiKey?: string;
|
||||
voiceId: string;
|
||||
modelId: string;
|
||||
};
|
||||
openai: {
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
};
|
||||
prefsPath?: string;
|
||||
maxTextLength: number;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
type TtsUserPrefs = {
|
||||
tts?: {
|
||||
enabled?: boolean;
|
||||
provider?: TtsProvider;
|
||||
maxLength?: number;
|
||||
summarize?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type TtsResult = {
|
||||
success: boolean;
|
||||
audioPath?: string;
|
||||
error?: string;
|
||||
latencyMs?: number;
|
||||
provider?: string;
|
||||
outputFormat?: string;
|
||||
voiceCompatible?: boolean;
|
||||
};
|
||||
|
||||
type TtsStatusEntry = {
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
textLength: number;
|
||||
summarized: boolean;
|
||||
provider?: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
let lastTtsAttempt: TtsStatusEntry | undefined;
|
||||
|
||||
export function resolveTtsConfig(cfg: ClawdbotConfig): ResolvedTtsConfig {
|
||||
const raw: TtsConfig = cfg.messages?.tts ?? {};
|
||||
return {
|
||||
enabled: raw.enabled ?? false,
|
||||
mode: raw.mode ?? "final",
|
||||
provider: raw.provider ?? "elevenlabs",
|
||||
elevenlabs: {
|
||||
apiKey: raw.elevenlabs?.apiKey,
|
||||
voiceId: raw.elevenlabs?.voiceId ?? DEFAULT_ELEVENLABS_VOICE_ID,
|
||||
modelId: raw.elevenlabs?.modelId ?? DEFAULT_ELEVENLABS_MODEL_ID,
|
||||
},
|
||||
openai: {
|
||||
apiKey: raw.openai?.apiKey,
|
||||
model: raw.openai?.model ?? DEFAULT_OPENAI_MODEL,
|
||||
voice: raw.openai?.voice ?? DEFAULT_OPENAI_VOICE,
|
||||
},
|
||||
prefsPath: raw.prefsPath,
|
||||
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
|
||||
timeoutMs: raw.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
|
||||
if (config.prefsPath?.trim()) return resolveUserPath(config.prefsPath.trim());
|
||||
const envPath = process.env.CLAWDBOT_TTS_PREFS?.trim();
|
||||
if (envPath) return resolveUserPath(envPath);
|
||||
return path.join(CONFIG_DIR, "settings", "tts.json");
|
||||
}
|
||||
|
||||
function readPrefs(prefsPath: string): TtsUserPrefs {
|
||||
try {
|
||||
if (!existsSync(prefsPath)) return {};
|
||||
return JSON.parse(readFileSync(prefsPath, "utf8")) as TtsUserPrefs;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function atomicWriteFileSync(filePath: string, content: string): void {
|
||||
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
||||
writeFileSync(tmpPath, content);
|
||||
try {
|
||||
renameSync(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
try {
|
||||
unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void): void {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
update(prefs);
|
||||
mkdirSync(path.dirname(prefsPath), { recursive: true });
|
||||
atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2));
|
||||
}
|
||||
|
||||
export function isTtsEnabled(config: ResolvedTtsConfig, prefsPath: string): boolean {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
if (prefs.tts?.enabled !== undefined) return prefs.tts.enabled === true;
|
||||
return config.enabled;
|
||||
}
|
||||
|
||||
export function setTtsEnabled(prefsPath: string, enabled: boolean): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, enabled };
|
||||
});
|
||||
}
|
||||
|
||||
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
return prefs.tts?.provider ?? config.provider;
|
||||
}
|
||||
|
||||
export function setTtsProvider(prefsPath: string, provider: TtsProvider): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, provider };
|
||||
});
|
||||
}
|
||||
|
||||
export function getTtsMaxLength(prefsPath: string): number {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
return prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;
|
||||
}
|
||||
|
||||
export function setTtsMaxLength(prefsPath: string, maxLength: number): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, maxLength };
|
||||
});
|
||||
}
|
||||
|
||||
export function isSummarizationEnabled(prefsPath: string): boolean {
|
||||
const prefs = readPrefs(prefsPath);
|
||||
return prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE;
|
||||
}
|
||||
|
||||
export function setSummarizationEnabled(prefsPath: string, enabled: boolean): void {
|
||||
updatePrefs(prefsPath, (prefs) => {
|
||||
prefs.tts = { ...prefs.tts, summarize: enabled };
|
||||
});
|
||||
}
|
||||
|
||||
export function getLastTtsAttempt(): TtsStatusEntry | undefined {
|
||||
return lastTtsAttempt;
|
||||
}
|
||||
|
||||
export function setLastTtsAttempt(entry: TtsStatusEntry | undefined): void {
|
||||
lastTtsAttempt = entry;
|
||||
}
|
||||
|
||||
function resolveOutputFormat(channelId?: string | null) {
|
||||
if (channelId === "telegram") return TELEGRAM_OUTPUT;
|
||||
return DEFAULT_OUTPUT;
|
||||
}
|
||||
|
||||
function resolveChannelId(channel: string | undefined): ChannelId | null {
|
||||
return channel ? normalizeChannelId(channel) : null;
|
||||
}
|
||||
|
||||
export function resolveTtsApiKey(
|
||||
config: ResolvedTtsConfig,
|
||||
provider: TtsProvider,
|
||||
): string | undefined {
|
||||
if (provider === "elevenlabs") {
|
||||
return config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
|
||||
}
|
||||
if (provider === "openai") {
|
||||
return config.openai.apiKey || process.env.OPENAI_API_KEY;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isValidVoiceId(voiceId: string): boolean {
|
||||
return /^[a-zA-Z0-9]{10,40}$/.test(voiceId);
|
||||
}
|
||||
|
||||
export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts"] as const;
|
||||
export const OPENAI_TTS_VOICES = [
|
||||
"alloy",
|
||||
"ash",
|
||||
"coral",
|
||||
"echo",
|
||||
"fable",
|
||||
"onyx",
|
||||
"nova",
|
||||
"sage",
|
||||
"shimmer",
|
||||
] as const;
|
||||
|
||||
type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
|
||||
|
||||
function isValidOpenAIModel(model: string): boolean {
|
||||
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
|
||||
}
|
||||
|
||||
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
|
||||
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
|
||||
}
|
||||
|
||||
type SummarizeResult = {
|
||||
summary: string;
|
||||
latencyMs: number;
|
||||
inputLength: number;
|
||||
outputLength: number;
|
||||
};
|
||||
|
||||
async function summarizeText(
|
||||
text: string,
|
||||
targetLength: number,
|
||||
apiKey: string,
|
||||
timeoutMs: number,
|
||||
): Promise<SummarizeResult> {
|
||||
if (targetLength < 100 || targetLength > 10_000) {
|
||||
throw new Error(`Invalid targetLength: ${targetLength}`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an assistant that summarizes texts concisely while keeping the most important information. Summarize the text to approximately ${targetLength} characters. Maintain the original tone and style. Reply only with the summary, without additional explanations.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `<text_to_summarize>\n${text}\n</text_to_summarize>`,
|
||||
},
|
||||
],
|
||||
max_tokens: Math.ceil(targetLength / 2),
|
||||
temperature: 0.3,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Summarization service unavailable");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
const summary = data.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!summary) {
|
||||
throw new Error("No summary returned");
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
latencyMs: Date.now() - startTime,
|
||||
inputLength: text.length,
|
||||
outputLength: summary.length,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCleanup(tempDir: string, delayMs: number = TEMP_FILE_CLEANUP_DELAY_MS): void {
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}, delayMs);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
async function elevenLabsTTS(params: {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
voiceId: string;
|
||||
modelId: string;
|
||||
outputFormat: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<Buffer> {
|
||||
const { text, apiKey, voiceId, modelId, outputFormat, timeoutMs } = params;
|
||||
if (!isValidVoiceId(voiceId)) {
|
||||
throw new Error("Invalid voiceId format");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const url = new URL(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`);
|
||||
if (outputFormat) {
|
||||
url.searchParams.set("output_format", outputFormat);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "audio/mpeg",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: modelId,
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
style: 0.0,
|
||||
use_speaker_boost: true,
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ElevenLabs API error (${response.status})`);
|
||||
}
|
||||
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function openaiTTS(params: {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
voice: string;
|
||||
responseFormat: "mp3" | "opus";
|
||||
timeoutMs: number;
|
||||
}): Promise<Buffer> {
|
||||
const { text, apiKey, model, voice, responseFormat, timeoutMs } = params;
|
||||
|
||||
if (!isValidOpenAIModel(model)) {
|
||||
throw new Error(`Invalid model: ${model}`);
|
||||
}
|
||||
if (!isValidOpenAIVoice(voice)) {
|
||||
throw new Error(`Invalid voice: ${voice}`);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.openai.com/v1/audio/speech", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: text,
|
||||
voice,
|
||||
response_format: responseFormat,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI TTS API error (${response.status})`);
|
||||
}
|
||||
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function textToSpeech(params: {
|
||||
text: string;
|
||||
cfg: ClawdbotConfig;
|
||||
prefsPath?: string;
|
||||
channel?: string;
|
||||
}): Promise<TtsResult> {
|
||||
const config = resolveTtsConfig(params.cfg);
|
||||
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
|
||||
const channelId = resolveChannelId(params.channel);
|
||||
const output = resolveOutputFormat(channelId);
|
||||
|
||||
if (params.text.length > config.maxTextLength) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Text too long (${params.text.length} chars, max ${config.maxTextLength})`,
|
||||
};
|
||||
}
|
||||
|
||||
const userProvider = getTtsProvider(config, prefsPath);
|
||||
const providers: TtsProvider[] = [
|
||||
userProvider,
|
||||
userProvider === "openai" ? "elevenlabs" : "openai",
|
||||
];
|
||||
|
||||
let lastError: string | undefined;
|
||||
|
||||
for (const provider of providers) {
|
||||
const apiKey = resolveTtsApiKey(config, provider);
|
||||
if (!apiKey) {
|
||||
lastError = `No API key for ${provider}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerStart = Date.now();
|
||||
try {
|
||||
let audioBuffer: Buffer;
|
||||
if (provider === "elevenlabs") {
|
||||
audioBuffer = await elevenLabsTTS({
|
||||
text: params.text,
|
||||
apiKey,
|
||||
voiceId: config.elevenlabs.voiceId,
|
||||
modelId: config.elevenlabs.modelId,
|
||||
outputFormat: output.elevenlabs,
|
||||
timeoutMs: config.timeoutMs,
|
||||
});
|
||||
} else {
|
||||
audioBuffer = await openaiTTS({
|
||||
text: params.text,
|
||||
apiKey,
|
||||
model: config.openai.model,
|
||||
voice: config.openai.voice,
|
||||
responseFormat: output.openai,
|
||||
timeoutMs: config.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - providerStart;
|
||||
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "tts-"));
|
||||
const audioPath = path.join(tempDir, `voice-${Date.now()}${output.extension}`);
|
||||
writeFileSync(audioPath, audioBuffer);
|
||||
scheduleCleanup(tempDir);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioPath,
|
||||
latencyMs,
|
||||
provider,
|
||||
outputFormat: provider === "openai" ? output.openai : output.elevenlabs,
|
||||
voiceCompatible: output.voiceCompatible,
|
||||
};
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
if (error.name === "AbortError") {
|
||||
lastError = `${provider}: request timed out`;
|
||||
} else {
|
||||
lastError = `${provider}: ${error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `TTS conversion failed: ${lastError || "no providers available"}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function maybeApplyTtsToPayload(params: {
|
||||
payload: ReplyPayload;
|
||||
cfg: ClawdbotConfig;
|
||||
channel?: string;
|
||||
kind?: "tool" | "block" | "final";
|
||||
}): Promise<ReplyPayload> {
|
||||
const config = resolveTtsConfig(params.cfg);
|
||||
const prefsPath = resolveTtsPrefsPath(config);
|
||||
if (!isTtsEnabled(config, prefsPath)) return params.payload;
|
||||
|
||||
const mode = config.mode ?? "final";
|
||||
if (mode === "final" && params.kind && params.kind !== "final") return params.payload;
|
||||
|
||||
const text = params.payload.text ?? "";
|
||||
if (!text.trim()) return params.payload;
|
||||
if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) return params.payload;
|
||||
if (text.includes("MEDIA:")) return params.payload;
|
||||
if (text.trim().length < 10) return params.payload;
|
||||
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
let textForAudio = text.trim();
|
||||
let wasSummarized = false;
|
||||
|
||||
if (textForAudio.length > maxLength) {
|
||||
if (!isSummarizationEnabled(prefsPath)) {
|
||||
logVerbose(
|
||||
`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
|
||||
);
|
||||
return params.payload;
|
||||
}
|
||||
|
||||
const openaiKey = resolveTtsApiKey(config, "openai");
|
||||
if (!openaiKey) {
|
||||
logVerbose("TTS: skipping summarization - OpenAI key missing.");
|
||||
return params.payload;
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await summarizeText(textForAudio, maxLength, openaiKey, config.timeoutMs);
|
||||
textForAudio = summary.summary;
|
||||
wasSummarized = true;
|
||||
if (textForAudio.length > config.maxTextLength) {
|
||||
logVerbose(
|
||||
`TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
|
||||
);
|
||||
textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
logVerbose(`TTS: summarization failed: ${error.message}`);
|
||||
return params.payload;
|
||||
}
|
||||
}
|
||||
|
||||
const ttsStart = Date.now();
|
||||
const result = await textToSpeech({
|
||||
text: textForAudio,
|
||||
cfg: params.cfg,
|
||||
prefsPath,
|
||||
channel: params.channel,
|
||||
});
|
||||
|
||||
if (result.success && result.audioPath) {
|
||||
lastTtsAttempt = {
|
||||
timestamp: Date.now(),
|
||||
success: true,
|
||||
textLength: text.length,
|
||||
summarized: wasSummarized,
|
||||
provider: result.provider,
|
||||
latencyMs: result.latencyMs,
|
||||
};
|
||||
|
||||
const channelId = resolveChannelId(params.channel);
|
||||
const shouldVoice = channelId === "telegram" && result.voiceCompatible === true;
|
||||
|
||||
return {
|
||||
...params.payload,
|
||||
mediaUrl: result.audioPath,
|
||||
audioAsVoice: shouldVoice || params.payload.audioAsVoice,
|
||||
};
|
||||
}
|
||||
|
||||
lastTtsAttempt = {
|
||||
timestamp: Date.now(),
|
||||
success: false,
|
||||
textLength: text.length,
|
||||
summarized: wasSummarized,
|
||||
error: result.error,
|
||||
};
|
||||
|
||||
const latency = Date.now() - ttsStart;
|
||||
logVerbose(`TTS: conversion failed after ${latency}ms (${result.error ?? "unknown"}).`);
|
||||
return params.payload;
|
||||
}
|
||||
|
||||
export const _test = {
|
||||
isValidVoiceId,
|
||||
isValidOpenAIVoice,
|
||||
isValidOpenAIModel,
|
||||
OPENAI_TTS_MODELS,
|
||||
OPENAI_TTS_VOICES,
|
||||
summarizeText,
|
||||
resolveOutputFormat,
|
||||
};
|
||||
216
src/tui/tui-event-handlers.test.ts
Normal file
216
src/tui/tui-event-handlers.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createEventHandlers } from "./tui-event-handlers.js";
|
||||
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
|
||||
|
||||
type MockChatLog = {
|
||||
startTool: ReturnType<typeof vi.fn>;
|
||||
updateToolResult: ReturnType<typeof vi.fn>;
|
||||
addSystem: ReturnType<typeof vi.fn>;
|
||||
updateAssistant: ReturnType<typeof vi.fn>;
|
||||
finalizeAssistant: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
describe("tui-event-handlers: handleAgentEvent", () => {
|
||||
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
|
||||
agentDefaultId: "main",
|
||||
sessionMainKey: "agent:main:main",
|
||||
sessionScope: "global",
|
||||
agents: [],
|
||||
currentAgentId: "main",
|
||||
currentSessionKey: "agent:main:main",
|
||||
currentSessionId: "session-1",
|
||||
activeChatRunId: "run-1",
|
||||
historyLoaded: true,
|
||||
sessionInfo: {},
|
||||
initialSessionApplied: true,
|
||||
isConnected: true,
|
||||
autoMessageSent: false,
|
||||
toolsExpanded: false,
|
||||
showThinking: false,
|
||||
connectionStatus: "connected",
|
||||
activityStatus: "idle",
|
||||
statusTimeout: null,
|
||||
lastCtrlCAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeContext = (state: TuiStateAccess) => {
|
||||
const chatLog: MockChatLog = {
|
||||
startTool: vi.fn(),
|
||||
updateToolResult: vi.fn(),
|
||||
addSystem: vi.fn(),
|
||||
updateAssistant: vi.fn(),
|
||||
finalizeAssistant: vi.fn(),
|
||||
};
|
||||
const tui = { requestRender: vi.fn() };
|
||||
const setActivityStatus = vi.fn();
|
||||
|
||||
return { chatLog, tui, state, setActivityStatus };
|
||||
};
|
||||
|
||||
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
|
||||
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleAgentEvent } = createEventHandlers({
|
||||
// Casts are fine here: TUI runtime shape is larger than we need in unit tests.
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
const evt: AgentEvent = {
|
||||
runId: "run-123",
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "start",
|
||||
toolCallId: "tc1",
|
||||
name: "exec",
|
||||
args: { command: "echo hi" },
|
||||
},
|
||||
};
|
||||
|
||||
handleAgentEvent(evt);
|
||||
|
||||
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", { command: "echo hi" });
|
||||
expect(tui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores tool events when runId does not match activeChatRunId", () => {
|
||||
const state = makeState({ activeChatRunId: "run-1" });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleAgentEvent } = createEventHandlers({
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
const evt: AgentEvent = {
|
||||
runId: "run-2",
|
||||
stream: "tool",
|
||||
data: { phase: "start", toolCallId: "tc1", name: "exec" },
|
||||
};
|
||||
|
||||
handleAgentEvent(evt);
|
||||
|
||||
expect(chatLog.startTool).not.toHaveBeenCalled();
|
||||
expect(chatLog.updateToolResult).not.toHaveBeenCalled();
|
||||
expect(tui.requestRender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes lifecycle events when runId matches activeChatRunId", () => {
|
||||
const state = makeState({ activeChatRunId: "run-9" });
|
||||
const { tui, setActivityStatus } = makeContext(state);
|
||||
const { handleAgentEvent } = createEventHandlers({
|
||||
chatLog: { startTool: vi.fn(), updateToolResult: vi.fn() } as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
const evt: AgentEvent = {
|
||||
runId: "run-9",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "start" },
|
||||
};
|
||||
|
||||
handleAgentEvent(evt);
|
||||
|
||||
expect(setActivityStatus).toHaveBeenCalledWith("running");
|
||||
expect(tui.requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("captures runId from chat events when activeChatRunId is unset", () => {
|
||||
const state = makeState({ activeChatRunId: null });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
const chatEvt: ChatEvent = {
|
||||
runId: "run-42",
|
||||
sessionKey: state.currentSessionKey,
|
||||
state: "delta",
|
||||
message: { content: "hello" },
|
||||
};
|
||||
|
||||
handleChatEvent(chatEvt);
|
||||
|
||||
expect(state.activeChatRunId).toBe("run-42");
|
||||
|
||||
const agentEvt: AgentEvent = {
|
||||
runId: "run-42",
|
||||
stream: "tool",
|
||||
data: { phase: "start", toolCallId: "tc1", name: "exec" },
|
||||
};
|
||||
|
||||
handleAgentEvent(agentEvt);
|
||||
|
||||
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", undefined);
|
||||
});
|
||||
|
||||
it("clears run mapping when the session changes", () => {
|
||||
const state = makeState({ activeChatRunId: null });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
handleChatEvent({
|
||||
runId: "run-old",
|
||||
sessionKey: state.currentSessionKey,
|
||||
state: "delta",
|
||||
message: { content: "hello" },
|
||||
});
|
||||
|
||||
state.currentSessionKey = "agent:main:other";
|
||||
state.activeChatRunId = null;
|
||||
tui.requestRender.mockClear();
|
||||
|
||||
handleAgentEvent({
|
||||
runId: "run-old",
|
||||
stream: "tool",
|
||||
data: { phase: "start", toolCallId: "tc2", name: "exec" },
|
||||
});
|
||||
|
||||
expect(chatLog.startTool).not.toHaveBeenCalled();
|
||||
expect(tui.requestRender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores lifecycle updates for non-active runs in the same session", () => {
|
||||
const state = makeState({ activeChatRunId: "run-active" });
|
||||
const { chatLog, tui, setActivityStatus } = makeContext(state);
|
||||
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
|
||||
chatLog: chatLog as any,
|
||||
tui: tui as any,
|
||||
state,
|
||||
setActivityStatus,
|
||||
});
|
||||
|
||||
handleChatEvent({
|
||||
runId: "run-other",
|
||||
sessionKey: state.currentSessionKey,
|
||||
state: "delta",
|
||||
message: { content: "hello" },
|
||||
});
|
||||
setActivityStatus.mockClear();
|
||||
tui.requestRender.mockClear();
|
||||
|
||||
handleAgentEvent({
|
||||
runId: "run-other",
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
expect(setActivityStatus).not.toHaveBeenCalled();
|
||||
expect(tui.requestRender).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,33 +15,58 @@ type EventHandlerContext = {
|
||||
export function createEventHandlers(context: EventHandlerContext) {
|
||||
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context;
|
||||
const finalizedRuns = new Map<string, number>();
|
||||
const streamAssembler = new TuiStreamAssembler();
|
||||
const sessionRuns = new Map<string, number>();
|
||||
let streamAssembler = new TuiStreamAssembler();
|
||||
let lastSessionKey = state.currentSessionKey;
|
||||
|
||||
const pruneRunMap = (runs: Map<string, number>) => {
|
||||
if (runs.size <= 200) return;
|
||||
const keepUntil = Date.now() - 10 * 60 * 1000;
|
||||
for (const [key, ts] of runs) {
|
||||
if (runs.size <= 150) break;
|
||||
if (ts < keepUntil) runs.delete(key);
|
||||
}
|
||||
if (runs.size > 200) {
|
||||
for (const key of runs.keys()) {
|
||||
runs.delete(key);
|
||||
if (runs.size <= 150) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncSessionKey = () => {
|
||||
if (state.currentSessionKey === lastSessionKey) return;
|
||||
lastSessionKey = state.currentSessionKey;
|
||||
finalizedRuns.clear();
|
||||
sessionRuns.clear();
|
||||
streamAssembler = new TuiStreamAssembler();
|
||||
};
|
||||
|
||||
const noteSessionRun = (runId: string) => {
|
||||
sessionRuns.set(runId, Date.now());
|
||||
pruneRunMap(sessionRuns);
|
||||
};
|
||||
|
||||
const noteFinalizedRun = (runId: string) => {
|
||||
finalizedRuns.set(runId, Date.now());
|
||||
sessionRuns.delete(runId);
|
||||
streamAssembler.drop(runId);
|
||||
if (finalizedRuns.size <= 200) return;
|
||||
const keepUntil = Date.now() - 10 * 60 * 1000;
|
||||
for (const [key, ts] of finalizedRuns) {
|
||||
if (finalizedRuns.size <= 150) break;
|
||||
if (ts < keepUntil) finalizedRuns.delete(key);
|
||||
}
|
||||
if (finalizedRuns.size > 200) {
|
||||
for (const key of finalizedRuns.keys()) {
|
||||
finalizedRuns.delete(key);
|
||||
if (finalizedRuns.size <= 150) break;
|
||||
}
|
||||
}
|
||||
pruneRunMap(finalizedRuns);
|
||||
};
|
||||
|
||||
const handleChatEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as ChatEvent;
|
||||
syncSessionKey();
|
||||
if (evt.sessionKey !== state.currentSessionKey) return;
|
||||
if (finalizedRuns.has(evt.runId)) {
|
||||
if (evt.state === "delta") return;
|
||||
if (evt.state === "final") return;
|
||||
}
|
||||
noteSessionRun(evt.runId);
|
||||
if (!state.activeChatRunId) {
|
||||
state.activeChatRunId = evt.runId;
|
||||
}
|
||||
if (evt.state === "delta") {
|
||||
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);
|
||||
if (!displayText) return;
|
||||
@@ -78,6 +103,7 @@ export function createEventHandlers(context: EventHandlerContext) {
|
||||
if (evt.state === "aborted") {
|
||||
chatLog.addSystem("run aborted");
|
||||
streamAssembler.drop(evt.runId);
|
||||
sessionRuns.delete(evt.runId);
|
||||
state.activeChatRunId = null;
|
||||
setActivityStatus("aborted");
|
||||
void refreshSessionInfo?.();
|
||||
@@ -85,6 +111,7 @@ export function createEventHandlers(context: EventHandlerContext) {
|
||||
if (evt.state === "error") {
|
||||
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||
streamAssembler.drop(evt.runId);
|
||||
sessionRuns.delete(evt.runId);
|
||||
state.activeChatRunId = null;
|
||||
setActivityStatus("error");
|
||||
void refreshSessionInfo?.();
|
||||
@@ -95,7 +122,11 @@ export function createEventHandlers(context: EventHandlerContext) {
|
||||
const handleAgentEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as AgentEvent;
|
||||
if (!state.currentSessionId || evt.runId !== state.currentSessionId) return;
|
||||
syncSessionKey();
|
||||
// Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the
|
||||
// active chat run id, not the session id.
|
||||
const isActiveRun = evt.runId === state.activeChatRunId;
|
||||
if (!isActiveRun && !sessionRuns.has(evt.runId)) return;
|
||||
if (evt.stream === "tool") {
|
||||
const data = evt.data ?? {};
|
||||
const phase = asString(data.phase, "");
|
||||
@@ -117,6 +148,7 @@ export function createEventHandlers(context: EventHandlerContext) {
|
||||
return;
|
||||
}
|
||||
if (evt.stream === "lifecycle") {
|
||||
if (!isActiveRun) return;
|
||||
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
|
||||
if (phase === "start") setActivityStatus("running");
|
||||
if (phase === "end") setActivityStatus("idle");
|
||||
|
||||
Reference in New Issue
Block a user