Merge branch 'main' into feature/add-tlon-plugin

This commit is contained in:
Peter Steinberger 2026-01-24 00:27:24 +00:00
commit c69111a4e6
144 changed files with 4218 additions and 1394 deletions

View File

@ -7,6 +7,7 @@
- Tests: colocated `*.test.ts`. - Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`. - Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them. - Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`). - Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs). - Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/` - Core channel docs: `docs/channels/`
@ -23,13 +24,14 @@
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”. - Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## exe.dev VM ops (general) ## exe.dev VM ops (general)
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal). - Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`). - SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset. - Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run: - Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
- Restart: stop old gateway and run:
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &` `pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`. - Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working). - Runtime baseline: Node **22+** (keep Node + Bun paths working).
@ -128,6 +130,10 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. - **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. - **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed. - Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant. - **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause. - Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.

View File

@ -6,16 +6,27 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. - Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
### Fixes ### Fixes
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`. - TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- CLI: hide auth probe timeout warnings from embedded runs.
- CLI: render auth probe results as a table in `clawdbot models status`.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla. - Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla. - Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman. - Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
## 2026.1.22 ## 2026.1.22

View File

@ -478,28 +478,29 @@ Thanks to all clawtributors:
<p align="left"> <p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
<a href="https://github.com/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/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/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/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/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/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/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a>
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/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/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/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a>
<a href="https://github.com/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/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/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a>
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/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/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a>
<a href="https://github.com/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/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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
<a href="https://github.com/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
<a href="https://github.com/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/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
<a href="https://github.com/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/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/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/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/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a>
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/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/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
<a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a>
<a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/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/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/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/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/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/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/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/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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
<a href="https://github.com/search?q=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/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/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/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/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/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/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/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/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/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>

View File

@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android" applicationId = "com.clawdbot.android"
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 202601210 versionCode = 202601230
versionName = "2026.1.21" versionName = "2026.1.23"
} }
buildTypes { buildTypes {

View File

@ -8,10 +8,14 @@ object WakeWords {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} }
fun parseIfChanged(input: String, current: List<String>): List<String>? {
val parsed = parseCommaSeparated(input)
return if (parsed == current) null else parsed
}
fun sanitize(words: List<String>, defaults: List<String>): List<String> { fun sanitize(words: List<String>, defaults: List<String>): List<String> {
val cleaned = val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults } return cleaned.ifEmpty { defaults }
} }
} }

View File

@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
@ -49,7 +51,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode
import com.clawdbot.android.MainViewModel import com.clawdbot.android.MainViewModel
import com.clawdbot.android.NodeForegroundService import com.clawdbot.android.NodeForegroundService
import com.clawdbot.android.VoiceWakeMode import com.clawdbot.android.VoiceWakeMode
import com.clawdbot.android.WakeWords
@Composable @Composable
fun SettingsSheet(viewModel: MainViewModel) { fun SettingsSheet(viewModel: MainViewModel) {
@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel = val deviceModel =
remember { remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL) listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
if (parsed != null) {
viewModel.setWakeWords(parsed)
}
}
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
value = wakeWordsText, value = wakeWordsText,
onValueChange = setWakeWordsText, onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") }, label = { Text("Wake Words (comma-separated)") },
modifier = Modifier.fillMaxWidth(), modifier =
Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) {
wakeWordsHadFocus = true
} else if (wakeWordsHadFocus) {
wakeWordsHadFocus = false
commitWakeWords()
}
},
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
commitWakeWords()
focusManager.clearFocus()
},
),
) )
} }
item { item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,
) {
Text("Save + Sync")
}
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
}
}
item { item {
Text( Text(
if (isConnected) { if (isConnected) {

View File

@ -1,6 +1,7 @@
package com.clawdbot.android package com.clawdbot.android
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
class WakeWordsTest { class WakeWordsTest {
@ -32,5 +33,18 @@ class WakeWordsTest {
assertEquals("w1", sanitized.first()) assertEquals("w1", sanitized.first())
assertEquals("w${WakeWords.maxWords}", sanitized.last()) assertEquals("w${WakeWords.maxWords}", sanitized.last())
} }
}
@Test
fun parseIfChangedSkipsWhenUnchanged() {
val current = listOf("clawd", "claude")
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
assertNull(parsed)
}
@Test
fun parseIfChangedReturnsUpdatedList() {
val current = listOf("clawd")
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
assertEquals(listOf("clawd", "jarvis"), parsed)
}
}

View File

@ -19,9 +19,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.21</string> <string>2026.1.23</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260121</string> <string>20260123</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@ -1,8 +1,10 @@
import SwiftUI import SwiftUI
import Combine
struct VoiceWakeWordsSettingsView: View { struct VoiceWakeWordsSettingsView: View {
@Environment(NodeAppModel.self) private var appModel @Environment(NodeAppModel.self) private var appModel
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@FocusState private var focusedTriggerIndex: Int?
@State private var syncTask: Task<Void, Never>? @State private var syncTask: Task<Void, Never>?
var body: some View { var body: some View {
@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
TextField("Wake word", text: self.binding(for: index)) TextField("Wake word", text: self.binding(for: index))
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused(self.$focusedTriggerIndex, equals: index)
.onSubmit {
self.commitTriggerWords()
}
} }
.onDelete(perform: self.removeWords) .onDelete(perform: self.removeWords)
@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
.onAppear { .onAppear {
if self.triggerWords.isEmpty { if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords self.triggerWords = VoiceWakePreferences.defaultTriggerWords
self.commitTriggerWords()
} }
} }
.onChange(of: self.triggerWords) { _, newValue in .onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
// Keep local voice wake responsive even if the gateway isn't connected yet. guard oldValue != nil, oldValue != newValue else { return }
VoiceWakePreferences.saveTriggerWords(newValue) self.commitTriggerWords()
}
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
self.syncTask?.cancel() guard self.focusedTriggerIndex == nil else { return }
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in let updated = VoiceWakePreferences.loadTriggerWords()
try? await Task.sleep(nanoseconds: 650_000_000) if updated != self.triggerWords {
await appModel?.setGlobalWakeWords(snapshot) self.triggerWords = updated
} }
} }
} }
@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
if self.triggerWords.isEmpty { if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords self.triggerWords = VoiceWakePreferences.defaultTriggerWords
} }
self.commitTriggerWords()
} }
private func binding(for index: Int) -> Binding<String> { private func binding(for index: Int) -> Binding<String> {
@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
self.triggerWords[index] = newValue self.triggerWords[index] = newValue
}) })
} }
private func commitTriggerWords() {
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
}
}
} }

View File

@ -6,6 +6,8 @@ enum VoiceWakePreferences {
// Keep defaults aligned with the mac app. // Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"] static let defaultTriggerWords: [String] = ["clawd", "claude"]
static let maxWords = 32
static let maxWordLength = 64
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
guard let data = payloadJSON.data(using: .utf8) else { return nil } guard let data = payloadJSON.data(using: .utf8) else { return nil }
@ -30,6 +32,8 @@ enum VoiceWakePreferences {
let cleaned = words let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
.prefix(Self.maxWords)
.map { String($0.prefix(Self.maxWordLength)) }
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
} }

View File

@ -17,8 +17,8 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.21</string> <string>2026.1.23</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260121</string> <string>20260123</string>
</dict> </dict>
</plist> </plist>

View File

@ -11,6 +11,18 @@ import Testing
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords) #expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
} }
@Test func sanitizeTriggerWordsLimitsWordLength() {
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
}
@Test func sanitizeTriggerWordsLimitsWordCount() {
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
#expect(cleaned.count == VoiceWakePreferences.maxWords)
}
@Test func displayStringUsesSanitizedWords() { @Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude") #expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
} }

View File

@ -81,8 +81,8 @@ targets:
properties: properties:
CFBundleDisplayName: Clawdbot CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.21" CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260121" CFBundleVersion: "20260123"
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist path: Tests/Info.plist
properties: properties:
CFBundleDisplayName: ClawdbotTests CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.21" CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260121" CFBundleVersion: "20260123"

View File

@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime" let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon" let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"] let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMaxWords = 32
let voiceWakeMaxWordLength = 64
let voiceWakeMicKey = "clawdbot.voiceWakeMicID" let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName" let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID" let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.21</string> <string>2026.1.23</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601210</string> <string>202601230</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Clawdbot</string> <string>Clawdbot</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>

View File

@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
let cleaned = words let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
.prefix(voiceWakeMaxWords)
.map { String($0.prefix(voiceWakeMaxWordLength)) }
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
} }

View File

@ -21,6 +21,7 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver() @State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>? @State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = [] @State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140 private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240 private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
@ -31,9 +32,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid } var id: String { self.uid }
} }
private struct IndexedWord: Identifiable { private struct TriggerEntry: Identifiable {
let id: Int let id: UUID
let value: String var value: String
} }
private var voiceWakeBinding: Binding<Bool> { private var voiceWakeBinding: Binding<Bool> {
@ -105,6 +106,7 @@ struct VoiceWakeSettings: View {
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.startMicObserver() self.startMicObserver()
self.loadTriggerEntries()
} }
.onChange(of: self.state.voiceWakeMicID) { _, _ in .onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return } guard !self.isPreview else { return }
@ -122,8 +124,10 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil self.micRefreshTask = nil
Task { await self.meter.stop() } Task { await self.meter.stop() }
self.micObserver.stop() self.micObserver.stop()
self.syncTriggerEntriesToState()
} else { } else {
self.startMicObserver() self.startMicObserver()
self.loadTriggerEntries()
} }
} }
.onDisappear { .onDisappear {
@ -136,11 +140,16 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil self.micRefreshTask = nil
self.micObserver.stop() self.micObserver.stop()
Task { await self.meter.stop() } Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
} }
} }
private var indexedWords: [IndexedWord] { private func loadTriggerEntries() {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
} }
private var triggerTable: some View { private var triggerTable: some View {
@ -154,29 +163,42 @@ struct VoiceWakeSettings: View {
} label: { } label: {
Label("Add word", systemImage: "plus") Label("Add word", systemImage: "plus")
} }
.disabled(self.state.swabbleTriggerWords .disabled(self.triggerEntries
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
} }
Table(self.indexedWords) { VStack(spacing: 0) {
TableColumn("Word") { row in ForEach(self.$triggerEntries) { $entry in
TextField("Wake word", text: self.binding(for: row.id)) HStack(spacing: 8) {
.textFieldStyle(.roundedBorder) TextField("Wake word", text: $entry.value)
} .textFieldStyle(.roundedBorder)
TableColumn("") { row in .onSubmit {
Button { self.syncTriggerEntriesToState()
self.removeWord(at: row.id) }
} label: {
Image(systemName: "trash") Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
} }
.buttonStyle(.borderless)
.help("Remove trigger word")
} }
.width(36)
} }
.frame(minHeight: 180) .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
@ -211,24 +233,12 @@ struct VoiceWakeSettings: View {
} }
private func addWord() { private func addWord() {
self.state.swabbleTriggerWords.append("") self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
} }
private func removeWord(at index: Int) { private func removeWord(id: UUID) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return } self.triggerEntries.removeAll { $0.id == id }
self.state.swabbleTriggerWords.remove(at: index) self.syncTriggerEntriesToState()
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
} }
private func toggleTest() { private func toggleTest() {
@ -638,13 +648,14 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"] state.swabbleTriggerWords = ["Claude", "Hey"]
let view = VoiceWakeSettings(state: state, isActive: true) var view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")] view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42 view.meterLevel = 0.42
view.meterError = "No input" view.meterError = "No input"
view.testState = .detected("ok") view.testState = .detected("ok")
view.isTesting = true view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body _ = view.body
_ = view.localePicker _ = view.localePicker
@ -654,8 +665,9 @@ extension VoiceWakeSettings {
_ = view.chimeSection _ = view.chimeSection
view.addWord() view.addWord()
_ = view.binding(for: 0).wrappedValue if let entryId = view.triggerEntries.first?.id {
view.removeWord(at: 0) view.removeWord(id: entryId)
}
} }
} }
#endif #endif

View File

@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests {
#expect(cleaned == defaultVoiceWakeTriggers) #expect(cleaned == defaultVoiceWakeTriggers)
} }
@Test func sanitizeTriggersLimitsWordLength() {
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
#expect(cleaned[1].count == voiceWakeMaxWordLength)
}
@Test func sanitizeTriggersLimitsWordCount() {
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
let cleaned = sanitizeVoiceWakeTriggers(words)
#expect(cleaned.count == voiceWakeMaxWords)
}
@Test func normalizeLocaleStripsCollation() { @Test func normalizeLocaleStripsCollation() {
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
} }

View File

@ -700,8 +700,15 @@ Options:
- `--json` - `--json`
- `--plain` - `--plain`
- `--check` (exit 1=expired/missing, 2=expiring) - `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>`
- `--probe-profile <id>` (repeat or comma-separated)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
Always includes the auth overview and OAuth expiry status for profiles in the auth store. Always includes the auth overview and OAuth expiry status for profiles in the auth store.
`--probe` runs live requests (may consume tokens and trigger rate limits).
### `models set <model>` ### `models set <model>`
Set `agents.defaults.model.primary`. Set `agents.defaults.model.primary`.

View File

@ -25,12 +25,26 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview. `clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers. provider usage headers.
Add `--probe` to run live auth probes against each configured provider profile.
Probes are real requests (may consume tokens and trigger rate limits).
Notes: Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias. - `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`). - Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID). - If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
### `models status`
Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>` (probe one provider)
- `--probe-profile <id>` (repeat or comma-separated profile ids)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
## Aliases + fallbacks ## Aliases + fallbacks
```bash ```bash

View File

