Merge branch 'main' into together-ai
This commit is contained in:
commit
46de50173f
@ -13,6 +13,7 @@ Status: beta.
|
|||||||
- Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy com.clawdbot migrations). Thanks @thewilloftheshadow.
|
- Branding: update launchd labels, mobile bundle IDs, and logging subsystems to bot.molt (legacy com.clawdbot migrations). Thanks @thewilloftheshadow.
|
||||||
- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.
|
- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.
|
||||||
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
|
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
|
||||||
|
- Memory Search: allow extra paths for memory indexing (ignores symlinks). (#3600) Thanks @kira-ariaki.
|
||||||
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
||||||
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
|
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
|
||||||
@ -72,11 +73,16 @@ Status: beta.
|
|||||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796)
|
||||||
|
- Mentions: honor mentionPatterns even when explicit mentions are present. (#3303) Thanks @HirokiKobayashi-R.
|
||||||
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
|
- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
|
||||||
- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
|
- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
|
||||||
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
|
- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
|
||||||
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
|
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
|
||||||
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
|
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
|
||||||
|
- Telegram: include AccountId in native command context for multi-agent routing. (#2942) Thanks @Chloe-VP.
|
||||||
|
- Telegram: handle video note attachments in media extraction. (#2905) Thanks @mylukin.
|
||||||
|
- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.
|
||||||
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
||||||
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
|
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
|
||||||
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
|
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
|
||||||
@ -106,6 +112,7 @@ Status: beta.
|
|||||||
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
|
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
|
||||||
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
||||||
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
|
- Media: fix text attachment MIME misclassification with CSV/TSV inference and UTF-16 detection; add XML attribute escaping for file output. (#3628) Thanks @frankekn.
|
||||||
- Build: align memory-core peer dependency with lockfile.
|
- Build: align memory-core peer dependency with lockfile.
|
||||||
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
|
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
|
||||||
- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.
|
- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.
|
||||||
|
|||||||
66
README.md
66
README.md
@ -479,36 +479,38 @@ Thanks to all clawtributors:
|
|||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a>
|
<a href="https://github.com/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/plum-dawg"><img src="https://avatars.githubusercontent.com/u/5909950?v=4&s=48" width="48" height="48" alt="plum-dawg" title="plum-dawg"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a>
|
||||||
<a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/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/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/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/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/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/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/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
||||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/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/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/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/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/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/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/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/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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/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/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/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/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/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/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/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/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/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/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/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/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></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/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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></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/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></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/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></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/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/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="hirefrank" title="hirefrank"/></a> <a href="https://github.com/joeynyc"><img src="https://avatars.githubusercontent.com/u/17919866?v=4&s=48" width="48" height="48" alt="joeynyc" title="joeynyc"/></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/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/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a>
|
<a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></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/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/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/rohannagpal"><img src="https://avatars.githubusercontent.com/u/4009239?v=4&s=48" width="48" height="48" alt="rohannagpal" title="rohannagpal"/></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/f-trycua"><img src="https://avatars.githubusercontent.com/u/195596869?v=4&s=48" width="48" height="48" alt="f-trycua" title="f-trycua"/></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/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
|
<a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/elliotsecops"><img src="https://avatars.githubusercontent.com/u/141947839?v=4&s=48" width="48" height="48" alt="elliotsecops" title="elliotsecops"/></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/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/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/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a>
|
<a href="https://github.com/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/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/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
|
||||||
<a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></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/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></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/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/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggialiang" title="nonggialiang"/></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/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="dominicnunez" title="dominicnunez"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></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/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="papago2355" title="papago2355"/></a>
|
||||||
<a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a>
|
<a href="https://github.com/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/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/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></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/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/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
||||||
<a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/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/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryancontent" title="ryancontent"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a>
|
||||||
<a href="https://github.com/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/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/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/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/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/uos-status"><img src="https://avatars.githubusercontent.com/u/255712580?v=4&s=48" width="48" height="48" alt="uos-status" title="uos-status"/></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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></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/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/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/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/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/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/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/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/JonUleis"><img src="https://avatars.githubusercontent.com/u/7644941?v=4&s=48" width="48" height="48" alt="JonUleis" title="JonUleis"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="shivamraut101" title="shivamraut101"/></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/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/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/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/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></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/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/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/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/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/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/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="kennyklee" title="kennyklee"/></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/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
<a href="https://github.com/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/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/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=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/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/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=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/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a>
|
||||||
<a href="https://github.com/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/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=Joshua%20Mitchell"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Joshua Mitchell" title="Joshua Mitchell"/></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/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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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/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/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></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/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a>
|
<a href="https://github.com/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/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></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/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></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/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
|
<a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a> <a href="https://github.com/search?q=Roopak%20Nijhara"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Roopak Nijhara" title="Roopak Nijhara"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a>
|
||||||
<a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a>
|
<a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a>
|
<a href="https://github.com/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/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/search?q=Pocket%20Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Pocket Clawd" title="Pocket Clawd"/></a>
|
||||||
<a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></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/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/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></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/tewatia"><img src="https://avatars.githubusercontent.com/u/22875334?v=4&s=48" width="48" height="48" alt="tewatia" title="tewatia"/></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/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a>
|
||||||
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a>
|
<a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></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/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a>
|
||||||
<a href="https://github.com/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=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></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/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></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/chenyuan99"><img src="https://avatars.githubusercontent.com/u/25518100?v=4&s=48" width="48" height="48" alt="chenyuan99" title="chenyuan99"/></a> <a href="https://github.com/search?q=Clawdbot%20Maintainers"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawdbot Maintainers" title="Clawdbot Maintainers"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/David-Marsh-Photo"><img src="https://avatars.githubusercontent.com/u/228404527?v=4&s=48" width="48" height="48" alt="David-Marsh-Photo" title="David-Marsh-Photo"/></a> <a href="https://github.com/search?q=Developer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Developer" title="Developer"/></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/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/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></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/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></a>
|
<a href="https://github.com/dylanneve1"><img src="https://avatars.githubusercontent.com/u/31746704?v=4&s=48" width="48" height="48" alt="dylanneve1" title="dylanneve1"/></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/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/grrowl"><img src="https://avatars.githubusercontent.com/u/907140?v=4&s=48" width="48" height="48" alt="grrowl" title="grrowl"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||||
<a href="https://github.com/search?q=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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></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/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=Jane"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jane" title="Jane"/></a> <a href="https://github.com/search?q=Jarvis%20Deploy"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis Deploy" title="Jarvis Deploy"/></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/jogi47"><img src="https://avatars.githubusercontent.com/u/1710139?v=4&s=48" width="48" height="48" alt="jogi47" title="jogi47"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></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/kira-ariaki"><img src="https://avatars.githubusercontent.com/u/257352493?v=4&s=48" width="48" height="48" alt="kira-ariaki" title="kira-ariaki"/></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/Kiwitwitter"><img src="https://avatars.githubusercontent.com/u/25277769?v=4&s=48" width="48" height="48" alt="Kiwitwitter" title="Kiwitwitter"/></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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/Seredeep"><img src="https://avatars.githubusercontent.com/u/22802816?v=4&s=48" width="48" height="48" alt="Seredeep" title="Seredeep"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/longjos"><img src="https://avatars.githubusercontent.com/u/740160?v=4&s=48" width="48" height="48" alt="longjos" title="longjos"/></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/louzhixian"><img src="https://avatars.githubusercontent.com/u/7994361?v=4&s=48" width="48" height="48" alt="louzhixian" title="louzhixian"/></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/mertcicekci0"><img src="https://avatars.githubusercontent.com/u/179321902?v=4&s=48" width="48" height="48" alt="mertcicekci0" title="mertcicekci0"/></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/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/Suksham-sharma"><img src="https://avatars.githubusercontent.com/u/94667656?v=4&s=48" width="48" height="48" alt="Suksham-sharma" title="Suksham-sharma"/></a> <a href="https://github.com/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></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/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/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/ppamment"><img src="https://avatars.githubusercontent.com/u/2122919?v=4&s=48" width="48" height="48" alt="ppamment" title="ppamment"/></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=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></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/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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=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/senoldogann"><img src="https://avatars.githubusercontent.com/u/45736551?v=4&s=48" width="48" height="48" alt="senoldogann" title="senoldogann"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/shiyuanhai"><img src="https://avatars.githubusercontent.com/u/1187370?v=4&s=48" width="48" height="48" alt="shiyuanhai" title="shiyuanhai"/></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/search?q=techboss"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="techboss" title="techboss"/></a>
|
||||||
<a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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/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/search?q=wolfred"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="wolfred" title="wolfred"/></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/YangHuang2280"><img src="https://avatars.githubusercontent.com/u/201681634?v=4&s=48" width="48" height="48" alt="YangHuang2280" title="YangHuang2280"/></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/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/YiWang24"><img src="https://avatars.githubusercontent.com/u/176262341?v=4&s=48" width="48" height="48" alt="YiWang24" title="YiWang24"/></a> <a href="https://github.com/search?q=ymat19"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ymat19" title="ymat19"/></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/zackerthescar"><img src="https://avatars.githubusercontent.com/u/38077284?v=4&s=48" width="48" height="48" alt="zackerthescar" title="zackerthescar"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></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/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></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>
|
</p>
|
||||||
|
|||||||
@ -125,7 +125,7 @@ the prefix (use `""` to remove it).
|
|||||||
- **DM policy**: `channels.whatsapp.dmPolicy` controls direct chat access (default: `pairing`).
|
- **DM policy**: `channels.whatsapp.dmPolicy` controls direct chat access (default: `pairing`).
|
||||||
- Pairing: unknown senders get a pairing code (approve via `moltbot pairing approve whatsapp <code>`; codes expire after 1 hour).
|
- Pairing: unknown senders get a pairing code (approve via `moltbot pairing approve whatsapp <code>`; codes expire after 1 hour).
|
||||||
- Open: requires `channels.whatsapp.allowFrom` to include `"*"`.
|
- Open: requires `channels.whatsapp.allowFrom` to include `"*"`.
|
||||||
- Self messages are always allowed; “self-chat mode” still requires `channels.whatsapp.allowFrom` to include your own number.
|
- Your linked WhatsApp number is implicitly trusted, so self messages skip `channels.whatsapp.dmPolicy` and `channels.whatsapp.allowFrom` checks.
|
||||||
|
|
||||||
### Personal-number mode (fallback)
|
### Personal-number mode (fallback)
|
||||||
If you run Moltbot on your **personal WhatsApp number**, enable `channels.whatsapp.selfChatMode` (see sample above).
|
If you run Moltbot on your **personal WhatsApp number**, enable `channels.whatsapp.selfChatMode` (see sample above).
|
||||||
|
|||||||
@ -39,3 +39,4 @@ Notes:
|
|||||||
- `memory status --deep` probes vector + embedding availability.
|
- `memory status --deep` probes vector + embedding availability.
|
||||||
- `memory status --deep --index` runs a reindex if the store is dirty.
|
- `memory status --deep --index` runs a reindex if the store is dirty.
|
||||||
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
||||||
|
- `memory status` includes any extra paths configured via `memorySearch.extraPaths`.
|
||||||
|
|||||||
@ -75,8 +75,9 @@ For the full compaction lifecycle, see
|
|||||||
|
|
||||||
## Vector memory search
|
## Vector memory search
|
||||||
|
|
||||||
Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` so
|
Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` (plus
|
||||||
semantic queries can find related notes even when wording differs.
|
any extra directories or files you opt in) so semantic queries can find related
|
||||||
|
notes even when wording differs.
|
||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
- Enabled by default.
|
- Enabled by default.
|
||||||
@ -96,6 +97,27 @@ embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
|||||||
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
|
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
|
||||||
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||||
|
|
||||||
|
### Additional memory paths
|
||||||
|
|
||||||
|
If you want to index Markdown files outside the default workspace layout, add
|
||||||
|
explicit paths:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
extraPaths: ["../team-docs", "/srv/shared-notes/overview.md"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Paths can be absolute or workspace-relative.
|
||||||
|
- Directories are scanned recursively for `.md` files.
|
||||||
|
- Only Markdown files are indexed.
|
||||||
|
- Symlinks are ignored (files or directories).
|
||||||
|
|
||||||
### Gemini embeddings (native)
|
### Gemini embeddings (native)
|
||||||
|
|
||||||
Set the provider to `gemini` to use the Gemini embeddings API directly:
|
Set the provider to `gemini` to use the Gemini embeddings API directly:
|
||||||
@ -189,14 +211,14 @@ Local mode:
|
|||||||
### How the memory tools work
|
### How the memory tools work
|
||||||
|
|
||||||
- `memory_search` semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, score, provider/model, and whether we fell back from local → remote embeddings. No full file payload is returned.
|
- `memory_search` semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, score, provider/model, and whether we fell back from local → remote embeddings. No full file payload is returned.
|
||||||
- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected.
|
- `memory_get` reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are allowed only when explicitly listed in `memorySearch.extraPaths`.
|
||||||
- Both tools are enabled only when `memorySearch.enabled` resolves true for the agent.
|
- Both tools are enabled only when `memorySearch.enabled` resolves true for the agent.
|
||||||
|
|
||||||
### What gets indexed (and when)
|
### What gets indexed (and when)
|
||||||
|
|
||||||
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`, plus any `.md` files under `memorySearch.extraPaths`).
|
||||||
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync.
|
- Freshness: watcher on `MEMORY.md`, `memory/`, and `memorySearch.extraPaths` marks the index dirty (debounce 1.5s). Sync is scheduled on session start, on search, or on an interval and runs asynchronously. Session transcripts use delta thresholds to trigger background sync.
|
||||||
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Moltbot automatically resets and reindexes the entire store.
|
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Moltbot automatically resets and reindexes the entire store.
|
||||||
|
|
||||||
### Hybrid search (BM25 + vector)
|
### Hybrid search (BM25 + vector)
|
||||||
|
|||||||
@ -267,7 +267,8 @@ Save to `~/.clawdbot/moltbot.json` and you can DM the bot from that number.
|
|||||||
model: "gemini-embedding-001",
|
model: "gemini-embedding-001",
|
||||||
remote: {
|
remote: {
|
||||||
apiKey: "${GEMINI_API_KEY}"
|
apiKey: "${GEMINI_API_KEY}"
|
||||||
}
|
},
|
||||||
|
extraPaths: ["../team-docs", "/srv/shared-notes"]
|
||||||
},
|
},
|
||||||
sandbox: {
|
sandbox: {
|
||||||
mode: "non-main",
|
mode: "non-main",
|
||||||
|
|||||||
@ -4,9 +4,9 @@ read_when:
|
|||||||
- You want privacy-focused inference in Moltbot
|
- You want privacy-focused inference in Moltbot
|
||||||
- You want Venice AI setup guidance
|
- You want Venice AI setup guidance
|
||||||
---
|
---
|
||||||
# Venice AI (Venius highlight)
|
# Venice AI (Venice highlight)
|
||||||
|
|
||||||
**Venius** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
|
**Venice** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models.
|
||||||
|
|
||||||
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.
|
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
@ -8,6 +9,24 @@ import { buildDockerExecArgs } from "./bash-tools.shared.js";
|
|||||||
import { sanitizeBinaryOutput } from "./shell-utils.js";
|
import { sanitizeBinaryOutput } from "./shell-utils.js";
|
||||||
|
|
||||||
const isWin = process.platform === "win32";
|
const isWin = process.platform === "win32";
|
||||||
|
const resolveShellFromPath = (name: string) => {
|
||||||
|
const envPath = process.env.PATH ?? "";
|
||||||
|
if (!envPath) return undefined;
|
||||||
|
const entries = envPath.split(path.delimiter).filter(Boolean);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const candidate = path.join(entry, name);
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidate, fs.constants.X_OK);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// ignore missing or non-executable entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const defaultShell = isWin
|
||||||
|
? undefined
|
||||||
|
: process.env.CLAWDBOT_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
|
||||||
// PowerShell: Start-Sleep for delays, ; for command separation, $null for null device
|
// PowerShell: Start-Sleep for delays, ; for command separation, $null for null device
|
||||||
const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05";
|
const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 50" : "sleep 0.05";
|
||||||
const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2";
|
const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 200" : "sleep 0.2";
|
||||||
@ -52,7 +71,7 @@ describe("exec tool backgrounding", () => {
|
|||||||
const originalShell = process.env.SHELL;
|
const originalShell = process.env.SHELL;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
if (!isWin) process.env.SHELL = "/bin/bash";
|
if (!isWin && defaultShell) process.env.SHELL = defaultShell;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -282,7 +301,7 @@ describe("exec PATH handling", () => {
|
|||||||
const originalShell = process.env.SHELL;
|
const originalShell = process.env.SHELL;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
if (!isWin) process.env.SHELL = "/bin/bash";
|
if (!isWin && defaultShell) process.env.SHELL = defaultShell;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@ -82,6 +82,29 @@ describe("memory search config", () => {
|
|||||||
expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib");
|
expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("merges extra memory paths from defaults and overrides", () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
extraPaths: ["/shared/notes", " docs "],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
default: true,
|
||||||
|
memorySearch: {
|
||||||
|
extraPaths: ["/shared/notes", "../team-notes"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||||
|
expect(resolved?.extraPaths).toEqual(["/shared/notes", "docs", "../team-notes"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes batch defaults for openai without remote overrides", () => {
|
it("includes batch defaults for openai without remote overrides", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { resolveAgentConfig } from "./agent-scope.js";
|
|||||||
export type ResolvedMemorySearchConfig = {
|
export type ResolvedMemorySearchConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sources: Array<"memory" | "sessions">;
|
sources: Array<"memory" | "sessions">;
|
||||||
|
extraPaths: string[];
|
||||||
provider: "openai" | "local" | "gemini" | "auto";
|
provider: "openai" | "local" | "gemini" | "auto";
|
||||||
remote?: {
|
remote?: {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@ -162,6 +163,10 @@ function mergeConfig(
|
|||||||
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
||||||
};
|
};
|
||||||
const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory);
|
const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory);
|
||||||
|
const rawPaths = [...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? [])]
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const extraPaths = Array.from(new Set(rawPaths));
|
||||||
const vector = {
|
const vector = {
|
||||||
enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true,
|
enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true,
|
||||||
extensionPath:
|
extensionPath:
|
||||||
@ -236,6 +241,7 @@ function mergeConfig(
|
|||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
sources,
|
sources,
|
||||||
|
extraPaths,
|
||||||
provider,
|
provider,
|
||||||
remote,
|
remote,
|
||||||
experimental: {
|
experimental: {
|
||||||
|
|||||||
@ -35,8 +35,8 @@ function isAlive(pid: number): boolean {
|
|||||||
function releaseAllLocksSync(): void {
|
function releaseAllLocksSync(): void {
|
||||||
for (const [sessionFile, held] of HELD_LOCKS) {
|
for (const [sessionFile, held] of HELD_LOCKS) {
|
||||||
try {
|
try {
|
||||||
if (typeof held.handle.fd === "number") {
|
if (typeof held.handle.close === "function") {
|
||||||
fsSync.closeSync(held.handle.fd);
|
void held.handle.close().catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors during cleanup - best effort
|
// Ignore errors during cleanup - best effort
|
||||||
|
|||||||
@ -83,7 +83,7 @@ export function createMemoryGetTool(options: {
|
|||||||
label: "Memory Get",
|
label: "Memory Get",
|
||||||
name: "memory_get",
|
name: "memory_get",
|
||||||
description:
|
description:
|
||||||
"Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; use after memory_search to pull only the needed lines and keep context small.",
|
"Safe snippet read from MEMORY.md, memory/*.md, or configured memorySearch.extraPaths with optional from/lines; use after memory_search to pull only the needed lines and keep context small.",
|
||||||
parameters: MemoryGetSchema,
|
parameters: MemoryGetSchema,
|
||||||
execute: async (_toolCallId, params) => {
|
execute: async (_toolCallId, params) => {
|
||||||
const relPath = readStringParam(params, "path", { required: true });
|
const relPath = readStringParam(params, "path", { required: true });
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as ssrf from "../../infra/net/ssrf.js";
|
||||||
|
|
||||||
const lookupMock = vi.fn();
|
const lookupMock = vi.fn();
|
||||||
|
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
|
||||||
vi.mock("node:dns/promises", () => ({
|
|
||||||
lookup: lookupMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
|
||||||
return {
|
return {
|
||||||
@ -33,6 +32,12 @@ function textResponse(body: string): Response {
|
|||||||
describe("web_fetch SSRF protection", () => {
|
describe("web_fetch SSRF protection", () => {
|
||||||
const priorFetch = global.fetch;
|
const priorFetch = global.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
|
||||||
|
resolvePinnedHostname(hostname, lookupMock),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// @ts-expect-error restore
|
// @ts-expect-error restore
|
||||||
global.fetch = priorFetch;
|
global.fetch = priorFetch;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as ssrf from "../../infra/net/ssrf.js";
|
||||||
import { createWebFetchTool } from "./web-tools.js";
|
import { createWebFetchTool } from "./web-tools.js";
|
||||||
|
|
||||||
type MockResponse = {
|
type MockResponse = {
|
||||||
@ -73,6 +74,18 @@ function requestUrl(input: RequestInfo): string {
|
|||||||
describe("web_fetch extraction fallbacks", () => {
|
describe("web_fetch extraction fallbacks", () => {
|
||||||
const priorFetch = global.fetch;
|
const priorFetch = global.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
|
||||||
|
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
const addresses = ["93.184.216.34", "93.184.216.35"];
|
||||||
|
return {
|
||||||
|
hostname: normalized,
|
||||||
|
addresses,
|
||||||
|
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// @ts-expect-error restore
|
// @ts-expect-error restore
|
||||||
global.fetch = priorFetch;
|
global.fetch = priorFetch;
|
||||||
|
|||||||
@ -138,7 +138,7 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not provide onToolResult when routing cross-provider", async () => {
|
it("provides onToolResult in DM sessions", async () => {
|
||||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
handled: false,
|
handled: false,
|
||||||
aborted: false,
|
aborted: false,
|
||||||
@ -147,9 +147,34 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
const cfg = {} as MoltbotConfig;
|
const cfg = {} as MoltbotConfig;
|
||||||
const dispatcher = createDispatcher();
|
const dispatcher = createDispatcher();
|
||||||
const ctx = buildTestCtx({
|
const ctx = buildTestCtx({
|
||||||
Provider: "slack",
|
Provider: "telegram",
|
||||||
OriginatingChannel: "telegram",
|
ChatType: "direct",
|
||||||
OriginatingTo: "telegram:999",
|
});
|
||||||
|
|
||||||
|
const replyResolver = async (
|
||||||
|
_ctx: MsgContext,
|
||||||
|
opts: GetReplyOptions | undefined,
|
||||||
|
_cfg: ClawdbotConfig,
|
||||||
|
) => {
|
||||||
|
expect(opts?.onToolResult).toBeDefined();
|
||||||
|
expect(typeof opts?.onToolResult).toBe("function");
|
||||||
|
return { text: "hi" } satisfies ReplyPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not provide onToolResult in group sessions", async () => {
|
||||||
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
|
handled: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const dispatcher = createDispatcher();
|
||||||
|
const ctx = buildTestCtx({
|
||||||
|
Provider: "telegram",
|
||||||
|
ChatType: "group",
|
||||||
});
|
});
|
||||||
|
|
||||||
const replyResolver = async (
|
const replyResolver = async (
|
||||||
@ -162,12 +187,62 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
expect(mocks.routeReply).toHaveBeenCalledWith(
|
it("sends tool results via dispatcher in DM sessions", async () => {
|
||||||
expect.objectContaining({
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
payload: expect.objectContaining({ text: "hi" }),
|
handled: false,
|
||||||
}),
|
aborted: false,
|
||||||
|
});
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const dispatcher = createDispatcher();
|
||||||
|
const ctx = buildTestCtx({
|
||||||
|
Provider: "telegram",
|
||||||
|
ChatType: "direct",
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyResolver = async (
|
||||||
|
_ctx: MsgContext,
|
||||||
|
opts: GetReplyOptions | undefined,
|
||||||
|
_cfg: ClawdbotConfig,
|
||||||
|
) => {
|
||||||
|
// Simulate tool result emission
|
||||||
|
await opts?.onToolResult?.({ text: "🔧 exec: ls" });
|
||||||
|
return { text: "done" } satisfies ReplyPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
expect(dispatcher.sendToolResult).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ text: "🔧 exec: ls" }),
|
||||||
);
|
);
|
||||||
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not provide onToolResult for native slash commands", async () => {
|
||||||
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
|
handled: false,
|
||||||
|
aborted: false,
|
||||||
|
});
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const dispatcher = createDispatcher();
|
||||||
|
const ctx = buildTestCtx({
|
||||||
|
Provider: "telegram",
|
||||||
|
ChatType: "direct",
|
||||||
|
CommandSource: "native",
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyResolver = async (
|
||||||
|
_ctx: MsgContext,
|
||||||
|
opts: GetReplyOptions | undefined,
|
||||||
|
_cfg: ClawdbotConfig,
|
||||||
|
) => {
|
||||||
|
expect(opts?.onToolResult).toBeUndefined();
|
||||||
|
return { text: "hi" } satisfies ReplyPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||||
|
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fast-aborts without calling the reply resolver", async () => {
|
it("fast-aborts without calling the reply resolver", async () => {
|
||||||
|
|||||||
@ -276,6 +276,27 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
ctx,
|
ctx,
|
||||||
{
|
{
|
||||||
...params.replyOptions,
|
...params.replyOptions,
|
||||||
|
onToolResult:
|
||||||
|
ctx.ChatType !== "group" && ctx.CommandSource !== "native"
|
||||||
|
? (payload: ReplyPayload) => {
|
||||||
|
const run = async () => {
|
||||||
|
const ttsPayload = await maybeApplyTtsToPayload({
|
||||||
|
payload,
|
||||||
|
cfg,
|
||||||
|
channel: ttsChannel,
|
||||||
|
kind: "tool",
|
||||||
|
inboundAudio,
|
||||||
|
ttsAuto: sessionTtsAuto,
|
||||||
|
});
|
||||||
|
if (shouldRouteToOriginating) {
|
||||||
|
await sendPayloadAsync(ttsPayload, undefined, false);
|
||||||
|
} else {
|
||||||
|
dispatcher.sendToolResult(ttsPayload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return run();
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
onBlockReply: (payload: ReplyPayload, context) => {
|
onBlockReply: (payload: ReplyPayload, context) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
// Accumulate block text for TTS generation after streaming
|
// Accumulate block text for TTS generation after streaming
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { matchesMentionWithExplicit } from "./mentions.js";
|
|||||||
describe("matchesMentionWithExplicit", () => {
|
describe("matchesMentionWithExplicit", () => {
|
||||||
const mentionRegexes = [/\bclawd\b/i];
|
const mentionRegexes = [/\bclawd\b/i];
|
||||||
|
|
||||||
it("prefers explicit mentions when other mentions are present", () => {
|
it("checks mentionPatterns even when explicit mention is available", () => {
|
||||||
const result = matchesMentionWithExplicit({
|
const result = matchesMentionWithExplicit({
|
||||||
text: "@clawd hello",
|
text: "@clawd hello",
|
||||||
mentionRegexes,
|
mentionRegexes,
|
||||||
@ -14,6 +14,19 @@ describe("matchesMentionWithExplicit", () => {
|
|||||||
canResolveExplicit: true,
|
canResolveExplicit: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when explicit is false and no regex match", () => {
|
||||||
|
const result = matchesMentionWithExplicit({
|
||||||
|
text: "<@999999> hello",
|
||||||
|
mentionRegexes,
|
||||||
|
explicit: {
|
||||||
|
hasAnyMention: true,
|
||||||
|
isExplicitlyMentioned: false,
|
||||||
|
canResolveExplicit: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -90,7 +90,9 @@ export function matchesMentionWithExplicit(params: {
|
|||||||
const explicit = params.explicit?.isExplicitlyMentioned === true;
|
const explicit = params.explicit?.isExplicitlyMentioned === true;
|
||||||
const explicitAvailable = params.explicit?.canResolveExplicit === true;
|
const explicitAvailable = params.explicit?.canResolveExplicit === true;
|
||||||
const hasAnyMention = params.explicit?.hasAnyMention === true;
|
const hasAnyMention = params.explicit?.hasAnyMention === true;
|
||||||
if (hasAnyMention && explicitAvailable) return explicit;
|
if (hasAnyMention && explicitAvailable) {
|
||||||
|
return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
|
||||||
|
}
|
||||||
if (!cleaned) return explicit;
|
if (!cleaned) return explicit;
|
||||||
return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
|
return explicit || params.mentionRegexes.some((re) => re.test(cleaned));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||||
|
|
||||||
// Keep channelData-only payloads so channel-specific replies survive normalization.
|
// Keep channelData-only payloads so channel-specific replies survive normalization.
|
||||||
@ -19,4 +20,30 @@ describe("normalizeReplyPayload", () => {
|
|||||||
expect(normalized?.text).toBeUndefined();
|
expect(normalized?.text).toBeUndefined();
|
||||||
expect(normalized?.channelData).toEqual(payload.channelData);
|
expect(normalized?.channelData).toEqual(payload.channelData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records silent skips", () => {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const normalized = normalizeReplyPayload(
|
||||||
|
{ text: SILENT_REPLY_TOKEN },
|
||||||
|
{
|
||||||
|
onSkip: (reason) => reasons.push(reason),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized).toBeNull();
|
||||||
|
expect(reasons).toEqual(["silent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records empty skips", () => {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const normalized = normalizeReplyPayload(
|
||||||
|
{ text: " " },
|
||||||
|
{
|
||||||
|
onSkip: (reason) => reasons.push(reason),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(normalized).toBeNull();
|
||||||
|
expect(reasons).toEqual(["empty"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import {
|
|||||||
} from "./response-prefix-template.js";
|
} from "./response-prefix-template.js";
|
||||||
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
|
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
|
||||||
|
|
||||||
|
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
|
||||||
|
|
||||||
export type NormalizeReplyOptions = {
|
export type NormalizeReplyOptions = {
|
||||||
responsePrefix?: string;
|
responsePrefix?: string;
|
||||||
/** Context for template variable interpolation in responsePrefix */
|
/** Context for template variable interpolation in responsePrefix */
|
||||||
@ -15,6 +17,7 @@ export type NormalizeReplyOptions = {
|
|||||||
onHeartbeatStrip?: () => void;
|
onHeartbeatStrip?: () => void;
|
||||||
stripHeartbeat?: boolean;
|
stripHeartbeat?: boolean;
|
||||||
silentToken?: string;
|
silentToken?: string;
|
||||||
|
onSkip?: (reason: NormalizeReplySkipReason) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeReplyPayload(
|
export function normalizeReplyPayload(
|
||||||
@ -26,12 +29,18 @@ export function normalizeReplyPayload(
|
|||||||
payload.channelData && Object.keys(payload.channelData).length > 0,
|
payload.channelData && Object.keys(payload.channelData).length > 0,
|
||||||
);
|
);
|
||||||
const trimmed = payload.text?.trim() ?? "";
|
const trimmed = payload.text?.trim() ?? "";
|
||||||
if (!trimmed && !hasMedia && !hasChannelData) return null;
|
if (!trimmed && !hasMedia && !hasChannelData) {
|
||||||
|
opts.onSkip?.("empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||||
let text = payload.text ?? undefined;
|
let text = payload.text ?? undefined;
|
||||||
if (text && isSilentReplyText(text, silentToken)) {
|
if (text && isSilentReplyText(text, silentToken)) {
|
||||||
if (!hasMedia && !hasChannelData) return null;
|
if (!hasMedia && !hasChannelData) {
|
||||||
|
opts.onSkip?.("silent");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
text = "";
|
text = "";
|
||||||
}
|
}
|
||||||
if (text && !trimmed) {
|
if (text && !trimmed) {
|
||||||
@ -43,14 +52,20 @@ export function normalizeReplyPayload(
|
|||||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||||
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
|
if (stripped.shouldSkip && !hasMedia && !hasChannelData) {
|
||||||
|
opts.onSkip?.("heartbeat");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
text = stripped.text;
|
text = stripped.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
text = sanitizeUserFacingText(text);
|
text = sanitizeUserFacingText(text);
|
||||||
}
|
}
|
||||||
if (!text?.trim() && !hasMedia && !hasChannelData) return null;
|
if (!text?.trim() && !hasMedia && !hasChannelData) {
|
||||||
|
opts.onSkip?.("empty");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
|
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
|
||||||
let enrichedPayload: ReplyPayload = { ...payload, text };
|
let enrichedPayload: ReplyPayload = { ...payload, text };
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { HumanDelayConfig } from "../../config/types.js";
|
import type { HumanDelayConfig } from "../../config/types.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js";
|
||||||
import type { ResponsePrefixContext } from "./response-prefix-template.js";
|
import type { ResponsePrefixContext } from "./response-prefix-template.js";
|
||||||
import type { TypingController } from "./typing.js";
|
import type { TypingController } from "./typing.js";
|
||||||
|
|
||||||
@ -8,6 +8,11 @@ export type ReplyDispatchKind = "tool" | "block" | "final";
|
|||||||
|
|
||||||
type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void;
|
type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void;
|
||||||
|
|
||||||
|
type ReplyDispatchSkipHandler = (
|
||||||
|
payload: ReplyPayload,
|
||||||
|
info: { kind: ReplyDispatchKind; reason: NormalizeReplySkipReason },
|
||||||
|
) => void;
|
||||||
|
|
||||||
type ReplyDispatchDeliverer = (
|
type ReplyDispatchDeliverer = (
|
||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
info: { kind: ReplyDispatchKind },
|
info: { kind: ReplyDispatchKind },
|
||||||
@ -42,6 +47,8 @@ export type ReplyDispatcherOptions = {
|
|||||||
onHeartbeatStrip?: () => void;
|
onHeartbeatStrip?: () => void;
|
||||||
onIdle?: () => void;
|
onIdle?: () => void;
|
||||||
onError?: ReplyDispatchErrorHandler;
|
onError?: ReplyDispatchErrorHandler;
|
||||||
|
// AIDEV-NOTE: onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback).
|
||||||
|
onSkip?: ReplyDispatchSkipHandler;
|
||||||
/** Human-like delay between block replies for natural rhythm. */
|
/** Human-like delay between block replies for natural rhythm. */
|
||||||
humanDelay?: HumanDelayConfig;
|
humanDelay?: HumanDelayConfig;
|
||||||
};
|
};
|
||||||
@ -65,15 +72,16 @@ export type ReplyDispatcher = {
|
|||||||
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
|
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NormalizeReplyPayloadInternalOptions = Pick<
|
||||||
|
ReplyDispatcherOptions,
|
||||||
|
"responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip"
|
||||||
|
> & {
|
||||||
|
onSkip?: (reason: NormalizeReplySkipReason) => void;
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeReplyPayloadInternal(
|
function normalizeReplyPayloadInternal(
|
||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
opts: Pick<
|
opts: NormalizeReplyPayloadInternalOptions,
|
||||||
ReplyDispatcherOptions,
|
|
||||||
| "responsePrefix"
|
|
||||||
| "responsePrefixContext"
|
|
||||||
| "responsePrefixContextProvider"
|
|
||||||
| "onHeartbeatStrip"
|
|
||||||
>,
|
|
||||||
): ReplyPayload | null {
|
): ReplyPayload | null {
|
||||||
// Prefer dynamic context provider over static context
|
// Prefer dynamic context provider over static context
|
||||||
const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext;
|
const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext;
|
||||||
@ -82,6 +90,7 @@ function normalizeReplyPayloadInternal(
|
|||||||
responsePrefix: opts.responsePrefix,
|
responsePrefix: opts.responsePrefix,
|
||||||
responsePrefixContext: prefixContext,
|
responsePrefixContext: prefixContext,
|
||||||
onHeartbeatStrip: opts.onHeartbeatStrip,
|
onHeartbeatStrip: opts.onHeartbeatStrip,
|
||||||
|
onSkip: opts.onSkip,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +108,13 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
|||||||
};
|
};
|
||||||
|
|
||||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||||
const normalized = normalizeReplyPayloadInternal(payload, options);
|
const normalized = normalizeReplyPayloadInternal(payload, {
|
||||||
|
responsePrefix: options.responsePrefix,
|
||||||
|
responsePrefixContext: options.responsePrefixContext,
|
||||||
|
responsePrefixContextProvider: options.responsePrefixContextProvider,
|
||||||
|
onHeartbeatStrip: options.onHeartbeatStrip,
|
||||||
|
onSkip: (reason) => options.onSkip?.(payload, { kind, reason }),
|
||||||
|
});
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
queuedCounts[kind] += 1;
|
queuedCounts[kind] += 1;
|
||||||
pending += 1;
|
pending += 1;
|
||||||
|
|||||||
@ -202,6 +202,16 @@ describe("canvas host", () => {
|
|||||||
|
|
||||||
it("serves the gateway-hosted A2UI scaffold", async () => {
|
it("serves the gateway-hosted A2UI scaffold", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-canvas-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-canvas-"));
|
||||||
|
const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui");
|
||||||
|
const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js");
|
||||||
|
let createdBundle = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.stat(bundlePath);
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(bundlePath, "window.moltbotA2UI = {};", "utf8");
|
||||||
|
createdBundle = true;
|
||||||
|
}
|
||||||
|
|
||||||
const server = await startCanvasHost({
|
const server = await startCanvasHost({
|
||||||
runtime: defaultRuntime,
|
runtime: defaultRuntime,
|
||||||
@ -226,6 +236,9 @@ describe("canvas host", () => {
|
|||||||
expect(js).toContain("moltbotA2UI");
|
expect(js).toContain("moltbotA2UI");
|
||||||
} finally {
|
} finally {
|
||||||
await server.close();
|
await server.close();
|
||||||
|
if (createdBundle) {
|
||||||
|
await fs.rm(bundlePath, { force: true });
|
||||||
|
}
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { setVerbose } from "../globals.js";
|
|||||||
import { withProgress, withProgressTotals } from "./progress.js";
|
import { withProgress, withProgressTotals } from "./progress.js";
|
||||||
import { formatErrorMessage, withManager } from "./cli-utils.js";
|
import { formatErrorMessage, withManager } from "./cli-utils.js";
|
||||||
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
|
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
|
||||||
import { listMemoryFiles } from "../memory/internal.js";
|
import { listMemoryFiles, normalizeExtraMemoryPaths } from "../memory/internal.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
@ -74,6 +74,10 @@ function resolveAgentIds(cfg: ReturnType<typeof loadConfig>, agent?: string): st
|
|||||||
return [resolveDefaultAgentId(cfg)];
|
return [resolveDefaultAgentId(cfg)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[] {
|
||||||
|
return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry));
|
||||||
|
}
|
||||||
|
|
||||||
async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> {
|
async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> {
|
||||||
try {
|
try {
|
||||||
await fs.access(pathname, fsSync.constants.R_OK);
|
await fs.access(pathname, fsSync.constants.R_OK);
|
||||||
@ -110,7 +114,10 @@ async function scanSessionFiles(agentId: string): Promise<SourceScan> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
async function scanMemoryFiles(
|
||||||
|
workspaceDir: string,
|
||||||
|
extraPaths: string[] = [],
|
||||||
|
): Promise<SourceScan> {
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
const memoryFile = path.join(workspaceDir, "MEMORY.md");
|
const memoryFile = path.join(workspaceDir, "MEMORY.md");
|
||||||
const altMemoryFile = path.join(workspaceDir, "memory.md");
|
const altMemoryFile = path.join(workspaceDir, "memory.md");
|
||||||
@ -121,6 +128,25 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
|||||||
if (primary.issue) issues.push(primary.issue);
|
if (primary.issue) issues.push(primary.issue);
|
||||||
if (alt.issue) issues.push(alt.issue);
|
if (alt.issue) issues.push(alt.issue);
|
||||||
|
|
||||||
|
const resolvedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths);
|
||||||
|
for (const extraPath of resolvedExtraPaths) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(extraPath);
|
||||||
|
if (stat.isSymbolicLink()) continue;
|
||||||
|
const extraCheck = await checkReadableFile(extraPath);
|
||||||
|
if (extraCheck.issue) issues.push(extraCheck.issue);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === "ENOENT") {
|
||||||
|
issues.push(`additional memory path missing (${shortenHomePath(extraPath)})`);
|
||||||
|
} else {
|
||||||
|
issues.push(
|
||||||
|
`additional memory path not accessible (${shortenHomePath(extraPath)}): ${code ?? "error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let dirReadable: boolean | null = null;
|
let dirReadable: boolean | null = null;
|
||||||
try {
|
try {
|
||||||
await fs.access(memoryDir, fsSync.constants.R_OK);
|
await fs.access(memoryDir, fsSync.constants.R_OK);
|
||||||
@ -141,7 +167,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
|||||||
let listed: string[] = [];
|
let listed: string[] = [];
|
||||||
let listedOk = false;
|
let listedOk = false;
|
||||||
try {
|
try {
|
||||||
listed = await listMemoryFiles(workspaceDir);
|
listed = await listMemoryFiles(workspaceDir, resolvedExtraPaths);
|
||||||
listedOk = true;
|
listedOk = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = (err as NodeJS.ErrnoException).code;
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
@ -176,11 +202,13 @@ async function scanMemorySources(params: {
|
|||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
sources: MemorySourceName[];
|
sources: MemorySourceName[];
|
||||||
|
extraPaths?: string[];
|
||||||
}): Promise<MemorySourceScan> {
|
}): Promise<MemorySourceScan> {
|
||||||
const scans: SourceScan[] = [];
|
const scans: SourceScan[] = [];
|
||||||
|
const extraPaths = params.extraPaths ?? [];
|
||||||
for (const source of params.sources) {
|
for (const source of params.sources) {
|
||||||
if (source === "memory") {
|
if (source === "memory") {
|
||||||
scans.push(await scanMemoryFiles(params.workspaceDir));
|
scans.push(await scanMemoryFiles(params.workspaceDir, extraPaths));
|
||||||
}
|
}
|
||||||
if (source === "sessions") {
|
if (source === "sessions") {
|
||||||
scans.push(await scanSessionFiles(params.agentId));
|
scans.push(await scanSessionFiles(params.agentId));
|
||||||
@ -268,6 +296,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
|||||||
workspaceDir: status.workspaceDir,
|
workspaceDir: status.workspaceDir,
|
||||||
agentId,
|
agentId,
|
||||||
sources,
|
sources,
|
||||||
|
extraPaths: status.extraPaths,
|
||||||
});
|
});
|
||||||
allResults.push({ agentId, status, embeddingProbe, indexError, scan });
|
allResults.push({ agentId, status, embeddingProbe, indexError, scan });
|
||||||
},
|
},
|
||||||
@ -299,6 +328,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
|||||||
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
|
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
|
||||||
defaultRuntime.log(line);
|
defaultRuntime.log(line);
|
||||||
}
|
}
|
||||||
|
const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []);
|
||||||
const lines = [
|
const lines = [
|
||||||
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
||||||
`${label("Provider")} ${info(status.provider)} ${muted(
|
`${label("Provider")} ${info(status.provider)} ${muted(
|
||||||
@ -306,6 +336,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
|||||||
)}`,
|
)}`,
|
||||||
`${label("Model")} ${info(status.model)}`,
|
`${label("Model")} ${info(status.model)}`,
|
||||||
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
|
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
|
||||||
|
extraPaths.length ? `${label("Extra paths")} ${info(extraPaths.join(", "))}` : null,
|
||||||
`${label("Indexed")} ${success(indexedLabel)}`,
|
`${label("Indexed")} ${success(indexedLabel)}`,
|
||||||
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
||||||
`${label("Store")} ${info(shortenHomePath(status.dbPath))}`,
|
`${label("Store")} ${info(shortenHomePath(status.dbPath))}`,
|
||||||
@ -469,6 +500,7 @@ export function registerMemoryCli(program: Command) {
|
|||||||
const sourceLabels = status.sources.map((source) =>
|
const sourceLabels = status.sources.map((source) =>
|
||||||
formatSourceLabel(source, status.workspaceDir, agentId),
|
formatSourceLabel(source, status.workspaceDir, agentId),
|
||||||
);
|
);
|
||||||
|
const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []);
|
||||||
const lines = [
|
const lines = [
|
||||||
`${heading("Memory Index")} ${muted(`(${agentId})`)}`,
|
`${heading("Memory Index")} ${muted(`(${agentId})`)}`,
|
||||||
`${label("Provider")} ${info(status.provider)} ${muted(
|
`${label("Provider")} ${info(status.provider)} ${muted(
|
||||||
@ -478,6 +510,9 @@ export function registerMemoryCli(program: Command) {
|
|||||||
sourceLabels.length
|
sourceLabels.length
|
||||||
? `${label("Sources")} ${info(sourceLabels.join(", "))}`
|
? `${label("Sources")} ${info(sourceLabels.join(", "))}`
|
||||||
: null,
|
: null,
|
||||||
|
extraPaths.length
|
||||||
|
? `${label("Extra paths")} ${info(extraPaths.join(", "))}`
|
||||||
|
: null,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
if (status.fallback) {
|
if (status.fallback) {
|
||||||
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
||||||
|
|||||||
@ -222,6 +222,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"agents.defaults.memorySearch": "Memory Search",
|
"agents.defaults.memorySearch": "Memory Search",
|
||||||
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
|
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
|
||||||
"agents.defaults.memorySearch.sources": "Memory Search Sources",
|
"agents.defaults.memorySearch.sources": "Memory Search Sources",
|
||||||
|
"agents.defaults.memorySearch.extraPaths": "Extra Memory Paths",
|
||||||
"agents.defaults.memorySearch.experimental.sessionMemory":
|
"agents.defaults.memorySearch.experimental.sessionMemory":
|
||||||
"Memory Search Session Index (Experimental)",
|
"Memory Search Session Index (Experimental)",
|
||||||
"agents.defaults.memorySearch.provider": "Memory Search Provider",
|
"agents.defaults.memorySearch.provider": "Memory Search Provider",
|
||||||
@ -499,6 +500,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
|
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
|
||||||
"agents.defaults.memorySearch.sources":
|
"agents.defaults.memorySearch.sources":
|
||||||
'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).',
|
'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).',
|
||||||
|
"agents.defaults.memorySearch.extraPaths":
|
||||||
|
"Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).",
|
||||||
"agents.defaults.memorySearch.experimental.sessionMemory":
|
"agents.defaults.memorySearch.experimental.sessionMemory":
|
||||||
"Enable experimental session transcript indexing for memory search (default: false).",
|
"Enable experimental session transcript indexing for memory search (default: false).",
|
||||||
"agents.defaults.memorySearch.provider": 'Embedding provider ("openai", "gemini", or "local").',
|
"agents.defaults.memorySearch.provider": 'Embedding provider ("openai", "gemini", or "local").',
|
||||||
|
|||||||
@ -226,6 +226,8 @@ export type MemorySearchConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Sources to index and search (default: ["memory"]). */
|
/** Sources to index and search (default: ["memory"]). */
|
||||||
sources?: Array<"memory" | "sessions">;
|
sources?: Array<"memory" | "sessions">;
|
||||||
|
/** Extra paths to include in memory search (directories or .md files). */
|
||||||
|
extraPaths?: string[];
|
||||||
/** Experimental memory search settings. */
|
/** Experimental memory search settings. */
|
||||||
experimental?: {
|
experimental?: {
|
||||||
/** Enable session transcript indexing (experimental, default: false). */
|
/** Enable session transcript indexing (experimental, default: false). */
|
||||||
|
|||||||
@ -304,6 +304,7 @@ export const MemorySearchSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(),
|
sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(),
|
||||||
|
extraPaths: z.array(z.string()).optional(),
|
||||||
experimental: z
|
experimental: z
|
||||||
.object({
|
.object({
|
||||||
sessionMemory: z.boolean().optional(),
|
sessionMemory: z.boolean().optional(),
|
||||||
|
|||||||
@ -135,7 +135,7 @@ describe("discord tool result dispatch", () => {
|
|||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("skips guild messages when another user is explicitly mentioned", async () => {
|
it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
const cfg = {
|
const cfg = {
|
||||||
agents: {
|
agents: {
|
||||||
@ -211,8 +211,8 @@ describe("discord tool result dispatch", () => {
|
|||||||
client,
|
client,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(dispatchMock).not.toHaveBeenCalled();
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(sendMock).not.toHaveBeenCalled();
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
|
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
|
||||||
|
|||||||
@ -41,7 +41,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
mockedResolveApiKey.mockClear();
|
mockedResolveApiKey.mockClear();
|
||||||
mockedFetchRemoteMedia.mockReset();
|
mockedFetchRemoteMedia.mockReset();
|
||||||
mockedFetchRemoteMedia.mockResolvedValue({
|
mockedFetchRemoteMedia.mockResolvedValue({
|
||||||
buffer: Buffer.from("audio-bytes"),
|
buffer: Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]),
|
||||||
contentType: "audio/ogg",
|
contentType: "audio/ogg",
|
||||||
fileName: "note.ogg",
|
fileName: "note.ogg",
|
||||||
});
|
});
|
||||||
@ -51,7 +51,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
const { applyMediaUnderstanding } = await loadApply();
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
const audioPath = path.join(dir, "note.ogg");
|
const audioPath = path.join(dir, "note.ogg");
|
||||||
await fs.writeFile(audioPath, "hello");
|
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
Body: "<media:audio>",
|
Body: "<media:audio>",
|
||||||
@ -94,7 +94,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
const { applyMediaUnderstanding } = await loadApply();
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
const audioPath = path.join(dir, "note.ogg");
|
const audioPath = path.join(dir, "note.ogg");
|
||||||
await fs.writeFile(audioPath, "hello");
|
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
Body: "<media:audio> /capture status",
|
Body: "<media:audio> /capture status",
|
||||||
@ -176,7 +176,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
const { applyMediaUnderstanding } = await loadApply();
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
const audioPath = path.join(dir, "large.wav");
|
const audioPath = path.join(dir, "large.wav");
|
||||||
await fs.writeFile(audioPath, "0123456789");
|
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]));
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
Body: "<media:audio>",
|
Body: "<media:audio>",
|
||||||
@ -211,7 +211,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
const { applyMediaUnderstanding } = await loadApply();
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
const audioPath = path.join(dir, "note.ogg");
|
const audioPath = path.join(dir, "note.ogg");
|
||||||
await fs.writeFile(audioPath, "hello");
|
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8]));
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
Body: "<media:audio>",
|
Body: "<media:audio>",
|
||||||
@ -352,7 +352,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
const { applyMediaUnderstanding } = await loadApply();
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
const audioPath = path.join(dir, "fallback.ogg");
|
const audioPath = path.join(dir, "fallback.ogg");
|
||||||
await fs.writeFile(audioPath, "hello");
|
await fs.writeFile(audioPath, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6]));
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
Body: "<media:audio>",
|
Body: "<media:audio>",
|
||||||
@ -390,8 +390,8 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
const audioPathA = path.join(dir, "note-a.ogg");
|
const audioPathA = path.join(dir, "note-a.ogg");
|
||||||
const audioPathB = path.join(dir, "note-b.ogg");
|
const audioPathB = path.join(dir, "note-b.ogg");
|
||||||
await fs.writeFile(audioPathA, "hello");
|
await fs.writeFile(audioPathA, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
|
||||||
await fs.writeFile(audioPathB, "world");
|
await fs.writeFile(audioPathB, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
Body: "<media:audio>",
|
Body: "<media:audio>",
|
||||||
@ -435,7 +435,7 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
const audioPath = path.join(dir, "note.ogg");
|
const audioPath = path.join(dir, "note.ogg");
|
||||||
const videoPath = path.join(dir, "clip.mp4");
|
const videoPath = path.join(dir, "clip.mp4");
|
||||||
await fs.writeFile(imagePath, "image-bytes");
|
await fs.writeFile(imagePath, "image-bytes");
|
||||||
await fs.writeFile(audioPath, "audio-bytes");
|
await fs.writeFile(audioPath, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208]));
|
||||||
await fs.writeFile(videoPath, "video-bytes");
|
await fs.writeFile(videoPath, "video-bytes");
|
||||||
|
|
||||||
const ctx: MsgContext = {
|
const ctx: MsgContext = {
|
||||||
@ -487,4 +487,187 @@ describe("applyMediaUnderstanding", () => {
|
|||||||
expect(ctx.CommandBody).toBe("audio ok");
|
expect(ctx.CommandBody).toBe("audio ok");
|
||||||
expect(ctx.BodyForCommands).toBe("audio ok");
|
expect(ctx.BodyForCommands).toBe("audio ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats text-like audio attachments as CSV (comma wins over tabs)", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
const csvPath = path.join(dir, "data.mp3");
|
||||||
|
const csvText = '"a","b"\t"c"\n"1","2"\t"3"';
|
||||||
|
const csvBuffer = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(csvText, "utf16le")]);
|
||||||
|
await fs.writeFile(csvPath, csvBuffer);
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:audio>",
|
||||||
|
MediaPath: csvPath,
|
||||||
|
MediaType: "audio/mpeg",
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
expect(ctx.Body).toContain('<file name="data.mp3" mime="text/csv">');
|
||||||
|
expect(ctx.Body).toContain('"a","b"\t"c"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("infers TSV when tabs are present without commas", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
const tsvPath = path.join(dir, "report.mp3");
|
||||||
|
const tsvText = "a\tb\tc\n1\t2\t3";
|
||||||
|
await fs.writeFile(tsvPath, tsvText);
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:audio>",
|
||||||
|
MediaPath: tsvPath,
|
||||||
|
MediaType: "audio/mpeg",
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
expect(ctx.Body).toContain('<file name="report.mp3" mime="text/tab-separated-values">');
|
||||||
|
expect(ctx.Body).toContain("a\tb\tc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes XML special characters in filenames to prevent injection", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
// Use & in filename — valid on all platforms (including Windows, which
|
||||||
|
// forbids < and > in NTFS filenames) and still requires XML escaping.
|
||||||
|
// Note: The sanitizeFilename in store.ts would strip most dangerous chars,
|
||||||
|
// but we test that even if some slip through, they get escaped in output
|
||||||
|
const filePath = path.join(dir, "file&test.txt");
|
||||||
|
await fs.writeFile(filePath, "safe content");
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:document>",
|
||||||
|
MediaPath: filePath,
|
||||||
|
MediaType: "text/plain",
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
// Verify XML special chars are escaped in the output
|
||||||
|
expect(ctx.Body).toContain("&");
|
||||||
|
// The name attribute should contain the escaped form, not a raw unescaped &
|
||||||
|
expect(ctx.Body).toMatch(/name="file&test\.txt"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes MIME types to prevent attribute injection", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
const filePath = path.join(dir, "data.txt");
|
||||||
|
await fs.writeFile(filePath, "test content");
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:document>",
|
||||||
|
MediaPath: filePath,
|
||||||
|
// Attempt to inject via MIME type with quotes - normalization should strip this
|
||||||
|
MediaType: 'text/plain" onclick="alert(1)',
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
// MIME normalization strips everything after first ; or " - verify injection is blocked
|
||||||
|
expect(ctx.Body).not.toContain("onclick=");
|
||||||
|
expect(ctx.Body).not.toContain("alert(1)");
|
||||||
|
// Verify the MIME type is normalized to just "text/plain"
|
||||||
|
expect(ctx.Body).toContain('mime="text/plain"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles path traversal attempts in filenames safely", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
// Even if a file somehow got a path-like name, it should be handled safely
|
||||||
|
const filePath = path.join(dir, "normal.txt");
|
||||||
|
await fs.writeFile(filePath, "legitimate content");
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:document>",
|
||||||
|
MediaPath: filePath,
|
||||||
|
MediaType: "text/plain",
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
// Verify the file was processed and output contains expected structure
|
||||||
|
expect(ctx.Body).toContain('<file name="');
|
||||||
|
expect(ctx.Body).toContain('mime="text/plain"');
|
||||||
|
expect(ctx.Body).toContain("legitimate content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles files with non-ASCII Unicode filenames", async () => {
|
||||||
|
const { applyMediaUnderstanding } = await loadApply();
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-media-"));
|
||||||
|
const filePath = path.join(dir, "文档.txt");
|
||||||
|
await fs.writeFile(filePath, "中文内容");
|
||||||
|
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
Body: "<media:document>",
|
||||||
|
MediaPath: filePath,
|
||||||
|
MediaType: "text/plain",
|
||||||
|
};
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: { enabled: false },
|
||||||
|
image: { enabled: false },
|
||||||
|
video: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyMediaUnderstanding({ ctx, cfg });
|
||||||
|
|
||||||
|
expect(result.appliedFile).toBe(true);
|
||||||
|
expect(ctx.Body).toContain("中文内容");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,22 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { MsgContext } from "../auto-reply/templating.js";
|
import type { MsgContext } from "../auto-reply/templating.js";
|
||||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
||||||
|
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
|
import {
|
||||||
|
DEFAULT_INPUT_FILE_MAX_BYTES,
|
||||||
|
DEFAULT_INPUT_FILE_MAX_CHARS,
|
||||||
|
DEFAULT_INPUT_FILE_MIMES,
|
||||||
|
DEFAULT_INPUT_MAX_REDIRECTS,
|
||||||
|
DEFAULT_INPUT_PDF_MAX_PAGES,
|
||||||
|
DEFAULT_INPUT_PDF_MAX_PIXELS,
|
||||||
|
DEFAULT_INPUT_PDF_MIN_TEXT_CHARS,
|
||||||
|
DEFAULT_INPUT_TIMEOUT_MS,
|
||||||
|
extractFileContentFromSource,
|
||||||
|
normalizeMimeList,
|
||||||
|
normalizeMimeType,
|
||||||
|
} from "../media/input-files.js";
|
||||||
import {
|
import {
|
||||||
extractMediaUserText,
|
extractMediaUserText,
|
||||||
formatAudioTranscripts,
|
formatAudioTranscripts,
|
||||||
@ -14,6 +30,7 @@ import type {
|
|||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { runWithConcurrency } from "./concurrency.js";
|
import { runWithConcurrency } from "./concurrency.js";
|
||||||
import { resolveConcurrency } from "./resolve.js";
|
import { resolveConcurrency } from "./resolve.js";
|
||||||
|
import { resolveAttachmentKind } from "./attachments.js";
|
||||||
import {
|
import {
|
||||||
type ActiveMediaModel,
|
type ActiveMediaModel,
|
||||||
buildProviderRegistry,
|
buildProviderRegistry,
|
||||||
@ -28,9 +45,279 @@ export type ApplyMediaUnderstandingResult = {
|
|||||||
appliedImage: boolean;
|
appliedImage: boolean;
|
||||||
appliedAudio: boolean;
|
appliedAudio: boolean;
|
||||||
appliedVideo: boolean;
|
appliedVideo: boolean;
|
||||||
|
appliedFile: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["image", "audio", "video"];
|
const CAPABILITY_ORDER: MediaUnderstandingCapability[] = ["image", "audio", "video"];
|
||||||
|
const EXTRA_TEXT_MIMES = [
|
||||||
|
"application/xml",
|
||||||
|
"text/xml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"text/yaml",
|
||||||
|
"application/yaml",
|
||||||
|
"application/javascript",
|
||||||
|
"text/javascript",
|
||||||
|
"text/tab-separated-values",
|
||||||
|
];
|
||||||
|
const TEXT_EXT_MIME = new Map<string, string>([
|
||||||
|
[".csv", "text/csv"],
|
||||||
|
[".tsv", "text/tab-separated-values"],
|
||||||
|
[".txt", "text/plain"],
|
||||||
|
[".md", "text/markdown"],
|
||||||
|
[".log", "text/plain"],
|
||||||
|
[".ini", "text/plain"],
|
||||||
|
[".cfg", "text/plain"],
|
||||||
|
[".conf", "text/plain"],
|
||||||
|
[".env", "text/plain"],
|
||||||
|
[".json", "application/json"],
|
||||||
|
[".yaml", "text/yaml"],
|
||||||
|
[".yml", "text/yaml"],
|
||||||
|
[".xml", "application/xml"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const XML_ESCAPE_MAP: Record<string, string> = {
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"&": "&",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes special XML characters in attribute values to prevent injection.
|
||||||
|
*/
|
||||||
|
function xmlEscapeAttr(value: string): string {
|
||||||
|
return value.replace(/[<>&"']/g, (char) => XML_ESCAPE_MAP[char] ?? char);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFileLimits(cfg: MoltbotConfig) {
|
||||||
|
const files = cfg.gateway?.http?.endpoints?.responses?.files;
|
||||||
|
return {
|
||||||
|
allowUrl: files?.allowUrl ?? true,
|
||||||
|
allowedMimes: normalizeMimeList(files?.allowedMimes, DEFAULT_INPUT_FILE_MIMES),
|
||||||
|
maxBytes: files?.maxBytes ?? DEFAULT_INPUT_FILE_MAX_BYTES,
|
||||||
|
maxChars: files?.maxChars ?? DEFAULT_INPUT_FILE_MAX_CHARS,
|
||||||
|
maxRedirects: files?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS,
|
||||||
|
timeoutMs: files?.timeoutMs ?? DEFAULT_INPUT_TIMEOUT_MS,
|
||||||
|
pdf: {
|
||||||
|
maxPages: files?.pdf?.maxPages ?? DEFAULT_INPUT_PDF_MAX_PAGES,
|
||||||
|
maxPixels: files?.pdf?.maxPixels ?? DEFAULT_INPUT_PDF_MAX_PIXELS,
|
||||||
|
minTextChars: files?.pdf?.minTextChars ?? DEFAULT_INPUT_PDF_MIN_TEXT_CHARS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFileBlocks(body: string | undefined, blocks: string[]): string {
|
||||||
|
if (!blocks || blocks.length === 0) {
|
||||||
|
return body ?? "";
|
||||||
|
}
|
||||||
|
const base = typeof body === "string" ? body.trim() : "";
|
||||||
|
const suffix = blocks.join("\n\n").trim();
|
||||||
|
if (!base) {
|
||||||
|
return suffix;
|
||||||
|
}
|
||||||
|
return `${base}\n\n${suffix}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUtf16Charset(buffer?: Buffer): "utf-16le" | "utf-16be" | undefined {
|
||||||
|
if (!buffer || buffer.length < 2) return undefined;
|
||||||
|
const b0 = buffer[0];
|
||||||
|
const b1 = buffer[1];
|
||||||
|
if (b0 === 0xff && b1 === 0xfe) {
|
||||||
|
return "utf-16le";
|
||||||
|
}
|
||||||
|
if (b0 === 0xfe && b1 === 0xff) {
|
||||||
|
return "utf-16be";
|
||||||
|
}
|
||||||
|
const sampleLen = Math.min(buffer.length, 2048);
|
||||||
|
let zeroCount = 0;
|
||||||
|
for (let i = 0; i < sampleLen; i += 1) {
|
||||||
|
if (buffer[i] === 0) zeroCount += 1;
|
||||||
|
}
|
||||||
|
if (zeroCount / sampleLen > 0.2) {
|
||||||
|
return "utf-16le";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUtf8Text(buffer?: Buffer): boolean {
|
||||||
|
if (!buffer || buffer.length === 0) return false;
|
||||||
|
const sampleLen = Math.min(buffer.length, 4096);
|
||||||
|
let printable = 0;
|
||||||
|
let other = 0;
|
||||||
|
for (let i = 0; i < sampleLen; i += 1) {
|
||||||
|
const byte = buffer[i];
|
||||||
|
if (byte === 0) {
|
||||||
|
other += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) {
|
||||||
|
printable += 1;
|
||||||
|
} else {
|
||||||
|
other += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = printable + other;
|
||||||
|
if (total === 0) return false;
|
||||||
|
return printable / total > 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTextSample(buffer?: Buffer): string {
|
||||||
|
if (!buffer || buffer.length === 0) return "";
|
||||||
|
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
|
||||||
|
const utf16Charset = resolveUtf16Charset(sample);
|
||||||
|
if (utf16Charset === "utf-16be") {
|
||||||
|
const swapped = Buffer.alloc(sample.length);
|
||||||
|
for (let i = 0; i + 1 < sample.length; i += 2) {
|
||||||
|
swapped[i] = sample[i + 1];
|
||||||
|
swapped[i + 1] = sample[i];
|
||||||
|
}
|
||||||
|
return new TextDecoder("utf-16le").decode(swapped);
|
||||||
|
}
|
||||||
|
if (utf16Charset === "utf-16le") {
|
||||||
|
return new TextDecoder("utf-16le").decode(sample);
|
||||||
|
}
|
||||||
|
return new TextDecoder("utf-8").decode(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessDelimitedMime(text: string): string | undefined {
|
||||||
|
if (!text) return undefined;
|
||||||
|
const line = text.split(/\r?\n/)[0] ?? "";
|
||||||
|
const tabs = (line.match(/\t/g) ?? []).length;
|
||||||
|
const commas = (line.match(/,/g) ?? []).length;
|
||||||
|
if (commas > 0) {
|
||||||
|
return "text/csv";
|
||||||
|
}
|
||||||
|
if (tabs > 0) {
|
||||||
|
return "text/tab-separated-values";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTextMimeFromName(name?: string): string | undefined {
|
||||||
|
if (!name) return undefined;
|
||||||
|
const ext = path.extname(name).toLowerCase();
|
||||||
|
return TEXT_EXT_MIME.get(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractFileBlocks(params: {
|
||||||
|
attachments: ReturnType<typeof normalizeMediaAttachments>;
|
||||||
|
cache: ReturnType<typeof createMediaAttachmentCache>;
|
||||||
|
limits: ReturnType<typeof resolveFileLimits>;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const { attachments, cache, limits } = params;
|
||||||
|
if (!attachments || attachments.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const blocks: string[] = [];
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (!attachment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const forcedTextMime = resolveTextMimeFromName(attachment.path ?? attachment.url ?? "");
|
||||||
|
const kind = forcedTextMime ? "document" : resolveAttachmentKind(attachment);
|
||||||
|
if (!forcedTextMime && (kind === "image" || kind === "video")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!limits.allowUrl && attachment.url && !attachment.path) {
|
||||||
|
if (shouldLogVerbose()) {
|
||||||
|
logVerbose(`media: file attachment skipped (url disabled) index=${attachment.index}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let bufferResult: Awaited<ReturnType<typeof cache.getBuffer>>;
|
||||||
|
try {
|
||||||
|
bufferResult = await cache.getBuffer({
|
||||||
|
attachmentIndex: attachment.index,
|
||||||
|
maxBytes: limits.maxBytes,
|
||||||
|
timeoutMs: limits.timeoutMs,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (shouldLogVerbose()) {
|
||||||
|
logVerbose(`media: file attachment skipped (buffer): ${String(err)}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nameHint = bufferResult?.fileName ?? attachment.path ?? attachment.url;
|
||||||
|
const forcedTextMimeResolved = forcedTextMime ?? resolveTextMimeFromName(nameHint ?? "");
|
||||||
|
const utf16Charset = resolveUtf16Charset(bufferResult?.buffer);
|
||||||
|
const textSample = decodeTextSample(bufferResult?.buffer);
|
||||||
|
const textLike = Boolean(utf16Charset) || looksLikeUtf8Text(bufferResult?.buffer);
|
||||||
|
if (!forcedTextMimeResolved && kind === "audio" && !textLike) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const guessedDelimited = textLike ? guessDelimitedMime(textSample) : undefined;
|
||||||
|
const textHint =
|
||||||
|
forcedTextMimeResolved ?? guessedDelimited ?? (textLike ? "text/plain" : undefined);
|
||||||
|
const rawMime = bufferResult?.mime ?? attachment.mime;
|
||||||
|
const mimeType = textHint ?? normalizeMimeType(rawMime);
|
||||||
|
// Log when MIME type is overridden from non-text to text for auditability
|
||||||
|
if (textHint && rawMime && !rawMime.startsWith("text/")) {
|
||||||
|
logVerbose(
|
||||||
|
`media: MIME override from "${rawMime}" to "${textHint}" for index=${attachment.index}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!mimeType) {
|
||||||
|
if (shouldLogVerbose()) {
|
||||||
|
logVerbose(`media: file attachment skipped (unknown mime) index=${attachment.index}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const allowedMimes = new Set(limits.allowedMimes);
|
||||||
|
for (const extra of EXTRA_TEXT_MIMES) {
|
||||||
|
allowedMimes.add(extra);
|
||||||
|
}
|
||||||
|
if (mimeType.startsWith("text/")) {
|
||||||
|
allowedMimes.add(mimeType);
|
||||||
|
}
|
||||||
|
if (!allowedMimes.has(mimeType)) {
|
||||||
|
if (shouldLogVerbose()) {
|
||||||
|
logVerbose(
|
||||||
|
`media: file attachment skipped (unsupported mime ${mimeType}) index=${attachment.index}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let extracted: Awaited<ReturnType<typeof extractFileContentFromSource>>;
|
||||||
|
try {
|
||||||
|
const mediaType = utf16Charset ? `${mimeType}; charset=${utf16Charset}` : mimeType;
|
||||||
|
extracted = await extractFileContentFromSource({
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
data: bufferResult.buffer.toString("base64"),
|
||||||
|
mediaType,
|
||||||
|
filename: bufferResult.fileName,
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
...limits,
|
||||||
|
allowedMimes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (shouldLogVerbose()) {
|
||||||
|
logVerbose(`media: file attachment skipped (extract): ${String(err)}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const text = extracted?.text?.trim() ?? "";
|
||||||
|
let blockText = text;
|
||||||
|
if (!blockText) {
|
||||||
|
if (extracted?.images && extracted.images.length > 0) {
|
||||||
|
blockText = "[PDF content rendered to images; images not forwarded to model]";
|
||||||
|
} else {
|
||||||
|
blockText = "[No extractable text]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const safeName = (bufferResult.fileName ?? `file-${attachment.index + 1}`)
|
||||||
|
.replace(/[\r\n\t]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
// Escape XML special characters in attributes to prevent injection
|
||||||
|
blocks.push(
|
||||||
|
`<file name="${xmlEscapeAttr(safeName)}" mime="${xmlEscapeAttr(mimeType)}">\n${blockText}\n</file>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyMediaUnderstanding(params: {
|
export async function applyMediaUnderstanding(params: {
|
||||||
ctx: MsgContext;
|
ctx: MsgContext;
|
||||||
@ -51,6 +338,12 @@ export async function applyMediaUnderstanding(params: {
|
|||||||
const cache = createMediaAttachmentCache(attachments);
|
const cache = createMediaAttachmentCache(attachments);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const fileBlocks = await extractFileBlocks({
|
||||||
|
attachments,
|
||||||
|
cache,
|
||||||
|
limits: resolveFileLimits(cfg),
|
||||||
|
});
|
||||||
|
|
||||||
const tasks = CAPABILITY_ORDER.map((capability) => async () => {
|
const tasks = CAPABILITY_ORDER.map((capability) => async () => {
|
||||||
const config = cfg.tools?.media?.[capability];
|
const config = cfg.tools?.media?.[capability];
|
||||||
return await runCapability({
|
return await runCapability({
|
||||||
@ -99,7 +392,15 @@ export async function applyMediaUnderstanding(params: {
|
|||||||
ctx.RawBody = originalUserText;
|
ctx.RawBody = originalUserText;
|
||||||
}
|
}
|
||||||
ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs];
|
ctx.MediaUnderstanding = [...(ctx.MediaUnderstanding ?? []), ...outputs];
|
||||||
finalizeInboundContext(ctx, { forceBodyForAgent: true, forceBodyForCommands: true });
|
}
|
||||||
|
if (fileBlocks.length > 0) {
|
||||||
|
ctx.Body = appendFileBlocks(ctx.Body, fileBlocks);
|
||||||
|
}
|
||||||
|
if (outputs.length > 0 || fileBlocks.length > 0) {
|
||||||
|
finalizeInboundContext(ctx, {
|
||||||
|
forceBodyForAgent: true,
|
||||||
|
forceBodyForCommands: outputs.length > 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -108,6 +409,7 @@ export async function applyMediaUnderstanding(params: {
|
|||||||
appliedImage: outputs.some((output) => output.kind === "image.description"),
|
appliedImage: outputs.some((output) => output.kind === "image.description"),
|
||||||
appliedAudio: outputs.some((output) => output.kind === "audio.transcription"),
|
appliedAudio: outputs.some((output) => output.kind === "audio.transcription"),
|
||||||
appliedVideo: outputs.some((output) => output.kind === "video.description"),
|
appliedVideo: outputs.some((output) => output.kind === "video.description"),
|
||||||
|
appliedFile: fileBlocks.length > 0,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await cache.cleanup();
|
await cache.cleanup();
|
||||||
|
|||||||
@ -412,4 +412,52 @@ describe("memory index", () => {
|
|||||||
manager = result.manager;
|
manager = result.manager;
|
||||||
await expect(result.manager.readFile({ relPath: "NOTES.md" })).rejects.toThrow("path required");
|
await expect(result.manager.readFile({ relPath: "NOTES.md" })).rejects.toThrow("path required");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows reading from additional memory paths and blocks symlinks", async () => {
|
||||||
|
const extraDir = path.join(workspaceDir, "extra");
|
||||||
|
await fs.mkdir(extraDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content.");
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
workspace: workspaceDir,
|
||||||
|
memorySearch: {
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-embed",
|
||||||
|
store: { path: indexPath },
|
||||||
|
sync: { watch: false, onSessionStart: false, onSearch: true },
|
||||||
|
extraPaths: [extraDir],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: [{ id: "main", default: true }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await getMemorySearchManager({ cfg, agentId: "main" });
|
||||||
|
expect(result.manager).not.toBeNull();
|
||||||
|
if (!result.manager) throw new Error("manager missing");
|
||||||
|
manager = result.manager;
|
||||||
|
await expect(result.manager.readFile({ relPath: "extra/extra.md" })).resolves.toEqual({
|
||||||
|
path: "extra/extra.md",
|
||||||
|
text: "Extra content.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkPath = path.join(extraDir, "linked.md");
|
||||||
|
let symlinkOk = true;
|
||||||
|
try {
|
||||||
|
await fs.symlink(path.join(extraDir, "extra.md"), linkPath, "file");
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === "EPERM" || code === "EACCES") {
|
||||||
|
symlinkOk = false;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (symlinkOk) {
|
||||||
|
await expect(result.manager.readFile({ relPath: "extra/linked.md" })).rejects.toThrow(
|
||||||
|
"path required",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,117 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { chunkMarkdown } from "./internal.js";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { chunkMarkdown, listMemoryFiles, normalizeExtraMemoryPaths } from "./internal.js";
|
||||||
|
|
||||||
|
describe("normalizeExtraMemoryPaths", () => {
|
||||||
|
it("trims, resolves, and dedupes paths", () => {
|
||||||
|
const workspaceDir = path.join(os.tmpdir(), "memory-test-workspace");
|
||||||
|
const absPath = path.resolve(path.sep, "shared-notes");
|
||||||
|
const result = normalizeExtraMemoryPaths(workspaceDir, [
|
||||||
|
" notes ",
|
||||||
|
"./notes",
|
||||||
|
absPath,
|
||||||
|
absPath,
|
||||||
|
"",
|
||||||
|
]);
|
||||||
|
expect(result).toEqual([path.resolve(workspaceDir, "notes"), absPath]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listMemoryFiles", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes files from additional paths (directory)", async () => {
|
||||||
|
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
|
||||||
|
const extraDir = path.join(tmpDir, "extra-notes");
|
||||||
|
await fs.mkdir(extraDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(extraDir, "note1.md"), "# Note 1");
|
||||||
|
await fs.writeFile(path.join(extraDir, "note2.md"), "# Note 2");
|
||||||
|
await fs.writeFile(path.join(extraDir, "ignore.txt"), "Not a markdown file");
|
||||||
|
|
||||||
|
const files = await listMemoryFiles(tmpDir, [extraDir]);
|
||||||
|
expect(files).toHaveLength(3);
|
||||||
|
expect(files.some((file) => file.endsWith("MEMORY.md"))).toBe(true);
|
||||||
|
expect(files.some((file) => file.endsWith("note1.md"))).toBe(true);
|
||||||
|
expect(files.some((file) => file.endsWith("note2.md"))).toBe(true);
|
||||||
|
expect(files.some((file) => file.endsWith("ignore.txt"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes files from additional paths (single file)", async () => {
|
||||||
|
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
|
||||||
|
const singleFile = path.join(tmpDir, "standalone.md");
|
||||||
|
await fs.writeFile(singleFile, "# Standalone");
|
||||||
|
|
||||||
|
const files = await listMemoryFiles(tmpDir, [singleFile]);
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(files.some((file) => file.endsWith("standalone.md"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles relative paths in additional paths", async () => {
|
||||||
|
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
|
||||||
|
const extraDir = path.join(tmpDir, "subdir");
|
||||||
|
await fs.mkdir(extraDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(extraDir, "nested.md"), "# Nested");
|
||||||
|
|
||||||
|
const files = await listMemoryFiles(tmpDir, ["subdir"]);
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
expect(files.some((file) => file.endsWith("nested.md"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-existent additional paths", async () => {
|
||||||
|
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
|
||||||
|
|
||||||
|
const files = await listMemoryFiles(tmpDir, ["/does/not/exist"]);
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores symlinked files and directories", async () => {
|
||||||
|
await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory");
|
||||||
|
const extraDir = path.join(tmpDir, "extra");
|
||||||
|
await fs.mkdir(extraDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(extraDir, "note.md"), "# Note");
|
||||||
|
|
||||||
|
const targetFile = path.join(tmpDir, "target.md");
|
||||||
|
await fs.writeFile(targetFile, "# Target");
|
||||||
|
const linkFile = path.join(extraDir, "linked.md");
|
||||||
|
|
||||||
|
const targetDir = path.join(tmpDir, "target-dir");
|
||||||
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(targetDir, "nested.md"), "# Nested");
|
||||||
|
const linkDir = path.join(tmpDir, "linked-dir");
|
||||||
|
|
||||||
|
let symlinksOk = true;
|
||||||
|
try {
|
||||||
|
await fs.symlink(targetFile, linkFile, "file");
|
||||||
|
await fs.symlink(targetDir, linkDir, "dir");
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === "EPERM" || code === "EACCES") {
|
||||||
|
symlinksOk = false;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await listMemoryFiles(tmpDir, [extraDir, linkDir]);
|
||||||
|
expect(files.some((file) => file.endsWith("note.md"))).toBe(true);
|
||||||
|
if (symlinksOk) {
|
||||||
|
expect(files.some((file) => file.endsWith("linked.md"))).toBe(false);
|
||||||
|
expect(files.some((file) => file.endsWith("nested.md"))).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("chunkMarkdown", () => {
|
describe("chunkMarkdown", () => {
|
||||||
it("splits overly long lines into max-sized chunks", () => {
|
it("splits overly long lines into max-sized chunks", () => {
|
||||||
|
|||||||
@ -30,6 +30,17 @@ export function normalizeRelPath(value: string): string {
|
|||||||
return trimmed.replace(/\\/g, "/");
|
return trimmed.replace(/\\/g, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeExtraMemoryPaths(workspaceDir: string, extraPaths?: string[]): string[] {
|
||||||
|
if (!extraPaths?.length) return [];
|
||||||
|
const resolved = extraPaths
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((value) =>
|
||||||
|
path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value),
|
||||||
|
);
|
||||||
|
return Array.from(new Set(resolved));
|
||||||
|
}
|
||||||
|
|
||||||
export function isMemoryPath(relPath: string): boolean {
|
export function isMemoryPath(relPath: string): boolean {
|
||||||
const normalized = normalizeRelPath(relPath);
|
const normalized = normalizeRelPath(relPath);
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
@ -37,19 +48,11 @@ export function isMemoryPath(relPath: string): boolean {
|
|||||||
return normalized.startsWith("memory/");
|
return normalized.startsWith("memory/");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exists(filePath: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await fs.access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walkDir(dir: string, files: string[]) {
|
async function walkDir(dir: string, files: string[]) {
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = path.join(dir, entry.name);
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isSymbolicLink()) continue;
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await walkDir(full, files);
|
await walkDir(full, files);
|
||||||
continue;
|
continue;
|
||||||
@ -60,15 +63,48 @@ async function walkDir(dir: string, files: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMemoryFiles(workspaceDir: string): Promise<string[]> {
|
export async function listMemoryFiles(
|
||||||
|
workspaceDir: string,
|
||||||
|
extraPaths?: string[],
|
||||||
|
): Promise<string[]> {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
const memoryFile = path.join(workspaceDir, "MEMORY.md");
|
const memoryFile = path.join(workspaceDir, "MEMORY.md");
|
||||||
const altMemoryFile = path.join(workspaceDir, "memory.md");
|
const altMemoryFile = path.join(workspaceDir, "memory.md");
|
||||||
if (await exists(memoryFile)) result.push(memoryFile);
|
|
||||||
if (await exists(altMemoryFile)) result.push(altMemoryFile);
|
|
||||||
const memoryDir = path.join(workspaceDir, "memory");
|
const memoryDir = path.join(workspaceDir, "memory");
|
||||||
if (await exists(memoryDir)) {
|
|
||||||
await walkDir(memoryDir, result);
|
const addMarkdownFile = async (absPath: string) => {
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(absPath);
|
||||||
|
if (stat.isSymbolicLink() || !stat.isFile()) return;
|
||||||
|
if (!absPath.endsWith(".md")) return;
|
||||||
|
result.push(absPath);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await addMarkdownFile(memoryFile);
|
||||||
|
await addMarkdownFile(altMemoryFile);
|
||||||
|
try {
|
||||||
|
const dirStat = await fs.lstat(memoryDir);
|
||||||
|
if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) {
|
||||||
|
await walkDir(memoryDir, result);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const normalizedExtraPaths = normalizeExtraMemoryPaths(workspaceDir, extraPaths);
|
||||||
|
if (normalizedExtraPaths.length > 0) {
|
||||||
|
for (const inputPath of normalizedExtraPaths) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(inputPath);
|
||||||
|
if (stat.isSymbolicLink()) continue;
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
await walkDir(inputPath, result);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stat.isFile() && inputPath.endsWith(".md")) {
|
||||||
|
result.push(inputPath);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (result.length <= 1) return result;
|
if (result.length <= 1) return result;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function computeMemoryManagerCacheKey(params: {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
enabled: settings.enabled,
|
enabled: settings.enabled,
|
||||||
sources: [...settings.sources].sort((a, b) => a.localeCompare(b)),
|
sources: [...settings.sources].sort((a, b) => a.localeCompare(b)),
|
||||||
|
extraPaths: [...settings.extraPaths].sort((a, b) => a.localeCompare(b)),
|
||||||
provider: settings.provider,
|
provider: settings.provider,
|
||||||
model: settings.model,
|
model: settings.model,
|
||||||
fallback: settings.fallback,
|
fallback: settings.fallback,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fsSync from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@ -35,9 +36,9 @@ import {
|
|||||||
hashText,
|
hashText,
|
||||||
isMemoryPath,
|
isMemoryPath,
|
||||||
listMemoryFiles,
|
listMemoryFiles,
|
||||||
|
normalizeExtraMemoryPaths,
|
||||||
type MemoryChunk,
|
type MemoryChunk,
|
||||||
type MemoryFileEntry,
|
type MemoryFileEntry,
|
||||||
normalizeRelPath,
|
|
||||||
parseEmbedding,
|
parseEmbedding,
|
||||||
} from "./internal.js";
|
} from "./internal.js";
|
||||||
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
|
import { bm25RankToScore, buildFtsQuery, mergeHybridResults } from "./hybrid.js";
|
||||||
@ -396,13 +397,52 @@ export class MemoryIndexManager {
|
|||||||
from?: number;
|
from?: number;
|
||||||
lines?: number;
|
lines?: number;
|
||||||
}): Promise<{ text: string; path: string }> {
|
}): Promise<{ text: string; path: string }> {
|
||||||
const relPath = normalizeRelPath(params.relPath);
|
const rawPath = params.relPath.trim();
|
||||||
if (!relPath || !isMemoryPath(relPath)) {
|
if (!rawPath) {
|
||||||
throw new Error("path required");
|
throw new Error("path required");
|
||||||
}
|
}
|
||||||
const absPath = path.resolve(this.workspaceDir, relPath);
|
const absPath = path.isAbsolute(rawPath)
|
||||||
if (!absPath.startsWith(this.workspaceDir)) {
|
? path.resolve(rawPath)
|
||||||
throw new Error("path escapes workspace");
|
: path.resolve(this.workspaceDir, rawPath);
|
||||||
|
const relPath = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/");
|
||||||
|
const inWorkspace =
|
||||||
|
relPath.length > 0 && !relPath.startsWith("..") && !path.isAbsolute(relPath);
|
||||||
|
const allowedWorkspace = inWorkspace && isMemoryPath(relPath);
|
||||||
|
let allowedAdditional = false;
|
||||||
|
if (!allowedWorkspace && this.settings.extraPaths.length > 0) {
|
||||||
|
const additionalPaths = normalizeExtraMemoryPaths(
|
||||||
|
this.workspaceDir,
|
||||||
|
this.settings.extraPaths,
|
||||||
|
);
|
||||||
|
for (const additionalPath of additionalPaths) {
|
||||||
|
try {
|
||||||
|
const stat = await fs.lstat(additionalPath);
|
||||||
|
if (stat.isSymbolicLink()) continue;
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
if (absPath === additionalPath || absPath.startsWith(`${additionalPath}${path.sep}`)) {
|
||||||
|
allowedAdditional = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stat.isFile()) {
|
||||||
|
if (absPath === additionalPath && absPath.endsWith(".md")) {
|
||||||
|
allowedAdditional = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allowedWorkspace && !allowedAdditional) {
|
||||||
|
throw new Error("path required");
|
||||||
|
}
|
||||||
|
if (!absPath.endsWith(".md")) {
|
||||||
|
throw new Error("path required");
|
||||||
|
}
|
||||||
|
const stat = await fs.lstat(absPath);
|
||||||
|
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||||
|
throw new Error("path required");
|
||||||
}
|
}
|
||||||
const content = await fs.readFile(absPath, "utf-8");
|
const content = await fs.readFile(absPath, "utf-8");
|
||||||
if (!params.from && !params.lines) {
|
if (!params.from && !params.lines) {
|
||||||
@ -425,6 +465,7 @@ export class MemoryIndexManager {
|
|||||||
model: string;
|
model: string;
|
||||||
requestedProvider: string;
|
requestedProvider: string;
|
||||||
sources: MemorySource[];
|
sources: MemorySource[];
|
||||||
|
extraPaths: string[];
|
||||||
sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
|
sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
|
||||||
cache?: { enabled: boolean; entries?: number; maxEntries?: number };
|
cache?: { enabled: boolean; entries?: number; maxEntries?: number };
|
||||||
fts?: { enabled: boolean; available: boolean; error?: string };
|
fts?: { enabled: boolean; available: boolean; error?: string };
|
||||||
@ -498,6 +539,7 @@ export class MemoryIndexManager {
|
|||||||
model: this.provider.model,
|
model: this.provider.model,
|
||||||
requestedProvider: this.requestedProvider,
|
requestedProvider: this.requestedProvider,
|
||||||
sources: Array.from(this.sources),
|
sources: Array.from(this.sources),
|
||||||
|
extraPaths: this.settings.extraPaths,
|
||||||
sourceCounts,
|
sourceCounts,
|
||||||
cache: this.cache.enabled
|
cache: this.cache.enabled
|
||||||
? {
|
? {
|
||||||
@ -769,11 +811,23 @@ export class MemoryIndexManager {
|
|||||||
|
|
||||||
private ensureWatcher() {
|
private ensureWatcher() {
|
||||||
if (!this.sources.has("memory") || !this.settings.sync.watch || this.watcher) return;
|
if (!this.sources.has("memory") || !this.settings.sync.watch || this.watcher) return;
|
||||||
const watchPaths = [
|
const additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths)
|
||||||
|
.map((entry) => {
|
||||||
|
try {
|
||||||
|
const stat = fsSync.lstatSync(entry);
|
||||||
|
return stat.isSymbolicLink() ? null : entry;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry): entry is string => Boolean(entry));
|
||||||
|
const watchPaths = new Set<string>([
|
||||||
path.join(this.workspaceDir, "MEMORY.md"),
|
path.join(this.workspaceDir, "MEMORY.md"),
|
||||||
|
path.join(this.workspaceDir, "memory.md"),
|
||||||
path.join(this.workspaceDir, "memory"),
|
path.join(this.workspaceDir, "memory"),
|
||||||
];
|
...additionalPaths,
|
||||||
this.watcher = chokidar.watch(watchPaths, {
|
]);
|
||||||
|
this.watcher = chokidar.watch(Array.from(watchPaths), {
|
||||||
ignoreInitial: true,
|
ignoreInitial: true,
|
||||||
awaitWriteFinish: {
|
awaitWriteFinish: {
|
||||||
stabilityThreshold: this.settings.sync.watchDebounceMs,
|
stabilityThreshold: this.settings.sync.watchDebounceMs,
|
||||||
@ -975,7 +1029,7 @@ export class MemoryIndexManager {
|
|||||||
needsFullReindex: boolean;
|
needsFullReindex: boolean;
|
||||||
progress?: MemorySyncProgressState;
|
progress?: MemorySyncProgressState;
|
||||||
}) {
|
}) {
|
||||||
const files = await listMemoryFiles(this.workspaceDir);
|
const files = await listMemoryFiles(this.workspaceDir, this.settings.extraPaths);
|
||||||
const fileEntries = await Promise.all(
|
const fileEntries = await Promise.all(
|
||||||
files.map(async (file) => buildFileEntry(file, this.workspaceDir)),
|
files.map(async (file) => buildFileEntry(file, this.workspaceDir)),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,6 +14,7 @@ type ProgressState = {
|
|||||||
|
|
||||||
export async function syncMemoryFiles(params: {
|
export async function syncMemoryFiles(params: {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
|
extraPaths?: string[];
|
||||||
db: DatabaseSync;
|
db: DatabaseSync;
|
||||||
needsFullReindex: boolean;
|
needsFullReindex: boolean;
|
||||||
progress?: ProgressState;
|
progress?: ProgressState;
|
||||||
@ -27,7 +28,7 @@ export async function syncMemoryFiles(params: {
|
|||||||
ftsAvailable: boolean;
|
ftsAvailable: boolean;
|
||||||
model: string;
|
model: string;
|
||||||
}) {
|
}) {
|
||||||
const files = await listMemoryFiles(params.workspaceDir);
|
const files = await listMemoryFiles(params.workspaceDir, params.extraPaths);
|
||||||
const fileEntries = await Promise.all(
|
const fileEntries = await Promise.all(
|
||||||
files.map(async (file) => buildFileEntry(file, params.workspaceDir)),
|
files.map(async (file) => buildFileEntry(file, params.workspaceDir)),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -392,7 +392,7 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips channel messages when another user is explicitly mentioned", async () => {
|
it("accepts channel messages when mentionPatterns match even if another user is mentioned", async () => {
|
||||||
slackTestState.config = {
|
slackTestState.config = {
|
||||||
messages: {
|
messages: {
|
||||||
responsePrefix: "PFX",
|
responsePrefix: "PFX",
|
||||||
@ -433,8 +433,8 @@ describe("monitorSlackProvider tool results", () => {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
await run;
|
await run;
|
||||||
|
|
||||||
expect(replyMock).not.toHaveBeenCalled();
|
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(sendMock).not.toHaveBeenCalled();
|
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats replies to bot threads as implicit mentions", async () => {
|
it("treats replies to bot threads as implicit mentions", async () => {
|
||||||
|
|||||||
@ -335,6 +335,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
let placeholder = "";
|
let placeholder = "";
|
||||||
if (msg.photo) placeholder = "<media:image>";
|
if (msg.photo) placeholder = "<media:image>";
|
||||||
else if (msg.video) placeholder = "<media:video>";
|
else if (msg.video) placeholder = "<media:video>";
|
||||||
|
else if (msg.video_note) placeholder = "<media:video>";
|
||||||
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
||||||
else if (msg.document) placeholder = "<media:document>";
|
else if (msg.document) placeholder = "<media:document>";
|
||||||
else if (msg.sticker) placeholder = "<media:sticker>";
|
else if (msg.sticker) placeholder = "<media:sticker>";
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import { createTelegramDraftStream } from "./draft-stream.js";
|
|||||||
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
|
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
|
||||||
import { resolveAgentDir } from "../agents/agent-scope.js";
|
import { resolveAgentDir } from "../agents/agent-scope.js";
|
||||||
|
|
||||||
|
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
|
||||||
|
|
||||||
async function resolveStickerVisionSupport(cfg, agentId) {
|
async function resolveStickerVisionSupport(cfg, agentId) {
|
||||||
try {
|
try {
|
||||||
const catalog = await loadModelCatalog({ config: cfg });
|
const catalog = await loadModelCatalog({ config: cfg });
|
||||||
@ -198,6 +200,15 @@ export const dispatchTelegramMessage = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replyQuoteText =
|
||||||
|
ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
|
||||||
|
? ctxPayload.ReplyToBody.trim() || undefined
|
||||||
|
: undefined;
|
||||||
|
const deliveryState = {
|
||||||
|
delivered: false,
|
||||||
|
skippedNonSilent: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
@ -209,12 +220,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
await flushDraft();
|
await flushDraft();
|
||||||
draftStream?.stop();
|
draftStream?.stop();
|
||||||
}
|
}
|
||||||
|
const result = await deliverReplies({
|
||||||
const replyQuoteText =
|
|
||||||
ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody
|
|
||||||
? ctxPayload.ReplyToBody.trim() || undefined
|
|
||||||
: undefined;
|
|
||||||
await deliverReplies({
|
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
chatId: String(chatId),
|
chatId: String(chatId),
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
@ -229,6 +235,12 @@ export const dispatchTelegramMessage = async ({
|
|||||||
linkPreview: telegramCfg.linkPreview,
|
linkPreview: telegramCfg.linkPreview,
|
||||||
replyQuoteText,
|
replyQuoteText,
|
||||||
});
|
});
|
||||||
|
if (result.delivered) {
|
||||||
|
deliveryState.delivered = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkip: (_payload, info) => {
|
||||||
|
if (info.reason !== "silent") deliveryState.skippedNonSilent += 1;
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
|
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
|
||||||
@ -260,7 +272,27 @@ export const dispatchTelegramMessage = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
draftStream?.stop();
|
draftStream?.stop();
|
||||||
if (!queuedFinal) {
|
let sentFallback = false;
|
||||||
|
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
||||||
|
const result = await deliverReplies({
|
||||||
|
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
|
||||||
|
chatId: String(chatId),
|
||||||
|
token: opts.token,
|
||||||
|
runtime,
|
||||||
|
bot,
|
||||||
|
replyToMode,
|
||||||
|
textLimit,
|
||||||
|
messageThreadId: resolvedThreadId,
|
||||||
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
|
linkPreview: telegramCfg.linkPreview,
|
||||||
|
replyQuoteText,
|
||||||
|
});
|
||||||
|
sentFallback = result.delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFinalResponse = queuedFinal || sentFallback;
|
||||||
|
if (!hasFinalResponse) {
|
||||||
if (isGroup && historyKey) {
|
if (isGroup && historyKey) {
|
||||||
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,8 @@ import {
|
|||||||
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
|
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
|
||||||
import { readTelegramAllowFromStore } from "./pairing-store.js";
|
import { readTelegramAllowFromStore } from "./pairing-store.js";
|
||||||
|
|
||||||
|
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
|
||||||
|
|
||||||
type TelegramNativeCommandContext = Context & { match?: string };
|
type TelegramNativeCommandContext = Context & { match?: string };
|
||||||
|
|
||||||
type TelegramCommandAuthResult = {
|
type TelegramCommandAuthResult = {
|
||||||
@ -468,6 +470,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
CommandSource: "native" as const,
|
CommandSource: "native" as const,
|
||||||
SessionKey: `telegram:slash:${senderId || chatId}`,
|
SessionKey: `telegram:slash:${senderId || chatId}`,
|
||||||
|
AccountId: route.accountId,
|
||||||
CommandTargetSessionKey: sessionKey,
|
CommandTargetSessionKey: sessionKey,
|
||||||
MessageThreadId: threadIdForSend,
|
MessageThreadId: threadIdForSend,
|
||||||
IsForum: isForum,
|
IsForum: isForum,
|
||||||
@ -482,13 +485,18 @@ export const registerTelegramNativeCommands = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
||||||
|
|
||||||
|
const deliveryState = {
|
||||||
|
delivered: false,
|
||||||
|
skippedNonSilent: 0,
|
||||||
|
};
|
||||||
|
|
||||||
await dispatchReplyWithBufferedBlockDispatcher({
|
await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||||
deliver: async (payload) => {
|
deliver: async (payload, _info) => {
|
||||||
await deliverReplies({
|
const result = await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
chatId: String(chatId),
|
chatId: String(chatId),
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
@ -501,6 +509,12 @@ export const registerTelegramNativeCommands = ({
|
|||||||
chunkMode,
|
chunkMode,
|
||||||
linkPreview: telegramCfg.linkPreview,
|
linkPreview: telegramCfg.linkPreview,
|
||||||
});
|
});
|
||||||
|
if (result.delivered) {
|
||||||
|
deliveryState.delivered = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSkip: (_payload, info) => {
|
||||||
|
if (info.reason !== "silent") deliveryState.skippedNonSilent += 1;
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
|
runtime.error?.(danger(`telegram slash ${info.kind} reply failed: ${String(err)}`));
|
||||||
@ -511,6 +525,21 @@ export const registerTelegramNativeCommands = ({
|
|||||||
disableBlockStreaming,
|
disableBlockStreaming,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
||||||
|
await deliverReplies({
|
||||||
|
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
|
||||||
|
chatId: String(chatId),
|
||||||
|
token: opts.token,
|
||||||
|
runtime,
|
||||||
|
bot,
|
||||||
|
replyToMode,
|
||||||
|
textLimit,
|
||||||
|
messageThreadId: threadIdForSend,
|
||||||
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
|
linkPreview: telegramCfg.linkPreview,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -212,7 +212,7 @@ describe("createTelegramBot", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips group messages when another user is explicitly mentioned", async () => {
|
it("accepts group messages when mentionPatterns match even if another user is mentioned", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
@ -249,7 +249,8 @@ describe("createTelegramBot", () => {
|
|||||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(replySpy).not.toHaveBeenCalled();
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replySpy.mock.calls[0][0].WasMentioned).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export async function deliverReplies(params: {
|
|||||||
linkPreview?: boolean;
|
linkPreview?: boolean;
|
||||||
/** Optional quote text for Telegram reply_parameters. */
|
/** Optional quote text for Telegram reply_parameters. */
|
||||||
replyQuoteText?: string;
|
replyQuoteText?: string;
|
||||||
}) {
|
}): Promise<{ delivered: boolean }> {
|
||||||
const {
|
const {
|
||||||
replies,
|
replies,
|
||||||
chatId,
|
chatId,
|
||||||
@ -58,6 +58,10 @@ export async function deliverReplies(params: {
|
|||||||
} = params;
|
} = params;
|
||||||
const chunkMode = params.chunkMode ?? "length";
|
const chunkMode = params.chunkMode ?? "length";
|
||||||
let hasReplied = false;
|
let hasReplied = false;
|
||||||
|
let hasDelivered = false;
|
||||||
|
const markDelivered = () => {
|
||||||
|
hasDelivered = true;
|
||||||
|
};
|
||||||
const chunkText = (markdown: string) => {
|
const chunkText = (markdown: string) => {
|
||||||
const markdownChunks =
|
const markdownChunks =
|
||||||
chunkMode === "newline"
|
chunkMode === "newline"
|
||||||
@ -114,6 +118,7 @@ export async function deliverReplies(params: {
|
|||||||
linkPreview,
|
linkPreview,
|
||||||
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
|
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
if (replyToId && !hasReplied) {
|
if (replyToId && !hasReplied) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
}
|
}
|
||||||
@ -165,18 +170,21 @@ export async function deliverReplies(params: {
|
|||||||
runtime,
|
runtime,
|
||||||
fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }),
|
fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }),
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
} else if (kind === "image") {
|
} else if (kind === "image") {
|
||||||
await withTelegramApiErrorLogging({
|
await withTelegramApiErrorLogging({
|
||||||
operation: "sendPhoto",
|
operation: "sendPhoto",
|
||||||
runtime,
|
runtime,
|
||||||
fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }),
|
fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }),
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
} else if (kind === "video") {
|
} else if (kind === "video") {
|
||||||
await withTelegramApiErrorLogging({
|
await withTelegramApiErrorLogging({
|
||||||
operation: "sendVideo",
|
operation: "sendVideo",
|
||||||
runtime,
|
runtime,
|
||||||
fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }),
|
fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }),
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
} else if (kind === "audio") {
|
} else if (kind === "audio") {
|
||||||
const { useVoice } = resolveTelegramVoiceSend({
|
const { useVoice } = resolveTelegramVoiceSend({
|
||||||
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
|
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
|
||||||
@ -195,6 +203,7 @@ export async function deliverReplies(params: {
|
|||||||
shouldLog: (err) => !isVoiceMessagesForbidden(err),
|
shouldLog: (err) => !isVoiceMessagesForbidden(err),
|
||||||
fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }),
|
fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }),
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
} catch (voiceErr) {
|
} catch (voiceErr) {
|
||||||
// Fall back to text if voice messages are forbidden in this chat.
|
// Fall back to text if voice messages are forbidden in this chat.
|
||||||
// This happens when the recipient has Telegram Premium privacy settings
|
// This happens when the recipient has Telegram Premium privacy settings
|
||||||
@ -221,6 +230,7 @@ export async function deliverReplies(params: {
|
|||||||
replyMarkup,
|
replyMarkup,
|
||||||
replyQuoteText,
|
replyQuoteText,
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
// Skip this media item; continue with next.
|
// Skip this media item; continue with next.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -233,6 +243,7 @@ export async function deliverReplies(params: {
|
|||||||
runtime,
|
runtime,
|
||||||
fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
|
fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await withTelegramApiErrorLogging({
|
await withTelegramApiErrorLogging({
|
||||||
@ -240,6 +251,7 @@ export async function deliverReplies(params: {
|
|||||||
runtime,
|
runtime,
|
||||||
fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
|
fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
}
|
}
|
||||||
if (replyToId && !hasReplied) {
|
if (replyToId && !hasReplied) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
@ -260,6 +272,7 @@ export async function deliverReplies(params: {
|
|||||||
linkPreview,
|
linkPreview,
|
||||||
replyMarkup: i === 0 ? replyMarkup : undefined,
|
replyMarkup: i === 0 ? replyMarkup : undefined,
|
||||||
});
|
});
|
||||||
|
markDelivered();
|
||||||
if (replyToId && !hasReplied) {
|
if (replyToId && !hasReplied) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
}
|
}
|
||||||
@ -268,6 +281,8 @@ export async function deliverReplies(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { delivered: hasDelivered };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveMedia(
|
export async function resolveMedia(
|
||||||
@ -310,7 +325,14 @@ export async function resolveMedia(
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
filePathHint: file.file_path,
|
filePathHint: file.file_path,
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
|
const originalName = fetched.fileName ?? file.file_path;
|
||||||
|
const saved = await saveMediaBuffer(
|
||||||
|
fetched.buffer,
|
||||||
|
fetched.contentType,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
originalName,
|
||||||
|
);
|
||||||
|
|
||||||
// Check sticker cache for existing description
|
// Check sticker cache for existing description
|
||||||
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
||||||
@ -361,7 +383,12 @@ export async function resolveMedia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const m =
|
const m =
|
||||||
msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
|
msg.photo?.[msg.photo.length - 1] ??
|
||||||
|
msg.video ??
|
||||||
|
msg.video_note ??
|
||||||
|
msg.document ??
|
||||||
|
msg.audio ??
|
||||||
|
msg.voice;
|
||||||
if (!m?.file_id) return null;
|
if (!m?.file_id) return null;
|
||||||
const file = await ctx.getFile();
|
const file = await ctx.getFile();
|
||||||
if (!file.file_path) {
|
if (!file.file_path) {
|
||||||
@ -377,10 +404,18 @@ export async function resolveMedia(
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
filePathHint: file.file_path,
|
filePathHint: file.file_path,
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
|
const originalName = fetched.fileName ?? file.file_path;
|
||||||
|
const saved = await saveMediaBuffer(
|
||||||
|
fetched.buffer,
|
||||||
|
fetched.contentType,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
originalName,
|
||||||
|
);
|
||||||
let placeholder = "<media:document>";
|
let placeholder = "<media:document>";
|
||||||
if (msg.photo) placeholder = "<media:image>";
|
if (msg.photo) placeholder = "<media:image>";
|
||||||
else if (msg.video) placeholder = "<media:video>";
|
else if (msg.video) placeholder = "<media:video>";
|
||||||
|
else if (msg.video_note) placeholder = "<media:video>";
|
||||||
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
||||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export async function downloadTelegramFile(
|
|||||||
filePath: info.file_path,
|
filePath: info.file_path,
|
||||||
});
|
});
|
||||||
// save with inbound subdir
|
// save with inbound subdir
|
||||||
const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes);
|
const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes, info.file_path);
|
||||||
// Ensure extension matches mime if possible
|
// Ensure extension matches mime if possible
|
||||||
if (!saved.contentType && mime) saved.contentType = mime;
|
if (!saved.contentType && mime) saved.contentType = mime;
|
||||||
return saved;
|
return saved;
|
||||||
|
|||||||
@ -757,11 +757,19 @@ export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"] as con
|
|||||||
* Custom OpenAI-compatible TTS endpoint.
|
* Custom OpenAI-compatible TTS endpoint.
|
||||||
* When set, model/voice validation is relaxed to allow non-OpenAI models.
|
* When set, model/voice validation is relaxed to allow non-OpenAI models.
|
||||||
* Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1
|
* Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1
|
||||||
|
*
|
||||||
|
* Note: Read at runtime (not module load) to support config.env loading.
|
||||||
*/
|
*/
|
||||||
const OPENAI_TTS_BASE_URL = (
|
function getOpenAITtsBaseUrl(): string {
|
||||||
process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1"
|
return (process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1").replace(
|
||||||
).replace(/\/+$/, "");
|
/\/+$/,
|
||||||
const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1";
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomOpenAIEndpoint(): boolean {
|
||||||
|
return getOpenAITtsBaseUrl() !== "https://api.openai.com/v1";
|
||||||
|
}
|
||||||
export const OPENAI_TTS_VOICES = [
|
export const OPENAI_TTS_VOICES = [
|
||||||
"alloy",
|
"alloy",
|
||||||
"ash",
|
"ash",
|
||||||
@ -778,13 +786,13 @@ type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
|
|||||||
|
|
||||||
function isValidOpenAIModel(model: string): boolean {
|
function isValidOpenAIModel(model: string): boolean {
|
||||||
// Allow any model when using custom endpoint (e.g., Kokoro, LocalAI)
|
// Allow any model when using custom endpoint (e.g., Kokoro, LocalAI)
|
||||||
if (isCustomOpenAIEndpoint) return true;
|
if (isCustomOpenAIEndpoint()) return true;
|
||||||
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
|
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
|
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
|
||||||
// Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices)
|
// Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices)
|
||||||
if (isCustomOpenAIEndpoint) return true;
|
if (isCustomOpenAIEndpoint()) return true;
|
||||||
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
|
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1011,7 +1019,7 @@ async function openaiTTS(params: {
|
|||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, {
|
const response = await fetch(`${getOpenAITtsBaseUrl()}/audio/speech`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type ChatHost = {
|
|||||||
basePath: string;
|
basePath: string;
|
||||||
hello: GatewayHelloOk | null;
|
hello: GatewayHelloOk | null;
|
||||||
chatAvatarUrl: string | null;
|
chatAvatarUrl: string | null;
|
||||||
|
refreshSessionsAfterChat: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isChatBusy(host: ChatHost) {
|
export function isChatBusy(host: ChatHost) {
|
||||||
@ -41,6 +42,14 @@ export function isChatStopCommand(text: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isChatResetCommand(text: string) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return false;
|
||||||
|
const normalized = trimmed.toLowerCase();
|
||||||
|
if (normalized === "/new" || normalized === "/reset") return true;
|
||||||
|
return normalized.startsWith("/new ") || normalized.startsWith("/reset ");
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleAbortChat(host: ChatHost) {
|
export async function handleAbortChat(host: ChatHost) {
|
||||||
if (!host.connected) return;
|
if (!host.connected) return;
|
||||||
host.chatMessage = "";
|
host.chatMessage = "";
|
||||||
@ -71,6 +80,7 @@ async function sendChatMessageNow(
|
|||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
previousAttachments?: ChatAttachment[];
|
previousAttachments?: ChatAttachment[];
|
||||||
restoreAttachments?: boolean;
|
restoreAttachments?: boolean;
|
||||||
|
refreshSessions?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||||
@ -94,6 +104,9 @@ async function sendChatMessageNow(
|
|||||||
if (ok && !host.chatRunId) {
|
if (ok && !host.chatRunId) {
|
||||||
void flushChatQueue(host);
|
void flushChatQueue(host);
|
||||||
}
|
}
|
||||||
|
if (ok && opts?.refreshSessions) {
|
||||||
|
host.refreshSessionsAfterChat = true;
|
||||||
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,6 +145,7 @@ export async function handleSendChat(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshSessions = isChatResetCommand(message);
|
||||||
if (messageOverride == null) {
|
if (messageOverride == null) {
|
||||||
host.chatMessage = "";
|
host.chatMessage = "";
|
||||||
// Clear attachments when sending
|
// Clear attachments when sending
|
||||||
@ -149,13 +163,14 @@ export async function handleSendChat(
|
|||||||
attachments: hasAttachments ? attachmentsToSend : undefined,
|
attachments: hasAttachments ? attachmentsToSend : undefined,
|
||||||
previousAttachments: messageOverride == null ? attachments : undefined,
|
previousAttachments: messageOverride == null ? attachments : undefined,
|
||||||
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
|
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
|
||||||
|
refreshSessions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshChat(host: ChatHost) {
|
export async function refreshChat(host: ChatHost) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadChatHistory(host as unknown as MoltbotApp),
|
loadChatHistory(host as unknown as MoltbotApp),
|
||||||
loadSessions(host as unknown as MoltbotApp),
|
loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }),
|
||||||
refreshChatAvatar(host),
|
refreshChatAvatar(host),
|
||||||
]);
|
]);
|
||||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import {
|
|||||||
import type { MoltbotApp } from "./app";
|
import type { MoltbotApp } from "./app";
|
||||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||||
import { loadAssistantIdentity } from "./controllers/assistant-identity";
|
import { loadAssistantIdentity } from "./controllers/assistant-identity";
|
||||||
|
import { loadSessions } from "./controllers/sessions";
|
||||||
|
|
||||||
type GatewayHost = {
|
type GatewayHost = {
|
||||||
settings: UiSettings;
|
settings: UiSettings;
|
||||||
@ -50,6 +51,7 @@ type GatewayHost = {
|
|||||||
assistantAgentId: string | null;
|
assistantAgentId: string | null;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
|
refreshSessionsAfterChat: boolean;
|
||||||
execApprovalQueue: ExecApprovalRequest[];
|
execApprovalQueue: ExecApprovalRequest[];
|
||||||
execApprovalError: string | null;
|
execApprovalError: string | null;
|
||||||
};
|
};
|
||||||
@ -194,6 +196,12 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
|||||||
void flushChatQueueForEvent(
|
void flushChatQueueForEvent(
|
||||||
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
|
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
|
||||||
);
|
);
|
||||||
|
if (host.refreshSessionsAfterChat) {
|
||||||
|
host.refreshSessionsAfterChat = false;
|
||||||
|
if (state === "final") {
|
||||||
|
void loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (state === "final") void loadChatHistory(host as unknown as MoltbotApp);
|
if (state === "final") void loadChatHistory(host as unknown as MoltbotApp);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -35,6 +35,9 @@ type LifecycleHost = {
|
|||||||
|
|
||||||
export function handleConnected(host: LifecycleHost) {
|
export function handleConnected(host: LifecycleHost) {
|
||||||
host.basePath = inferBasePath();
|
host.basePath = inferBasePath();
|
||||||
|
applySettingsFromUrl(
|
||||||
|
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
|
||||||
|
);
|
||||||
syncTabWithLocation(
|
syncTabWithLocation(
|
||||||
host as unknown as Parameters<typeof syncTabWithLocation>[0],
|
host as unknown as Parameters<typeof syncTabWithLocation>[0],
|
||||||
true,
|
true,
|
||||||
@ -46,9 +49,6 @@ export function handleConnected(host: LifecycleHost) {
|
|||||||
host as unknown as Parameters<typeof attachThemeListener>[0],
|
host as unknown as Parameters<typeof attachThemeListener>[0],
|
||||||
);
|
);
|
||||||
window.addEventListener("popstate", host.popStateHandler);
|
window.addEventListener("popstate", host.popStateHandler);
|
||||||
applySettingsFromUrl(
|
|
||||||
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
|
|
||||||
);
|
|
||||||
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
|
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
|
||||||
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
||||||
if (host.tab === "logs") {
|
if (host.tab === "logs") {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { AppViewState } from "./app-view-state";
|
|||||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
|
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation";
|
||||||
import { icons } from "./icons";
|
import { icons } from "./icons";
|
||||||
import { loadChatHistory } from "./controllers/chat";
|
import { loadChatHistory } from "./controllers/chat";
|
||||||
|
import { refreshChat } from "./app-chat";
|
||||||
import { syncUrlWithSessionKey } from "./app-settings";
|
import { syncUrlWithSessionKey } from "./app-settings";
|
||||||
import type { SessionsListResult } from "./types";
|
import type { SessionsListResult } from "./types";
|
||||||
import type { ThemeMode } from "./theme";
|
import type { ThemeMode } from "./theme";
|
||||||
@ -39,7 +40,12 @@ export function renderTab(state: AppViewState, tab: Tab) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderChatControls(state: AppViewState) {
|
export function renderChatControls(state: AppViewState) {
|
||||||
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
|
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
|
||||||
|
const sessionOptions = resolveSessionOptions(
|
||||||
|
state.sessionKey,
|
||||||
|
state.sessionsResult,
|
||||||
|
mainSessionKey,
|
||||||
|
);
|
||||||
const disableThinkingToggle = state.onboarding;
|
const disableThinkingToggle = state.onboarding;
|
||||||
const disableFocusToggle = state.onboarding;
|
const disableFocusToggle = state.onboarding;
|
||||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||||
@ -87,9 +93,9 @@ export function renderChatControls(state: AppViewState) {
|
|||||||
?disabled=${state.chatLoading || !state.connected}
|
?disabled=${state.chatLoading || !state.connected}
|
||||||
@click=${() => {
|
@click=${() => {
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
void loadChatHistory(state);
|
void refreshChat(state as unknown as Parameters<typeof refreshChat>[0]);
|
||||||
}}
|
}}
|
||||||
title="Refresh chat history"
|
title="Refresh chat data"
|
||||||
>
|
>
|
||||||
${refreshIcon}
|
${refreshIcon}
|
||||||
</button>
|
</button>
|
||||||
@ -132,15 +138,47 @@ export function renderChatControls(state: AppViewState) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSessionOptions(sessionKey: string, sessions: SessionsListResult | null) {
|
type SessionDefaultsSnapshot = {
|
||||||
|
mainSessionKey?: string;
|
||||||
|
mainKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveMainSessionKey(
|
||||||
|
hello: AppViewState["hello"],
|
||||||
|
sessions: SessionsListResult | null,
|
||||||
|
): string | null {
|
||||||
|
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
|
||||||
|
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
|
||||||
|
if (mainSessionKey) return mainSessionKey;
|
||||||
|
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
|
||||||
|
if (mainKey) return mainKey;
|
||||||
|
if (sessions?.sessions?.some((row) => row.key === "main")) return "main";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSessionOptions(
|
||||||
|
sessionKey: string,
|
||||||
|
sessions: SessionsListResult | null,
|
||||||
|
mainSessionKey?: string | null,
|
||||||
|
) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const options: Array<{ key: string; displayName?: string }> = [];
|
const options: Array<{ key: string; displayName?: string }> = [];
|
||||||
|
|
||||||
|
const resolvedMain =
|
||||||
|
mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
|
||||||
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
|
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
|
||||||
|
|
||||||
// Add current session key first
|
// Add main session key first
|
||||||
seen.add(sessionKey);
|
if (mainSessionKey) {
|
||||||
options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
|
seen.add(mainSessionKey);
|
||||||
|
options.push({ key: mainSessionKey, displayName: resolvedMain?.displayName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current session key next
|
||||||
|
if (!seen.has(sessionKey)) {
|
||||||
|
seen.add(sessionKey);
|
||||||
|
options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
|
||||||
|
}
|
||||||
|
|
||||||
// Add sessions from the result
|
// Add sessions from the result
|
||||||
if (sessions?.sessions) {
|
if (sessions?.sessions) {
|
||||||
|
|||||||
@ -258,6 +258,7 @@ export class MoltbotApp extends LitElement {
|
|||||||
private logsScrollFrame: number | null = null;
|
private logsScrollFrame: number | null = null;
|
||||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||||
private toolStreamOrder: string[] = [];
|
private toolStreamOrder: string[] = [];
|
||||||
|
refreshSessionsAfterChat = false;
|
||||||
basePath = "";
|
basePath = "";
|
||||||
private popStateHandler = () =>
|
private popStateHandler = () =>
|
||||||
onPopStateInternal(
|
onPopStateInternal(
|
||||||
|
|||||||
@ -14,18 +14,29 @@ export type SessionsState = {
|
|||||||
sessionsIncludeUnknown: boolean;
|
sessionsIncludeUnknown: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadSessions(state: SessionsState) {
|
export async function loadSessions(
|
||||||
|
state: SessionsState,
|
||||||
|
overrides?: {
|
||||||
|
activeMinutes?: number;
|
||||||
|
limit?: number;
|
||||||
|
includeGlobal?: boolean;
|
||||||
|
includeUnknown?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
if (!state.client || !state.connected) return;
|
if (!state.client || !state.connected) return;
|
||||||
if (state.sessionsLoading) return;
|
if (state.sessionsLoading) return;
|
||||||
state.sessionsLoading = true;
|
state.sessionsLoading = true;
|
||||||
state.sessionsError = null;
|
state.sessionsError = null;
|
||||||
try {
|
try {
|
||||||
|
const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal;
|
||||||
|
const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown;
|
||||||
|
const activeMinutes =
|
||||||
|
overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0);
|
||||||
|
const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0);
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
includeGlobal: state.sessionsIncludeGlobal,
|
includeGlobal,
|
||||||
includeUnknown: state.sessionsIncludeUnknown,
|
includeUnknown,
|
||||||
};
|
};
|
||||||
const activeMinutes = toNumber(state.sessionsFilterActive, 0);
|
|
||||||
const limit = toNumber(state.sessionsFilterLimit, 0);
|
|
||||||
if (activeMinutes > 0) params.activeMinutes = activeMinutes;
|
if (activeMinutes > 0) params.activeMinutes = activeMinutes;
|
||||||
if (limit > 0) params.limit = limit;
|
if (limit > 0) params.limit = limit;
|
||||||
const res = (await state.client.request("sessions.list", params)) as
|
const res = (await state.client.request("sessions.list", params)) as
|
||||||
|
|||||||
@ -260,6 +260,11 @@ function renderTextInput(params: {
|
|||||||
}
|
}
|
||||||
onPatch(path, raw);
|
onPatch(path, raw);
|
||||||
}}
|
}}
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
if (inputType === "number") return;
|
||||||
|
const raw = (e.target as HTMLInputElement).value;
|
||||||
|
onPatch(path, raw.trim());
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
${schema.default !== undefined ? html`
|
${schema.default !== undefined ? html`
|
||||||
<button
|
<button
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user