Merge branch 'main' into feature/add-tlon-plugin
This commit is contained in:
commit
c69111a4e6
18
AGENTS.md
18
AGENTS.md
@ -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.
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@ -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
|
||||||
|
|
||||||
|
|||||||
41
README.md
41
README.md
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 it’s 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)
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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"],
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) });
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
107
src/agents/compaction.test.ts
Normal file
107
src/agents/compaction.test.ts
Normal 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
341
src/agents/compaction.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -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...`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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`);
|
||||||
|
|||||||
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/auto-reply/dispatch.ts
Normal file
77
src/auto-reply/dispatch.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
269
src/channels/ack-reactions.test.ts
Normal file
269
src/channels/ack-reactions.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/channels/ack-reactions.ts
Normal file
71
src/channels/ack-reactions.ts
Normal 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
33
src/channels/logging.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
41
src/channels/reply-prefix.ts
Normal file
41
src/channels/reply-prefix.ts
Normal 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
49
src/channels/session.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
42
src/channels/typing.test.ts
Normal file
42
src/channels/typing.test.ts
Normal 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
28
src/channels/typing.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
414
src/commands/models/list.probe.ts
Normal file
414
src/commands/models/list.probe.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 } };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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-"));
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
123
src/discord/monitor/message-handler.process.test.ts
Normal file
123
src/discord/monitor/message-handler.process.test.ts
Normal 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: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user