@ -43,6 +43,14 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http) - [Control UI](/web/control-ui#insecure-http)
### `docs.clawd.bot` shows an SSL error (Comcast/Xfinity)
Some Comcast/Xfinity connections block `docs.clawd.bot` via Xfinity Advanced Security.
Disable Advanced Security or add `docs.clawd.bot` to the allowlist, then retry.
- Xfinity Advanced Security help: https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security
- Quick sanity checks: try a mobile hotspot or VPN to confirm its ISP-level filtering
### Service says running, but RPC probe fails ### Service says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting) - [Gateway troubleshooting](/gateway/troubleshooting)

View File

@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled. # From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare. # APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \ BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.21 \ APP_VERSION=2026.1.23 \
APP_BUILD="$(git rev-list --count HEAD)" \ APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \ BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \ SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support) # Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
# Optional: also build a styled DMG for humans (drag to /Applications) # Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
# Recommended: build + notarize/staple zip + DMG # Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once: # First, create a keychain profile once:
@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>" # --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \ BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.21 \ APP_VERSION=2026.1.23 \
APP_BUILD="$(git rev-list --count HEAD)" \ APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \ BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \ SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release # Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip
``` ```
## Appcast entry ## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes: Use the release note generator so Sparkle renders formatted HTML notes:
```bash ```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
``` ```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify ## Publish & verify
- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`. - Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks: - Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.

View File

@ -88,6 +88,8 @@ Session lifecycle:
- `/settings` - `/settings`
- `/exit` - `/exit`
Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
## Local shell commands ## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host. - Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session. - The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/bluebubbles", "name": "@clawdbot/bluebubbles",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot BlueBubbles channel plugin", "description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { removeAckReactionAfterReply, shouldAckReaction } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import { import {
handleBlueBubblesWebhookRequest, handleBlueBubblesWebhookRequest,
@ -128,6 +129,7 @@ function createMockRuntime(): PluginRuntime {
session: { session: {
resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
recordInboundSession: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"], recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"], updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
}, },
@ -135,6 +137,10 @@ function createMockRuntime(): PluginRuntime {
buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
}, },
reactions: {
shouldAckReaction,
removeAckReactionAfterReply,
},
groups: { groups: {
resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],

View File

@ -1,7 +1,13 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveAckReaction } from "clawdbot/plugin-sdk"; import {
logAckFailure,
logInboundDrop,
logTypingFailure,
resolveAckReaction,
resolveControlCommandGate,
} from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js"; import { downloadBlueBubblesAttachment } from "./attachments.js";
@ -1346,23 +1352,25 @@ async function processMessage(
}) })
: false; : false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands; const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
const commandAuthorized = isGroup const commandGate = resolveControlCommandGate({
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups,
useAccessGroups, authorizers: [
authorizers: [ { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, ],
], allowTextCommands: true,
}) hasControlCommand: hasControlCmd,
: dmAuthorized; });
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
// Block control commands from unauthorized senders in groups // Block control commands from unauthorized senders in groups
if (isGroup && hasControlCmd && !commandAuthorized) { if (isGroup && commandGate.shouldBlock) {
logVerbose( logInboundDrop({
core, log: (msg) => logVerbose(core, runtime, msg),
runtime, channel: "bluebubbles",
`bluebubbles: drop control command from unauthorized sender ${message.senderId}`, reason: "control command (unauthorized)",
); target: message.senderId,
});
return; return;
} }
@ -1521,19 +1529,20 @@ async function processMessage(
core, core,
runtime, runtime,
}); });
const shouldAckReaction = () => { const shouldAckReaction = () =>
if (!ackReactionValue) return false; Boolean(
if (ackReactionScope === "all") return true; ackReactionValue &&
if (ackReactionScope === "direct") return !isGroup; core.channel.reactions.shouldAckReaction({
if (ackReactionScope === "group-all") return isGroup; scope: ackReactionScope,
if (ackReactionScope === "group-mentions") { isDirect: !isGroup,
if (!isGroup) return false; isGroup,
if (!requireMention) return false; isMentionableGroup: isGroup,
if (!canDetectMention) return false; requireMention: Boolean(requireMention),
return effectiveWasMentioned; canDetectMention,
} effectiveWasMentioned,
return false; shouldBypassMention,
}; }),
);
const ackMessageId = message.messageId?.trim() || ""; const ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise = const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
@ -1749,29 +1758,27 @@ async function processMessage(
}, },
}); });
} finally { } finally {
if ( if (sentMessage && chatGuidForActions && ackMessageId) {
removeAckAfterReply && core.channel.reactions.removeAckReactionAfterReply({
sentMessage && removeAfterReply: removeAckAfterReply,
ackReactionPromise && ackReactionPromise,
ackReactionValue && ackReactionValue: ackReactionValue ?? null,
chatGuidForActions && remove: () =>
ackMessageId sendBlueBubblesReaction({
) { chatGuid: chatGuidForActions,
void ackReactionPromise.then((didAck) => { messageGuid: ackMessageId,
if (!didAck) return; emoji: ackReactionValue ?? "",
sendBlueBubblesReaction({ remove: true,
chatGuid: chatGuidForActions, opts: { cfg: config, accountId: account.accountId },
messageGuid: ackMessageId, }),
emoji: ackReactionValue, onError: (err) => {
remove: true, logAckFailure({
opts: { cfg: config, accountId: account.accountId }, log: (msg) => logVerbose(core, runtime, msg),
}).catch((err) => { channel: "bluebubbles",
logVerbose( target: `${chatGuidForActions}/${ackMessageId}`,
core, error: err,
runtime, });
`ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`, },
);
});
}); });
} }
if (chatGuidForActions && baseUrl && password && !sentMessage) { if (chatGuidForActions && baseUrl && password && !sentMessage) {
@ -1780,7 +1787,13 @@ async function processMessage(
cfg: config, cfg: config,
accountId: account.accountId, accountId: account.accountId,
}).catch((err) => { }).catch((err) => {
logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`); logTypingFailure({
log: (msg) => logVerbose(core, runtime, msg),
channel: "bluebubbles",
action: "stop",
target: chatGuidForActions,
error: err,
});
}); });
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/copilot-proxy", "name": "@clawdbot/copilot-proxy",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Copilot Proxy provider plugin", "description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/diagnostics-otel", "name": "@clawdbot/diagnostics-otel",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter", "description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/discord", "name": "@clawdbot/discord",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Discord channel plugin", "description": "Clawdbot Discord channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/google-antigravity-auth", "name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin", "description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/google-gemini-cli-auth", "name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin", "description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/imessage", "name": "@clawdbot/imessage",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot iMessage channel plugin", "description": "Clawdbot iMessage channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/lobster", "name": "@clawdbot/lobster",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": { "clawdbot": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/matrix", "name": "@clawdbot/matrix",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Matrix channel plugin", "description": "Clawdbot Matrix channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,7 +1,12 @@
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk"; import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
import { import {
createReplyPrefixContext,
createTypingCallbacks,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
resolveControlCommandGate,
type RuntimeEnv, type RuntimeEnv,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js"; import type { CoreConfig, ReplyToMode } from "../../types.js";
@ -376,21 +381,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
userName: senderName, userName: senderName,
}) })
: false; : false;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup }, { configured: groupAllowConfigured, allowed: senderAllowedForGroup },
], ],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
}); });
if ( const commandAuthorized = commandGate.commandAuthorized;
isRoom && if (isRoom && commandGate.shouldBlock) {
allowTextCommands && logInboundDrop({
core.channel.text.hasControlCommand(bodyText, cfg) && log: logVerboseMessage,
!commandAuthorized channel: "matrix",
) { reason: "control command (unauthorized)",
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`); target: senderId,
});
return; return;
} }
const shouldRequireMention = isRoom const shouldRequireMention = isRoom
@ -409,7 +418,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!wasMentioned && !wasMentioned &&
!hasExplicitMention && !hasExplicitMention &&
commandAuthorized && commandAuthorized &&
core.channel.text.hasControlCommand(bodyText); hasControlCommandInMessage;
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message"); logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return; return;
@ -486,47 +496,45 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
OriginatingTo: `room:${roomId}`, OriginatingTo: `room:${roomId}`,
}); });
void core.channel.session await core.channel.session.recordInboundSession({
.recordSessionMetaFromInbound({ storePath,
storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload,
ctx: ctxPayload, updateLastRoute: isDirectMessage
}) ? {
.catch((err) => { sessionKey: route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn( logger.warn(
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
"failed updating session meta", "failed updating session meta",
); );
}); },
});
if (isDirectMessage) {
await core.channel.session.updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
ctx: ctxPayload,
});
}
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const shouldAckReaction = () => { const shouldAckReaction = () =>
if (!ackReaction) return false; Boolean(
if (ackScope === "all") return true; ackReaction &&
if (ackScope === "direct") return isDirectMessage; core.channel.reactions.shouldAckReaction({
if (ackScope === "group-all") return isRoom; scope: ackScope,
if (ackScope === "group-mentions") { isDirect: isDirectMessage,
if (!isRoom) return false; isGroup: isRoom,
if (!shouldRequireMention) return false; isMentionableGroup: isRoom,
return wasMentioned || shouldBypassMention; requireMention: Boolean(shouldRequireMention),
} canDetectMention,
return false; effectiveWasMentioned: wasMentioned || shouldBypassMention,
}; shouldBypassMention,
}),
);
if (shouldAckReaction() && messageId) { if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
@ -553,10 +561,33 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
channel: "matrix", channel: "matrix",
accountId: route.accountId, accountId: route.accountId,
}); });
const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
action: "start",
target: roomId,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix",
action: "stop",
target: roomId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({ core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) responsePrefix: prefixContext.responsePrefix,
.responsePrefix, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload) => {
await deliverMatrixReplies({ await deliverMatrixReplies({
@ -575,10 +606,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
}, },
onReplyStart: () => onReplyStart: typingCallbacks.onReplyStart,
sendTypingMatrix(roomId, true, undefined, client).catch(() => {}), onIdle: typingCallbacks.onIdle,
onIdle: () =>
sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
}); });
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
@ -588,6 +617,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
replyOptions: { replyOptions: {
...replyOptions, ...replyOptions,
skillFilter: roomConfig?.skills, skillFilter: roomConfig?.skills,
onModelSelected: prefixContext.onModelSelected,
}, },
}); });
markDispatchIdle(); markDispatchIdle();

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/mattermost", "name": "@clawdbot/mattermost",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Mattermost channel plugin", "description": "Clawdbot Mattermost channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -7,10 +7,15 @@ import type {
RuntimeEnv, RuntimeEnv,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { import {
createReplyPrefixContext,
createTypingCallbacks,
logInboundDrop,
logTypingFailure,
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry, recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveChannelMediaMaxBytes, resolveChannelMediaMaxBytes,
type HistoryEntry, type HistoryEntry,
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
@ -30,12 +35,9 @@ import {
} from "./client.js"; } from "./client.js";
import { import {
createDedupeCache, createDedupeCache,
extractShortModelName,
formatInboundFromLabel, formatInboundFromLabel,
rawDataToString, rawDataToString,
resolveIdentityName,
resolveThreadSessionKeys, resolveThreadSessionKeys,
type ResponsePrefixContext,
} from "./monitor-helpers.js"; } from "./monitor-helpers.js";
import { sendMessageMattermost } from "./send.js"; import { sendMessageMattermost } from "./send.js";
@ -307,11 +309,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}; };
const sendTypingIndicator = async (channelId: string, parentId?: string) => { const sendTypingIndicator = async (channelId: string, parentId?: string) => {
try { await sendMattermostTyping(client, { channelId, parentId });
await sendMattermostTyping(client, { channelId, parentId });
} catch (err) {
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
}
}; };
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => { const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
@ -403,7 +401,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg, cfg,
surface: "mattermost", surface: "mattermost",
}); });
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg); const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
const isControlCommand = allowTextCommands && hasControlCommand;
const useAccessGroups = cfg.commands?.useAccessGroups !== false; const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed({ const senderAllowedForCommands = isSenderAllowed({
senderId, senderId,
@ -415,19 +414,20 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderName, senderName,
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
}); });
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = const commandAuthorized =
kind === "dm" kind === "dm" ? dmPolicy === "open" || senderAllowedForCommands : commandGate.commandAuthorized;
? dmPolicy === "open" || senderAllowedForCommands
: core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: effectiveGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
});
if (kind === "dm") { if (kind === "dm") {
if (dmPolicy === "disabled") { if (dmPolicy === "disabled") {
@ -488,10 +488,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
} }
} }
if (kind !== "dm" && isControlCommand && !commandAuthorized) { if (kind !== "dm" && commandGate.shouldBlock) {
logVerboseMessage( logInboundDrop({
`mattermost: drop control command from unauthorized sender ${senderId}`, log: logVerboseMessage,
); channel: "mattermost",
reason: "control command (unauthorized)",
target: senderId,
});
return; return;
} }
@ -534,19 +537,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: ""); : "");
const pendingSender = senderName; const pendingSender = senderName;
const recordPendingHistory = () => { const recordPendingHistory = () => {
if (!historyKey || historyLimit <= 0) return;
const trimmed = pendingBody.trim(); const trimmed = pendingBody.trim();
if (!trimmed) return; recordPendingHistoryEntryIfEnabled({
recordPendingHistoryEntry({
historyMap: channelHistories, historyMap: channelHistories,
historyKey,
limit: historyLimit, limit: historyLimit,
entry: { historyKey: historyKey ?? "",
sender: pendingSender, entry: historyKey && trimmed
body: trimmed, ? {
timestamp: typeof post.create_at === "number" ? post.create_at : undefined, sender: pendingSender,
messageId: post.id ?? undefined, body: trimmed,
}, timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
messageId: post.id ?? undefined,
}
: null,
}); });
}; };
@ -623,7 +626,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
sender: { name: senderName, id: senderId }, sender: { name: senderName, id: senderId },
}); });
let combinedBody = body; let combinedBody = body;
if (historyKey && historyLimit > 0) { if (historyKey) {
combinedBody = buildPendingHistoryContextFromMap({ combinedBody = buildPendingHistoryContextFromMap({
historyMap: channelHistories, historyMap: channelHistories,
historyKey, historyKey,
@ -713,15 +716,23 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId, accountId: account.accountId,
}); });
let prefixContext: ResponsePrefixContext = { const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
identityName: resolveIdentityName(cfg, route.agentId),
};
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingIndicator(channelId, threadRootId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: channelId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } = const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({ core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) responsePrefix: prefixContext.responsePrefix,
.responsePrefix, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
responsePrefixContextProvider: () => prefixContext,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => { deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@ -752,7 +763,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
}, },
onReplyStart: () => sendTypingIndicator(channelId, threadRootId), onReplyStart: typingCallbacks.onReplyStart,
}); });
await core.channel.reply.dispatchReplyFromConfig({ await core.channel.reply.dispatchReplyFromConfig({
@ -763,17 +774,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...replyOptions, ...replyOptions,
disableBlockStreaming: disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected: (ctx) => { onModelSelected: prefixContext.onModelSelected,
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
}, },
}); });
markDispatchIdle(); markDispatchIdle();
if (historyKey && historyLimit > 0) { if (historyKey) {
clearHistoryEntries({ historyMap: channelHistories, historyKey }); clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit });
} }
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/memory-core", "name": "@clawdbot/memory-core",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot core memory search plugin", "description": "Clawdbot core memory search plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/memory-lancedb", "name": "@clawdbot/memory-lancedb",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": { "dependencies": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/msteams", "name": "@clawdbot/msteams",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Microsoft Teams channel plugin", "description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] {
host.endsWith("1drv.ms") || host.endsWith("1drv.ms") ||
host.includes("sharepoint"); host.includes("sharepoint");
return looksLikeGraph return looksLikeGraph
? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"] ? ["https://graph.microsoft.com", "https://api.botframework.com"]
: ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"]; : ["https://api.botframework.com", "https://graph.microsoft.com"];
} catch { } catch {
return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"]; return ["https://api.botframework.com", "https://graph.microsoft.com"];
} }
} }

View File

@ -198,7 +198,7 @@ export async function downloadMSTeamsGraphMedia(params: {
const messageUrl = params.messageUrl; const messageUrl = params.messageUrl;
let accessToken: string; let accessToken: string;
try { try {
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
} catch { } catch {
return { media: [], messageUrl, tokenError: true }; return { media: [], messageUrl, tokenError: true };
} }

View File

@ -64,7 +64,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
if (!creds) throw new Error("MS Teams credentials missing"); if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig); const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const accessToken = readAccessToken(token); const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable"); if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken; return accessToken;

View File

@ -13,7 +13,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GRAPH_BETA = "https://graph.microsoft.com/beta"; const GRAPH_BETA = "https://graph.microsoft.com/beta";
const GRAPH_SCOPE = "https://graph.microsoft.com/.default"; const GRAPH_SCOPE = "https://graph.microsoft.com";
export interface OneDriveUploadResult { export interface OneDriveUploadResult {
id: string; id: string;

View File

@ -1,8 +1,10 @@
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT, DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry, logInboundDrop,
recordPendingHistoryEntryIfEnabled,
resolveControlCommandGate,
resolveMentionGating, resolveMentionGating,
formatAllowlistMatchMeta, formatAllowlistMatchMeta,
type HistoryEntry, type HistoryEntry,
@ -251,15 +253,24 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId, senderId,
senderName, senderName,
}); });
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
], ],
allowTextCommands: true,
hasControlCommand: hasControlCommandInMessage,
}); });
if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) { const commandAuthorized = commandGate.commandAuthorized;
logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`); if (commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "msteams",
reason: "control command (unauthorized)",
target: senderId,
});
return; return;
} }
@ -371,19 +382,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
requireMention, requireMention,
mentioned, mentioned,
}); });
if (historyLimit > 0) { recordPendingHistoryEntryIfEnabled({
recordPendingHistoryEntry({ historyMap: conversationHistories,
historyMap: conversationHistories, historyKey: conversationId,
historyKey: conversationId, limit: historyLimit,
limit: historyLimit, entry: {
entry: { sender: senderName,
sender: senderName, body: rawBody,
body: rawBody, timestamp: timestamp?.getTime(),
timestamp: timestamp?.getTime(), messageId: activity.id ?? undefined,
messageId: activity.id ?? undefined, },
}, });
});
}
return; return;
} }
} }
@ -426,7 +435,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
let combinedBody = body; let combinedBody = body;
const isRoomish = !isDirectMessage; const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined; const historyKey = isRoomish ? conversationId : undefined;
if (isRoomish && historyKey && historyLimit > 0) { if (isRoomish && historyKey) {
combinedBody = buildPendingHistoryContextFromMap({ combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories, historyMap: conversationHistories,
historyKey, historyKey,
@ -467,12 +476,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload, ...mediaPayload,
}); });
void core.channel.session.recordSessionMetaFromInbound({ await core.channel.session.recordInboundSession({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
}).catch((err) => { onRecordError: (err) => {
logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`); logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
},
}); });
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
@ -512,10 +522,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const didSendReply = counts.final + counts.tool + counts.block > 0; const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) { if (!queuedFinal) {
if (isRoomish && historyKey && historyLimit > 0) { if (isRoomish && historyKey) {
clearHistoryEntries({ clearHistoryEntriesIfEnabled({
historyMap: conversationHistories, historyMap: conversationHistories,
historyKey, historyKey,
limit: historyLimit,
}); });
} }
return; return;
@ -524,8 +535,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
logVerboseMessage( logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
); );
if (isRoomish && historyKey && historyLimit > 0) { if (isRoomish && historyKey) {
clearHistoryEntries({ historyMap: conversationHistories, historyKey }); clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
limit: historyLimit,
});
} }
} catch (err) { } catch (err) {
log.error("dispatch failed", { error: String(err) }); log.error("dispatch failed", { error: String(err) });

View File

@ -1,4 +1,7 @@
import { import {
createReplyPrefixContext,
createTypingCallbacks,
logTypingFailure,
resolveChannelMediaMaxBytes, resolveChannelMediaMaxBytes,
type ClawdbotConfig, type ClawdbotConfig,
type MSTeamsReplyStyle, type MSTeamsReplyStyle,
@ -39,23 +42,33 @@ export function createMSTeamsReplyDispatcher(params: {
}) { }) {
const core = getMSTeamsRuntime(); const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => { const sendTypingIndicator = async () => {
try { await params.context.sendActivities([{ type: "typing" }]);
await params.context.sendActivities([{ type: "typing" }]);
} catch {
// Typing indicator is best-effort.
}
}; };
const typingCallbacks = createTypingCallbacks({
return core.channel.reply.createReplyDispatcherWithTyping({ start: sendTypingIndicator,
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig( onStartError: (err) => {
params.cfg, logTypingFailure({
params.agentId, log: (message) => params.log.debug(message),
).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams", channel: "msteams",
action: "start",
error: err,
});
},
});
const prefixContext = createReplyPrefixContext({
cfg: params.cfg,
agentId: params.agentId,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
}); });
const messages = renderReplyPayloadsToMessages([payload], { const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit, textChunkLimit: params.textLimit,
@ -87,21 +100,27 @@ export function createMSTeamsReplyDispatcher(params: {
mediaMaxBytes, mediaMaxBytes,
}); });
if (ids.length > 0) params.onSentMessageIds?.(ids); if (ids.length > 0) params.onSentMessageIds?.(ids);
}, },
onError: (err, info) => { onError: (err, info) => {
const errMsg = formatUnknownError(err); const errMsg = formatUnknownError(err);
const classification = classifyMSTeamsSendError(err); const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification); const hint = formatMSTeamsSendErrorHint(classification);
params.runtime.error?.( params.runtime.error?.(
`msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`, `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
); );
params.log.error("reply failed", { params.log.error("reply failed", {
kind: info.kind, kind: info.kind,
error: errMsg, error: errMsg,
classification, classification,
hint, hint,
}); });
}, },
onReplyStart: sendTypingIndicator, onReplyStart: typingCallbacks.onReplyStart,
}); });
return {
dispatcher,
replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
markDispatchIdle,
};
} }

