Compare commits

..
76 changed files with 2092 additions and 1910 deletions

No files matched your search

-7
View File
@@ -13,7 +13,6 @@ 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.
@@ -73,16 +72,11 @@ 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: send a fallback reply when delivery is empty to avoid silent errors. (#3483) Thanks @kiranjd.
- 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.
@@ -112,7 +106,6 @@ 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.
+32 -34
View File
@@ -479,38 +479,36 @@ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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>
+1 -1
View File
@@ -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 `"*"`.
- Your linked WhatsApp number is implicitly trusted, so self messages skip ⁠`channels.whatsapp.dmPolicy` and `channels.whatsapp.allowFrom` checks. - Self messages are always allowed; “self-chat mode” still requires `channels.whatsapp.allowFrom` to include your own number.
### 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).
-1
View File
@@ -39,4 +39,3 @@ 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`.
+110 -19
View File
@@ -75,9 +75,8 @@ 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` (plus Moltbot can build a small vector index over `MEMORY.md` and `memory/*.md` so
any extra directories or files you opt in) so semantic queries can find related semantic queries can find related notes even when wording differs.
notes even when wording differs.
Defaults: Defaults:
- Enabled by default. - Enabled by default.
@@ -97,26 +96,118 @@ 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 ### QMD backend (experimental)
If you want to index Markdown files outside the default workspace layout, add Set `memory.backend = "qmd"` to swap the built-in SQLite indexer for
explicit paths: [QMD](https://github.com/tobi/qmd): a local-first search sidecar that combines
BM25 + vectors + reranking. Markdown stays the source of truth; Moltbot shells
out to QMD for retrieval. Key points:
**Prereqs**
- Disabled by default. Opt in per-config (`memory.backend = "qmd"`).
- Install the QMD CLI separately (`bun install -g github.com/tobi/qmd` or grab
a release) and make sure the `qmd` binary is on the gateway’s `PATH`.
- QMD needs an SQLite build that allows extensions (`brew install sqlite` on
macOS).
- QMD runs fully locally via Bun + `node-llama-cpp` and auto-downloads GGUF
models from HuggingFace on first use (no separate Ollama daemon required).
- The gateway runs QMD in a self-contained XDG home under
`~/.clawdbot/agents/<agentId>/qmd/` by setting `XDG_CONFIG_HOME` and
`XDG_CACHE_HOME`.
- OS support: macOS and Linux work out of the box once Bun + SQLite are
installed. Windows is best supported via WSL2.
**How the sidecar runs**
- The gateway writes a self-contained QMD home under
`~/.clawdbot/agents/<agentId>/qmd/` (config + cache + sqlite DB).
- Collections are rewritten from `memory.qmd.paths` (plus default workspace
memory files) into `index.yml`, then `qmd update` + `qmd embed` run on boot and
on a configurable interval (`memory.qmd.update.interval`, default 5 m).
- Searches run via `qmd query --json`. If QMD fails or the binary is missing,
Moltbot automatically falls back to the builtin SQLite manager so memory tools
keep working.
- **First search may be slow**: QMD may download local GGUF models (reranker/query
expansion) on the first `qmd query` run.
- Moltbot sets `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` automatically when it runs QMD.
- If you want to pre-download models manually (and warm the same index Moltbot
uses), run a one-off query with the agent’s XDG dirs.
Moltbot’s QMD state lives under your **state dir** (usually `~/.clawdbot`, or
`~/.moltbot` on newer installs). You can point `qmd` at the exact same index
by exporting the same XDG vars Moltbot uses:
```bash
# Pick the same state dir Moltbot uses
STATE_DIR="${MOLTBOT_STATE_DIR:-${CLAWDBOT_STATE_DIR:-$HOME/.clawdbot}}"
if [ -d "$HOME/.moltbot" ] && [ ! -d "$HOME/.clawdbot" ] \
&& [ -z "${MOLTBOT_STATE_DIR:-}" ] && [ -z "${CLAWDBOT_STATE_DIR:-}" ]; then
STATE_DIR="$HOME/.moltbot"
fi
export XDG_CONFIG_HOME="$STATE_DIR/agents/main/qmd/xdg-config"
export XDG_CACHE_HOME="$STATE_DIR/agents/main/qmd/xdg-cache"
# (Optional) force an index refresh + embeddings
qmd update
qmd embed
# Warm up / trigger first-time model downloads
qmd query "test" -c memory-root --json >/dev/null 2>&1
```
**Config surface (`memory.qmd.*`)**
- `command` (default `qmd`): override the executable path.
- `includeDefaultMemory` (default `true`): auto-index `MEMORY.md` + `memory/**/*.md`.
- `paths[]`: add extra directories/files (`path`, optional `pattern`, optional
stable `name`).
- `sessions`: opt into session JSONL indexing (`enabled`, `retentionDays`,
`exportDir`).
- `update`: controls refresh cadence (`interval`, `debounceMs`, `onBoot`, `embedInterval`).
- `limits`: clamp recall payload (`maxResults`, `maxSnippetChars`,
`maxInjectedChars`, `timeoutMs`).
- `scope`: same schema as [`session.sendPolicy`](/reference/configuration#session-sendpolicy).
Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD
hits in groups/channels.
- Snippets sourced outside the workspace show up as
`qmd/<collection>/<relative-path>` in `memory_search` results; `memory_get`
understands that prefix and reads from the configured QMD collection root.
- When `memory.qmd.sessions.enabled = true`, Moltbot exports sanitized session
transcripts (User/Assistant turns) into a dedicated QMD collection under
`~/.clawdbot/agents/<id>/qmd/sessions/`, so `memory_search` can recall recent
conversations without touching the builtin SQLite index.
- `memory_search` snippets now include a `Source: <path#line>` footer when
`memory.citations` is `auto`/`on`; set `memory.citations = "off"` to keep
the path metadata internal (the agent still receives the path for
`memory_get`, but the snippet text omits the footer and the system prompt
warns the agent not to cite it).
**Example**
```json5 ```json5
agents: { memory: {
defaults: { backend: "qmd",
memorySearch: { citations: "auto",
extraPaths: ["../team-docs", "/srv/shared-notes/overview.md"] qmd: {
} includeDefaultMemory: true,
update: { interval: "5m", debounceMs: 15000 },
limits: { maxResults: 6, timeoutMs: 4000 },
scope: {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }]
},
paths: [
{ name: "docs", path: "~/notes", pattern: "**/*.md" }
]
} }
} }
``` ```
Notes: **Citations & fallback**
- Paths can be absolute or workspace-relative. - `memory.citations` applies regardless of backend (`auto`/`on`/`off`).
- Directories are scanned recursively for `.md` files. - When `qmd` runs, we tag `status().backend = "qmd"` so diagnostics show which
- Only Markdown files are indexed. engine served the results. If the QMD subprocess exits or JSON output can’t be
- Symlinks are ignored (files or directories). parsed, the search manager logs a warning and returns the builtin provider
(existing Markdown embeddings) until QMD recovers.
### Gemini embeddings (native) ### Gemini embeddings (native)
@@ -211,14 +302,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 allowed only when explicitly listed in `memorySearch.extraPaths`. - `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.
- 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`, plus any `.md` files under `memorySearch.extraPaths`). - File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
- 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/`, 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. - 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.
- 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)
+1 -2
View File
@@ -267,8 +267,7 @@ 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",
+2 -2
View File
@@ -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 (Venice highlight) # Venice AI (Venius highlight)
**Venice** is our highlight Venice setup for privacy-first inference with optional anonymized access to proprietary models. **Venius** 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
View File
@@ -212,6 +212,7 @@ export function buildSystemPrompt(params: {
userTimeFormat, userTimeFormat,
contextFiles: params.contextFiles, contextFiles: params.contextFiles,
ttsHint, ttsHint,
memoryCitationsMode: params.config?.memory?.citations,
}); });
} }
-23
View File
@@ -82,29 +82,6 @@ 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: {
-6
View File
@@ -9,7 +9,6 @@ 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;
@@ -163,10 +162,6 @@ 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:
@@ -241,7 +236,6 @@ function mergeConfig(
return { return {
enabled, enabled,
sources, sources,
extraPaths,
provider, provider,
remote, remote,
experimental: { experimental: {
+1
View File
@@ -346,6 +346,7 @@ export async function compactEmbeddedPiSessionDirect(
userTime, userTime,
userTimeFormat, userTimeFormat,
contextFiles, contextFiles,
memoryCitationsMode: params.config?.memory?.citations,
}); });
const systemPrompt = createSystemPromptOverride(appendPrompt); const systemPrompt = createSystemPromptOverride(appendPrompt);
@@ -359,6 +359,7 @@ export async function runEmbeddedAttempt(
userTime, userTime,
userTimeFormat, userTimeFormat,
contextFiles, contextFiles,
memoryCitationsMode: params.config?.memory?.citations,
}); });
const systemPromptReport = buildSystemPromptReport({ const systemPromptReport = buildSystemPromptReport({
source: "run", source: "run",
@@ -1,4 +1,5 @@
import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { MemoryCitationsMode } from "../../config/types.memory.js";
import type { ResolvedTimeFormat } from "../date-time.js"; import type { ResolvedTimeFormat } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js"; import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js";
@@ -45,6 +46,7 @@ export function buildEmbeddedSystemPrompt(params: {
userTime?: string; userTime?: string;
userTimeFormat?: ResolvedTimeFormat; userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[]; contextFiles?: EmbeddedContextFile[];
memoryCitationsMode?: MemoryCitationsMode;
}): string { }): string {
return buildAgentSystemPrompt({ return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
@@ -70,6 +72,7 @@ export function buildEmbeddedSystemPrompt(params: {
userTime: params.userTime, userTime: params.userTime,
userTimeFormat: params.userTimeFormat, userTimeFormat: params.userTimeFormat,
contextFiles: params.contextFiles, contextFiles: params.contextFiles,
memoryCitationsMode: params.memoryCitationsMode,
}); });
} }
+24 -4
View File
@@ -1,6 +1,7 @@
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { MemoryCitationsMode } from "../config/types.memory.js";
import type { ResolvedTimeFormat } from "./date-time.js"; import type { ResolvedTimeFormat } from "./date-time.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
@@ -32,16 +33,30 @@ function buildSkillsSection(params: {
]; ];
} }
function buildMemorySection(params: { isMinimal: boolean; availableTools: Set<string> }) { function buildMemorySection(params: {
isMinimal: boolean;
availableTools: Set<string>;
citationsMode?: MemoryCitationsMode;
}) {
if (params.isMinimal) return []; if (params.isMinimal) return [];
if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) { if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) {
return []; return [];
} }
return [ const lines = [
"## Memory Recall", "## Memory Recall",
"Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.", "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.",
"",
]; ];
if (params.citationsMode === "off") {
lines.push(
"Citations are disabled: do not mention file paths or line numbers in replies unless the user explicitly asks.",
);
} else {
lines.push(
"Citations: include Source: <path#line> when it helps the user verify memory snippets.",
);
}
lines.push("");
return lines;
} }
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) { function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
@@ -178,6 +193,7 @@ export function buildAgentSystemPrompt(params: {
level: "minimal" | "extensive"; level: "minimal" | "extensive";
channel: string; channel: string;
}; };
memoryCitationsMode?: MemoryCitationsMode;
}) { }) {
const coreToolSummaries: Record<string, string> = { const coreToolSummaries: Record<string, string> = {
read: "Read file contents", read: "Read file contents",
@@ -314,7 +330,11 @@ export function buildAgentSystemPrompt(params: {
isMinimal, isMinimal,
readToolName, readToolName,
}); });
const memorySection = buildMemorySection({ isMinimal, availableTools }); const memorySection = buildMemorySection({
isMinimal,
availableTools,
citationsMode: params.memoryCitationsMode,
});
const docsSection = buildDocsSection({ const docsSection = buildDocsSection({
docsPath: params.docsPath, docsPath: params.docsPath,
isMinimal, isMinimal,
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const stubManager = {
search: vi.fn(async () => [
{
path: "MEMORY.md",
startLine: 5,
endLine: 7,
score: 0.9,
snippet: "@@ -5,3 @@\nAssistant: noted",
source: "memory" as const,
},
]),
readFile: vi.fn(),
status: () => ({
backend: "builtin" as const,
files: 1,
chunks: 1,
dirty: false,
workspaceDir: "/workspace",
dbPath: "/workspace/.memory/index.sqlite",
provider: "builtin",
model: "builtin",
requestedProvider: "builtin",
sources: ["memory" as const],
sourceCounts: [{ source: "memory" as const, files: 1, chunks: 1 }],
}),
sync: vi.fn(),
probeVectorAvailability: vi.fn(async () => true),
close: vi.fn(),
};
vi.mock("../../memory/index.js", () => {
return {
getMemorySearchManager: async () => ({ manager: stubManager }),
};
});
import { createMemorySearchTool } from "./memory-tool.js";
beforeEach(() => {
vi.clearAllMocks();
});
describe("memory search citations", () => {
it("appends source information when citations are enabled", async () => {
const cfg = { memory: { citations: "on" }, agents: { list: [{ id: "main", default: true }] } };
const tool = createMemorySearchTool({ config: cfg });
if (!tool) throw new Error("tool missing");
const result = await tool.execute("call_citations_on", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/);
expect(details.results[0]?.citation).toBe("MEMORY.md#L5-L7");
});
it("leaves snippet untouched when citations are off", async () => {
const cfg = { memory: { citations: "off" }, agents: { list: [{ id: "main", default: true }] } };
const tool = createMemorySearchTool({ config: cfg });
if (!tool) throw new Error("tool missing");
const result = await tool.execute("call_citations_off", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
expect(details.results[0]?.snippet).not.toMatch(/Source:/);
expect(details.results[0]?.citation).toBeUndefined();
});
});
+54 -2
View File
@@ -1,7 +1,9 @@
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import type { MoltbotConfig } from "../../config/config.js"; import type { MoltbotConfig } from "../../config/config.js";
import type { MemoryCitationsMode } from "../../config/types.memory.js";
import { getMemorySearchManager } from "../../memory/index.js"; import { getMemorySearchManager } from "../../memory/index.js";
import type { MemorySearchResult } from "../../memory/types.js";
import { resolveSessionAgentId } from "../agent-scope.js"; import { resolveSessionAgentId } from "../agent-scope.js";
import { resolveMemorySearchConfig } from "../memory-search.js"; import { resolveMemorySearchConfig } from "../memory-search.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
@@ -48,17 +50,24 @@ export function createMemorySearchTool(options: {
return jsonResult({ results: [], disabled: true, error }); return jsonResult({ results: [], disabled: true, error });
} }
try { try {
const results = await manager.search(query, { const citationsMode = resolveMemoryCitationsMode(cfg);
const includeCitations = shouldIncludeCitations({
mode: citationsMode,
sessionKey: options.agentSessionKey,
});
const rawResults = await manager.search(query, {
maxResults, maxResults,
minScore, minScore,
sessionKey: options.agentSessionKey, sessionKey: options.agentSessionKey,
}); });
const status = manager.status(); const status = manager.status();
const results = decorateCitations(rawResults, includeCitations);
return jsonResult({ return jsonResult({
results, results,
provider: status.provider, provider: status.provider,
model: status.model, model: status.model,
fallback: status.fallback, fallback: status.fallback,
citations: citationsMode,
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -83,7 +92,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, memory/*.md, or configured memorySearch.extraPaths with optional from/lines; use after memory_search to pull only the needed lines and keep context small.", "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.",
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 });
@@ -110,3 +119,46 @@ export function createMemoryGetTool(options: {
}, },
}; };
} }
function resolveMemoryCitationsMode(cfg: MoltbotConfig): MemoryCitationsMode {
const mode = cfg.memory?.citations;
if (mode === "on" || mode === "off" || mode === "auto") return mode;
return "auto";
}
function decorateCitations(results: MemorySearchResult[], include: boolean): MemorySearchResult[] {
if (!include) {
return results.map((entry) => ({ ...entry, citation: undefined }));
}
return results.map((entry) => {
const citation = formatCitation(entry);
const snippet = `${entry.snippet.trim()}\n\nSource: ${citation}`;
return { ...entry, citation, snippet };
});
}
function formatCitation(entry: MemorySearchResult): string {
const lineRange =
entry.startLine === entry.endLine
? `#L${entry.startLine}`
: `#L${entry.startLine}-L${entry.endLine}`;
return `${entry.path}${lineRange}`;
}
function shouldIncludeCitations(params: {
mode: MemoryCitationsMode;
sessionKey?: string;
}): boolean {
if (params.mode === "on") return true;
if (params.mode === "off") return false;
// auto: show citations in direct chats; suppress in groups/channels by default.
const chatType = deriveChatTypeFromSessionKey(params.sessionKey);
return chatType === "direct";
}
function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | "channel" {
if (!sessionKey) return "direct";
if (sessionKey.includes(":group:")) return "group";
if (sessionKey.includes(":channel:")) return "channel";
return "direct";
}
@@ -150,6 +150,7 @@ async function resolveContextReport(
ttsHint, ttsHint,
runtimeInfo, runtimeInfo,
sandboxInfo, sandboxInfo,
memoryCitationsMode: params.cfg?.memory?.citations,
}); });
return buildSystemPromptReport({ return buildSystemPromptReport({
@@ -138,7 +138,7 @@ describe("dispatchReplyFromConfig", () => {
); );
}); });
it("provides onToolResult in DM sessions", async () => { it("does not provide onToolResult when routing cross-provider", async () => {
mocks.tryFastAbortFromMessage.mockResolvedValue({ mocks.tryFastAbortFromMessage.mockResolvedValue({
handled: false, handled: false,
aborted: false, aborted: false,
@@ -147,34 +147,9 @@ describe("dispatchReplyFromConfig", () => {
const cfg = {} as MoltbotConfig; const cfg = {} as MoltbotConfig;
const dispatcher = createDispatcher(); const dispatcher = createDispatcher();
const ctx = buildTestCtx({ const ctx = buildTestCtx({
Provider: "telegram", Provider: "slack",
ChatType: "direct", OriginatingChannel: "telegram",
}); 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 (
@@ -187,62 +162,12 @@ describe("dispatchReplyFromConfig", () => {
}; };
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
});
it("sends tool results via dispatcher in DM sessions", async () => { expect(mocks.routeReply).toHaveBeenCalledWith(
mocks.tryFastAbortFromMessage.mockResolvedValue({ expect.objectContaining({
handled: false, payload: expect.objectContaining({ text: "hi" }),
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,27 +276,6 @@ 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
+1 -14
View File
@@ -4,7 +4,7 @@ import { matchesMentionWithExplicit } from "./mentions.js";
describe("matchesMentionWithExplicit", () => { describe("matchesMentionWithExplicit", () => {
const mentionRegexes = [/\bclawd\b/i]; const mentionRegexes = [/\bclawd\b/i];
it("checks mentionPatterns even when explicit mention is available", () => { it("prefers explicit mentions when other mentions are present", () => {
const result = matchesMentionWithExplicit({ const result = matchesMentionWithExplicit({
text: "@clawd hello", text: "@clawd hello",
mentionRegexes, mentionRegexes,
@@ -14,19 +14,6 @@ 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);
}); });
+1 -3
View File
@@ -90,9 +90,7 @@ 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) { if (hasAnyMention && explicitAvailable) return explicit;
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,6 +1,5 @@
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.
@@ -20,30 +19,4 @@ 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"]);
});
}); });
+4 -19
View File
@@ -8,8 +8,6 @@ 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 */
@@ -17,7 +15,6 @@ 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(
@@ -29,18 +26,12 @@ 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) { if (!trimmed && !hasMedia && !hasChannelData) return null;
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) { if (!hasMedia && !hasChannelData) return null;
opts.onSkip?.("silent");
return null;
}
text = ""; text = "";
} }
if (text && !trimmed) { if (text && !trimmed) {
@@ -52,20 +43,14 @@ 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) { if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
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) { if (!text?.trim() && !hasMedia && !hasChannelData) return null;
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 };
+9 -24
View File
@@ -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, type NormalizeReplySkipReason } from "./normalize-reply.js"; import { normalizeReplyPayload } 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,11 +8,6 @@ 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 },
@@ -47,8 +42,6 @@ export type ReplyDispatcherOptions = {
onHeartbeatStrip?: () => void; onHeartbeatStrip?: () => void;
onIdle?: () => void; onIdle?: () => void;
onError?: ReplyDispatchErrorHandler; onError?: ReplyDispatchErrorHandler;
/** 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;
}; };
@@ -72,16 +65,15 @@ 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: NormalizeReplyPayloadInternalOptions, opts: Pick<
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;
@@ -90,7 +82,6 @@ function normalizeReplyPayloadInternal(
responsePrefix: opts.responsePrefix, responsePrefix: opts.responsePrefix,
responsePrefixContext: prefixContext, responsePrefixContext: prefixContext,
onHeartbeatStrip: opts.onHeartbeatStrip, onHeartbeatStrip: opts.onHeartbeatStrip,
onSkip: opts.onSkip,
}); });
} }
@@ -108,13 +99,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
}; };
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
const normalized = normalizeReplyPayloadInternal(payload, { const normalized = normalizeReplyPayloadInternal(payload, options);
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;
-1
View File
@@ -71,7 +71,6 @@ const LOBSTER_ASCII = [
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████", "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ", " 🦞 FRESH DAILY 🦞 ",
" ",
]; ];
export function formatCliBannerArt(options: BannerOptions = {}): string { export function formatCliBannerArt(options: BannerOptions = {}): string {
+1 -1
View File
@@ -242,7 +242,7 @@ describe("memory cli", () => {
await program.parseAsync(["memory", "status", "--index"], { from: "user" }); await program.parseAsync(["memory", "status", "--index"], { from: "user" });
expect(sync).toHaveBeenCalledWith( expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", progress: expect.any(Function) }), expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
); );
expect(probeEmbeddingAvailability).toHaveBeenCalled(); expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
+54 -67
View File
@@ -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, normalizeExtraMemoryPaths } from "../memory/internal.js"; import { listMemoryFiles } 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";
@@ -24,6 +24,7 @@ type MemoryCommandOptions = {
json?: boolean; json?: boolean;
deep?: boolean; deep?: boolean;
index?: boolean; index?: boolean;
force?: boolean;
verbose?: boolean; verbose?: boolean;
}; };
@@ -74,10 +75,6 @@ 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);
@@ -114,10 +111,7 @@ async function scanSessionFiles(agentId: string): Promise<SourceScan> {
} }
} }
async function scanMemoryFiles( async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
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");
@@ -128,25 +122,6 @@ async function scanMemoryFiles(
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);
@@ -167,7 +142,7 @@ async function scanMemoryFiles(
let listed: string[] = []; let listed: string[] = [];
let listedOk = false; let listedOk = false;
try { try {
listed = await listMemoryFiles(workspaceDir, resolvedExtraPaths); listed = await listMemoryFiles(workspaceDir);
listedOk = true; listedOk = true;
} catch (err) { } catch (err) {
const code = (err as NodeJS.ErrnoException).code; const code = (err as NodeJS.ErrnoException).code;
@@ -202,13 +177,11 @@ 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, extraPaths)); scans.push(await scanMemoryFiles(params.workspaceDir));
} }
if (source === "sessions") { if (source === "sessions") {
scans.push(await scanSessionFiles(params.agentId)); scans.push(await scanSessionFiles(params.agentId));
@@ -241,13 +214,16 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) => onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(), close: async (manager) => {
await manager.close?.();
},
run: async (manager) => { run: async (manager) => {
const deep = Boolean(opts.deep || opts.index); const deep = Boolean(opts.deep || opts.index);
let embeddingProbe: let embeddingProbe:
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>> | Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
| undefined; | undefined;
let indexError: string | undefined; let indexError: string | undefined;
const syncFn = manager.sync ? manager.sync.bind(manager) : undefined;
if (deep) { if (deep) {
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => { await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
progress.setLabel("Probing vector…"); progress.setLabel("Probing vector…");
@@ -257,7 +233,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
embeddingProbe = await manager.probeEmbeddingAvailability(); embeddingProbe = await manager.probeEmbeddingAvailability();
progress.tick(); progress.tick();
}); });
if (opts.index) { if (opts.index && syncFn) {
await withProgressTotals( await withProgressTotals(
{ {
label: "Indexing memory…", label: "Indexing memory…",
@@ -266,8 +242,9 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
}, },
async (update, progress) => { async (update, progress) => {
try { try {
await manager.sync({ await syncFn({
reason: "cli", reason: "cli",
force: Boolean(opts.force),
progress: (syncUpdate) => { progress: (syncUpdate) => {
update({ update({
completed: syncUpdate.completed, completed: syncUpdate.completed,
@@ -284,6 +261,8 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
} }
}, },
); );
} else if (opts.index && !syncFn) {
defaultRuntime.log("Memory backend does not support manual reindex.");
} }
} else { } else {
await manager.probeVectorAvailability(); await manager.probeVectorAvailability();
@@ -292,12 +271,10 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
const sources = ( const sources = (
status.sources?.length ? status.sources : ["memory"] status.sources?.length ? status.sources : ["memory"]
) as MemorySourceName[]; ) as MemorySourceName[];
const scan = await scanMemorySources({ const workspaceDir = status.workspaceDir;
workspaceDir: status.workspaceDir, const scan = workspaceDir
agentId, ? await scanMemorySources({ workspaceDir, agentId, sources })
sources, : undefined;
extraPaths: status.extraPaths,
});
allResults.push({ agentId, status, embeddingProbe, indexError, scan }); allResults.push({ agentId, status, embeddingProbe, indexError, scan });
}, },
}); });
@@ -319,28 +296,31 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
for (const result of allResults) { for (const result of allResults) {
const { agentId, status, embeddingProbe, indexError, scan } = result; const { agentId, status, embeddingProbe, indexError, scan } = result;
const filesIndexed = status.files ?? 0;
const chunksIndexed = status.chunks ?? 0;
const totalFiles = scan?.totalFiles ?? null; const totalFiles = scan?.totalFiles ?? null;
const indexedLabel = const indexedLabel =
totalFiles === null totalFiles === null
? `${status.files}/? files · ${status.chunks} chunks` ? `${filesIndexed}/? files · ${chunksIndexed} chunks`
: `${status.files}/${totalFiles} files · ${status.chunks} chunks`; : `${filesIndexed}/${totalFiles} files · ${chunksIndexed} chunks`;
if (opts.index) { if (opts.index) {
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
defaultRuntime.log(line); defaultRuntime.log(line);
} }
const extraPaths = formatExtraPaths(status.workspaceDir, status.extraPaths ?? []); const requestedProvider = status.requestedProvider ?? status.provider;
const modelLabel = status.model ?? status.provider;
const storePath = status.dbPath ? shortenHomePath(status.dbPath) : "<unknown>";
const workspacePath = status.workspaceDir ? shortenHomePath(status.workspaceDir) : "<unknown>";
const sourceList = status.sources?.length ? status.sources.join(", ") : null;
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(`(requested: ${requestedProvider})`)}`,
`(requested: ${status.requestedProvider})`, `${label("Model")} ${info(modelLabel)}`,
)}`, sourceList ? `${label("Sources")} ${info(sourceList)}` : null,
`${label("Model")} ${info(status.model)}`,
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(storePath)}`,
`${label("Workspace")} ${info(shortenHomePath(status.workspaceDir))}`, `${label("Workspace")} ${info(workspacePath)}`,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
if (embeddingProbe) { if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable"; const state = embeddingProbe.ok ? "ready" : "unavailable";
@@ -353,7 +333,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
if (status.sourceCounts?.length) { if (status.sourceCounts?.length) {
lines.push(label("By source")); lines.push(label("By source"));
for (const entry of status.sourceCounts) { for (const entry of status.sourceCounts) {
const total = scan?.sources.find( const total = scan?.sources?.find(
(scanEntry) => scanEntry.source === entry.source, (scanEntry) => scanEntry.source === entry.source,
)?.totalFiles; )?.totalFiles;
const counts = const counts =
@@ -466,7 +446,7 @@ export function registerMemoryCli(program: Command) {
.option("--deep", "Probe embedding provider availability") .option("--deep", "Probe embedding provider availability")
.option("--index", "Reindex if dirty (implies --deep)") .option("--index", "Reindex if dirty (implies --deep)")
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions) => { .action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
await runMemoryStatus(opts); await runMemoryStatus(opts);
}); });
@@ -476,7 +456,7 @@ export function registerMemoryCli(program: Command) {
.option("--agent <id>", "Agent id (default: default agent)") .option("--agent <id>", "Agent id (default: default agent)")
.option("--force", "Force full reindex", false) .option("--force", "Force full reindex", false)
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => { .action(async (opts: MemoryCommandOptions) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
const cfg = loadConfig(); const cfg = loadConfig();
const agentIds = resolveAgentIds(cfg, opts.agent); const agentIds = resolveAgentIds(cfg, opts.agent);
@@ -486,9 +466,12 @@ export function registerMemoryCli(program: Command) {
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) => onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(), close: async (manager) => {
await manager.close?.();
},
run: async (manager) => { run: async (manager) => {
try { try {
const syncFn = manager.sync ? manager.sync.bind(manager) : undefined;
if (opts.verbose) { if (opts.verbose) {
const status = manager.status(); const status = manager.status();
const rich = isRich(); const rich = isRich();
@@ -497,22 +480,20 @@ export function registerMemoryCli(program: Command) {
const info = (text: string) => colorize(rich, theme.info, text); const info = (text: string) => colorize(rich, theme.info, text);
const warn = (text: string) => colorize(rich, theme.warn, text); const warn = (text: string) => colorize(rich, theme.warn, text);
const label = (text: string) => muted(`${text}:`); const label = (text: string) => muted(`${text}:`);
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 requestedProvider = status.requestedProvider ?? status.provider;
const modelLabel = status.model ?? status.provider;
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(
`(requested: ${status.requestedProvider})`, `(requested: ${requestedProvider})`,
)}`, )}`,
`${label("Model")} ${info(status.model)}`, `${label("Model")} ${info(modelLabel)}`,
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)}`);
@@ -549,6 +530,10 @@ export function registerMemoryCli(program: Command) {
? `${lastLabel} · elapsed ${elapsed} · eta ${eta}` ? `${lastLabel} · elapsed ${elapsed} · eta ${eta}`
: `${lastLabel} · elapsed ${elapsed}`; : `${lastLabel} · elapsed ${elapsed}`;
}; };
if (!syncFn) {
defaultRuntime.log("Memory backend does not support manual reindex.");
return;
}
await withProgressTotals( await withProgressTotals(
{ {
label: "Indexing memory…", label: "Indexing memory…",
@@ -560,9 +545,9 @@ export function registerMemoryCli(program: Command) {
progress.setLabel(buildLabel()); progress.setLabel(buildLabel());
}, 1000); }, 1000);
try { try {
await manager.sync({ await syncFn({
reason: "cli", reason: "cli",
force: opts.force, force: Boolean(opts.force),
progress: (syncUpdate) => { progress: (syncUpdate) => {
if (syncUpdate.label) lastLabel = syncUpdate.label; if (syncUpdate.label) lastLabel = syncUpdate.label;
lastCompleted = syncUpdate.completed; lastCompleted = syncUpdate.completed;
@@ -614,7 +599,9 @@ export function registerMemoryCli(program: Command) {
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) => onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(), close: async (manager) => {
await manager.close?.();
},
run: async (manager) => { run: async (manager) => {
let results: Awaited<ReturnType<typeof manager.search>>; let results: Awaited<ReturnType<typeof manager.search>>;
try { try {
-5
View File
@@ -168,11 +168,6 @@ const entries: SubCliEntry[] = [
name: "pairing", name: "pairing",
description: "Pairing helpers", description: "Pairing helpers",
register: async (program) => { register: async (program) => {
// Initialize plugins before registering pairing CLI.
// The pairing CLI calls listPairingChannels() at registration time,
// which requires the plugin registry to be populated with channel plugins.
const { registerPluginCliCommands } = await import("../../plugins/cli.js");
registerPluginCliCommands(program, await loadConfig());
const mod = await import("../pairing-cli.js"); const mod = await import("../pairing-cli.js");
mod.registerPairingCli(program); mod.registerPairingCli(program);
}, },
+1 -2
View File
@@ -69,8 +69,7 @@ export function printWizardHeader(runtime: RuntimeEnv) {
"██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████", "██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
"██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████", "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
" 🦞 FRESH DAILY 🦞 ", " 🦞 FRESH DAILY 🦞 ",
" ",
].join("\n"); ].join("\n");
runtime.log(header); runtime.log(header);
} }
+5 -5
View File
@@ -6,7 +6,8 @@ import { probeGateway } from "../gateway/probe.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js"; import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveOsSummary } from "../infra/os-summary.js"; import { resolveOsSummary } from "../infra/os-summary.js";
import { getTailnetHostname } from "../infra/tailscale.js"; import { getTailnetHostname } from "../infra/tailscale.js";
import type { MemoryIndexManager } from "../memory/manager.js"; import { getMemorySearchManager } from "../memory/index.js";
import type { MemoryProviderStatus } from "../memory/types.js";
import { runExec } from "../process/exec.js"; import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { getAgentLocalStatuses } from "./status.agent-local.js"; import { getAgentLocalStatuses } from "./status.agent-local.js";
@@ -15,7 +16,7 @@ import { getStatusSummary } from "./status.summary.js";
import { getUpdateCheckResult } from "./status.update.js"; import { getUpdateCheckResult } from "./status.update.js";
import { buildChannelsTable } from "./status-all/channels.js"; import { buildChannelsTable } from "./status-all/channels.js";
type MemoryStatusSnapshot = ReturnType<MemoryIndexManager["status"]> & { type MemoryStatusSnapshot = MemoryProviderStatus & {
agentId: string; agentId: string;
}; };
@@ -151,14 +152,13 @@ export async function scanStatus(
if (!memoryPlugin.enabled) return null; if (!memoryPlugin.enabled) return null;
if (memoryPlugin.slot !== "memory-core") return null; if (memoryPlugin.slot !== "memory-core") return null;
const agentId = agentStatus.defaultId ?? "main"; const agentId = agentStatus.defaultId ?? "main";
const { MemoryIndexManager } = await import("../memory/manager.js"); const { manager } = await getMemorySearchManager({ cfg, agentId });
const manager = await MemoryIndexManager.get({ cfg, agentId }).catch(() => null);
if (!manager) return null; if (!manager) return null;
try { try {
await manager.probeVectorAvailability(); await manager.probeVectorAvailability();
} catch {} } catch {}
const status = manager.status(); const status = manager.status();
await manager.close().catch(() => {}); await manager.close?.().catch(() => {});
return { agentId, ...status }; return { agentId, ...status };
})(); })();
progress.tick(); progress.tick();
+52 -3
View File
@@ -222,7 +222,6 @@ 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",
@@ -253,6 +252,27 @@ const FIELD_LABELS: Record<string, string> = {
"Memory Search Hybrid Candidate Multiplier", "Memory Search Hybrid Candidate Multiplier",
"agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache",
"agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries",
memory: "Memory",
"memory.backend": "Memory Backend",
"memory.citations": "Memory Citations Mode",
"memory.qmd.command": "QMD Binary",
"memory.qmd.includeDefaultMemory": "QMD Include Default Memory",
"memory.qmd.paths": "QMD Extra Paths",
"memory.qmd.paths.path": "QMD Path",
"memory.qmd.paths.pattern": "QMD Path Pattern",
"memory.qmd.paths.name": "QMD Path Name",
"memory.qmd.sessions.enabled": "QMD Session Indexing",
"memory.qmd.sessions.exportDir": "QMD Session Export Directory",
"memory.qmd.sessions.retentionDays": "QMD Session Retention (days)",
"memory.qmd.update.interval": "QMD Update Interval",
"memory.qmd.update.debounceMs": "QMD Update Debounce (ms)",
"memory.qmd.update.onBoot": "QMD Update on Startup",
"memory.qmd.update.embedInterval": "QMD Embed Interval",
"memory.qmd.limits.maxResults": "QMD Max Results",
"memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars",
"memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars",
"memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)",
"memory.qmd.scope": "QMD Surface Scope",
"auth.profiles": "Auth Profiles", "auth.profiles": "Auth Profiles",
"auth.order": "Auth Profile Order", "auth.order": "Auth Profile Order",
"auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)",
@@ -500,8 +520,6 @@ 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").',
@@ -540,6 +558,37 @@ const FIELD_HELP: Record<string, string> = {
"Multiplier for candidate pool size (default: 4).", "Multiplier for candidate pool size (default: 4).",
"agents.defaults.memorySearch.cache.enabled": "agents.defaults.memorySearch.cache.enabled":
"Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).", "Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).",
memory: "Memory backend configuration (global).",
"memory.backend": 'Memory backend ("builtin" for Moltbot embeddings, "qmd" for QMD sidecar).',
"memory.citations": 'Default citation behavior ("auto", "on", or "off").',
"memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).",
"memory.qmd.includeDefaultMemory":
"Whether to automatically index MEMORY.md + memory/**/*.md (default: true).",
"memory.qmd.paths":
"Additional directories/files to index with QMD (path + optional glob pattern).",
"memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.",
"memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).",
"memory.qmd.paths.name":
"Optional stable name for the QMD collection (default derived from path).",
"memory.qmd.sessions.enabled":
"Enable QMD session transcript indexing (experimental, default: false).",
"memory.qmd.sessions.exportDir":
"Override directory for sanitized session exports before indexing.",
"memory.qmd.sessions.retentionDays":
"Retention window for exported sessions before pruning (default: unlimited).",
"memory.qmd.update.interval":
"How often the QMD sidecar refreshes indexes (duration string, default: 5m).",
"memory.qmd.update.debounceMs":
"Minimum delay between successive QMD refresh runs (default: 15000).",
"memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).",
"memory.qmd.update.embedInterval":
"How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.",
"memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).",
"memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).",
"memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.",
"memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).",
"memory.qmd.scope":
"Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).",
"agents.defaults.memorySearch.cache.maxEntries": "agents.defaults.memorySearch.cache.maxEntries":
"Optional cap on cached embeddings (best-effort).", "Optional cap on cached embeddings (best-effort).",
"agents.defaults.memorySearch.sync.onSearch": "agents.defaults.memorySearch.sync.onSearch":
+2
View File
@@ -23,6 +23,7 @@ import type { NodeHostConfig } from "./types.node-host.js";
import type { PluginsConfig } from "./types.plugins.js"; import type { PluginsConfig } from "./types.plugins.js";
import type { SkillsConfig } from "./types.skills.js"; import type { SkillsConfig } from "./types.skills.js";
import type { ToolsConfig } from "./types.tools.js"; import type { ToolsConfig } from "./types.tools.js";
import type { MemoryConfig } from "./types.memory.js";
export type MoltbotConfig = { export type MoltbotConfig = {
meta?: { meta?: {
@@ -95,6 +96,7 @@ export type MoltbotConfig = {
canvasHost?: CanvasHostConfig; canvasHost?: CanvasHostConfig;
talk?: TalkConfig; talk?: TalkConfig;
gateway?: GatewayConfig; gateway?: GatewayConfig;
memory?: MemoryConfig;
}; };
export type ConfigValidationIssue = { export type ConfigValidationIssue = {
+46
View File
@@ -0,0 +1,46 @@
import type { SessionSendPolicyConfig } from "./types.base.js";
export type MemoryBackend = "builtin" | "qmd";
export type MemoryCitationsMode = "auto" | "on" | "off";
export type MemoryConfig = {
backend?: MemoryBackend;
citations?: MemoryCitationsMode;
qmd?: MemoryQmdConfig;
};
export type MemoryQmdConfig = {
command?: string;
includeDefaultMemory?: boolean;
paths?: MemoryQmdIndexPath[];
sessions?: MemoryQmdSessionConfig;
update?: MemoryQmdUpdateConfig;
limits?: MemoryQmdLimitsConfig;
scope?: SessionSendPolicyConfig;
};
export type MemoryQmdIndexPath = {
path: string;
name?: string;
pattern?: string;
};
export type MemoryQmdSessionConfig = {
enabled?: boolean;
exportDir?: string;
retentionDays?: number;
};
export type MemoryQmdUpdateConfig = {
interval?: string;
debounceMs?: number;
onBoot?: boolean;
embedInterval?: string;
};
export type MemoryQmdLimitsConfig = {
maxResults?: number;
maxSnippetChars?: number;
maxInjectedChars?: number;
timeoutMs?: number;
};
-2
View File
@@ -226,8 +226,6 @@ 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). */
+1
View File
@@ -28,3 +28,4 @@ export * from "./types.telegram.js";
export * from "./types.tts.js"; export * from "./types.tts.js";
export * from "./types.tools.js"; export * from "./types.tools.js";
export * from "./types.whatsapp.js"; export * from "./types.whatsapp.js";
export * from "./types.memory.js";
-1
View File
@@ -304,7 +304,6 @@ 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(),
+26 -25
View File
@@ -16,6 +16,31 @@ const SessionResetConfigSchema = z
}) })
.strict(); .strict();
export const SessionSendPolicySchema = z
.object({
default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
rules: z
.array(
z
.object({
action: z.union([z.literal("allow"), z.literal("deny")]),
match: z
.object({
channel: z.string().optional(),
chatType: z
.union([z.literal("direct"), z.literal("group"), z.literal("channel")])
.optional(),
keyPrefix: z.string().optional(),
})
.strict()
.optional(),
})
.strict(),
)
.optional(),
})
.strict();
export const SessionSchema = z export const SessionSchema = z
.object({ .object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
@@ -51,31 +76,7 @@ export const SessionSchema = z
]) ])
.optional(), .optional(),
mainKey: z.string().optional(), mainKey: z.string().optional(),
sendPolicy: z sendPolicy: SessionSendPolicySchema.optional(),
.object({
default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
rules: z
.array(
z
.object({
action: z.union([z.literal("allow"), z.literal("deny")]),
match: z
.object({
channel: z.string().optional(),
chatType: z
.union([z.literal("direct"), z.literal("group"), z.literal("channel")])
.optional(),
keyPrefix: z.string().optional(),
})
.strict()
.optional(),
})
.strict(),
)
.optional(),
})
.strict()
.optional(),
agentToAgent: z agentToAgent: z
.object({ .object({
maxPingPongTurns: z.number().int().min(0).max(5).optional(), maxPingPongTurns: z.number().int().min(0).max(5).optional(),
+62 -1
View File
@@ -5,7 +5,12 @@ import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zo
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js"; import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
import { ChannelsSchema } from "./zod-schema.providers.js"; import { ChannelsSchema } from "./zod-schema.providers.js";
import { CommandsSchema, MessagesSchema, SessionSchema } from "./zod-schema.session.js"; import {
CommandsSchema,
MessagesSchema,
SessionSchema,
SessionSendPolicySchema,
} from "./zod-schema.session.js";
const BrowserSnapshotDefaultsSchema = z const BrowserSnapshotDefaultsSchema = z
.object({ .object({
@@ -27,6 +32,61 @@ const NodeHostSchema = z
.strict() .strict()
.optional(); .optional();
const MemoryQmdPathSchema = z
.object({
path: z.string(),
name: z.string().optional(),
pattern: z.string().optional(),
})
.strict();
const MemoryQmdSessionSchema = z
.object({
enabled: z.boolean().optional(),
exportDir: z.string().optional(),
retentionDays: z.number().int().nonnegative().optional(),
})
.strict();
const MemoryQmdUpdateSchema = z
.object({
interval: z.string().optional(),
debounceMs: z.number().int().nonnegative().optional(),
onBoot: z.boolean().optional(),
embedInterval: z.string().optional(),
})
.strict();
const MemoryQmdLimitsSchema = z
.object({
maxResults: z.number().int().positive().optional(),
maxSnippetChars: z.number().int().positive().optional(),
maxInjectedChars: z.number().int().positive().optional(),
timeoutMs: z.number().int().nonnegative().optional(),
})
.strict();
const MemoryQmdSchema = z
.object({
command: z.string().optional(),
includeDefaultMemory: z.boolean().optional(),
paths: z.array(MemoryQmdPathSchema).optional(),
sessions: MemoryQmdSessionSchema.optional(),
update: MemoryQmdUpdateSchema.optional(),
limits: MemoryQmdLimitsSchema.optional(),
scope: SessionSendPolicySchema.optional(),
})
.strict();
const MemorySchema = z
.object({
backend: z.union([z.literal("builtin"), z.literal("qmd")]).optional(),
citations: z.union([z.literal("auto"), z.literal("on"), z.literal("off")]).optional(),
qmd: MemoryQmdSchema.optional(),
})
.strict()
.optional();
export const MoltbotSchema = z export const MoltbotSchema = z
.object({ .object({
meta: z meta: z
@@ -445,6 +505,7 @@ export const MoltbotSchema = z
}) })
.strict() .strict()
.optional(), .optional(),
memory: MemorySchema,
skills: z skills: z
.object({ .object({
allowBundled: z.array(z.string()).optional(), allowBundled: z.array(z.string()).optional(),
@@ -135,7 +135,7 @@ describe("discord tool result dispatch", () => {
expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1);
}, 20_000); }, 20_000);
it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => { it("skips guild messages when another user is explicitly 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).toHaveBeenCalledTimes(1); expect(dispatchMock).not.toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1); expect(sendMock).not.toHaveBeenCalled();
}, 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 () => {
+4
View File
@@ -84,10 +84,14 @@ export async function resolveDiscordTarget(
const likelyUsername = isLikelyUsername(trimmed); const likelyUsername = isLikelyUsername(trimmed);
const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername; const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
// Parse directly if it's already a known format. Use a safe parse so ambiguous
// numeric targets don't throw when we still want to attempt username lookup.
const directParse = safeParseDiscordTarget(trimmed, parseOptions); const directParse = safeParseDiscordTarget(trimmed, parseOptions);
if (directParse && directParse.kind !== "channel" && !likelyUsername) { if (directParse && directParse.kind !== "channel" && !likelyUsername) {
return directParse; return directParse;
} }
if (!shouldLookup) { if (!shouldLookup) {
return directParse ?? parseDiscordTarget(trimmed, parseOptions); return directParse ?? parseDiscordTarget(trimmed, parseOptions);
} }
+2 -25
View File
@@ -23,7 +23,7 @@ Automatically saves session context to your workspace memory when you issue the
When you run `/new` to start a fresh session: When you run `/new` to start a fresh session:
1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript 1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript
2. **Extracts conversation** - Reads the last N user/assistant messages from the session (default: 15, configurable) 2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content 3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md` 4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md`
5. **Sends confirmation** - Notifies you with the file path 5. **Sends confirmation** - Notifies you with the file path
@@ -57,30 +57,7 @@ The hook uses your configured LLM provider to generate slugs, so it works with a
## Configuration ## Configuration
The hook supports optional configuration: No additional configuration required. The hook automatically:
| Option | Type | Default | Description |
| ---------- | ------ | ------- | --------------------------------------------------------------- |
| `messages` | number | 15 | Number of user/assistant messages to include in the memory file |
Example configuration:
```json
{
"hooks": {
"internal": {
"entries": {
"session-memory": {
"enabled": true,
"messages": 25
}
}
}
}
}
```
The hook automatically:
- Uses your workspace directory (`~/clawd` by default) - Uses your workspace directory (`~/clawd` by default)
- Uses your configured LLM for slug generation - Uses your configured LLM for slug generation
@@ -1,379 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import handler from "./handler.js";
import { createHookEvent } from "../../hooks.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
/**
* Create a mock session JSONL file with various entry types
*/
function createMockSessionContent(
entries: Array<{ role: string; content: string } | { type: string }>,
): string {
return entries
.map((entry) => {
if ("role" in entry) {
return JSON.stringify({
type: "message",
message: {
role: entry.role,
content: entry.content,
},
});
}
// Non-message entry (tool call, system, etc.)
return JSON.stringify(entry);
})
.join("\n");
}
describe("session-memory hook", () => {
it("skips non-command events", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const event = createHookEvent("agent", "bootstrap", "agent:main:main", {
workspaceDir: tempDir,
});
await handler(event);
// Memory directory should not be created for non-command events
const memoryDir = path.join(tempDir, "memory");
await expect(fs.access(memoryDir)).rejects.toThrow();
});
it("skips commands other than new", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const event = createHookEvent("command", "help", "agent:main:main", {
workspaceDir: tempDir,
});
await handler(event);
// Memory directory should not be created for other commands
const memoryDir = path.join(tempDir, "memory");
await expect(fs.access(memoryDir)).rejects.toThrow();
});
it("creates memory file with session content on /new command", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create a mock session file with user/assistant messages
const sessionContent = createMockSessionContent([
{ role: "user", content: "Hello there" },
{ role: "assistant", content: "Hi! How can I help?" },
{ role: "user", content: "What is 2+2?" },
{ role: "assistant", content: "2+2 equals 4" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
// Memory file should be created
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files.length).toBe(1);
// Read the memory file and verify content
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
expect(memoryContent).toContain("user: Hello there");
expect(memoryContent).toContain("assistant: Hi! How can I help?");
expect(memoryContent).toContain("user: What is 2+2?");
expect(memoryContent).toContain("assistant: 2+2 equals 4");
});
it("filters out non-message entries (tool calls, system)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create session with mixed entry types
const sessionContent = createMockSessionContent([
{ role: "user", content: "Hello" },
{ type: "tool_use", tool: "search", input: "test" },
{ role: "assistant", content: "World" },
{ type: "tool_result", result: "found it" },
{ role: "user", content: "Thanks" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Only user/assistant messages should be present
expect(memoryContent).toContain("user: Hello");
expect(memoryContent).toContain("assistant: World");
expect(memoryContent).toContain("user: Thanks");
// Tool entries should not appear
expect(memoryContent).not.toContain("tool_use");
expect(memoryContent).not.toContain("tool_result");
expect(memoryContent).not.toContain("search");
});
it("filters out command messages starting with /", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionContent = createMockSessionContent([
{ role: "user", content: "/help" },
{ role: "assistant", content: "Here is help info" },
{ role: "user", content: "Normal message" },
{ role: "user", content: "/new" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Command messages should be filtered out
expect(memoryContent).not.toContain("/help");
expect(memoryContent).not.toContain("/new");
// Normal messages should be present
expect(memoryContent).toContain("assistant: Here is help info");
expect(memoryContent).toContain("user: Normal message");
});
it("respects custom messages config (limits to N messages)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create 10 messages
const entries = [];
for (let i = 1; i <= 10; i++) {
entries.push({ role: "user", content: `Message ${i}` });
}
const sessionContent = createMockSessionContent(entries);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
// Configure to only include last 3 messages
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, messages: 3 },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Only last 3 messages should be present
expect(memoryContent).not.toContain("user: Message 1\n");
expect(memoryContent).not.toContain("user: Message 7\n");
expect(memoryContent).toContain("user: Message 8");
expect(memoryContent).toContain("user: Message 9");
expect(memoryContent).toContain("user: Message 10");
});
it("filters messages before slicing (fix for #2681)", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Create session with many tool entries interspersed with messages
// This tests that we filter FIRST, then slice - not the other way around
const entries = [
{ role: "user", content: "First message" },
{ type: "tool_use", tool: "test1" },
{ type: "tool_result", result: "result1" },
{ role: "assistant", content: "Second message" },
{ type: "tool_use", tool: "test2" },
{ type: "tool_result", result: "result2" },
{ role: "user", content: "Third message" },
{ type: "tool_use", tool: "test3" },
{ type: "tool_result", result: "result3" },
{ role: "assistant", content: "Fourth message" },
];
const sessionContent = createMockSessionContent(entries);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
// Request 3 messages - if we sliced first, we'd only get 1-2 messages
// because the last 3 lines include tool entries
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
hooks: {
internal: {
entries: {
"session-memory": { enabled: true, messages: 3 },
},
},
},
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Should have exactly 3 user/assistant messages (the last 3)
expect(memoryContent).not.toContain("First message");
expect(memoryContent).toContain("user: Third message");
expect(memoryContent).toContain("assistant: Second message");
expect(memoryContent).toContain("assistant: Fourth message");
});
it("handles empty session files gracefully", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: "",
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
// Should not throw
await handler(event);
// Memory file should still be created with metadata
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files.length).toBe(1);
});
it("handles session files with fewer messages than requested", async () => {
const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
// Only 2 messages but requesting 15 (default)
const sessionContent = createMockSessionContent([
{ role: "user", content: "Only message 1" },
{ role: "assistant", content: "Only message 2" },
]);
const sessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: sessionContent,
});
const cfg: ClawdbotConfig = {
agents: { defaults: { workspace: tempDir } },
};
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
// Both messages should be included
expect(memoryContent).toContain("user: Only message 1");
expect(memoryContent).toContain("assistant: Only message 2");
});
});
+14 -22
View File
@@ -8,27 +8,25 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { fileURLToPath } from "node:url";
import type { MoltbotConfig } from "../../../config/config.js"; import type { MoltbotConfig } from "../../../config/config.js";
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
import { resolveHookConfig } from "../../config.js";
import type { HookHandler } from "../../hooks.js"; import type { HookHandler } from "../../hooks.js";
/** /**
* Read recent messages from session file for slug generation * Read recent messages from session file for slug generation
*/ */
async function getRecentSessionContent( async function getRecentSessionContent(sessionFilePath: string): Promise<string | null> {
sessionFilePath: string,
messageCount: number = 15,
): Promise<string | null> {
try { try {
const content = await fs.readFile(sessionFilePath, "utf-8"); const content = await fs.readFile(sessionFilePath, "utf-8");
const lines = content.trim().split("\n"); const lines = content.trim().split("\n");
// Parse JSONL and extract user/assistant messages first // Get last 15 lines (recent conversation)
const allMessages: string[] = []; const recentLines = lines.slice(-15);
for (const line of lines) {
// Parse JSONL and extract messages
const messages: string[] = [];
for (const line of recentLines) {
try { try {
const entry = JSON.parse(line); const entry = JSON.parse(line);
// Session files have entries with type="message" containing a nested message object // Session files have entries with type="message" containing a nested message object
@@ -41,7 +39,7 @@ async function getRecentSessionContent(
? msg.content.find((c: any) => c.type === "text")?.text ? msg.content.find((c: any) => c.type === "text")?.text
: msg.content; : msg.content;
if (text && !text.startsWith("/")) { if (text && !text.startsWith("/")) {
allMessages.push(`${role}: ${text}`); messages.push(`${role}: ${text}`);
} }
} }
} }
@@ -50,9 +48,7 @@ async function getRecentSessionContent(
} }
} }
// Then slice to get exactly messageCount messages return messages.join("\n");
const recentMessages = allMessages.slice(-messageCount);
return recentMessages.join("\n");
} catch { } catch {
return null; return null;
} }
@@ -97,19 +93,12 @@ const saveSessionToMemory: HookHandler = async (event) => {
const sessionFile = currentSessionFile || undefined; const sessionFile = currentSessionFile || undefined;
// Read message count from hook config (default: 15)
const hookConfig = resolveHookConfig(cfg, "session-memory");
const messageCount =
typeof hookConfig?.messages === "number" && hookConfig.messages > 0
? hookConfig.messages
: 15;
let slug: string | null = null; let slug: string | null = null;
let sessionContent: string | null = null; let sessionContent: string | null = null;
if (sessionFile) { if (sessionFile) {
// Get recent conversation content // Get recent conversation content
sessionContent = await getRecentSessionContent(sessionFile, messageCount); sessionContent = await getRecentSessionContent(sessionFile);
console.log("[session-memory] sessionContent length:", sessionContent?.length || 0); console.log("[session-memory] sessionContent length:", sessionContent?.length || 0);
if (sessionContent && cfg) { if (sessionContent && cfg) {
@@ -117,7 +106,10 @@ const saveSessionToMemory: HookHandler = async (event) => {
// Dynamically import the LLM slug generator (avoids module caching issues) // Dynamically import the LLM slug generator (avoids module caching issues)
// When compiled, handler is at dist/hooks/bundled/session-memory/handler.js // When compiled, handler is at dist/hooks/bundled/session-memory/handler.js
// Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js // Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js
const moltbotRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const moltbotRoot = path.resolve(
path.dirname(import.meta.url.replace("file://", "")),
"../..",
);
const slugGenPath = path.join(moltbotRoot, "llm-slug-generator.js"); const slugGenPath = path.join(moltbotRoot, "llm-slug-generator.js");
const { generateSlugViaLLM } = await import(slugGenPath); const { generateSlugViaLLM } = await import(slugGenPath);
+9 -192
View File
@@ -41,7 +41,7 @@ describe("applyMediaUnderstanding", () => {
mockedResolveApiKey.mockClear(); mockedResolveApiKey.mockClear();
mockedFetchRemoteMedia.mockReset(); mockedFetchRemoteMedia.mockReset();
mockedFetchRemoteMedia.mockResolvedValue({ mockedFetchRemoteMedia.mockResolvedValue({
buffer: Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), buffer: Buffer.from("audio-bytes"),
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, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); await fs.writeFile(audioPath, "hello");
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, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); await fs.writeFile(audioPath, "hello");
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, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])); await fs.writeFile(audioPath, "0123456789");
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, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6, 7, 8])); await fs.writeFile(audioPath, "hello");
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, Buffer.from([0, 255, 0, 1, 2, 3, 4, 5, 6])); await fs.writeFile(audioPath, "hello");
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, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); await fs.writeFile(audioPathA, "hello");
await fs.writeFile(audioPathB, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); await fs.writeFile(audioPathB, "world");
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, Buffer.from([200, 201, 202, 203, 204, 205, 206, 207, 208])); await fs.writeFile(audioPath, "audio-bytes");
await fs.writeFile(videoPath, "video-bytes"); await fs.writeFile(videoPath, "video-bytes");
const ctx: MsgContext = { const ctx: MsgContext = {
@@ -487,187 +487,4 @@ 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-"));
// Create file with XML special characters in the name (what filesystem allows)
// 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("&lt;");
expect(ctx.Body).toContain("&gt;");
// The raw < and > should not appear unescaped in the name attribute
expect(ctx.Body).not.toMatch(/name="[^"]*<[^"]*"/);
});
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 -303
View File
@@ -1,22 +1,6 @@
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,
@@ -30,7 +14,6 @@ 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,
@@ -45,279 +28,9 @@ 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> = {
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
'"': "&quot;",
"'": "&apos;",
};
/**
* 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;
@@ -338,12 +51,6 @@ 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({
@@ -392,15 +99,7 @@ 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 {
@@ -409,7 +108,6 @@ 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();
+1 -45
View File
@@ -1,7 +1,7 @@
import JSZip from "jszip"; import JSZip from "jszip";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { detectMime, extensionForMime, imageMimeFromFormat } from "./mime.js"; import { detectMime, imageMimeFromFormat } from "./mime.js";
async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise<Buffer> { async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise<Buffer> {
const zip = new JSZip(); const zip = new JSZip();
@@ -53,47 +53,3 @@ describe("mime detection", () => {
expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
}); });
}); });
describe("extensionForMime", () => {
it("maps image MIME types to extensions", () => {
expect(extensionForMime("image/jpeg")).toBe(".jpg");
expect(extensionForMime("image/png")).toBe(".png");
expect(extensionForMime("image/webp")).toBe(".webp");
expect(extensionForMime("image/gif")).toBe(".gif");
expect(extensionForMime("image/heic")).toBe(".heic");
});
it("maps audio MIME types to extensions", () => {
expect(extensionForMime("audio/mpeg")).toBe(".mp3");
expect(extensionForMime("audio/ogg")).toBe(".ogg");
expect(extensionForMime("audio/x-m4a")).toBe(".m4a");
expect(extensionForMime("audio/mp4")).toBe(".m4a");
});
it("maps video MIME types to extensions", () => {
expect(extensionForMime("video/mp4")).toBe(".mp4");
expect(extensionForMime("video/quicktime")).toBe(".mov");
});
it("maps document MIME types to extensions", () => {
expect(extensionForMime("application/pdf")).toBe(".pdf");
expect(extensionForMime("text/plain")).toBe(".txt");
expect(extensionForMime("text/markdown")).toBe(".md");
});
it("handles case insensitivity", () => {
expect(extensionForMime("IMAGE/JPEG")).toBe(".jpg");
expect(extensionForMime("Audio/X-M4A")).toBe(".m4a");
expect(extensionForMime("Video/QuickTime")).toBe(".mov");
});
it("returns undefined for unknown MIME types", () => {
expect(extensionForMime("video/unknown")).toBeUndefined();
expect(extensionForMime("application/x-custom")).toBeUndefined();
});
it("returns undefined for null or undefined input", () => {
expect(extensionForMime(null)).toBeUndefined();
expect(extensionForMime(undefined)).toBeUndefined();
});
});
-3
View File
@@ -13,10 +13,7 @@ const EXT_BY_MIME: Record<string, string> = {
"image/gif": ".gif", "image/gif": ".gif",
"audio/ogg": ".ogg", "audio/ogg": ".ogg",
"audio/mpeg": ".mp3", "audio/mpeg": ".mp3",
"audio/x-m4a": ".m4a",
"audio/mp4": ".m4a",
"video/mp4": ".mp4", "video/mp4": ".mp4",
"video/quicktime": ".mov",
"application/pdf": ".pdf", "application/pdf": ".pdf",
"application/json": ".json", "application/json": ".json",
"application/zip": ".zip", "application/zip": ".zip",
+58
View File
@@ -0,0 +1,58 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { MoltbotConfig } from "../config/config.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { resolveMemoryBackendConfig } from "./backend-config.js";
describe("resolveMemoryBackendConfig", () => {
it("defaults to builtin backend when config missing", () => {
const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } } } as MoltbotConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.backend).toBe("builtin");
expect(resolved.citations).toBe("auto");
expect(resolved.qmd).toBeUndefined();
});
it("resolves qmd backend with default collections", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
memory: {
backend: "qmd",
qmd: {},
},
} as MoltbotConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.backend).toBe("qmd");
expect(resolved.qmd?.collections.length).toBeGreaterThanOrEqual(3);
expect(resolved.qmd?.command).toBe("qmd");
expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0);
});
it("resolves custom paths relative to workspace", () => {
const cfg = {
agents: {
defaults: { workspace: "/workspace/root" },
list: [{ id: "main", workspace: "/workspace/root" }],
},
memory: {
backend: "qmd",
qmd: {
paths: [
{
path: "notes",
name: "custom-notes",
pattern: "**/*.md",
},
],
},
},
} as MoltbotConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const custom = resolved.qmd?.collections.find((c) => c.name.startsWith("custom-notes"));
expect(custom).toBeDefined();
const workspaceRoot = resolveAgentWorkspaceDir(cfg, "main");
expect(custom?.path).toBe(path.resolve(workspaceRoot, "notes"));
});
});
+255
View File
@@ -0,0 +1,255 @@
import path from "node:path";
import { parseDurationMs } from "../cli/parse-duration.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { MoltbotConfig } from "../config/config.js";
import type {
MemoryBackend,
MemoryCitationsMode,
MemoryQmdConfig,
MemoryQmdIndexPath,
} from "../config/types.memory.js";
import type { SessionSendPolicyConfig } from "../config/types.base.js";
import { resolveUserPath } from "../utils.js";
export type ResolvedMemoryBackendConfig = {
backend: MemoryBackend;
citations: MemoryCitationsMode;
qmd?: ResolvedQmdConfig;
};
export type ResolvedQmdCollection = {
name: string;
path: string;
pattern: string;
kind: "memory" | "custom" | "sessions";
};
export type ResolvedQmdUpdateConfig = {
intervalMs: number;
debounceMs: number;
onBoot: boolean;
embedIntervalMs: number;
};
export type ResolvedQmdLimitsConfig = {
maxResults: number;
maxSnippetChars: number;
maxInjectedChars: number;
timeoutMs: number;
};
export type ResolvedQmdSessionConfig = {
enabled: boolean;
exportDir?: string;
retentionDays?: number;
};
export type ResolvedQmdConfig = {
command: string;
collections: ResolvedQmdCollection[];
sessions: ResolvedQmdSessionConfig;
update: ResolvedQmdUpdateConfig;
limits: ResolvedQmdLimitsConfig;
includeDefaultMemory: boolean;
scope?: SessionSendPolicyConfig;
};
const DEFAULT_BACKEND: MemoryBackend = "builtin";
const DEFAULT_CITATIONS: MemoryCitationsMode = "auto";
const DEFAULT_QMD_INTERVAL = "5m";
const DEFAULT_QMD_DEBOUNCE_MS = 15_000;
const DEFAULT_QMD_TIMEOUT_MS = 4_000;
const DEFAULT_QMD_EMBED_INTERVAL = "60m";
const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = {
maxResults: 6,
maxSnippetChars: 700,
maxInjectedChars: 4_000,
timeoutMs: DEFAULT_QMD_TIMEOUT_MS,
};
const DEFAULT_QMD_SCOPE: SessionSendPolicyConfig = {
default: "deny",
rules: [
{
action: "allow",
match: { chatType: "direct" },
},
],
};
function sanitizeName(input: string): string {
const lower = input.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
const trimmed = lower.replace(/^-+|-+$/g, "");
return trimmed || "collection";
}
function ensureUniqueName(base: string, existing: Set<string>): string {
let name = sanitizeName(base);
if (!existing.has(name)) {
existing.add(name);
return name;
}
let suffix = 2;
while (existing.has(`${name}-${suffix}`)) {
suffix += 1;
}
const unique = `${name}-${suffix}`;
existing.add(unique);
return unique;
}
function resolvePath(raw: string, workspaceDir: string): string {
const trimmed = raw.trim();
if (!trimmed) throw new Error("path required");
if (trimmed.startsWith("~") || path.isAbsolute(trimmed)) {
return path.normalize(resolveUserPath(trimmed));
}
return path.normalize(path.resolve(workspaceDir, trimmed));
}
function resolveIntervalMs(raw: string | undefined): number {
const value = raw?.trim();
if (!value) return parseDurationMs(DEFAULT_QMD_INTERVAL, { defaultUnit: "m" });
try {
return parseDurationMs(value, { defaultUnit: "m" });
} catch {
return parseDurationMs(DEFAULT_QMD_INTERVAL, { defaultUnit: "m" });
}
}
function resolveEmbedIntervalMs(raw: string | undefined): number {
const value = raw?.trim();
if (!value) return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" });
try {
return parseDurationMs(value, { defaultUnit: "m" });
} catch {
return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" });
}
}
function resolveDebounceMs(raw: number | undefined): number {
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
return Math.floor(raw);
}
return DEFAULT_QMD_DEBOUNCE_MS;
}
function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig {
const parsed: ResolvedQmdLimitsConfig = { ...DEFAULT_QMD_LIMITS };
if (raw?.maxResults && raw.maxResults > 0) parsed.maxResults = Math.floor(raw.maxResults);
if (raw?.maxSnippetChars && raw.maxSnippetChars > 0) {
parsed.maxSnippetChars = Math.floor(raw.maxSnippetChars);
}
if (raw?.maxInjectedChars && raw.maxInjectedChars > 0) {
parsed.maxInjectedChars = Math.floor(raw.maxInjectedChars);
}
if (raw?.timeoutMs && raw.timeoutMs > 0) {
parsed.timeoutMs = Math.floor(raw.timeoutMs);
}
return parsed;
}
function resolveSessionConfig(
cfg: MemoryQmdConfig["sessions"],
workspaceDir: string,
): ResolvedQmdSessionConfig {
const enabled = Boolean(cfg?.enabled);
const exportDirRaw = cfg?.exportDir?.trim();
const exportDir = exportDirRaw ? resolvePath(exportDirRaw, workspaceDir) : undefined;
const retentionDays =
cfg?.retentionDays && cfg.retentionDays > 0 ? Math.floor(cfg.retentionDays) : undefined;
return {
enabled,
exportDir,
retentionDays,
};
}
function resolveCustomPaths(
rawPaths: MemoryQmdIndexPath[] | undefined,
workspaceDir: string,
existing: Set<string>,
): ResolvedQmdCollection[] {
if (!rawPaths?.length) return [];
const collections: ResolvedQmdCollection[] = [];
rawPaths.forEach((entry, index) => {
const trimmedPath = entry?.path?.trim();
if (!trimmedPath) return;
let resolved: string;
try {
resolved = resolvePath(trimmedPath, workspaceDir);
} catch {
return;
}
const pattern = entry.pattern?.trim() || "**/*.md";
const baseName = entry.name?.trim() || `custom-${index + 1}`;
const name = ensureUniqueName(baseName, existing);
collections.push({
name,
path: resolved,
pattern,
kind: "custom",
});
});
return collections;
}
function resolveDefaultCollections(
include: boolean,
workspaceDir: string,
existing: Set<string>,
): ResolvedQmdCollection[] {
if (!include) return [];
const entries: Array<{ path: string; pattern: string; base: string }> = [
{ path: workspaceDir, pattern: "MEMORY.md", base: "memory-root" },
{ path: workspaceDir, pattern: "memory.md", base: "memory-alt" },
{ path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" },
];
return entries.map((entry) => ({
name: ensureUniqueName(entry.base, existing),
path: entry.path,
pattern: entry.pattern,
kind: "memory",
}));
}
export function resolveMemoryBackendConfig(params: {
cfg: MoltbotConfig;
agentId: string;
}): ResolvedMemoryBackendConfig {
const backend = params.cfg.memory?.backend ?? DEFAULT_BACKEND;
const citations = params.cfg.memory?.citations ?? DEFAULT_CITATIONS;
if (backend !== "qmd") {
return { backend: "builtin", citations };
}
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
const qmdCfg = params.cfg.memory?.qmd;
const includeDefaultMemory = qmdCfg?.includeDefaultMemory !== false;
const nameSet = new Set<string>();
const collections = [
...resolveDefaultCollections(includeDefaultMemory, workspaceDir, nameSet),
...resolveCustomPaths(qmdCfg?.paths, workspaceDir, nameSet),
];
const resolved: ResolvedQmdConfig = {
command: (qmdCfg?.command?.trim() || "qmd").split(/\s+/)[0] || "qmd",
collections,
includeDefaultMemory,
sessions: resolveSessionConfig(qmdCfg?.sessions, workspaceDir),
update: {
intervalMs: resolveIntervalMs(qmdCfg?.update?.interval),
debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs),
onBoot: qmdCfg?.update?.onBoot !== false,
embedIntervalMs: resolveEmbedIntervalMs(qmdCfg?.update?.embedInterval),
},
limits: resolveLimits(qmdCfg?.limits),
scope: qmdCfg?.scope ?? DEFAULT_QMD_SCOPE,
};
return {
backend: "qmd",
citations,
qmd: resolved,
};
}
-48
View File
@@ -412,52 +412,4 @@ 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",
);
}
});
}); });
+6 -1
View File
@@ -1,2 +1,7 @@
export type { MemoryIndexManager, MemorySearchResult } from "./manager.js"; export { MemoryIndexManager } from "./manager.js";
export type {
MemoryEmbeddingProbeResult,
MemorySearchManager,
MemorySearchResult,
} from "./types.js";
export { getMemorySearchManager, type MemorySearchManagerResult } from "./search-manager.js"; export { getMemorySearchManager, type MemorySearchManagerResult } from "./search-manager.js";
+2 -113
View File
@@ -1,117 +1,6 @@
import fs from "node:fs/promises"; import { describe, expect, it } from "vitest";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { chunkMarkdown } from "./internal.js";
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", () => {
+14 -50
View File
@@ -30,17 +30,6 @@ 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;
@@ -48,11 +37,19 @@ 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;
@@ -63,48 +60,15 @@ async function walkDir(dir: string, files: string[]) {
} }
} }
export async function listMemoryFiles( export async function listMemoryFiles(workspaceDir: string): Promise<string[]> {
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)) {
const addMarkdownFile = async (absPath: string) => { await walkDir(memoryDir, result);
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>();
-1
View File
@@ -13,7 +13,6 @@ 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,
+23 -117
View File
@@ -1,5 +1,4 @@
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";
@@ -36,9 +35,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";
@@ -46,17 +45,14 @@ import { searchKeyword, searchVector } from "./manager-search.js";
import { ensureMemoryIndexSchema } from "./memory-schema.js"; import { ensureMemoryIndexSchema } from "./memory-schema.js";
import { requireNodeSqlite } from "./sqlite.js"; import { requireNodeSqlite } from "./sqlite.js";
import { loadSqliteVecExtension } from "./sqlite-vec.js"; import { loadSqliteVecExtension } from "./sqlite-vec.js";
import type {
type MemorySource = "memory" | "sessions"; MemoryEmbeddingProbeResult,
MemoryProviderStatus,
export type MemorySearchResult = { MemorySearchManager,
path: string; MemorySearchResult,
startLine: number; MemorySource,
endLine: number; MemorySyncProgressUpdate,
score: number; } from "./types.js";
snippet: string;
source: MemorySource;
};
type MemoryIndexMeta = { type MemoryIndexMeta = {
model: string; model: string;
@@ -76,12 +72,6 @@ type SessionFileEntry = {
content: string; content: string;
}; };
type MemorySyncProgressUpdate = {
completed: number;
total: number;
label?: string;
};
type MemorySyncProgressState = { type MemorySyncProgressState = {
completed: number; completed: number;
total: number; total: number;
@@ -116,7 +106,7 @@ const INDEX_CACHE = new Map<string, MemoryIndexManager>();
const vectorToBlob = (embedding: number[]): Buffer => const vectorToBlob = (embedding: number[]): Buffer =>
Buffer.from(new Float32Array(embedding).buffer); Buffer.from(new Float32Array(embedding).buffer);
export class MemoryIndexManager { export class MemoryIndexManager implements MemorySearchManager {
private readonly cacheKey: string; private readonly cacheKey: string;
private readonly cfg: MoltbotConfig; private readonly cfg: MoltbotConfig;
private readonly agentId: string; private readonly agentId: string;
@@ -397,52 +387,13 @@ export class MemoryIndexManager {
from?: number; from?: number;
lines?: number; lines?: number;
}): Promise<{ text: string; path: string }> { }): Promise<{ text: string; path: string }> {
const rawPath = params.relPath.trim(); const relPath = normalizeRelPath(params.relPath);
if (!rawPath) { if (!relPath || !isMemoryPath(relPath)) {
throw new Error("path required"); throw new Error("path required");
} }
const absPath = path.isAbsolute(rawPath) const absPath = path.resolve(this.workspaceDir, relPath);
? path.resolve(rawPath) if (!absPath.startsWith(this.workspaceDir)) {
: path.resolve(this.workspaceDir, rawPath); throw new Error("path escapes workspace");
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) {
@@ -455,40 +406,7 @@ export class MemoryIndexManager {
return { text: slice.join("\n"), path: relPath }; return { text: slice.join("\n"), path: relPath };
} }
status(): { status(): MemoryProviderStatus {
files: number;
chunks: number;
dirty: boolean;
workspaceDir: string;
dbPath: string;
provider: string;
model: string;
requestedProvider: string;
sources: MemorySource[];
extraPaths: string[];
sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
cache?: { enabled: boolean; entries?: number; maxEntries?: number };
fts?: { enabled: boolean; available: boolean; error?: string };
fallback?: { from: string; reason?: string };
vector?: {
enabled: boolean;
available?: boolean;
extensionPath?: string;
loadError?: string;
dims?: number;
};
batch?: {
enabled: boolean;
failures: number;
limit: number;
wait: boolean;
concurrency: number;
pollIntervalMs: number;
timeoutMs: number;
lastError?: string;
lastProvider?: string;
};
} {
const sourceFilter = this.buildSourceFilter(); const sourceFilter = this.buildSourceFilter();
const files = this.db const files = this.db
.prepare(`SELECT COUNT(*) as c FROM files WHERE 1=1${sourceFilter.sql}`) .prepare(`SELECT COUNT(*) as c FROM files WHERE 1=1${sourceFilter.sql}`)
@@ -530,16 +448,16 @@ export class MemoryIndexManager {
return sources.map((source) => ({ source, ...bySource.get(source)! })); return sources.map((source) => ({ source, ...bySource.get(source)! }));
})(); })();
return { return {
backend: "builtin",
files: files?.c ?? 0, files: files?.c ?? 0,
chunks: chunks?.c ?? 0, chunks: chunks?.c ?? 0,
dirty: this.dirty, dirty: this.dirty || this.sessionsDirty,
workspaceDir: this.workspaceDir, workspaceDir: this.workspaceDir,
dbPath: this.settings.store.path, dbPath: this.settings.store.path,
provider: this.provider.id, provider: this.provider.id,
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
? { ? {
@@ -587,7 +505,7 @@ export class MemoryIndexManager {
return this.ensureVectorReady(); return this.ensureVectorReady();
} }
async probeEmbeddingAvailability(): Promise<{ ok: boolean; error?: string }> { async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
try { try {
await this.embedBatchWithRetry(["ping"]); await this.embedBatchWithRetry(["ping"]);
return { ok: true }; return { ok: true };
@@ -811,23 +729,11 @@ 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 additionalPaths = normalizeExtraMemoryPaths(this.workspaceDir, this.settings.extraPaths) const watchPaths = [
.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,
@@ -1029,7 +935,7 @@ export class MemoryIndexManager {
needsFullReindex: boolean; needsFullReindex: boolean;
progress?: MemorySyncProgressState; progress?: MemorySyncProgressState;
}) { }) {
const files = await listMemoryFiles(this.workspaceDir, this.settings.extraPaths); const files = await listMemoryFiles(this.workspaceDir);
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)),
); );
+98
View File
@@ -0,0 +1,98 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => {
const spawn = vi.fn((_cmd: string, _args: string[]) => {
const stdout = new EventEmitter();
const stderr = new EventEmitter();
const child = new EventEmitter() as {
stdout: EventEmitter;
stderr: EventEmitter;
kill: () => void;
emit: (event: string, code: number) => boolean;
};
child.stdout = stdout;
child.stderr = stderr;
child.kill = () => {
child.emit("close", 0);
};
setImmediate(() => {
stdout.emit("data", "");
stderr.emit("data", "");
child.emit("close", 0);
});
return child;
});
return { spawn };
});
import { spawn as mockedSpawn } from "node:child_process";
import type { MoltbotConfig } from "../config/config.js";
import { resolveMemoryBackendConfig } from "./backend-config.js";
import { QmdMemoryManager } from "./qmd-manager.js";
const spawnMock = mockedSpawn as unknown as vi.Mock;
describe("QmdMemoryManager", () => {
let tmpRoot: string;
let workspaceDir: string;
let stateDir: string;
let cfg: MoltbotConfig;
const agentId = "main";
beforeEach(async () => {
spawnMock.mockClear();
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-manager-test-"));
workspaceDir = path.join(tmpRoot, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
stateDir = path.join(tmpRoot, "state");
await fs.mkdir(stateDir, { recursive: true });
process.env.MOLTBOT_STATE_DIR = stateDir;
cfg = {
agents: {
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as MoltbotConfig;
});
afterEach(async () => {
delete process.env.MOLTBOT_STATE_DIR;
await fs.rm(tmpRoot, { recursive: true, force: true });
});
it("debounces back-to-back sync calls", async () => {
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
expect(manager).toBeTruthy();
if (!manager) throw new Error("manager missing");
const baselineCalls = spawnMock.mock.calls.length;
await manager.sync({ reason: "manual" });
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
await manager.sync({ reason: "manual-again" });
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 2);
(manager as unknown as { lastUpdateAt: number | null }).lastUpdateAt =
Date.now() - (resolved.qmd?.update.debounceMs ?? 0) - 10;
await manager.sync({ reason: "after-wait" });
// By default we refresh embeddings less frequently than index updates.
expect(spawnMock.mock.calls.length).toBe(baselineCalls + 3);
await manager.close();
});
});
+684
View File
@@ -0,0 +1,684 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { MoltbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
listSessionFilesForAgent,
buildSessionEntry,
type SessionFileEntry,
} from "./session-files.js";
import { requireNodeSqlite } from "./sqlite.js";
import type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,
} from "./types.js";
type SqliteDatabase = import("node:sqlite").DatabaseSync;
import type { ResolvedMemoryBackendConfig, ResolvedQmdConfig } from "./backend-config.js";
const log = createSubsystemLogger("memory");
const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/;
type QmdQueryResult = {
docid?: string;
score?: number;
file?: string;
snippet?: string;
body?: string;
};
type CollectionRoot = {
path: string;
kind: MemorySource;
};
type SessionExporterConfig = {
dir: string;
retentionMs?: number;
collectionName: string;
};
export class QmdMemoryManager implements MemorySearchManager {
static async create(params: {
cfg: MoltbotConfig;
agentId: string;
resolved: ResolvedMemoryBackendConfig;
}): Promise<QmdMemoryManager | null> {
const resolved = params.resolved.qmd;
if (!resolved) return null;
const manager = new QmdMemoryManager({ cfg: params.cfg, agentId: params.agentId, resolved });
await manager.initialize();
return manager;
}
private readonly cfg: MoltbotConfig;
private readonly agentId: string;
private readonly qmd: ResolvedQmdConfig;
private readonly workspaceDir: string;
private readonly stateDir: string;
private readonly agentStateDir: string;
private readonly qmdDir: string;
private readonly xdgConfigHome: string;
private readonly xdgCacheHome: string;
private readonly indexPath: string;
private readonly env: NodeJS.ProcessEnv;
private readonly collectionRoots = new Map<string, CollectionRoot>();
private readonly sources = new Set<MemorySource>();
private readonly docPathCache = new Map<
string,
{ rel: string; abs: string; source: MemorySource }
>();
private readonly sessionExporter: SessionExporterConfig | null;
private updateTimer: NodeJS.Timeout | null = null;
private pendingUpdate: Promise<void> | null = null;
private closed = false;
private db: SqliteDatabase | null = null;
private lastUpdateAt: number | null = null;
private lastEmbedAt: number | null = null;
private constructor(params: {
cfg: MoltbotConfig;
agentId: string;
resolved: ResolvedQmdConfig;
}) {
this.cfg = params.cfg;
this.agentId = params.agentId;
this.qmd = params.resolved;
this.workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
this.stateDir = resolveStateDir(process.env, os.homedir);
this.agentStateDir = path.join(this.stateDir, "agents", this.agentId);
this.qmdDir = path.join(this.agentStateDir, "qmd");
// QMD uses XDG base dirs for its internal state.
// Collections are managed via `qmd collection add` and stored inside the index DB.
// - config: $XDG_CONFIG_HOME (contexts, etc.)
// - cache: $XDG_CACHE_HOME/qmd/index.sqlite
this.xdgConfigHome = path.join(this.qmdDir, "xdg-config");
this.xdgCacheHome = path.join(this.qmdDir, "xdg-cache");
this.indexPath = path.join(this.xdgCacheHome, "qmd", "index.sqlite");
this.env = {
...process.env,
XDG_CONFIG_HOME: this.xdgConfigHome,
XDG_CACHE_HOME: this.xdgCacheHome,
NO_COLOR: "1",
};
this.sessionExporter = this.qmd.sessions.enabled
? {
dir: this.qmd.sessions.exportDir ?? path.join(this.qmdDir, "sessions"),
retentionMs: this.qmd.sessions.retentionDays
? this.qmd.sessions.retentionDays * 24 * 60 * 60 * 1000
: undefined,
collectionName: this.pickSessionCollectionName(),
}
: null;
if (this.sessionExporter) {
this.qmd.collections = [
...this.qmd.collections,
{
name: this.sessionExporter.collectionName,
path: this.sessionExporter.dir,
pattern: "**/*.md",
kind: "sessions",
},
];
}
}
private async initialize(): Promise<void> {
await fs.mkdir(this.xdgConfigHome, { recursive: true });
await fs.mkdir(this.xdgCacheHome, { recursive: true });
await fs.mkdir(path.dirname(this.indexPath), { recursive: true });
this.bootstrapCollections();
await this.ensureCollections();
if (this.qmd.update.onBoot) {
await this.runUpdate("boot", true);
}
if (this.qmd.update.intervalMs > 0) {
this.updateTimer = setInterval(() => {
void this.runUpdate("interval").catch((err) => {
log.warn(`qmd update failed (${String(err)})`);
});
}, this.qmd.update.intervalMs);
}
}
private bootstrapCollections(): void {
this.collectionRoots.clear();
this.sources.clear();
for (const collection of this.qmd.collections) {
const kind: MemorySource = collection.kind === "sessions" ? "sessions" : "memory";
this.collectionRoots.set(collection.name, { path: collection.path, kind });
this.sources.add(kind);
}
}
private async ensureCollections(): Promise<void> {
// QMD collections are persisted inside the index database and must be created
// via the CLI. Prefer listing existing collections when supported, otherwise
// fall back to best-effort idempotent `qmd collection add`.
const existing = new Set<string>();
try {
const result = await this.runQmd(["collection", "list", "--json"]);
const parsed = JSON.parse(result.stdout) as unknown;
if (Array.isArray(parsed)) {
for (const entry of parsed) {
if (typeof entry === "string") existing.add(entry);
else if (entry && typeof entry === "object") {
const name = (entry as { name?: unknown }).name;
if (typeof name === "string") existing.add(name);
}
}
}
} catch {
// ignore; older qmd versions might not support list --json.
}
for (const collection of this.qmd.collections) {
if (existing.has(collection.name)) continue;
try {
await this.runQmd([
"collection",
"add",
collection.path,
"--name",
collection.name,
"--mask",
collection.pattern,
]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Idempotency: qmd exits non-zero if the collection name already exists.
if (message.toLowerCase().includes("already exists")) continue;
if (message.toLowerCase().includes("exists")) continue;
log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
}
}
}
async search(
query: string,
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
): Promise<MemorySearchResult[]> {
if (!this.isScopeAllowed(opts?.sessionKey)) return [];
const trimmed = query.trim();
if (!trimmed) return [];
await this.pendingUpdate?.catch(() => undefined);
const limit = Math.min(
this.qmd.limits.maxResults,
opts?.maxResults ?? this.qmd.limits.maxResults,
);
const args = ["query", trimmed, "--json", "-n", String(limit)];
let stdout: string;
try {
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
stdout = result.stdout;
} catch (err) {
log.warn(`qmd query failed: ${String(err)}`);
throw err instanceof Error ? err : new Error(String(err));
}
let parsed: QmdQueryResult[] = [];
try {
parsed = JSON.parse(stdout);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`qmd query returned invalid JSON: ${message}`);
throw new Error(`qmd query returned invalid JSON: ${message}`);
}
const results: MemorySearchResult[] = [];
for (const entry of parsed) {
const doc = await this.resolveDocLocation(entry.docid);
if (!doc) continue;
const snippet = entry.snippet?.slice(0, this.qmd.limits.maxSnippetChars) ?? "";
const lines = this.extractSnippetLines(snippet);
const score = typeof entry.score === "number" ? entry.score : 0;
const minScore = opts?.minScore ?? 0;
if (score < minScore) continue;
results.push({
path: doc.rel,
startLine: lines.startLine,
endLine: lines.endLine,
score,
snippet,
source: doc.source,
});
}
return this.clampResultsByInjectedChars(results.slice(0, limit));
}
async sync(params?: {
reason?: string;
force?: boolean;
progress?: (update: MemorySyncProgressUpdate) => void;
}): Promise<void> {
if (params?.progress) {
params.progress({ completed: 0, total: 1, label: "Updating QMD index…" });
}
await this.runUpdate(params?.reason ?? "manual", params?.force);
if (params?.progress) {
params.progress({ completed: 1, total: 1, label: "QMD index updated" });
}
}
async readFile(params: {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }> {
const relPath = params.relPath?.trim();
if (!relPath) throw new Error("path required");
const absPath = this.resolveReadPath(relPath);
const content = await fs.readFile(absPath, "utf-8");
if (!params.from && !params.lines) {
return { text: content, path: relPath };
}
const lines = content.split("\n");
const start = Math.max(1, params.from ?? 1);
const count = Math.max(1, params.lines ?? lines.length);
const slice = lines.slice(start - 1, start - 1 + count);
return { text: slice.join("\n"), path: relPath };
}
status(): MemoryProviderStatus {
const counts = this.readCounts();
return {
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: counts.totalDocuments,
chunks: counts.totalDocuments,
dirty: false,
workspaceDir: this.workspaceDir,
dbPath: this.indexPath,
sources: Array.from(this.sources),
sourceCounts: counts.sourceCounts,
vector: { enabled: true, available: true },
batch: {
enabled: false,
failures: 0,
limit: 0,
wait: false,
concurrency: 0,
pollIntervalMs: 0,
timeoutMs: 0,
},
custom: {
qmd: {
collections: this.qmd.collections.length,
lastUpdateAt: this.lastUpdateAt,
},
},
};
}
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
return { ok: true };
}
async probeVectorAvailability(): Promise<boolean> {
return true;
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
if (this.updateTimer) {
clearInterval(this.updateTimer);
this.updateTimer = null;
}
await this.pendingUpdate?.catch(() => undefined);
if (this.db) {
this.db.close();
this.db = null;
}
}
private async runUpdate(reason: string, force?: boolean): Promise<void> {
if (this.pendingUpdate && !force) return this.pendingUpdate;
if (this.shouldSkipUpdate(force)) {
return;
}
const run = async () => {
if (this.sessionExporter) {
await this.exportSessions();
}
await this.runQmd(["update"], { timeoutMs: 120_000 });
const embedIntervalMs = this.qmd.update.embedIntervalMs;
const shouldEmbed =
Boolean(force) ||
this.lastEmbedAt === null ||
(embedIntervalMs > 0 && Date.now() - this.lastEmbedAt > embedIntervalMs);
if (shouldEmbed) {
try {
await this.runQmd(["embed"], { timeoutMs: 120_000 });
this.lastEmbedAt = Date.now();
} catch (err) {
log.warn(`qmd embed failed (${reason}): ${String(err)}`);
}
}
this.lastUpdateAt = Date.now();
this.docPathCache.clear();
};
this.pendingUpdate = run().finally(() => {
this.pendingUpdate = null;
});
await this.pendingUpdate;
}
private async runQmd(
args: string[],
opts?: { timeoutMs?: number },
): Promise<{ stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(this.qmd.command, args, {
env: this.env,
cwd: this.workspaceDir,
});
let stdout = "";
let stderr = "";
const timer = opts?.timeoutMs
? setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`qmd ${args.join(" ")} timed out after ${opts.timeoutMs}ms`));
}, opts.timeoutMs)
: null;
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += data.toString();
});
child.on("error", (err) => {
if (timer) clearTimeout(timer);
reject(err);
});
child.on("close", (code) => {
if (timer) clearTimeout(timer);
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`qmd ${args.join(" ")} failed (code ${code}): ${stderr || stdout}`));
}
});
});
}
private ensureDb(): SqliteDatabase {
if (this.db) return this.db;
const { DatabaseSync } = requireNodeSqlite();
this.db = new DatabaseSync(this.indexPath, { readOnly: true });
return this.db;
}
private async exportSessions(): Promise<void> {
if (!this.sessionExporter) return;
const exportDir = this.sessionExporter.dir;
await fs.mkdir(exportDir, { recursive: true });
const files = await listSessionFilesForAgent(this.agentId);
const keep = new Set<string>();
const cutoff = this.sessionExporter.retentionMs
? Date.now() - this.sessionExporter.retentionMs
: null;
for (const sessionFile of files) {
const entry = await buildSessionEntry(sessionFile);
if (!entry) continue;
if (cutoff && entry.mtimeMs < cutoff) continue;
const target = path.join(exportDir, `${path.basename(sessionFile, ".jsonl")}.md`);
await fs.writeFile(target, this.renderSessionMarkdown(entry), "utf-8");
keep.add(target);
}
const exported = await fs.readdir(exportDir).catch(() => []);
for (const name of exported) {
if (!name.endsWith(".md")) continue;
const full = path.join(exportDir, name);
if (!keep.has(full)) {
await fs.rm(full, { force: true });
}
}
}
private renderSessionMarkdown(entry: SessionFileEntry): string {
const header = `# Session ${path.basename(entry.absPath, path.extname(entry.absPath))}`;
const body = entry.content?.trim().length ? entry.content.trim() : "(empty)";
return `${header}\n\n${body}\n`;
}
private pickSessionCollectionName(): string {
const existing = new Set(this.qmd.collections.map((collection) => collection.name));
if (!existing.has("sessions")) return "sessions";
let counter = 2;
let candidate = `sessions-${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `sessions-${counter}`;
}
return candidate;
}
private async resolveDocLocation(
docid?: string,
): Promise<{ rel: string; abs: string; source: MemorySource } | null> {
if (!docid) return null;
const normalized = docid.startsWith("#") ? docid.slice(1) : docid;
if (!normalized) return null;
const cached = this.docPathCache.get(normalized);
if (cached) return cached;
const db = this.ensureDb();
const row = db
.prepare("SELECT collection, path FROM documents WHERE hash LIKE ? AND active = 1 LIMIT 1")
.get(`${normalized}%`) as { collection: string; path: string } | undefined;
if (!row) return null;
const location = this.toDocLocation(row.collection, row.path);
if (!location) return null;
this.docPathCache.set(normalized, location);
return location;
}
private extractSnippetLines(snippet: string): { startLine: number; endLine: number } {
const match = SNIPPET_HEADER_RE.exec(snippet);
if (match) {
const start = Number(match[1]);
const count = Number(match[2]);
if (Number.isFinite(start) && Number.isFinite(count)) {
return { startLine: start, endLine: start + count - 1 };
}
}
const lines = snippet.split("\n").length;
return { startLine: 1, endLine: lines };
}
private readCounts(): {
totalDocuments: number;
sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }>;
} {
try {
const db = this.ensureDb();
const rows = db
.prepare(
"SELECT collection, COUNT(*) as c FROM documents WHERE active = 1 GROUP BY collection",
)
.all() as Array<{ collection: string; c: number }>;
const bySource = new Map<MemorySource, { files: number; chunks: number }>();
for (const source of this.sources) {
bySource.set(source, { files: 0, chunks: 0 });
}
let total = 0;
for (const row of rows) {
const root = this.collectionRoots.get(row.collection);
const source = root?.kind ?? "memory";
const entry = bySource.get(source) ?? { files: 0, chunks: 0 };
entry.files += row.c ?? 0;
entry.chunks += row.c ?? 0;
bySource.set(source, entry);
total += row.c ?? 0;
}
return {
totalDocuments: total,
sourceCounts: Array.from(bySource.entries()).map(([source, value]) => ({
source,
files: value.files,
chunks: value.chunks,
})),
};
} catch (err) {
log.warn(`failed to read qmd index stats: ${String(err)}`);
return {
totalDocuments: 0,
sourceCounts: Array.from(this.sources).map((source) => ({ source, files: 0, chunks: 0 })),
};
}
}
private isScopeAllowed(sessionKey?: string): boolean {
const scope = this.qmd.scope;
if (!scope) return true;
const channel = this.deriveChannelFromKey(sessionKey);
const chatType = this.deriveChatTypeFromKey(sessionKey);
const normalizedKey = sessionKey ?? "";
for (const rule of scope.rules ?? []) {
if (!rule) continue;
const match = rule.match ?? {};
if (match.channel && match.channel !== channel) continue;
if (match.chatType && match.chatType !== chatType) continue;
if (match.keyPrefix && !normalizedKey.startsWith(match.keyPrefix)) continue;
return rule.action === "allow";
}
const fallback = scope.default ?? "allow";
return fallback === "allow";
}
private deriveChannelFromKey(key?: string) {
if (!key) return undefined;
const parts = key.split(":").filter(Boolean);
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
return parts[0]?.toLowerCase();
}
return undefined;
}
private deriveChatTypeFromKey(key?: string) {
if (!key) return undefined;
if (key.includes(":group:")) return "group";
if (key.includes(":channel:")) return "channel";
return "direct";
}
private toDocLocation(
collection: string,
collectionRelativePath: string,
): { rel: string; abs: string; source: MemorySource } | null {
const root = this.collectionRoots.get(collection);
if (!root) return null;
const normalizedRelative = collectionRelativePath.replace(/\\/g, "/");
const absPath = path.normalize(path.resolve(root.path, collectionRelativePath));
const relativeToWorkspace = path.relative(this.workspaceDir, absPath);
const relPath = this.buildSearchPath(
collection,
normalizedRelative,
relativeToWorkspace,
absPath,
);
return { rel: relPath, abs: absPath, source: root.kind };
}
private buildSearchPath(
collection: string,
collectionRelativePath: string,
relativeToWorkspace: string,
absPath: string,
): string {
const insideWorkspace = this.isInsideWorkspace(relativeToWorkspace);
if (insideWorkspace) {
const normalized = relativeToWorkspace.replace(/\\/g, "/");
if (!normalized) return path.basename(absPath);
return normalized;
}
const sanitized = collectionRelativePath.replace(/^\/+/, "");
return `qmd/${collection}/${sanitized}`;
}
private isInsideWorkspace(relativePath: string): boolean {
if (!relativePath) return true;
if (relativePath.startsWith("..")) return false;
if (relativePath.startsWith(`..${path.sep}`)) return false;
return !path.isAbsolute(relativePath);
}
private resolveReadPath(relPath: string): string {
if (relPath.startsWith("qmd/")) {
const [, collection, ...rest] = relPath.split("/");
if (!collection || rest.length === 0) {
throw new Error("invalid qmd path");
}
const root = this.collectionRoots.get(collection);
if (!root) throw new Error(`unknown qmd collection: ${collection}`);
const joined = rest.join("/");
const resolved = path.resolve(root.path, joined);
if (!this.isWithinRoot(root.path, resolved)) {
throw new Error("qmd path escapes collection");
}
return resolved;
}
const absPath = path.resolve(this.workspaceDir, relPath);
if (!this.isWithinWorkspace(absPath)) {
throw new Error("path escapes workspace");
}
return absPath;
}
private isWithinWorkspace(absPath: string): boolean {
const normalizedWorkspace = this.workspaceDir.endsWith(path.sep)
? this.workspaceDir
: `${this.workspaceDir}${path.sep}`;
if (absPath === this.workspaceDir) return true;
const candidate = absPath.endsWith(path.sep) ? absPath : `${absPath}${path.sep}`;
return candidate.startsWith(normalizedWorkspace);
}
private isWithinRoot(root: string, candidate: string): boolean {
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
if (candidate === root) return true;
const next = candidate.endsWith(path.sep) ? candidate : `${candidate}${path.sep}`;
return next.startsWith(normalizedRoot);
}
private clampResultsByInjectedChars(results: MemorySearchResult[]): MemorySearchResult[] {
const budget = this.qmd.limits.maxInjectedChars;
if (!budget || budget <= 0) return results;
let remaining = budget;
const clamped: MemorySearchResult[] = [];
for (const entry of results) {
if (remaining <= 0) break;
const snippet = entry.snippet ?? "";
if (snippet.length <= remaining) {
clamped.push(entry);
remaining -= snippet.length;
} else {
const trimmed = snippet.slice(0, Math.max(0, remaining));
clamped.push({ ...entry, snippet: trimmed });
break;
}
}
return clamped;
}
private shouldSkipUpdate(force?: boolean): boolean {
if (force) return false;
const debounceMs = this.qmd.update.debounceMs;
if (debounceMs <= 0) return false;
if (!this.lastUpdateAt) return false;
return Date.now() - this.lastUpdateAt < debounceMs;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockPrimary = {
search: vi.fn(async () => []),
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
status: vi.fn(() => ({
backend: "qmd" as const,
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp",
dbPath: "/tmp/index.sqlite",
sources: ["memory" as const],
sourceCounts: [{ source: "memory" as const, files: 0, chunks: 0 }],
})),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
close: vi.fn(async () => {}),
};
vi.mock("./qmd-manager.js", () => ({
QmdMemoryManager: {
create: vi.fn(async () => mockPrimary),
},
}));
vi.mock("./manager.js", () => ({
MemoryIndexManager: {
get: vi.fn(async () => null),
},
}));
import { QmdMemoryManager } from "./qmd-manager.js";
import { getMemorySearchManager } from "./search-manager.js";
beforeEach(() => {
mockPrimary.search.mockClear();
mockPrimary.readFile.mockClear();
mockPrimary.status.mockClear();
mockPrimary.sync.mockClear();
mockPrimary.probeEmbeddingAvailability.mockClear();
mockPrimary.probeVectorAvailability.mockClear();
mockPrimary.close.mockClear();
QmdMemoryManager.create.mockClear();
});
describe("getMemorySearchManager caching", () => {
it("reuses the same QMD manager instance for repeated calls", async () => {
const cfg = {
memory: { backend: "qmd", qmd: {} },
agents: { list: [{ id: "main", default: true, workspace: "/tmp/workspace" }] },
} as const;
const first = await getMemorySearchManager({ cfg, agentId: "main" });
const second = await getMemorySearchManager({ cfg, agentId: "main" });
expect(first.manager).toBe(second.manager);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(QmdMemoryManager.create).toHaveBeenCalledTimes(1);
});
});
+174 -2
View File
@@ -1,8 +1,18 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { MoltbotConfig } from "../config/config.js"; import type { MoltbotConfig } from "../config/config.js";
import type { MemoryIndexManager } from "./manager.js"; import { resolveMemoryBackendConfig } from "./backend-config.js";
import type { ResolvedQmdConfig } from "./backend-config.js";
import type {
MemoryEmbeddingProbeResult,
MemorySearchManager,
MemorySyncProgressUpdate,
} from "./types.js";
const log = createSubsystemLogger("memory");
const QMD_MANAGER_CACHE = new Map<string, MemorySearchManager>();
export type MemorySearchManagerResult = { export type MemorySearchManagerResult = {
manager: MemoryIndexManager | null; manager: MemorySearchManager | null;
error?: string; error?: string;
}; };
@@ -10,6 +20,40 @@ export async function getMemorySearchManager(params: {
cfg: MoltbotConfig; cfg: MoltbotConfig;
agentId: string; agentId: string;
}): Promise<MemorySearchManagerResult> { }): Promise<MemorySearchManagerResult> {
const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) {
const cacheKey = buildQmdCacheKey(params.agentId, resolved.qmd);
const cached = QMD_MANAGER_CACHE.get(cacheKey);
if (cached) {
return { manager: cached };
}
try {
const { QmdMemoryManager } = await import("./qmd-manager.js");
const primary = await QmdMemoryManager.create({
cfg: params.cfg,
agentId: params.agentId,
resolved,
});
if (primary) {
const wrapper = new FallbackMemoryManager(
{
primary,
fallbackFactory: async () => {
const { MemoryIndexManager } = await import("./manager.js");
return await MemoryIndexManager.get(params);
},
},
() => QMD_MANAGER_CACHE.delete(cacheKey),
);
QMD_MANAGER_CACHE.set(cacheKey, wrapper);
return { manager: wrapper };
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`qmd memory unavailable; falling back to builtin: ${message}`);
}
}
try { try {
const { MemoryIndexManager } = await import("./manager.js"); const { MemoryIndexManager } = await import("./manager.js");
const manager = await MemoryIndexManager.get(params); const manager = await MemoryIndexManager.get(params);
@@ -19,3 +63,131 @@ export async function getMemorySearchManager(params: {
return { manager: null, error: message }; return { manager: null, error: message };
} }
} }
class FallbackMemoryManager implements MemorySearchManager {
private fallback: MemorySearchManager | null = null;
private primaryFailed = false;
private lastError?: string;
constructor(
private readonly deps: {
primary: MemorySearchManager;
fallbackFactory: () => Promise<MemorySearchManager | null>;
},
private readonly onClose?: () => void,
) {}
async search(
query: string,
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
) {
if (!this.primaryFailed) {
try {
return await this.deps.primary.search(query, opts);
} catch (err) {
this.primaryFailed = true;
this.lastError = err instanceof Error ? err.message : String(err);
log.warn(`qmd memory failed; switching to builtin index: ${this.lastError}`);
await this.deps.primary.close?.().catch(() => {});
}
}
const fallback = await this.ensureFallback();
if (fallback) {
return await fallback.search(query, opts);
}
throw new Error(this.lastError ?? "memory search unavailable");
}
async readFile(params: { relPath: string; from?: number; lines?: number }) {
if (!this.primaryFailed) {
return await this.deps.primary.readFile(params);
}
const fallback = await this.ensureFallback();
if (fallback) {
return await fallback.readFile(params);
}
throw new Error(this.lastError ?? "memory read unavailable");
}
status() {
if (!this.primaryFailed) {
return this.deps.primary.status();
}
const fallbackStatus = this.fallback?.status();
const fallbackInfo = { from: "qmd", reason: this.lastError ?? "unknown" };
if (fallbackStatus) {
const custom = fallbackStatus.custom ?? {};
return {
...fallbackStatus,
fallback: fallbackInfo,
custom: {
...custom,
fallback: { disabled: true, reason: this.lastError ?? "unknown" },
},
};
}
const primaryStatus = this.deps.primary.status();
const custom = primaryStatus.custom ?? {};
return {
...primaryStatus,
fallback: fallbackInfo,
custom: {
...custom,
fallback: { disabled: true, reason: this.lastError ?? "unknown" },
},
};
}
async sync(params?: {
reason?: string;
force?: boolean;
progress?: (update: MemorySyncProgressUpdate) => void;
}) {
if (!this.primaryFailed) {
await this.deps.primary.sync?.(params);
return;
}
const fallback = await this.ensureFallback();
await fallback?.sync?.(params);
}
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
if (!this.primaryFailed) {
return await this.deps.primary.probeEmbeddingAvailability();
}
const fallback = await this.ensureFallback();
if (fallback) {
return await fallback.probeEmbeddingAvailability();
}
return { ok: false, error: this.lastError ?? "memory embeddings unavailable" };
}
async probeVectorAvailability() {
if (!this.primaryFailed) {
return await this.deps.primary.probeVectorAvailability();
}
const fallback = await this.ensureFallback();
return (await fallback?.probeVectorAvailability()) ?? false;
}
async close() {
await this.deps.primary.close?.();
await this.fallback?.close?.();
this.onClose?.();
}
private async ensureFallback(): Promise<MemorySearchManager | null> {
if (this.fallback) return this.fallback;
const fallback = await this.deps.fallbackFactory();
if (!fallback) {
log.warn("memory fallback requested but builtin index is unavailable");
return null;
}
this.fallback = fallback;
return this.fallback;
}
}
function buildQmdCacheKey(agentId: string, config: ResolvedQmdConfig): string {
return `${agentId}:${JSON.stringify(config)}`;
}
+3 -1
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { redactSensitiveText } from "../logging/redact.js";
import { hashText } from "./internal.js"; import { hashText } from "./internal.js";
const log = createSubsystemLogger("memory"); const log = createSubsystemLogger("memory");
@@ -87,8 +88,9 @@ export async function buildSessionEntry(absPath: string): Promise<SessionFileEnt
if (message.role !== "user" && message.role !== "assistant") continue; if (message.role !== "user" && message.role !== "assistant") continue;
const text = extractSessionText(message.content); const text = extractSessionText(message.content);
if (!text) continue; if (!text) continue;
const safe = redactSensitiveText(text, { mode: "tools" });
const label = message.role === "user" ? "User" : "Assistant"; const label = message.role === "user" ? "User" : "Assistant";
collected.push(`${label}: ${text}`); collected.push(`${label}: ${safe}`);
} }
const content = collected.join("\n"); const content = collected.join("\n");
return { return {
+1 -2
View File
@@ -14,7 +14,6 @@ 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;
@@ -28,7 +27,7 @@ export async function syncMemoryFiles(params: {
ftsAvailable: boolean; ftsAvailable: boolean;
model: string; model: string;
}) { }) {
const files = await listMemoryFiles(params.workspaceDir, params.extraPaths); const files = await listMemoryFiles(params.workspaceDir);
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)),
); );
+79
View File
@@ -0,0 +1,79 @@
export type MemorySource = "memory" | "sessions";
export type MemorySearchResult = {
path: string;
startLine: number;
endLine: number;
score: number;
snippet: string;
source: MemorySource;
citation?: string;
};
export type MemoryEmbeddingProbeResult = {
ok: boolean;
error?: string;
};
export type MemorySyncProgressUpdate = {
completed: number;
total: number;
label?: string;
};
export type MemoryProviderStatus = {
backend: "builtin" | "qmd";
provider: string;
model?: string;
requestedProvider?: string;
files?: number;
chunks?: number;
dirty?: boolean;
workspaceDir?: string;
dbPath?: string;
sources?: MemorySource[];
sourceCounts?: Array<{ source: MemorySource; files: number; chunks: number }>;
cache?: { enabled: boolean; entries?: number; maxEntries?: number };
fts?: { enabled: boolean; available: boolean; error?: string };
fallback?: { from: string; reason?: string };
vector?: {
enabled: boolean;
available?: boolean;
extensionPath?: string;
loadError?: string;
dims?: number;
};
batch?: {
enabled: boolean;
failures: number;
limit: number;
wait: boolean;
concurrency: number;
pollIntervalMs: number;
timeoutMs: number;
lastError?: string;
lastProvider?: string;
};
custom?: Record<string, unknown>;
};
export interface MemorySearchManager {
search(
query: string,
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
): Promise<MemorySearchResult[]>;
readFile(params: {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }>;
status(): MemoryProviderStatus;
sync?(params?: {
reason?: string;
force?: boolean;
progress?: (update: MemorySyncProgressUpdate) => void;
}): Promise<void>;
probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>;
probeVectorAvailability(): Promise<boolean>;
close?(): Promise<void>;
}
@@ -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("accepts channel messages when mentionPatterns match even if another user is mentioned", async () => { it("skips channel messages when another user is explicitly 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).toHaveBeenCalledTimes(1); expect(replyMock).not.toHaveBeenCalled();
expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); expect(sendMock).not.toHaveBeenCalled();
}); });
it("treats replies to bot threads as implicit mentions", async () => { it("treats replies to bot threads as implicit mentions", async () => {
-1
View File
@@ -335,7 +335,6 @@ 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>";
+7 -39
View File
@@ -21,8 +21,6 @@ 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 });
@@ -200,15 +198,6 @@ 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,
@@ -220,7 +209,12 @@ 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,
@@ -235,12 +229,6 @@ 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)}`));
@@ -272,27 +260,7 @@ export const dispatchTelegramMessage = async ({
}, },
}); });
draftStream?.stop(); draftStream?.stop();
let sentFallback = false; if (!queuedFinal) {
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 });
} }
+2 -31
View File
@@ -50,8 +50,6 @@ 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 = {
@@ -470,7 +468,6 @@ 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,
@@ -485,18 +482,13 @@ 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, _info) => { deliver: async (payload) => {
const result = await deliverReplies({ await deliverReplies({
replies: [payload], replies: [payload],
chatId: String(chatId), chatId: String(chatId),
token: opts.token, token: opts.token,
@@ -509,12 +501,6 @@ 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)}`));
@@ -525,21 +511,6 @@ 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("accepts group messages when mentionPatterns match even if another user is mentioned", async () => { it("skips group messages when another user is explicitly 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,8 +249,7 @@ describe("createTelegramBot", () => {
getFile: async () => ({ download: async () => new Uint8Array() }), getFile: async () => ({ download: async () => new Uint8Array() }),
}); });
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).not.toHaveBeenCalled();
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 () => {
+4 -38
View File
@@ -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,10 +58,6 @@ 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"
@@ -118,7 +114,6 @@ 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;
} }
@@ -170,21 +165,18 @@ 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)
@@ -203,7 +195,6 @@ 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
@@ -230,7 +221,6 @@ 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;
} }
@@ -243,7 +233,6 @@ 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({
@@ -251,7 +240,6 @@ 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;
@@ -272,7 +260,6 @@ 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;
} }
@@ -281,7 +268,6 @@ export async function deliverReplies(params: {
} }
} }
} }
return { delivered: hasDelivered };
} }
export async function resolveMedia( export async function resolveMedia(
@@ -324,14 +310,7 @@ export async function resolveMedia(
fetchImpl, fetchImpl,
filePathHint: file.file_path, filePathHint: file.file_path,
}); });
const originalName = fetched.fileName ?? file.file_path; const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
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;
@@ -382,12 +361,7 @@ export async function resolveMedia(
} }
const m = const m =
msg.photo?.[msg.photo.length - 1] ?? msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.document ?? msg.audio ?? msg.voice;
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) {
@@ -403,18 +377,10 @@ export async function resolveMedia(
fetchImpl, fetchImpl,
filePathHint: file.file_path, filePathHint: file.file_path,
}); });
const originalName = fetched.fileName ?? file.file_path; const saved = await saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes);
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 };
} }
+1 -1
View File
@@ -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, info.file_path); const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes);
// 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;
+7 -15
View File
@@ -757,19 +757,11 @@ 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.
*/ */
function getOpenAITtsBaseUrl(): string { const OPENAI_TTS_BASE_URL = (
return (process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1").replace( process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1"
/\/+$/, ).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",
@@ -786,13 +778,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);
} }
@@ -1019,7 +1011,7 @@ async function openaiTTS(params: {
const timeout = setTimeout(() => controller.abort(), timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetch(`${getOpenAITtsBaseUrl()}/audio/speech`, { const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
-2
View File
@@ -42,7 +42,6 @@ import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview"; import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions"; import { renderSessions } from "./views/sessions";
import { renderExecApprovalPrompt } from "./views/exec-approval"; import { renderExecApprovalPrompt } from "./views/exec-approval";
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation";
import { import {
approveDevicePairing, approveDevicePairing,
loadDevices, loadDevices,
@@ -579,7 +578,6 @@ export function renderApp(state: AppViewState) {
: nothing} : nothing}
</main> </main>
${renderExecApprovalPrompt(state)} ${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
</div> </div>
`; `;
} }
+1 -2
View File
@@ -33,7 +33,6 @@ type SettingsHost = {
basePath: string; basePath: string;
themeMedia: MediaQueryList | null; themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null;
}; };
export function applySettings(host: SettingsHost, next: UiSettings) { export function applySettings(host: SettingsHost, next: UiSettings) {
@@ -99,7 +98,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
if (gatewayUrlRaw != null) { if (gatewayUrlRaw != null) {
const gatewayUrl = gatewayUrlRaw.trim(); const gatewayUrl = gatewayUrlRaw.trim();
if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) { if (gatewayUrl && gatewayUrl !== host.settings.gatewayUrl) {
host.pendingGatewayUrl = gatewayUrl; applySettings(host, { ...host.settings, gatewayUrl });
} }
params.delete("gatewayUrl"); params.delete("gatewayUrl");
shouldCleanUrl = true; shouldCleanUrl = true;
-3
View File
@@ -73,7 +73,6 @@ export type AppViewState = {
execApprovalQueue: ExecApprovalRequest[]; execApprovalQueue: ExecApprovalRequest[];
execApprovalBusy: boolean; execApprovalBusy: boolean;
execApprovalError: string | null; execApprovalError: string | null;
pendingGatewayUrl: string | null;
configLoading: boolean; configLoading: boolean;
configRaw: string; configRaw: string;
configRawOriginal: string; configRawOriginal: string;
@@ -166,8 +165,6 @@ export type AppViewState = {
handleNostrProfileImport: () => Promise<void>; handleNostrProfileImport: () => Promise<void>;
handleNostrProfileToggleAdvanced: () => void; handleNostrProfileToggleAdvanced: () => void;
handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>; handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise<void>;
handleGatewayUrlConfirm: () => void;
handleGatewayUrlCancel: () => void;
handleConfigLoad: () => Promise<void>; handleConfigLoad: () => Promise<void>;
handleConfigSave: () => Promise<void>; handleConfigSave: () => Promise<void>;
handleConfigApply: () => Promise<void>; handleConfigApply: () => Promise<void>;
-16
View File
@@ -152,7 +152,6 @@ export class MoltbotApp extends LitElement {
@state() execApprovalQueue: ExecApprovalRequest[] = []; @state() execApprovalQueue: ExecApprovalRequest[] = [];
@state() execApprovalBusy = false; @state() execApprovalBusy = false;
@state() execApprovalError: string | null = null; @state() execApprovalError: string | null = null;
@state() pendingGatewayUrl: string | null = null;
@state() configLoading = false; @state() configLoading = false;
@state() configRaw = "{\n}\n"; @state() configRaw = "{\n}\n";
@@ -449,21 +448,6 @@ export class MoltbotApp extends LitElement {
} }
} }
handleGatewayUrlConfirm() {
const nextGatewayUrl = this.pendingGatewayUrl;
if (!nextGatewayUrl) return;
this.pendingGatewayUrl = null;
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],
{ ...this.settings, gatewayUrl: nextGatewayUrl },
);
this.connect();
}
handleGatewayUrlCancel() {
this.pendingGatewayUrl = null;
}
// Sidebar handlers for tool output viewing // Sidebar handlers for tool output viewing
handleOpenSidebar(content: string) { handleOpenSidebar(content: string) {
if (this.sidebarCloseTimer != null) { if (this.sidebarCloseTimer != null) {
-5
View File
@@ -260,11 +260,6 @@ 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
@@ -1,39 +0,0 @@
import { html, nothing } from "lit";
import type { AppViewState } from "../app-view-state";
export function renderGatewayUrlConfirmation(state: AppViewState) {
const { pendingGatewayUrl } = state;
if (!pendingGatewayUrl) return nothing;
return html`
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
<div class="exec-approval-card">
<div class="exec-approval-header">
<div>
<div class="exec-approval-title">Change Gateway URL</div>
<div class="exec-approval-sub">This will reconnect to a different gateway server</div>
</div>
</div>
<div class="exec-approval-command mono">${pendingGatewayUrl}</div>
<div class="callout danger" style="margin-top: 12px;">
Only confirm if you trust this URL. Malicious URLs can compromise your system.
</div>
<div class="exec-approval-actions">
<button
class="btn primary"
@click=${() => state.handleGatewayUrlConfirm()}
>
Confirm
</button>
<button
class="btn"
@click=${() => state.handleGatewayUrlCancel()}
>
Cancel
</button>
</div>
</div>
</div>
`;
}