View File

@ -143,7 +143,7 @@ async function resolveGraphToken(cfg: unknown): Promise<string> {
if (!creds) throw new Error("MS Teams credentials missing"); if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig); const tokenProvider = new sdk.MsalTokenProvider(authConfig);
const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const accessToken = readAccessToken(token); const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable"); if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken; return accessToken;

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/nextcloud-talk", "name": "@clawdbot/nextcloud-talk",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin", "description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,4 +1,9 @@
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; import {
logInboundDrop,
resolveControlCommandGate,
type ClawdbotConfig,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import { import {
@ -118,7 +123,11 @@ export async function handleNextcloudTalkInbound(params: {
senderId, senderId,
senderName, senderName,
}).allowed; }).allowed;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const commandGate = resolveControlCommandGate({
useAccessGroups, useAccessGroups,
authorizers: [ authorizers: [
{ {
@ -127,7 +136,10 @@ export async function handleNextcloudTalkInbound(params: {
allowed: senderAllowedForCommands, allowed: senderAllowedForCommands,
}, },
], ],
allowTextCommands,
hasControlCommand,
}); });
const commandAuthorized = commandGate.commandAuthorized;
if (isGroup) { if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({ const groupAllow = resolveNextcloudTalkGroupAllow({
@ -188,15 +200,13 @@ export async function handleNextcloudTalkInbound(params: {
} }
} }
if ( if (isGroup && commandGate.shouldBlock) {
isGroup && logInboundDrop({
allowTextCommands && log: (message) => runtime.log?.(message),
core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) && channel: CHANNEL_ID,
commandAuthorized !== true reason: "control command (unauthorized)",
) { target: senderId,
runtime.log?.( });
`nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
);
return; return;
} }
@ -212,10 +222,6 @@ export async function handleNextcloudTalkInbound(params: {
wildcardConfig: roomMatch.wildcardConfig, wildcardConfig: roomMatch.wildcardConfig,
}) })
: false; : false;
const hasControlCommand = core.channel.text.hasControlCommand(
rawBody,
config as ClawdbotConfig,
);
const mentionGate = resolveNextcloudTalkMentionGate({ const mentionGate = resolveNextcloudTalkMentionGate({
isGroup, isGroup,
requireMention: shouldRequireMention, requireMention: shouldRequireMention,
@ -287,15 +293,14 @@ export async function handleNextcloudTalkInbound(params: {
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
}); });
void core.channel.session await core.channel.session.recordInboundSession({
.recordSessionMetaFromInbound({ storePath,
storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload,
ctx: ctxPayload, onRecordError: (err) => {
})
.catch((err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`); runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
}); },
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/nostr", "name": "@clawdbot/nostr",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": { "clawdbot": {

View File

@ -1,9 +1,11 @@
{ {
"name": "@clawdbot/open-prose", "name": "@clawdbot/open-prose",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).", "description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": { "clawdbot": {
"extensions": ["./index.ts"] "extensions": [
"./index.ts"
]
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/signal", "name": "@clawdbot/signal",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Signal channel plugin", "description": "Clawdbot Signal channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/slack", "name": "@clawdbot/slack",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Slack channel plugin", "description": "Clawdbot Slack channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/telegram", "name": "@clawdbot/telegram",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Telegram channel plugin", "description": "Clawdbot Telegram channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/voice-call", "name": "@clawdbot/voice-call",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot voice-call plugin", "description": "Clawdbot voice-call plugin",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/whatsapp", "name": "@clawdbot/whatsapp",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot WhatsApp channel plugin", "description": "Clawdbot WhatsApp channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/zalo", "name": "@clawdbot/zalo",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Zalo channel plugin", "description": "Clawdbot Zalo channel plugin",
"clawdbot": { "clawdbot": {

View File

@ -570,12 +570,13 @@ async function processMessageWithPipeline(params: {
OriginatingTo: `zalo:${chatId}`, OriginatingTo: `zalo:${chatId}`,
}); });
void core.channel.session.recordSessionMetaFromInbound({ await core.channel.session.recordInboundSession({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
}).catch((err) => { onRecordError: (err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
},
}); });
const tableMode = core.channel.text.resolveMarkdownTableMode({ const tableMode = core.channel.text.resolveMarkdownTableMode({

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.23
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.22 ## 2026.1.22
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/zalouser", "name": "@clawdbot/zalouser",
"version": "2026.1.22", "version": "2026.1.23",
"type": "module", "type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli", "description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": { "dependencies": {

View File

@ -311,12 +311,13 @@ async function processMessage(
OriginatingTo: `zalouser:${chatId}`, OriginatingTo: `zalouser:${chatId}`,
}); });
void core.channel.session.recordSessionMetaFromInbound({ await core.channel.session.recordInboundSession({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
}).catch((err) => { onRecordError: (err) => {
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`); runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
},
}); });
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({

View File

@ -1,6 +1,6 @@
{ {
"name": "clawdbot", "name": "clawdbot",
"version": "2026.1.22", "version": "2026.1.23",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@ -0,0 +1,107 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
estimateMessagesTokens,
pruneHistoryForContextShare,
splitMessagesByTokenShare,
} from "./compaction.js";
function makeMessage(id: number, size: number): AgentMessage {
return {
role: "user",
content: "x".repeat(size),
timestamp: id,
};
}
describe("splitMessagesByTokenShare", () => {
it("splits messages into two non-empty parts", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
];
const parts = splitMessagesByTokenShare(messages, 2);
expect(parts.length).toBeGreaterThanOrEqual(2);
expect(parts[0]?.length).toBeGreaterThan(0);
expect(parts[1]?.length).toBeGreaterThan(0);
expect(parts.flat().length).toBe(messages.length);
});
it("preserves message order across parts", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
makeMessage(5, 4000),
makeMessage(6, 4000),
];
const parts = splitMessagesByTokenShare(messages, 3);
expect(parts.flat().map((msg) => msg.timestamp)).toEqual(messages.map((msg) => msg.timestamp));
});
});
describe("pruneHistoryForContextShare", () => {
it("drops older chunks until the history budget is met", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
];
const maxContextTokens = 2000; // budget is 1000 tokens (50%)
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
expect(pruned.droppedChunks).toBeGreaterThan(0);
expect(pruned.keptTokens).toBeLessThanOrEqual(Math.floor(maxContextTokens * 0.5));
expect(pruned.messages.length).toBeGreaterThan(0);
});
it("keeps the newest messages when pruning", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
makeMessage(5, 4000),
makeMessage(6, 4000),
];
const totalTokens = estimateMessagesTokens(messages);
const maxContextTokens = Math.max(1, Math.floor(totalTokens * 0.5)); // budget = 25%
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
const keptIds = pruned.messages.map((msg) => msg.timestamp);
const expectedSuffix = messages.slice(-keptIds.length).map((msg) => msg.timestamp);
expect(keptIds).toEqual(expectedSuffix);
});
it("keeps history when already within budget", () => {
const messages: AgentMessage[] = [makeMessage(1, 1000)];
const maxContextTokens = 2000;
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
expect(pruned.droppedChunks).toBe(0);
expect(pruned.messages.length).toBe(messages.length);
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
});
});

341
src/agents/compaction.ts Normal file
View File

@ -0,0 +1,341 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
export const BASE_CHUNK_RATIO = 0.4;
export const MIN_CHUNK_RATIO = 0.15;
export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
const DEFAULT_SUMMARY_FALLBACK = "No prior history.";
const DEFAULT_PARTS = 2;
const MERGE_SUMMARIES_INSTRUCTIONS =
"Merge these partial summaries into a single cohesive summary. Preserve decisions," +
" TODOs, open questions, and any constraints.";
export function estimateMessagesTokens(messages: AgentMessage[]): number {
return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
}
function normalizeParts(parts: number, messageCount: number): number {
if (!Number.isFinite(parts) || parts <= 1) return 1;
return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount));
}
export function splitMessagesByTokenShare(
messages: AgentMessage[],
parts = DEFAULT_PARTS,
): AgentMessage[][] {
if (messages.length === 0) return [];
const normalizedParts = normalizeParts(parts, messages.length);
if (normalizedParts <= 1) return [messages];
const totalTokens = estimateMessagesTokens(messages);
const targetTokens = totalTokens / normalizedParts;
const chunks: AgentMessage[][] = [];
let current: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (
chunks.length < normalizedParts - 1 &&
current.length > 0 &&
currentTokens + messageTokens > targetTokens
) {
chunks.push(current);
current = [];
currentTokens = 0;
}
current.push(message);
currentTokens += messageTokens;
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
export function chunkMessagesByMaxTokens(
messages: AgentMessage[],
maxTokens: number,
): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const totalTokens = estimateMessagesTokens(messages);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/**
* Check if a single message is too large to summarize.
* If single message > 50% of context, it can't be summarized safely.
*/
export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
return tokens > contextWindow * 0.5;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
const chunks = chunkMessagesByMaxTokens(params.messages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? DEFAULT_SUMMARY_FALLBACK;
}
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
export async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
// Try full summarization first
try {
return await summarizeChunks(params);
} catch (fullError) {
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`,
);
}
// Fallback 1: Summarize only small messages, note oversized ones
const smallMessages: AgentMessage[] = [];
const oversizedNotes: string[] = [];
for (const msg of messages) {
if (isOversizedForSummary(msg, contextWindow)) {
const role = (msg as { role?: string }).role ?? "message";
const tokens = estimateTokens(msg);
oversizedNotes.push(
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
);
} else {
smallMessages.push(msg);
}
}
if (smallMessages.length > 0) {
try {
const partialSummary = await summarizeChunks({
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
}`,
);
}
}
// Final fallback: Just note what was there
return (
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
`Summary unavailable due to size limits.`
);
}
export async function summarizeInStages(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
parts?: number;
minMessagesForSplit?: number;
}): Promise<string> {
const { messages } = params;
if (messages.length === 0) {
return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
}
const minMessagesForSplit = Math.max(2, params.minMessagesForSplit ?? 4);
const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, messages.length);
const totalTokens = estimateMessagesTokens(messages);
if (parts <= 1 || messages.length < minMessagesForSplit || totalTokens <= params.maxChunkTokens) {
return summarizeWithFallback(params);
}
const splits = splitMessagesByTokenShare(messages, parts).filter((chunk) => chunk.length > 0);
if (splits.length <= 1) {
return summarizeWithFallback(params);
}
const partialSummaries: string[] = [];
for (const chunk of splits) {
partialSummaries.push(
await summarizeWithFallback({
...params,
messages: chunk,
previousSummary: undefined,
}),
);
}
if (partialSummaries.length === 1) {
return partialSummaries[0];
}
const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({
role: "user",
content: summary,
timestamp: Date.now(),
}));
const mergeInstructions = params.customInstructions
? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\nAdditional focus:\n${params.customInstructions}`
: MERGE_SUMMARIES_INSTRUCTIONS;
return summarizeWithFallback({
...params,
messages: summaryMessages,
customInstructions: mergeInstructions,
});
}
export function pruneHistoryForContextShare(params: {
messages: AgentMessage[];
maxContextTokens: number;
maxHistoryShare?: number;
parts?: number;
}): {
messages: AgentMessage[];
droppedChunks: number;
droppedMessages: number;
droppedTokens: number;
keptTokens: number;
budgetTokens: number;
} {
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
let keptMessages = params.messages;
let droppedChunks = 0;
let droppedMessages = 0;
let droppedTokens = 0;
const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, keptMessages.length);
while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) {
const chunks = splitMessagesByTokenShare(keptMessages, parts);
if (chunks.length <= 1) break;
const [dropped, ...rest] = chunks;
droppedChunks += 1;
droppedMessages += dropped.length;
droppedTokens += estimateMessagesTokens(dropped);
keptMessages = rest.flat();
}
return {
messages: keptMessages,
droppedChunks,
droppedMessages,
droppedTokens,
keptTokens: estimateMessagesTokens(keptMessages),
budgetTokens,
};
}
export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number {
return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS));
}

View File

@ -79,6 +79,7 @@ export async function runEmbeddedPiAgent(
? "markdown" ? "markdown"
: "plain" : "plain"
: "markdown"); : "markdown");
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
return enqueueCommandInLane(sessionLane, () => return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => { enqueueGlobal(async () => {
@ -455,7 +456,7 @@ export async function runEmbeddedPiAgent(
cfg: params.config, cfg: params.config,
agentDir: params.agentDir, agentDir: params.agentDir,
}); });
if (timedOut) { if (timedOut && !isProbeSession) {
log.warn( log.warn(
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`, `Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
); );

View File

@ -595,18 +595,23 @@ export async function runEmbeddedAttempt(
setActiveEmbeddedRun(params.sessionId, queueHandle); setActiveEmbeddedRun(params.sessionId, queueHandle);
let abortWarnTimer: NodeJS.Timeout | undefined; let abortWarnTimer: NodeJS.Timeout | undefined;
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
const abortTimer = setTimeout( const abortTimer = setTimeout(
() => { () => {
log.warn( if (!isProbeSession) {
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`, log.warn(
); `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
);
}
abortRun(true); abortRun(true);
if (!abortWarnTimer) { if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => { abortWarnTimer = setTimeout(() => {
if (!activeSession.isStreaming) return; if (!activeSession.isStreaming) return;
log.warn( if (!isProbeSession) {
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`, log.warn(
); `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
);
}
}, 10_000); }, 10_000);
} }
}, },

View File

@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
state: "processing", state: "processing",
reason: wasActive ? "run_replaced" : "run_started", reason: wasActive ? "run_replaced" : "run_started",
}); });
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); if (!sessionId.startsWith("probe-")) {
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
} }
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) { export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) { if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId); ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" }); logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); if (!sessionId.startsWith("probe-")) {
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
notifyEmbeddedRunEnded(sessionId); notifyEmbeddedRunEnded(sessionId);
} else { } else {
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`); diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);

View File

@ -1,12 +1,16 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent"; import {
BASE_CHUNK_RATIO,
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; MIN_CHUNK_RATIO,
SAFETY_MARGIN,
const BASE_CHUNK_RATIO = 0.4; computeAdaptiveChunkRatio,
const MIN_CHUNK_RATIO = 0.15; estimateMessagesTokens,
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy isOversizedForSummary,
pruneHistoryForContextShare,
resolveContextWindowTokens,
summarizeInStages,
} from "../compaction.js";
const FALLBACK_SUMMARY = const FALLBACK_SUMMARY =
"Summary unavailable due to context limits. Older messages were truncated."; "Summary unavailable due to context limits. Older messages were truncated.";
const TURN_PREFIX_INSTRUCTIONS = const TURN_PREFIX_INSTRUCTIONS =
@ -129,175 +133,6 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str
return `\n\n${sections.join("\n\n")}`; return `\n\n${sections.join("\n\n")}`;
} }
function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessage[][] {
if (messages.length === 0) return [];
const chunks: AgentMessage[][] = [];
let currentChunk: AgentMessage[] = [];
let currentTokens = 0;
for (const message of messages) {
const messageTokens = estimateTokens(message);
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(message);
currentTokens += messageTokens;
if (messageTokens > maxTokens) {
// Split oversized messages to avoid unbounded chunk growth.
chunks.push(currentChunk);
currentChunk = [];
currentTokens = 0;
}
}
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
/**
* Compute adaptive chunk ratio based on average message size.
* When messages are large, we use smaller chunks to avoid exceeding model limits.
*/
function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
if (messages.length === 0) return BASE_CHUNK_RATIO;
const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
const avgTokens = totalTokens / messages.length;
// Apply safety margin to account for estimation inaccuracy
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
const avgRatio = safeAvgTokens / contextWindow;
// If average message is > 10% of context, reduce chunk ratio
if (avgRatio > 0.1) {
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
}
return BASE_CHUNK_RATIO;
}
/**
* Check if a single message is too large to summarize.
* If single message > 50% of context, it can't be summarized safely.
*/
function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
return tokens > contextWindow * 0.5;
}
async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
if (params.messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
const chunks = chunkMessages(params.messages, params.maxChunkTokens);
let summary = params.previousSummary;
for (const chunk of chunks) {
summary = await generateSummary(
chunk,
params.model,
params.reserveTokens,
params.apiKey,
params.signal,
params.customInstructions,
summary,
);
}
return summary ?? "No prior history.";
}
/**
* Summarize with progressive fallback for handling oversized messages.
* If full summarization fails, tries partial summarization excluding oversized messages.
*/
async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
contextWindow: number;
customInstructions?: string;
previousSummary?: string;
}): Promise<string> {
const { messages, contextWindow } = params;
if (messages.length === 0) {
return params.previousSummary ?? "No prior history.";
}
// Try full summarization first
try {
return await summarizeChunks(params);
} catch (fullError) {
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
}`,
);
}
// Fallback 1: Summarize only small messages, note oversized ones
const smallMessages: AgentMessage[] = [];
const oversizedNotes: string[] = [];
for (const msg of messages) {
if (isOversizedForSummary(msg, contextWindow)) {
const role = (msg as { role?: string }).role ?? "message";
const tokens = estimateTokens(msg);
oversizedNotes.push(
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
);
} else {
smallMessages.push(msg);
}
}
if (smallMessages.length > 0) {
try {
const partialSummary = await summarizeChunks({
...params,
messages: smallMessages,
});
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
}`,
);
}
}
// Final fallback: Just note what was there
return (
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
`Summary unavailable due to size limits.`
);
}
export default function compactionSafeguardExtension(api: ExtensionAPI): void { export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => { api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event; const { preparation, customInstructions, signal } = event;
@ -335,19 +170,48 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
} }
try { try {
const contextWindowTokens = Math.max( const contextWindowTokens = resolveContextWindowTokens(model);
1, const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS), let messagesToSummarize = preparation.messagesToSummarize;
);
const tokensBefore =
typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
? preparation.tokensBefore
: undefined;
if (tokensBefore !== undefined) {
const summarizableTokens =
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5);
if (newContentTokens > maxHistoryTokens) {
const pruned = pruneHistoryForContextShare({
messages: messagesToSummarize,
maxContextTokens: contextWindowTokens,
maxHistoryShare: 0.5,
parts: 2,
});
if (pruned.droppedChunks > 0) {
const newContentRatio = (newContentTokens / contextWindowTokens) * 100;
console.warn(
`Compaction safeguard: new content uses ${newContentRatio.toFixed(
1,
)}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` +
`(${pruned.droppedMessages} messages) to fit history budget.`,
);
messagesToSummarize = pruned.messages;
}
}
}
// Use adaptive chunk ratio based on message sizes // Use adaptive chunk ratio based on message sizes
const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages]; const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens); const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio)); const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens)); const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
const historySummary = await summarizeWithFallback({ const historySummary = await summarizeInStages({
messages: preparation.messagesToSummarize, messages: messagesToSummarize,
model, model,
apiKey, apiKey,
signal, signal,
@ -359,9 +223,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
}); });
let summary = historySummary; let summary = historySummary;
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) { if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
const prefixSummary = await summarizeWithFallback({ const prefixSummary = await summarizeInStages({
messages: preparation.turnPrefixMessages, messages: turnPrefixMessages,
model, model,
apiKey, apiKey,
signal, signal,
@ -369,6 +233,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
maxChunkTokens, maxChunkTokens,
contextWindow: contextWindowTokens, contextWindow: contextWindowTokens,
customInstructions: TURN_PREFIX_INSTRUCTIONS, customInstructions: TURN_PREFIX_INSTRUCTIONS,
previousSummary: undefined,
}); });
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`; summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
} }

View File

@ -0,0 +1,77 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { FinalizedMsgContext, MsgContext } from "./templating.js";
import type { GetReplyOptions } from "./types.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.js";
import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
import {
createReplyDispatcher,
createReplyDispatcherWithTyping,
type ReplyDispatcher,
type ReplyDispatcherOptions,
type ReplyDispatcherWithTypingOptions,
} from "./reply/reply-dispatcher.js";
export type DispatchInboundResult = DispatchFromConfigResult;
export async function dispatchInboundMessage(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcher: ReplyDispatcher;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const finalized = finalizeInboundContext(params.ctx);
return await dispatchReplyFromConfig({
ctx: finalized,
cfg: params.cfg,
dispatcher: params.dispatcher,
replyOptions: params.replyOptions,
replyResolver: params.replyResolver,
});
}
export async function dispatchInboundMessageWithBufferedDispatcher(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherWithTypingOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
const result = await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: {
...params.replyOptions,
...replyOptions,
},
});
markDispatchIdle();
return result;
}
export async function dispatchInboundMessageWithDispatcher(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const dispatcher = createReplyDispatcher(params.dispatcherOptions);
const result = await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
});
await dispatcher.waitForIdle();
return result;
}

View File

@ -82,7 +82,8 @@ export async function runAgentTurnWithFallback(params: {
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates. // Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
const directlySentBlockKeys = new Set<string>(); const directlySentBlockKeys = new Set<string>();
const runId = crypto.randomUUID(); const runId = params.opts?.runId ?? crypto.randomUUID();
params.opts?.onAgentRunStart?.(runId);
if (params.sessionKey) { if (params.sessionKey) {
registerAgentRunContext(runId, { registerAgentRunContext(runId, {
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
@ -174,6 +175,7 @@ export async function runAgentTurnWithFallback(params: {
extraSystemPrompt: params.followupRun.run.extraSystemPrompt, extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers, ownerNumbers: params.followupRun.run.ownerNumbers,
cliSessionId, cliSessionId,
images: params.opts?.images,
}) })
.then((result) => { .then((result) => {
emitAgentEvent({ emitAgentEvent({
@ -248,6 +250,8 @@ export async function runAgentTurnWithFallback(params: {
bashElevated: params.followupRun.run.bashElevated, bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs, timeoutMs: params.followupRun.run.timeoutMs,
runId, runId,
images: params.opts?.images,
abortSignal: params.opts?.abortSignal,
blockReplyBreak: params.resolvedBlockStreamingBreak, blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking, blockReplyChunking: params.blockReplyChunking,
onPartialReply: allowPartialStream onPartialReply: allowPartialStream

View File

@ -5,7 +5,9 @@ import {
buildHistoryContextFromEntries, buildHistoryContextFromEntries,
buildHistoryContextFromMap, buildHistoryContextFromMap,
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
HISTORY_CONTEXT_MARKER, HISTORY_CONTEXT_MARKER,
recordPendingHistoryEntryIfEnabled,
} from "./history.js"; } from "./history.js";
import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
@ -105,4 +107,46 @@ describe("history helpers", () => {
expect(result).toContain(CURRENT_MESSAGE_MARKER); expect(result).toContain(CURRENT_MESSAGE_MARKER);
expect(result).toContain("current"); expect(result).toContain("current");
}); });
it("records pending entries only when enabled", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>();
recordPendingHistoryEntryIfEnabled({
historyMap,
historyKey: "group",
limit: 0,
entry: { sender: "A", body: "one" },
});
expect(historyMap.get("group")).toEqual(undefined);
recordPendingHistoryEntryIfEnabled({
historyMap,
historyKey: "group",
limit: 2,
entry: null,
});
expect(historyMap.get("group")).toEqual(undefined);
recordPendingHistoryEntryIfEnabled({
historyMap,
historyKey: "group",
limit: 2,
entry: { sender: "B", body: "two" },
});
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]);
});
it("clears history entries only when enabled", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>();
historyMap.set("group", [
{ sender: "A", body: "one" },
{ sender: "B", body: "two" },
]);
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 });
expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 });
expect(historyMap.get("group")).toEqual([]);
});
}); });

View File

@ -47,6 +47,22 @@ export function recordPendingHistoryEntry<T extends HistoryEntry>(params: {
return appendHistoryEntry(params); return appendHistoryEntry(params);
} }
export function recordPendingHistoryEntryIfEnabled<T extends HistoryEntry>(params: {
historyMap: Map<string, T[]>;
historyKey: string;
entry?: T | null;
limit: number;
}): T[] {
if (!params.entry) return [];
if (params.limit <= 0) return [];
return recordPendingHistoryEntry({
historyMap: params.historyMap,
historyKey: params.historyKey,
entry: params.entry,
limit: params.limit,
});
}
export function buildPendingHistoryContextFromMap(params: { export function buildPendingHistoryContextFromMap(params: {
historyMap: Map<string, HistoryEntry[]>; historyMap: Map<string, HistoryEntry[]>;
historyKey: string; historyKey: string;
@ -101,6 +117,15 @@ export function clearHistoryEntries(params: {
params.historyMap.set(params.historyKey, []); params.historyMap.set(params.historyKey, []);
} }
export function clearHistoryEntriesIfEnabled(params: {
historyMap: Map<string, HistoryEntry[]>;
historyKey: string;
limit: number;
}): void {
if (params.limit <= 0) return;
clearHistoryEntries({ historyMap: params.historyMap, historyKey: params.historyKey });
}
export function buildHistoryContextFromEntries(params: { export function buildHistoryContextFromEntries(params: {
entries: HistoryEntry[]; entries: HistoryEntry[];
currentMessage: string; currentMessage: string;

View File

@ -1,58 +1,44 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import type { FinalizedMsgContext } from "../templating.js"; import type { FinalizedMsgContext, MsgContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js"; import type { GetReplyOptions } from "../types.js";
import type { DispatchFromConfigResult } from "./dispatch-from-config.js"; import type { DispatchInboundResult } from "../dispatch.js";
import { dispatchReplyFromConfig } from "./dispatch-from-config.js";
import { import {
createReplyDispatcher, dispatchInboundMessageWithBufferedDispatcher,
createReplyDispatcherWithTyping, dispatchInboundMessageWithDispatcher,
type ReplyDispatcherOptions, } from "../dispatch.js";
type ReplyDispatcherWithTypingOptions, import type {
ReplyDispatcherOptions,
ReplyDispatcherWithTypingOptions,
} from "./reply-dispatcher.js"; } from "./reply-dispatcher.js";
export async function dispatchReplyWithBufferedBlockDispatcher(params: { export async function dispatchReplyWithBufferedBlockDispatcher(params: {
ctx: FinalizedMsgContext; ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherWithTypingOptions; dispatcherOptions: ReplyDispatcherWithTypingOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">; replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("../reply.js").getReplyFromConfig; replyResolver?: typeof import("../reply.js").getReplyFromConfig;
}): Promise<DispatchFromConfigResult> { }): Promise<DispatchInboundResult> {
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping( return await dispatchInboundMessageWithBufferedDispatcher({
params.dispatcherOptions,
);
const result = await dispatchReplyFromConfig({
ctx: params.ctx, ctx: params.ctx,
cfg: params.cfg, cfg: params.cfg,
dispatcher, dispatcherOptions: params.dispatcherOptions,
replyResolver: params.replyResolver, replyResolver: params.replyResolver,
replyOptions: { replyOptions: params.replyOptions,
...params.replyOptions,
...replyOptions,
},
}); });
markDispatchIdle();
return result;
} }
export async function dispatchReplyWithDispatcher(params: { export async function dispatchReplyWithDispatcher(params: {
ctx: FinalizedMsgContext; ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherOptions; dispatcherOptions: ReplyDispatcherOptions;
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">; replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: typeof import("../reply.js").getReplyFromConfig; replyResolver?: typeof import("../reply.js").getReplyFromConfig;
}): Promise<DispatchFromConfigResult> { }): Promise<DispatchInboundResult> {
const dispatcher = createReplyDispatcher(params.dispatcherOptions); return await dispatchInboundMessageWithDispatcher({
const result = await dispatchReplyFromConfig({
ctx: params.ctx, ctx: params.ctx,
cfg: params.cfg, cfg: params.cfg,
dispatcher, dispatcherOptions: params.dispatcherOptions,
replyResolver: params.replyResolver, replyResolver: params.replyResolver,
replyOptions: params.replyOptions, replyOptions: params.replyOptions,
}); });
await dispatcher.waitForIdle();
return result;
} }

View File

@ -1,3 +1,4 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { TypingController } from "./reply/typing.js"; import type { TypingController } from "./reply/typing.js";
export type BlockReplyContext = { export type BlockReplyContext = {
@ -13,6 +14,14 @@ export type ModelSelectedContext = {
}; };
export type GetReplyOptions = { export type GetReplyOptions = {
/** Override run id for agent events (defaults to random UUID). */
runId?: string;
/** Abort signal for the underlying agent run. */
abortSignal?: AbortSignal;
/** Optional inbound images (used for webchat attachments). */
images?: ImageContent[];
/** Notifies when an agent run actually starts (useful for webchat command handling). */
onAgentRunStart?: (runId: string) => void;
onReplyStart?: () => Promise<void> | void; onReplyStart?: () => Promise<void> | void;
onTypingController?: (typing: TypingController) => void; onTypingController?: (typing: TypingController) => void;
isHeartbeat?: boolean; isHeartbeat?: boolean;

View File

@ -0,0 +1,269 @@
import { describe, expect, it, vi } from "vitest";
import {
removeAckReactionAfterReply,
shouldAckReaction,
shouldAckReactionForWhatsApp,
} from "./ack-reactions.js";
describe("shouldAckReaction", () => {
it("honors direct and group-all scopes", () => {
expect(
shouldAckReaction({
scope: "direct",
isDirect: true,
isGroup: false,
isMentionableGroup: false,
requireMention: false,
canDetectMention: false,
effectiveWasMentioned: false,
}),
).toBe(true);
expect(
shouldAckReaction({
scope: "group-all",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: false,
canDetectMention: false,
effectiveWasMentioned: false,
}),
).toBe(true);
});
it("skips when scope is off or none", () => {
expect(
shouldAckReaction({
scope: "off",
isDirect: true,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "none",
isDirect: true,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
});
it("defaults to group-mentions gating", () => {
expect(
shouldAckReaction({
scope: undefined,
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(true);
});
it("requires mention gating for group-mentions", () => {
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: false,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: false,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: false,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(false);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
}),
).toBe(true);
expect(
shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: false,
shouldBypassMention: true,
}),
).toBe(true);
});
});
describe("shouldAckReactionForWhatsApp", () => {
it("respects direct and group modes", () => {
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: true,
isGroup: false,
directEnabled: true,
groupMode: "mentions",
wasMentioned: false,
groupActivated: false,
}),
).toBe(true);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: true,
isGroup: false,
directEnabled: false,
groupMode: "mentions",
wasMentioned: false,
groupActivated: false,
}),
).toBe(false);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "always",
wasMentioned: false,
groupActivated: false,
}),
).toBe(true);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "never",
wasMentioned: true,
groupActivated: true,
}),
).toBe(false);
});
it("honors mentions or activation for group-mentions", () => {
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "mentions",
wasMentioned: true,
groupActivated: false,
}),
).toBe(true);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "mentions",
wasMentioned: false,
groupActivated: true,
}),
).toBe(true);
expect(
shouldAckReactionForWhatsApp({
emoji: "👀",
isDirect: false,
isGroup: true,
directEnabled: true,
groupMode: "mentions",
wasMentioned: false,
groupActivated: false,
}),
).toBe(false);
});
});
describe("removeAckReactionAfterReply", () => {
it("removes only when ack succeeded", async () => {
const remove = vi.fn().mockResolvedValue(undefined);
const onError = vi.fn();
removeAckReactionAfterReply({
removeAfterReply: true,
ackReactionPromise: Promise.resolve(true),
ackReactionValue: "👀",
remove,
onError,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(remove).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
});
it("skips removal when ack did not happen", async () => {
const remove = vi.fn().mockResolvedValue(undefined);
removeAckReactionAfterReply({
removeAfterReply: true,
ackReactionPromise: Promise.resolve(false),
ackReactionValue: "👀",
remove,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(remove).not.toHaveBeenCalled();
});
it("skips when not configured", async () => {
const remove = vi.fn().mockResolvedValue(undefined);
removeAckReactionAfterReply({
removeAfterReply: false,
ackReactionPromise: Promise.resolve(true),
ackReactionValue: "👀",
remove,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(remove).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,71 @@
export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none";
export type WhatsAppAckReactionMode = "always" | "mentions" | "never";
export type AckReactionGateParams = {
scope: AckReactionScope | undefined;
isDirect: boolean;
isGroup: boolean;
isMentionableGroup: boolean;
requireMention: boolean;
canDetectMention: boolean;
effectiveWasMentioned: boolean;
shouldBypassMention?: boolean;
};
export function shouldAckReaction(params: AckReactionGateParams): boolean {
const scope = params.scope ?? "group-mentions";
if (scope === "off" || scope === "none") return false;
if (scope === "all") return true;
if (scope === "direct") return params.isDirect;
if (scope === "group-all") return params.isGroup;
if (scope === "group-mentions") {
if (!params.isMentionableGroup) return false;
if (!params.requireMention) return false;
if (!params.canDetectMention) return false;
return params.effectiveWasMentioned || params.shouldBypassMention === true;
}
return false;
}
export function shouldAckReactionForWhatsApp(params: {
emoji: string;
isDirect: boolean;
isGroup: boolean;
directEnabled: boolean;
groupMode: WhatsAppAckReactionMode;
wasMentioned: boolean;
groupActivated: boolean;
}): boolean {
if (!params.emoji) return false;
if (params.isDirect) return params.directEnabled;
if (!params.isGroup) return false;
if (params.groupMode === "never") return false;
if (params.groupMode === "always") return true;
return shouldAckReaction({
scope: "group-mentions",
isDirect: false,
isGroup: true,
isMentionableGroup: true,
requireMention: true,
canDetectMention: true,
effectiveWasMentioned: params.wasMentioned,
shouldBypassMention: params.groupActivated,
});
}
export function removeAckReactionAfterReply(params: {
removeAfterReply: boolean;
ackReactionPromise: Promise<boolean> | null;
ackReactionValue: string | null;
remove: () => Promise<void>;
onError?: (err: unknown) => void;
}) {
if (!params.removeAfterReply) return;
if (!params.ackReactionPromise) return;
if (!params.ackReactionValue) return;
void params.ackReactionPromise.then((didAck) => {
if (!didAck) return;
params.remove().catch((err) => params.onError?.(err));
});
}

33
src/channels/logging.ts Normal file
View File

@ -0,0 +1,33 @@
export type LogFn = (message: string) => void;
export function logInboundDrop(params: {
log: LogFn;
channel: string;
reason: string;
target?: string;
}): void {
const target = params.target ? ` target=${params.target}` : "";
params.log(`${params.channel}: drop ${params.reason}${target}`);
}
export function logTypingFailure(params: {
log: LogFn;
channel: string;
target?: string;
action?: "start" | "stop";
error: unknown;
}): void {
const target = params.target ? ` target=${params.target}` : "";
const action = params.action ? ` action=${params.action}` : "";
params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`);
}
export function logAckFailure(params: {
log: LogFn;
channel: string;
target?: string;
error: unknown;
}): void {
const target = params.target ? ` target=${params.target}` : "";
params.log(`${params.channel} ack cleanup failed${target}: ${String(params.error)}`);
}

View File

@ -0,0 +1,41 @@
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { GetReplyOptions } from "../auto-reply/types.js";
import {
extractShortModelName,
type ResponsePrefixContext,
} from "../auto-reply/reply/response-prefix-template.js";
type ModelSelectionContext = Parameters<NonNullable<GetReplyOptions["onModelSelected"]>>[0];
export type ReplyPrefixContextBundle = {
prefixContext: ResponsePrefixContext;
responsePrefix?: string;
responsePrefixContextProvider: () => ResponsePrefixContext;
onModelSelected: (ctx: ModelSelectionContext) => void;
};
export function createReplyPrefixContext(params: {
cfg: ClawdbotConfig;
agentId: string;
}): ReplyPrefixContextBundle {
const { cfg, agentId } = params;
const prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, agentId),
};
const onModelSelected = (ctx: ModelSelectionContext) => {
// Mutate the object directly instead of reassigning to ensure closures see updates.
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
};
return {
prefixContext,
responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
onModelSelected,
};
}

49
src/channels/session.ts Normal file
View File

@ -0,0 +1,49 @@
import type { MsgContext } from "../auto-reply/templating.js";
import {
recordSessionMetaFromInbound,
type GroupKeyResolution,
type SessionEntry,
updateLastRoute,
} from "../config/sessions.js";
export type InboundLastRouteUpdate = {
sessionKey: string;
channel: SessionEntry["lastChannel"];
to: string;
accountId?: string;
threadId?: string | number;
};
export async function recordInboundSession(params: {
storePath: string;
sessionKey: string;
ctx: MsgContext;
groupResolution?: GroupKeyResolution | null;
createIfMissing?: boolean;
updateLastRoute?: InboundLastRouteUpdate;
onRecordError: (err: unknown) => void;
}): Promise<void> {
const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params;
void recordSessionMetaFromInbound({
storePath,
sessionKey,
ctx,
groupResolution,
createIfMissing,
}).catch(params.onRecordError);
const update = params.updateLastRoute;
if (!update) return;
await updateLastRoute({
storePath,
sessionKey: update.sessionKey,
deliveryContext: {
channel: update.channel,
to: update.to,
accountId: update.accountId,
threadId: update.threadId,
},
ctx,
groupResolution,
});
}

View File

@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { createTypingCallbacks } from "./typing.js";
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("createTypingCallbacks", () => {
it("invokes start on reply start", async () => {
const start = vi.fn().mockResolvedValue(undefined);
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, onStartError });
await callbacks.onReplyStart();
expect(start).toHaveBeenCalledTimes(1);
expect(onStartError).not.toHaveBeenCalled();
});
it("reports start errors", async () => {
const start = vi.fn().mockRejectedValue(new Error("fail"));
const onStartError = vi.fn();
const callbacks = createTypingCallbacks({ start, onStartError });
await callbacks.onReplyStart();
expect(onStartError).toHaveBeenCalledTimes(1);
});
it("invokes stop on idle and reports stop errors", async () => {
const start = vi.fn().mockResolvedValue(undefined);
const stop = vi.fn().mockRejectedValue(new Error("stop"));
const onStartError = vi.fn();
const onStopError = vi.fn();
const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
callbacks.onIdle?.();
await flush();
expect(stop).toHaveBeenCalledTimes(1);
expect(onStopError).toHaveBeenCalledTimes(1);
});
});

28
src/channels/typing.ts Normal file
View File

@ -0,0 +1,28 @@
export type TypingCallbacks = {
onReplyStart: () => Promise<void>;
onIdle?: () => void;
};
export function createTypingCallbacks(params: {
start: () => Promise<void>;
stop?: () => Promise<void>;
onStartError: (err: unknown) => void;
onStopError?: (err: unknown) => void;
}): TypingCallbacks {
const stop = params.stop;
const onReplyStart = async () => {
try {
await params.start();
} catch (err) {
params.onStartError(err);
}
};
const onIdle = stop
? () => {
void stop().catch((err) => (params.onStopError ?? params.onStartError)(err));
}
: undefined;
return { onReplyStart, onIdle };
}

View File

@ -71,9 +71,36 @@ export function registerModelsCli(program: Command) {
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)", "Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
false, false,
) )
.option("--probe", "Probe configured provider auth (live)", false)
.option("--probe-provider <name>", "Only probe a single provider")
.option(
"--probe-profile <id>",
"Only probe specific auth profile ids (repeat or comma-separated)",
(value, previous) => {
const next = Array.isArray(previous) ? previous : previous ? [previous] : [];
next.push(value);
return next;
},
)
.option("--probe-timeout <ms>", "Per-probe timeout in ms")
.option("--probe-concurrency <n>", "Concurrent probes")
.option("--probe-max-tokens <n>", "Probe max tokens (best-effort)")
.action(async (opts) => { .action(async (opts) => {
await runModelsCommand(async () => { await runModelsCommand(async () => {
await modelsStatusCommand(opts, defaultRuntime); await modelsStatusCommand(
{
json: Boolean(opts.json),
plain: Boolean(opts.plain),
check: Boolean(opts.check),
probe: Boolean(opts.probe),
probeProvider: opts.probeProvider as string | undefined,
probeProfile: opts.probeProfile as string | string[] | undefined,
probeTimeout: opts.probeTimeout as string | undefined,
probeConcurrency: opts.probeConcurrency as string | undefined,
probeMaxTokens: opts.probeMaxTokens as string | undefined,
},
defaultRuntime,
);
}); });
}); });

View File

@ -17,6 +17,7 @@ const discoverModels = vi.fn();
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json", CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json",
STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state",
loadConfig, loadConfig,
})); }));

View File

@ -0,0 +1,414 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import {
ensureAuthProfileStore,
listProfilesForProvider,
resolveAuthProfileDisplayLabel,
} from "../../agents/auth-profiles.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { describeFailoverError } from "../../agents/failover-error.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { normalizeProviderId, parseModelRef } from "../../agents/model-selection.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveSessionTranscriptPath,
resolveSessionTranscriptsDirForAgent,
} from "../../config/sessions/paths.js";
import { redactSecrets } from "../status-all/format.js";
import { DEFAULT_PROVIDER, formatMs } from "./shared.js";
const PROBE_PROMPT = "Reply with OK. Do not use tools.";
export type AuthProbeStatus =
| "ok"
| "auth"
| "rate_limit"
| "billing"
| "timeout"
| "format"
| "unknown"
| "no_model";
export type AuthProbeResult = {
provider: string;
model?: string;
profileId?: string;
label: string;
source: "profile" | "env" | "models.json";
mode?: string;
status: AuthProbeStatus;
error?: string;
latencyMs?: number;
};
type AuthProbeTarget = {
provider: string;
model?: { provider: string; model: string } | null;
profileId?: string;
label: string;
source: "profile" | "env" | "models.json";
mode?: string;
};
export type AuthProbeSummary = {
startedAt: number;
finishedAt: number;
durationMs: number;
totalTargets: number;
options: {
provider?: string;
profileIds?: string[];
timeoutMs: number;
concurrency: number;
maxTokens: number;
};
results: AuthProbeResult[];
};
export type AuthProbeOptions = {
provider?: string;
profileIds?: string[];
timeoutMs: number;
concurrency: number;
maxTokens: number;
};
const toStatus = (reason?: string | null): AuthProbeStatus => {
if (!reason) return "unknown";
if (reason === "auth") return "auth";
if (reason === "rate_limit") return "rate_limit";
if (reason === "billing") return "billing";
if (reason === "timeout") return "timeout";
if (reason === "format") return "format";
return "unknown";
};
function buildCandidateMap(modelCandidates: string[]): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const raw of modelCandidates) {
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
if (!parsed) continue;
const list = map.get(parsed.provider) ?? [];
if (!list.includes(parsed.model)) list.push(parsed.model);
map.set(parsed.provider, list);
}
return map;
}
function selectProbeModel(params: {
provider: string;
candidates: Map<string, string[]>;
catalog: Array<{ provider: string; id: string }>;
}): { provider: string; model: string } | null {
const { provider, candidates, catalog } = params;
const direct = candidates.get(provider);
if (direct && direct.length > 0) {
return { provider, model: direct[0] };
}
const fromCatalog = catalog.find((entry) => entry.provider === provider);
if (fromCatalog) return { provider: fromCatalog.provider, model: fromCatalog.id };
return null;
}
function buildProbeTargets(params: {
cfg: ClawdbotConfig;
providers: string[];
modelCandidates: string[];
options: AuthProbeOptions;
}): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> {
const { cfg, providers, modelCandidates, options } = params;
const store = ensureAuthProfileStore();
const providerFilter = options.provider?.trim();
const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null;
const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean));
return loadModelCatalog({ config: cfg }).then((catalog) => {
const candidates = buildCandidateMap(modelCandidates);
const targets: AuthProbeTarget[] = [];
const results: AuthProbeResult[] = [];
for (const provider of providers) {
const providerKey = normalizeProviderId(provider);
if (providerFilterKey && providerKey !== providerFilterKey) continue;
const model = selectProbeModel({
provider: providerKey,
candidates,
catalog,
});
const profileIds = listProfilesForProvider(store, providerKey);
const filteredProfiles = profileFilter.size
? profileIds.filter((id) => profileFilter.has(id))
: profileIds;
if (filteredProfiles.length > 0) {
for (const profileId of filteredProfiles) {
const profile = store.profiles[profileId];
const mode = profile?.type;
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
if (!model) {
results.push({
provider: providerKey,
model: undefined,
profileId,
label,
source: "profile",
mode,
status: "no_model",
error: "No model available for probe",
});
continue;
}
targets.push({
provider: providerKey,
model,
profileId,
label,
source: "profile",
mode,
});
}
continue;
}
if (profileFilter.size > 0) continue;
const envKey = resolveEnvApiKey(providerKey);
const customKey = getCustomProviderApiKey(cfg, providerKey);
if (!envKey && !customKey) continue;
const label = envKey ? "env" : "models.json";
const source = envKey ? "env" : "models.json";
const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key";
if (!model) {
results.push({
provider: providerKey,
model: undefined,
label,
source,
mode,
status: "no_model",
error: "No model available for probe",
});
continue;
}
targets.push({
provider: providerKey,
model,
label,
source,
mode,
});
}
return { targets, results };
});
}
async function probeTarget(params: {
cfg: ClawdbotConfig;
agentId: string;
agentDir: string;
workspaceDir: string;
sessionDir: string;
target: AuthProbeTarget;
timeoutMs: number;
maxTokens: number;
}): Promise<AuthProbeResult> {
const { cfg, agentId, agentDir, workspaceDir, sessionDir, target, timeoutMs, maxTokens } = params;
if (!target.model) {
return {
provider: target.provider,
model: undefined,
profileId: target.profileId,
label: target.label,
source: target.source,
mode: target.mode,
status: "no_model",
error: "No model available for probe",
};
}
const sessionId = `probe-${target.provider}-${crypto.randomUUID()}`;
const sessionFile = resolveSessionTranscriptPath(sessionId, agentId);
await fs.mkdir(sessionDir, { recursive: true });
const start = Date.now();
try {
await runEmbeddedPiAgent({
sessionId,
sessionFile,
workspaceDir,
agentDir,
config: cfg,
prompt: PROBE_PROMPT,
provider: target.model.provider,
model: target.model.model,
authProfileId: target.profileId,
authProfileIdSource: target.profileId ? "user" : undefined,
timeoutMs,
runId: `probe-${crypto.randomUUID()}`,
lane: `auth-probe:${target.provider}:${target.profileId ?? target.source}`,
thinkLevel: "off",
reasoningLevel: "off",
verboseLevel: "off",
streamParams: { maxTokens },
});
return {
provider: target.provider,
model: `${target.model.provider}/${target.model.model}`,
profileId: target.profileId,
label: target.label,
source: target.source,
mode: target.mode,
status: "ok",
latencyMs: Date.now() - start,
};
} catch (err) {
const described = describeFailoverError(err);
return {
provider: target.provider,
model: `${target.model.provider}/${target.model.model}`,
profileId: target.profileId,
label: target.label,
source: target.source,
mode: target.mode,
status: toStatus(described.reason),
error: redactSecrets(described.message),
latencyMs: Date.now() - start,
};
}
}
async function runTargetsWithConcurrency(params: {
cfg: ClawdbotConfig;
targets: AuthProbeTarget[];
timeoutMs: number;
maxTokens: number;
concurrency: number;
onProgress?: (update: { completed: number; total: number; label?: string }) => void;
}): Promise<AuthProbeResult[]> {
const { cfg, targets, timeoutMs, maxTokens, onProgress } = params;
const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency));
const agentId = resolveDefaultAgentId(cfg);
const agentDir = resolveClawdbotAgentDir();
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir();
const sessionDir = resolveSessionTranscriptsDirForAgent(agentId);
await fs.mkdir(workspaceDir, { recursive: true });
let completed = 0;
const results: Array<AuthProbeResult | undefined> = Array.from({ length: targets.length });
let cursor = 0;
const worker = async () => {
while (true) {
const index = cursor;
cursor += 1;
if (index >= targets.length) return;
const target = targets[index];
onProgress?.({
completed,
total: targets.length,
label: `Probing ${target.provider}${target.profileId ? ` (${target.label})` : ""}`,
});
const result = await probeTarget({
cfg,
agentId,
agentDir,
workspaceDir,
sessionDir,
target,
timeoutMs,
maxTokens,
});
results[index] = result;
completed += 1;
onProgress?.({ completed, total: targets.length });
}
};
await Promise.all(Array.from({ length: concurrency }, () => worker()));
return results.filter((entry): entry is AuthProbeResult => Boolean(entry));
}
export async function runAuthProbes(params: {
cfg: ClawdbotConfig;
providers: string[];
modelCandidates: string[];
options: AuthProbeOptions;
onProgress?: (update: { completed: number; total: number; label?: string }) => void;
}): Promise<AuthProbeSummary> {
const startedAt = Date.now();
const plan = await buildProbeTargets({
cfg: params.cfg,
providers: params.providers,
modelCandidates: params.modelCandidates,
options: params.options,
});
const totalTargets = plan.targets.length;
params.onProgress?.({ completed: 0, total: totalTargets });
const results = totalTargets
? await runTargetsWithConcurrency({
cfg: params.cfg,
targets: plan.targets,
timeoutMs: params.options.timeoutMs,
maxTokens: params.options.maxTokens,
concurrency: params.options.concurrency,
onProgress: params.onProgress,
})
: [];
const finishedAt = Date.now();
return {
startedAt,
finishedAt,
durationMs: finishedAt - startedAt,
totalTargets,
options: params.options,
results: [...plan.results, ...results],
};
}
export function formatProbeLatency(latencyMs?: number | null) {
if (!latencyMs && latencyMs !== 0) return "-";
return formatMs(latencyMs);
}
export function groupProbeResults(results: AuthProbeResult[]): Map<string, AuthProbeResult[]> {
const map = new Map<string, AuthProbeResult[]>();
for (const result of results) {
const list = map.get(result.provider) ?? [];
list.push(result);
map.set(result.provider, list);
}
return map;
}
export function sortProbeResults(results: AuthProbeResult[]): AuthProbeResult[] {
return results.slice().sort((a, b) => {
const provider = a.provider.localeCompare(b.provider);
if (provider !== 0) return provider;
const aLabel = a.label || a.profileId || "";
const bLabel = b.label || b.profileId || "";
return aLabel.localeCompare(bLabel);
});
}
export function describeProbeSummary(summary: AuthProbeSummary): string {
if (summary.totalTargets === 0) return "No probe targets.";
return `Probed ${summary.totalTargets} target${summary.totalTargets === 1 ? "" : "s"} in ${formatMs(summary.durationMs)}`;
}

View File

@ -11,9 +11,15 @@ import {
resolveProfileUnusableUntilForDisplay, resolveProfileUnusableUntilForDisplay,
} from "../../agents/auth-profiles.js"; } from "../../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../../agents/model-auth.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js";
import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js"; import {
buildModelAliasIndex,
parseModelRef,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
import { withProgressTotals } from "../../cli/progress.js";
import { import {
formatUsageWindowSummary, formatUsageWindowSummary,
loadProviderUsageSummary, loadProviderUsageSummary,
@ -22,17 +28,38 @@ import {
} from "../../infra/provider-usage.js"; } from "../../infra/provider-usage.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { colorize, theme } from "../../terminal/theme.js"; import { colorize, theme } from "../../terminal/theme.js";
import { renderTable } from "../../terminal/table.js";
import { formatCliCommand } from "../../cli/command-format.js"; import { formatCliCommand } from "../../cli/command-format.js";
import { shortenHomePath } from "../../utils.js"; import { shortenHomePath } from "../../utils.js";
import { resolveProviderAuthOverview } from "./list.auth-overview.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js";
import { isRich } from "./list.format.js"; import { isRich } from "./list.format.js";
import {
describeProbeSummary,
formatProbeLatency,
runAuthProbes,
sortProbeResults,
type AuthProbeSummary,
} from "./list.probe.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js";
export async function modelsStatusCommand( export async function modelsStatusCommand(
opts: { json?: boolean; plain?: boolean; check?: boolean }, opts: {
json?: boolean;
plain?: boolean;
check?: boolean;
probe?: boolean;
probeProvider?: string;
probeProfile?: string | string[];
probeTimeout?: string;
probeConcurrency?: string;
probeMaxTokens?: string;
},
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
ensureFlagCompatibility(opts); ensureFlagCompatibility(opts);
if (opts.plain && opts.probe) {
throw new Error("--probe cannot be used with --plain output.");
}
const cfg = loadConfig(); const cfg = loadConfig();
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({
cfg, cfg,
@ -139,6 +166,69 @@ export async function modelsStatusCommand(
.filter((provider) => !providerAuthMap.has(provider)) .filter((provider) => !providerAuthMap.has(provider))
.sort((a, b) => a.localeCompare(b)); .sort((a, b) => a.localeCompare(b));
const probeProfileIds = (() => {
if (!opts.probeProfile) return [];
const raw = Array.isArray(opts.probeProfile) ? opts.probeProfile : [opts.probeProfile];
return raw
.flatMap((value) => String(value ?? "").split(","))
.map((value) => value.trim())
.filter(Boolean);
})();
const probeTimeoutMs = opts.probeTimeout ? Number(opts.probeTimeout) : 8000;
if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs <= 0) {
throw new Error("--probe-timeout must be a positive number (ms).");
}
const probeConcurrency = opts.probeConcurrency ? Number(opts.probeConcurrency) : 2;
if (!Number.isFinite(probeConcurrency) || probeConcurrency <= 0) {
throw new Error("--probe-concurrency must be > 0.");
}
const probeMaxTokens = opts.probeMaxTokens ? Number(opts.probeMaxTokens) : 8;
if (!Number.isFinite(probeMaxTokens) || probeMaxTokens <= 0) {
throw new Error("--probe-max-tokens must be > 0.");
}
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER });
const rawCandidates = [
rawModel || resolvedLabel,
...fallbacks,
imageModel,
...imageFallbacks,
...allowed,
].filter(Boolean);
const resolvedCandidates = rawCandidates
.map(
(raw) =>
resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
aliasIndex,
})?.ref,
)
.filter((ref): ref is { provider: string; model: string } => Boolean(ref));
const modelCandidates = resolvedCandidates.map((ref) => `${ref.provider}/${ref.model}`);
let probeSummary: AuthProbeSummary | undefined;
if (opts.probe) {
probeSummary = await withProgressTotals(
{ label: "Probing auth profiles…", total: 1 },
async (update) => {
return await runAuthProbes({
cfg,
providers,
modelCandidates,
options: {
provider: opts.probeProvider,
profileIds: probeProfileIds,
timeoutMs: probeTimeoutMs,
concurrency: probeConcurrency,
maxTokens: probeMaxTokens,
},
onProgress: update,
});
},
);
}
const providersWithOauth = providerAuth const providersWithOauth = providerAuth
.filter( .filter(
(entry) => (entry) =>
@ -228,6 +318,7 @@ export async function modelsStatusCommand(
profiles: authHealth.profiles, profiles: authHealth.profiles,
providers: authHealth.providers, providers: authHealth.providers,
}, },
probes: probeSummary,
}, },
}, },
null, null,
@ -406,72 +497,118 @@ export async function modelsStatusCommand(
runtime.log(colorize(rich, theme.heading, "OAuth/token status")); runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
if (oauthProfiles.length === 0) { if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none")); runtime.log(colorize(rich, theme.muted, "- none"));
return; } else {
} const usageByProvider = new Map<string, string>();
const usageProviders = Array.from(
const usageByProvider = new Map<string, string>(); new Set(
const usageProviders = Array.from( oauthProfiles
new Set( .map((profile) => resolveUsageProviderId(profile.provider))
oauthProfiles .filter((provider): provider is UsageProviderId => Boolean(provider)),
.map((profile) => resolveUsageProviderId(profile.provider)) ),
.filter((provider): provider is UsageProviderId => Boolean(provider)), );
), if (usageProviders.length > 0) {
); try {
if (usageProviders.length > 0) { const usageSummary = await loadProviderUsageSummary({
try { providers: usageProviders,
const usageSummary = await loadProviderUsageSummary({ agentDir,
providers: usageProviders, timeoutMs: 3500,
agentDir,
timeoutMs: 3500,
});
for (const snapshot of usageSummary.providers) {
const formatted = formatUsageWindowSummary(snapshot, {
now: Date.now(),
maxWindows: 2,
includeResets: true,
}); });
if (formatted) { for (const snapshot of usageSummary.providers) {
usageByProvider.set(snapshot.provider, formatted); const formatted = formatUsageWindowSummary(snapshot, {
now: Date.now(),
maxWindows: 2,
includeResets: true,
});
if (formatted) {
usageByProvider.set(snapshot.provider, formatted);
}
} }
} catch {
// ignore usage failures
}
}
const formatStatus = (status: string) => {
if (status === "ok") return colorize(rich, theme.success, "ok");
if (status === "static") return colorize(rich, theme.muted, "static");
if (status === "expiring") return colorize(rich, theme.warn, "expiring");
if (status === "missing") return colorize(rich, theme.warn, "unknown");
return colorize(rich, theme.error, "expired");
};
const profilesByProvider = new Map<string, typeof oauthProfiles>();
for (const profile of oauthProfiles) {
const current = profilesByProvider.get(profile.provider);
if (current) current.push(profile);
else profilesByProvider.set(profile.provider, [profile]);
}
for (const [provider, profiles] of profilesByProvider) {
const usageKey = resolveUsageProviderId(provider);
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
for (const profile of profiles) {
const labelText = profile.label || profile.profileId;
const label = colorize(rich, theme.accent, labelText);
const status = formatStatus(profile.status);
const expiry =
profile.status === "static"
? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
runtime.log(` - ${label} ${status}${expiry}${source}`);
} }
} catch {
// ignore usage failures
} }
} }
const formatStatus = (status: string) => { if (probeSummary) {
if (status === "ok") return colorize(rich, theme.success, "ok"); runtime.log("");
if (status === "static") return colorize(rich, theme.muted, "static"); runtime.log(colorize(rich, theme.heading, "Auth probes"));
if (status === "expiring") return colorize(rich, theme.warn, "expiring"); if (probeSummary.results.length === 0) {
if (status === "missing") return colorize(rich, theme.warn, "unknown"); runtime.log(colorize(rich, theme.muted, "- none"));
return colorize(rich, theme.error, "expired"); } else {
}; const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const sorted = sortProbeResults(probeSummary.results);
const profilesByProvider = new Map<string, typeof oauthProfiles>(); const statusColor = (status: string) => {
for (const profile of oauthProfiles) { if (status === "ok") return theme.success;
const current = profilesByProvider.get(profile.provider); if (status === "rate_limit") return theme.warn;
if (current) current.push(profile); if (status === "timeout" || status === "billing") return theme.warn;
else profilesByProvider.set(profile.provider, [profile]); if (status === "auth" || status === "format") return theme.error;
} if (status === "no_model") return theme.muted;
return theme.muted;
for (const [provider, profiles] of profilesByProvider) { };
const usageKey = resolveUsageProviderId(provider); const rows = sorted.map((result) => {
const usage = usageKey ? usageByProvider.get(usageKey) : undefined; const status = colorize(rich, statusColor(result.status), result.status);
const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; const latency = formatProbeLatency(result.latencyMs);
runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); const detail = result.error ? colorize(rich, theme.muted, result.error) : "";
for (const profile of profiles) { const modelLabel = result.model ?? `${result.provider}/-`;
const labelText = profile.label || profile.profileId; const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : "";
const label = colorize(rich, theme.accent, labelText); const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`;
const status = formatStatus(profile.status); const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}`;
const expiry = return {
profile.status === "static" Model: colorize(rich, theme.heading, modelLabel),
? "" Profile: profile,
: profile.expiresAt Status: statusLabel,
? ` expires in ${formatRemainingShort(profile.remainingMs)}` Detail: detail,
: " expires unknown"; };
const source = });
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; runtime.log(
runtime.log(` - ${label} ${status}${expiry}${source}`); renderTable({
width: tableWidth,
columns: [
{ key: "Model", header: "Model", minWidth: 18 },
{ key: "Profile", header: "Profile", minWidth: 24 },
{ key: "Status", header: "Status", minWidth: 12 },
{ key: "Detail", header: "Detail", minWidth: 16, flex: true },
],
rows,
}).trimEnd(),
);
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
} }
} }

View File

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
const dispatchMock = vi.fn(); const dispatchMock = vi.fn();
@ -20,15 +21,34 @@ vi.mock("@buape/carbon", () => ({
}, },
})); }));
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
})); return {
...actual,
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
};
});
beforeEach(() => { beforeEach(() => {
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatchMock.mockReset().mockImplementation(async (params) => {
dispatcher.sendToolResult({ text: "tool update" }); if ("dispatcher" in params && params.dispatcher) {
dispatcher.sendFinalReply({ text: "final reply" }); params.dispatcher.sendToolResult({ text: "tool update" });
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } }; params.dispatcher.sendFinalReply({ text: "final reply" });
return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
}
if ("dispatcherOptions" in params && params.dispatcherOptions) {
const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
dispatcher.sendToolResult({ text: "tool update" });
dispatcher.sendFinalReply({ text: "final reply" });
await dispatcher.waitForIdle();
markDispatchIdle();
return { queuedFinal: true, counts: dispatcher.getQueuedCounts() };
}
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
}); });
}); });

View File

@ -377,12 +377,63 @@ describe("discord mention gating", () => {
resolveDiscordShouldRequireMention({ resolveDiscordShouldRequireMention({
isGuildMessage: true, isGuildMessage: true,
isThread: true, isThread: true,
botId: "bot123",
threadOwnerId: "bot123",
channelConfig, channelConfig,
guildInfo, guildInfo,
}), }),
).toBe(false); ).toBe(false);
}); });
it("requires mention inside user-created threads with autoThread enabled", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
threadOwnerId: "user456",
channelConfig,
guildInfo,
}),
).toBe(true);
});
it("requires mention when thread owner is unknown", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
botId: "bot123",
channelConfig,
guildInfo,
}),
).toBe(true);
});
it("inherits parent channel mention rules for threads", () => { it("inherits parent channel mention rules for threads", () => {
const guildInfo: DiscordGuildEntryResolved = { const guildInfo: DiscordGuildEntryResolved = {
requireMention: true, requireMention: true,

View File

@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({
reactMock(...args); reactMock(...args);
}, },
})); }));
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
})); return {
...actual,
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
};
});
vi.mock("../pairing/pairing-store.js", () => ({ vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
@ -41,7 +47,7 @@ beforeEach(() => {
updateLastRouteMock.mockReset(); updateLastRouteMock.mockReset();
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
dispatcher.sendFinalReply({ text: "hi" }); dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } }; return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
}); });
readAllowFromStoreMock.mockReset().mockResolvedValue([]); readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });

View File

@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({
reactMock(...args); reactMock(...args);
}, },
})); }));
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
})); return {
...actual,
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
};
});
vi.mock("../pairing/pairing-store.js", () => ({ vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
@ -40,7 +46,7 @@ beforeEach(() => {
updateLastRouteMock.mockReset(); updateLastRouteMock.mockReset();
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
dispatcher.sendFinalReply({ text: "hi" }); dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } }; return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
}); });
readAllowFromStoreMock.mockReset().mockResolvedValue([]); readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });

View File

@ -282,14 +282,33 @@ export function resolveDiscordChannelConfigWithFallback(params: {
export function resolveDiscordShouldRequireMention(params: { export function resolveDiscordShouldRequireMention(params: {
isGuildMessage: boolean; isGuildMessage: boolean;
isThread: boolean; isThread: boolean;
botId?: string | null;
threadOwnerId?: string | null;
channelConfig?: DiscordChannelConfigResolved | null; channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null; guildInfo?: DiscordGuildEntryResolved | null;
/** Pass pre-computed value to avoid redundant checks. */
isAutoThreadOwnedByBot?: boolean;
}): boolean { }): boolean {
if (!params.isGuildMessage) return false; if (!params.isGuildMessage) return false;
if (params.isThread && params.channelConfig?.autoThread) return false; // Only skip mention requirement in threads created by the bot (when autoThread is enabled).
const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params);
if (isBotThread) return false;
return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true; return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true;
} }
export function isDiscordAutoThreadOwnedByBot(params: {
isThread: boolean;
channelConfig?: DiscordChannelConfigResolved | null;
botId?: string | null;
threadOwnerId?: string | null;
}): boolean {
if (!params.isThread) return false;
if (!params.channelConfig?.autoThread) return false;
const botId = params.botId?.trim();
const threadOwnerId = params.threadOwnerId?.trim();
return Boolean(botId && threadOwnerId && botId === threadOwnerId);
}
export function isDiscordGroupAllowedByPolicy(params: { export function isDiscordGroupAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist"; groupPolicy: "open" | "disabled" | "allowlist";
guildAllowlisted: boolean; guildAllowlisted: boolean;

View File

@ -9,17 +9,24 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont
let capturedCtx: MsgContext | undefined; let capturedCtx: MsgContext | undefined;
vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => {
dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => { const actual = await importOriginal<typeof import("../../auto-reply/dispatch.js")>();
const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => {
capturedCtx = params.ctx; capturedCtx = params.ctx;
return { queuedFinal: false, counts: { tool: 0, block: 0 } }; return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
}), });
})); return {
...actual,
dispatchInboundMessage,
dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
};
});
import { processDiscordMessage } from "./message-handler.process.js"; import { processDiscordMessage } from "./message-handler.process.js";
describe("discord processDiscordMessage inbound contract", () => { describe("discord processDiscordMessage inbound contract", () => {
it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => { it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
capturedCtx = undefined; capturedCtx = undefined;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));

View File

@ -2,7 +2,10 @@ import { ChannelType, MessageType, type User } from "@buape/carbon";
import { hasControlCommand } from "../../auto-reply/command-detection.js"; import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
import { recordPendingHistoryEntry, type HistoryEntry } from "../../auto-reply/reply/history.js"; import {
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js"; import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { recordChannelActivity } from "../../infra/channel-activity.js"; import { recordChannelActivity } from "../../infra/channel-activity.js";
@ -18,6 +21,7 @@ import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js"; import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
import { sendMessageDiscord } from "../send.js"; import { sendMessageDiscord } from "../send.js";
import { resolveControlCommandGate } from "../../channels/command-gating.js"; import { resolveControlCommandGate } from "../../channels/command-gating.js";
import { logInboundDrop } from "../../channels/logging.js";
import { import {
allowListMatches, allowListMatches,
isDiscordGroupAllowedByPolicy, isDiscordGroupAllowedByPolicy,
@ -328,9 +332,12 @@ export async function preflightDiscordMessage(
} satisfies HistoryEntry) } satisfies HistoryEntry)
: undefined; : undefined;
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
const shouldRequireMention = resolveDiscordShouldRequireMention({ const shouldRequireMention = resolveDiscordShouldRequireMention({
isGuildMessage, isGuildMessage,
isThread: Boolean(threadChannel), isThread: Boolean(threadChannel),
botId,
threadOwnerId,
channelConfig, channelConfig,
guildInfo, guildInfo,
}); });
@ -379,7 +386,12 @@ export async function preflightDiscordMessage(
commandAuthorized = commandGate.commandAuthorized; commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) { if (commandGate.shouldBlock) {
logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`); logInboundDrop({
log: logVerbose,
channel: "discord",
reason: "control command (unauthorized)",
target: author.id,
});
return null; return null;
} }
} }
@ -407,14 +419,12 @@ export async function preflightDiscordMessage(
}, },
"discord: skipping guild message", "discord: skipping guild message",
); );
if (historyEntry && params.historyLimit > 0) { recordPendingHistoryEntryIfEnabled({
recordPendingHistoryEntry({ historyMap: params.guildHistories,
historyMap: params.guildHistories, historyKey: message.channelId,
historyKey: message.channelId, limit: params.historyLimit,
limit: params.historyLimit, entry: historyEntry ?? null,
entry: historyEntry, });
});
}
return null; return null;
} }
} }

View File

@ -0,0 +1,123 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const reactMessageDiscord = vi.fn(async () => {});
const removeReactionDiscord = vi.fn(async () => {});
vi.mock("../send.js", () => ({
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
}));
vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: vi.fn(async () => ({
queuedFinal: false,
counts: { final: 0, tool: 0, block: 0 },
})),
}));
vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({
createReplyDispatcherWithTyping: vi.fn(() => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
})),
}));
import { processDiscordMessage } from "./message-handler.process.js";
async function createBaseContext(overrides: Record<string, unknown> = {}) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));
const storePath = path.join(dir, "sessions.json");
return {
cfg: { messages: { ackReaction: "👀" }, session: { store: storePath } },
discordConfig: {},
accountId: "default",
token: "token",
runtime: { log: () => {}, error: () => {} },
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1024,
textLimit: 4000,
replyToMode: "off",
ackReactionScope: "group-mentions",
groupPolicy: "open",
data: { guild: { id: "g1", name: "Guild" } },
client: { rest: {} },
message: {
id: "m1",
channelId: "c1",
timestamp: new Date().toISOString(),
attachments: [],
},
author: {
id: "U1",
username: "alice",
discriminator: "0",
globalName: "Alice",
},
channelInfo: { name: "general" },
channelName: "general",
isGuildMessage: true,
isDirectMessage: false,
isGroupDm: false,
commandAuthorized: true,
baseText: "hi",
messageText: "hi",
wasMentioned: false,
shouldRequireMention: true,
canDetectMention: true,
effectiveWasMentioned: true,
shouldBypassMention: false,
threadChannel: null,
threadParentId: undefined,
threadParentName: undefined,
threadParentType: undefined,
threadName: undefined,
displayChannelSlug: "general",
guildInfo: null,
guildSlug: "guild",
channelConfig: null,
baseSessionKey: "agent:main:discord:guild:g1",
route: {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: "agent:main:discord:guild:g1",
mainSessionKey: "agent:main:main",
},
...overrides,
};
}
beforeEach(() => {
reactMessageDiscord.mockClear();
removeReactionDiscord.mockClear();
});
describe("processDiscordMessage ack reactions", () => {
it("skips ack reactions for group-mentions when mentions are not required", async () => {
const ctx = await createBaseContext({
shouldRequireMention: false,
effectiveWasMentioned: false,
});
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).not.toHaveBeenCalled();
});
it("sends ack reactions for mention-gated guild messages when mentioned", async () => {
const ctx = await createBaseContext({
shouldRequireMention: true,
effectiveWasMentioned: true,
});
await processDiscordMessage(ctx as any);
expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} });
});
});

View File

@ -1,32 +1,26 @@
import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
import { import {
resolveAckReaction, removeAckReactionAfterReply,
resolveEffectiveMessagesConfig, shouldAckReaction as shouldAckReactionGate,
resolveHumanDelayConfig, } from "../../channels/ack-reactions.js";
resolveIdentityName, import { logTypingFailure, logAckFailure } from "../../channels/logging.js";
} from "../../agents/identity.js"; import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
import { import { createTypingCallbacks } from "../../channels/typing.js";
extractShortModelName,
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
import { import {
formatInboundEnvelope, formatInboundEnvelope,
formatThreadStarterEnvelope, formatThreadStarterEnvelope,
resolveEnvelopeFormatOptions, resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js"; } from "../../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntriesIfEnabled,
} from "../../auto-reply/reply/history.js"; } from "../../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import { import { recordInboundSession } from "../../channels/session.js";
readSessionUpdatedAt, import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
recordSessionMetaFromInbound,
resolveStorePath,
updateLastRoute,
} from "../../config/sessions.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js";
@ -73,6 +67,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
shouldRequireMention, shouldRequireMention,
canDetectMention, canDetectMention,
effectiveWasMentioned, effectiveWasMentioned,
shouldBypassMention,
threadChannel, threadChannel,
threadParentId, threadParentId,
threadParentName, threadParentName,
@ -95,20 +90,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
} }
const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => { const shouldAckReaction = () =>
if (!ackReaction) return false; Boolean(
if (ackReactionScope === "all") return true; ackReaction &&
if (ackReactionScope === "direct") return isDirectMessage; shouldAckReactionGate({
const isGroupChat = isGuildMessage || isGroupDm; scope: ackReactionScope,
if (ackReactionScope === "group-all") return isGroupChat; isDirect: isDirectMessage,
if (ackReactionScope === "group-mentions") { isGroup: isGuildMessage || isGroupDm,
if (!isGuildMessage) return false; isMentionableGroup: isGuildMessage,
if (!shouldRequireMention) return false; requireMention: Boolean(shouldRequireMention),
if (!canDetectMention) return false; canDetectMention,
return effectiveWasMentioned; effectiveWasMentioned,
} shouldBypassMention,
return false; }),
}; );
const ackReactionPromise = shouldAckReaction() const ackReactionPromise = shouldAckReaction()
? reactMessageDiscord(message.channelId, message.id, ackReaction, { ? reactMessageDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest, rest: client.rest,
@ -288,27 +283,23 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget, OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
}); });
void recordSessionMetaFromInbound({ await recordInboundSession({
storePath, storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey, sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload, ctx: ctxPayload,
}).catch((err) => { updateLastRoute: isDirectMessage
logVerbose(`discord: failed updating session meta: ${String(err)}`); ? {
sessionKey: route.mainSessionKey,
channel: "discord",
to: `user:${author.id}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);
},
}); });
if (isDirectMessage) {
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "discord",
to: `user:${author.id}`,
accountId: route.accountId,
},
ctx: ctxPayload,
});
}
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n"); const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose( logVerbose(
@ -320,10 +311,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
? deliverTarget.slice("channel:".length) ? deliverTarget.slice("channel:".length)
: message.channelId; : message.channelId;
// Create mutable context for response prefix template interpolation const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const tableMode = resolveMarkdownTableMode({ const tableMode = resolveMarkdownTableMode({
cfg, cfg,
channel: "discord", channel: "discord",
@ -331,8 +319,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}); });
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: () => prefixContext, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId), humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => { deliver: async (payload: ReplyPayload) => {
const replyToId = replyReference.use(); const replyToId = replyReference.use();
@ -353,10 +341,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`)); runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
}, },
onReplyStart: () => sendTyping({ client, channelId: typingChannelId }), onReplyStart: createTypingCallbacks({
start: () => sendTyping({ client, channelId: typingChannelId }),
onStartError: (err) => {
logTypingFailure({
log: logVerbose,
channel: "discord",
target: typingChannelId,
error: err,
});
},
}).onReplyStart,
}); });
const { queuedFinal, counts } = await dispatchReplyFromConfig({ const { queuedFinal, counts } = await dispatchInboundMessage({
ctx: ctxPayload, ctx: ctxPayload,
cfg, cfg,
dispatcher, dispatcher,
@ -368,20 +366,17 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
? !discordConfig.blockStreaming ? !discordConfig.blockStreaming
: undefined, : undefined,
onModelSelected: (ctx) => { onModelSelected: (ctx) => {
// Mutate the object directly instead of reassigning to ensure the closure sees updates prefixContext.onModelSelected(ctx);
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
}, },
}, },
}); });
markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) { if (!queuedFinal) {
if (isGuildMessage && historyLimit > 0) { if (isGuildMessage) {
clearHistoryEntries({ clearHistoryEntriesIfEnabled({
historyMap: guildHistories, historyMap: guildHistories,
historyKey: message.channelId, historyKey: message.channelId,
limit: historyLimit,
}); });
} }
return; return;
@ -392,23 +387,29 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, `discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
); );
} }
if (removeAckAfterReply && ackReactionPromise && ackReaction) { removeAckReactionAfterReply({
const ackReactionValue = ackReaction; removeAfterReply: removeAckAfterReply,
void ackReactionPromise.then((didAck) => { ackReactionPromise,
if (!didAck) return; ackReactionValue: ackReaction,
removeReactionDiscord(message.channelId, message.id, ackReactionValue, { remove: async () => {
await removeReactionDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest, rest: client.rest,
}).catch((err) => {
logVerbose(
`discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
);
}); });
}); },
} onError: (err) => {
if (isGuildMessage && historyLimit > 0) { logAckFailure({
clearHistoryEntries({ log: logVerbose,
channel: "discord",
target: `${message.channelId}/${message.id}`,
error: err,
});
},
});
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories, historyMap: guildHistories,
historyKey: message.channelId, historyKey: message.channelId,
limit: historyLimit,
}); });
} }
} }

View File

@ -16,6 +16,7 @@ export type DiscordChannelInfo = {
name?: string; name?: string;
topic?: string; topic?: string;
parentId?: string; parentId?: string;
ownerId?: string;
}; };
type DiscordSnapshotAuthor = { type DiscordSnapshotAuthor = {
@ -69,11 +70,13 @@ export async function resolveDiscordChannelInfo(
const name = "name" in channel ? (channel.name ?? undefined) : undefined; const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined;
const payload: DiscordChannelInfo = { const payload: DiscordChannelInfo = {
type: channel.type, type: channel.type,
name, name,
topic, topic,
parentId, parentId,
ownerId,
}; };
DISCORD_CHANNEL_INFO_CACHE.set(channelId, { DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload, value: payload,

Some files were not shown because too many files have changed in this diff Show More