Compare commits
103 Commits
fix/memory
...
patch-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
300d2c9339 | ||
|
|
cae7e3451f | ||
|
|
39d8e9be0f | ||
|
|
ac45c8b404 | ||
|
|
fa746b05de | ||
|
|
49c518951c | ||
|
|
0dca8acbe2 | ||
|
|
c42e9b1d19 | ||
|
|
298901208d | ||
|
|
ef9ba66798 | ||
|
|
4b6cdd1d3c | ||
|
|
eaeb52f70a | ||
|
|
be1cdc9370 | ||
|
|
8002143d92 | ||
|
|
4a9123d415 | ||
|
|
dbf139d14e | ||
|
|
d905ca0e02 | ||
|
|
ab000398be | ||
|
|
1bbbb10abf | ||
|
|
c02204fd1e | ||
|
|
5482803547 | ||
|
|
3dcaa70531 | ||
|
|
a6ddd82a14 | ||
|
|
585e20b72e | ||
|
|
d8a6317dfc | ||
|
|
c8c58c0537 | ||
|
|
cfdd5a8c2e | ||
|
|
6765fd15eb | ||
|
|
4074fa0471 | ||
|
|
ea2ccd8ae6 | ||
|
|
b1ac7e0501 | ||
|
|
b4a2dc81a2 | ||
|
|
d73e8ecca3 | ||
|
|
faa90fc206 | ||
|
|
f1083cd52c | ||
|
|
7f7550e53c | ||
|
|
d4d17025cf | ||
|
|
7b76db2841 | ||
|
|
f9cf508cff | ||
|
|
9b12275fe1 | ||
|
|
f70ac0c7c2 | ||
|
|
09a72f1ede | ||
|
|
2b8b3c4b10 | ||
|
|
8ea8801d06 | ||
|
|
c97bf23a4a | ||
|
|
3fff943ba1 | ||
|
|
90685ef814 | ||
|
|
a8f2ac5411 | ||
|
|
dea96a2c3d | ||
|
|
90ae2f541c | ||
|
|
d9a467fe3b | ||
|
|
aef88cd9f1 | ||
|
|
104d977d12 | ||
|
|
4b24753be7 | ||
|
|
df09e583aa | ||
|
|
46e6546bb9 | ||
|
|
5428c97685 | ||
|
|
202d7af855 | ||
|
|
72020b37c3 | ||
|
|
b051621bd4 | ||
|
|
ff52aec38e | ||
|
|
15620b1092 | ||
|
|
ad7fc4964a | ||
|
|
8f4426052c | ||
|
|
6a60d47c53 | ||
|
|
b1482957f5 | ||
|
|
4d2e9e8113 | ||
|
|
72d62a54c6 | ||
|
|
ae48066d28 | ||
|
|
f56f799990 | ||
|
|
7e498ab94a | ||
|
|
6bd6ae41b1 | ||
|
|
f648aae440 | ||
|
|
b56587f26e | ||
|
|
4ee808dbcb | ||
|
|
66eec295b8 | ||
|
|
675019cb6f | ||
|
|
795b592286 | ||
|
|
9d98e55ed5 | ||
|
|
c07949a99c | ||
|
|
e51bf46abe | ||
|
|
eba0625a70 | ||
|
|
886752217d | ||
|
|
5662a9cdfc | ||
|
|
fd23b9b209 | ||
|
|
975f5a5284 | ||
|
|
63176ccb8a | ||
|
|
6c3a9fc092 | ||
|
|
d9f173a03d | ||
|
|
c3cb26f7ca | ||
|
|
dd06028827 | ||
|
|
71203829d8 | ||
|
|
dfa80e1e5d | ||
|
|
951a4ea065 | ||
|
|
4fa1517e6d | ||
|
|
de2d986008 | ||
|
|
d57cb2e1a8 | ||
|
|
b697374ce5 | ||
|
|
b9106ba5f9 | ||
|
|
3ba9821254 | ||
|
|
17f2a990a8 | ||
|
|
71f7bd1cfd | ||
|
|
c4c01089ab |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -4,22 +4,52 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.23 (Unreleased)
|
||||
|
||||
### Highlights
|
||||
- TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).
|
||||
|
||||
### Changes
|
||||
- Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.
|
||||
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
|
||||
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
|
||||
- Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.
|
||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||
- Docs: add emoji reaction guidance to AGENTS.md template. (#1591) Thanks @EnzeD.
|
||||
- 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.
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||
- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.
|
||||
|
||||
### Fixes
|
||||
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
|
||||
- Routing/Cron: normalize agentId casing for bindings and cron payloads. (#1591)
|
||||
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||
- Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)
|
||||
- Sessions: normalize session key casing to lowercase for consistent routing.
|
||||
- BlueBubbles: normalize group session keys for outbound mirroring. (#1520)
|
||||
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.
|
||||
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).
|
||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||
@@ -32,15 +62,21 @@ Docs: https://docs.clawd.bot
|
||||
- CLI: explain when auth profiles are excluded by auth.order in probe details.
|
||||
- CLI: drop the em dash when the banner tagline wraps to a second line.
|
||||
- CLI: inline auth probe errors in status rows to reduce wrapping.
|
||||
- Telegram: render markdown in media captions. (#1478)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
|
||||
- Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)
|
||||
- Daemon: use platform PATH delimiters when building minimal service paths.
|
||||
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
|
||||
- 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: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
|
||||
- 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)
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
|
||||
- MS Teams (plugin): remove `.default` suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.
|
||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
|
||||
49
README.md
49
README.md
@@ -479,28 +479,29 @@ Thanks to all clawtributors:
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
|
||||
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
|
||||
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
|
||||
<a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||
<a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a>
|
||||
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a>
|
||||
<a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
||||
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a>
|
||||
<a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/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/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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a>
|
||||
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
|
||||
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
|
||||
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a>
|
||||
<a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
|
||||
<a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/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/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/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/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a>
|
||||
<a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a>
|
||||
<a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/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/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
|
||||
<a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
||||
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
|
||||
<a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
|
||||
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
|
||||
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a>
|
||||
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
|
||||
<a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
|
||||
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
|
||||
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a>
|
||||
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
||||
<a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
|
||||
<a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
||||
<a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
|
||||
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
|
||||
<a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
|
||||
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
|
||||
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
|
||||
@@ -385,6 +385,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -395,6 +396,7 @@ public struct SendParams: Codable, Sendable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -404,6 +406,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -414,6 +417,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
@@ -478,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let groupid: String?
|
||||
public let groupchannel: String?
|
||||
public let groupspace: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -500,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
groupid: String?,
|
||||
groupchannel: String?,
|
||||
groupspace: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -521,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.groupid = groupid
|
||||
self.groupchannel = groupchannel
|
||||
self.groupspace = groupspace
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -543,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case groupid = "groupId"
|
||||
case groupchannel = "groupChannel"
|
||||
case groupspace = "groupSpace"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -948,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let sessionid: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
@@ -956,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
sessionid: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
@@ -963,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
@@ -971,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
|
||||
@@ -385,6 +385,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -395,6 +396,7 @@ public struct SendParams: Codable, Sendable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -404,6 +406,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -414,6 +417,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
@@ -478,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let groupid: String?
|
||||
public let groupchannel: String?
|
||||
public let groupspace: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -500,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
groupid: String?,
|
||||
groupchannel: String?,
|
||||
groupspace: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -521,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.groupid = groupid
|
||||
self.groupchannel = groupchannel
|
||||
self.groupspace = groupspace
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -543,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case groupid = "groupId"
|
||||
case groupchannel = "groupChannel"
|
||||
case groupspace = "groupSpace"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -948,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let sessionid: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
@@ -956,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
sessionid: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
@@ -963,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
@@ -971,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway-daemon",
|
||||
"gateway",
|
||||
"--bind",
|
||||
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
||||
"--port",
|
||||
|
||||
@@ -263,15 +263,15 @@ Run history:
|
||||
clawdbot cron runs --id <jobId> --limit 50
|
||||
```
|
||||
|
||||
Immediate wake without creating a job:
|
||||
Immediate system event without creating a job:
|
||||
```bash
|
||||
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
||||
clawdbot system event --mode now --text "Next heartbeat: check battery."
|
||||
```
|
||||
|
||||
## Gateway API surface
|
||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||
- `cron.run` (force or due), `cron.runs`
|
||||
- `wake` (enqueue system event + optional heartbeat)
|
||||
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -271,4 +271,4 @@ clawdbot cron add \
|
||||
|
||||
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
|
||||
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
|
||||
- [Wake](/cli/wake) - manual wake command
|
||||
- [System](/cli/system) - system events + heartbeat controls
|
||||
|
||||
@@ -44,6 +44,7 @@ clawdbot channels logout --channel whatsapp
|
||||
|
||||
- Run `clawdbot status --deep` for a broad probe.
|
||||
- Use `clawdbot doctor` for guided fixes.
|
||||
- `clawdbot channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude Code CLI.
|
||||
|
||||
## Capabilities probe
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`sessions`](/cli/sessions)
|
||||
- [`gateway`](/cli/gateway)
|
||||
- [`logs`](/cli/logs)
|
||||
- [`system`](/cli/system)
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`nodes`](/cli/nodes)
|
||||
@@ -38,7 +39,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`sandbox`](/cli/sandbox)
|
||||
- [`tui`](/cli/tui)
|
||||
- [`browser`](/cli/browser)
|
||||
- [`wake`](/cli/wake)
|
||||
- [`cron`](/cli/cron)
|
||||
- [`dns`](/cli/dns)
|
||||
- [`docs`](/cli/docs)
|
||||
@@ -145,6 +145,10 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
restart
|
||||
run
|
||||
logs
|
||||
system
|
||||
event
|
||||
heartbeat last|enable|disable
|
||||
presence
|
||||
models
|
||||
list
|
||||
status
|
||||
@@ -160,7 +164,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
list
|
||||
recreate
|
||||
explain
|
||||
wake
|
||||
cron
|
||||
status
|
||||
list
|
||||
@@ -763,9 +766,9 @@ Options:
|
||||
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
|
||||
- `clear`: `--provider <name>`, `--agent <id>`
|
||||
|
||||
## Cron + wake
|
||||
## System
|
||||
|
||||
### `wake`
|
||||
### `system event`
|
||||
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
|
||||
|
||||
Required:
|
||||
@@ -776,7 +779,21 @@ Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
### `cron`
|
||||
### `system heartbeat last|enable|disable`
|
||||
Heartbeat controls (Gateway RPC).
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
### `system presence`
|
||||
List system presence entries (Gateway RPC).
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
## Cron
|
||||
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
|
||||
|
||||
Subcommands:
|
||||
|
||||
@@ -23,6 +23,24 @@ Common use cases:
|
||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||
node host, so you can keep command access scoped and explicit.
|
||||
|
||||
## Browser proxy (zero-config)
|
||||
|
||||
Node hosts automatically advertise a browser proxy if `browser.enabled` is not
|
||||
disabled on the node. This lets the agent use browser automation on that node
|
||||
without extra configuration.
|
||||
|
||||
Disable it on the node if needed:
|
||||
|
||||
```json5
|
||||
{
|
||||
nodeHost: {
|
||||
browserProxy: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Run (foreground)
|
||||
|
||||
```bash
|
||||
|
||||
55
docs/cli/system.md
Normal file
55
docs/cli/system.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot system` (system events, heartbeat, presence)"
|
||||
read_when:
|
||||
- You want to enqueue a system event without creating a cron job
|
||||
- You need to enable or disable heartbeats
|
||||
- You want to inspect system presence entries
|
||||
---
|
||||
|
||||
# `clawdbot system`
|
||||
|
||||
System-level helpers for the Gateway: enqueue system events, control heartbeats,
|
||||
and view presence.
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
clawdbot system event --text "Check for urgent follow-ups" --mode now
|
||||
clawdbot system heartbeat enable
|
||||
clawdbot system heartbeat last
|
||||
clawdbot system presence
|
||||
```
|
||||
|
||||
## `system event`
|
||||
|
||||
Enqueue a system event on the **main** session. The next heartbeat will inject
|
||||
it as a `System:` line in the prompt. Use `--mode now` to trigger the heartbeat
|
||||
immediately; `next-heartbeat` waits for the next scheduled tick.
|
||||
|
||||
Flags:
|
||||
- `--text <text>`: required system event text.
|
||||
- `--mode <mode>`: `now` or `next-heartbeat` (default).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## `system heartbeat last|enable|disable`
|
||||
|
||||
Heartbeat controls:
|
||||
- `last`: show the last heartbeat event.
|
||||
- `enable`: turn heartbeats back on (use this if they were disabled).
|
||||
- `disable`: pause heartbeats.
|
||||
|
||||
Flags:
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## `system presence`
|
||||
|
||||
List the current system presence entries the Gateway knows about (nodes,
|
||||
instances, and similar status lines).
|
||||
|
||||
Flags:
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires a running Gateway reachable by your current config (local or remote).
|
||||
- System events are ephemeral and not persisted across restarts.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot wake` (enqueue a system event and optionally trigger an immediate heartbeat)"
|
||||
read_when:
|
||||
- You want to “poke” a running Gateway to process a system event
|
||||
- You use `wake` with cron jobs or remote nodes
|
||||
---
|
||||
|
||||
# `clawdbot wake`
|
||||
|
||||
Enqueue a system event on the Gateway and optionally trigger an immediate heartbeat.
|
||||
|
||||
This is a lightweight “poke” for automation flows where you don’t want to run a full command, but you do want the Gateway to react quickly.
|
||||
|
||||
Related:
|
||||
- Cron jobs: [Cron](/cli/cron)
|
||||
- Gateway heartbeat: [Heartbeat](/gateway/heartbeat)
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
clawdbot wake --text "sync"
|
||||
clawdbot wake --text "sync" --mode now
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
- `--text <text>`: system event text.
|
||||
- `--mode <mode>`: `now` or `next-heartbeat` (default).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires a running Gateway reachable by your current config (local or remote).
|
||||
- If you’re using sandboxing, `wake` still targets the Gateway; sandboxing does not block the command itself.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
summary: "Alias for compaction docs"
|
||||
read_when:
|
||||
- You looked for /compaction; canonical doc lives in /concepts/compaction
|
||||
---
|
||||
# Compaction
|
||||
|
||||
Canonical compaction docs live in [Compaction](/concepts/compaction).
|
||||
@@ -56,19 +56,20 @@ Row shape (JSON):
|
||||
Fetch transcript for one session.
|
||||
|
||||
Parameters:
|
||||
- `sessionKey` (required)
|
||||
- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`)
|
||||
- `limit?: number` max messages (server clamps)
|
||||
- `includeTools?: boolean` (default false)
|
||||
|
||||
Behavior:
|
||||
- `includeTools=false` filters `role: "toolResult"` messages.
|
||||
- Returns messages array in the raw transcript format.
|
||||
- When given a `sessionId`, Clawdbot resolves it to the corresponding session key (missing ids error).
|
||||
|
||||
## sessions_send
|
||||
Send a message into another session.
|
||||
|
||||
Parameters:
|
||||
- `sessionKey` (required)
|
||||
- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`)
|
||||
- `message` (required)
|
||||
- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget)
|
||||
|
||||
|
||||
@@ -66,12 +66,12 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
|
||||
|
||||
## Time handling
|
||||
|
||||
The system prompt includes a dedicated **Current Date & Time** section when user
|
||||
time or timezone is known. It is explicit about:
|
||||
The system prompt includes a dedicated **Current Date & Time** section when the
|
||||
user timezone is known. To keep the prompt cache-stable, it now only includes
|
||||
the **time zone** (no dynamic clock or time format).
|
||||
|
||||
- The user’s **local time** (already converted).
|
||||
- The **time zone** used for the conversion.
|
||||
- The **time format** (12-hour / 24-hour).
|
||||
Use `session_status` when the agent needs the current time; the status card
|
||||
includes a timestamp line.
|
||||
|
||||
Configure with:
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics.
|
||||
Clawdbot defaults to **host-local time for transport timestamps** and **user timezone only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics (current time is available via `session_status`).
|
||||
|
||||
## Message envelopes (local by default)
|
||||
|
||||
@@ -63,16 +63,16 @@ You can override this behavior:
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
If the user timezone or local time is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section:
|
||||
If the user timezone is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section with the **time zone only** (no clock/time format)
|
||||
to keep prompt caching stable:
|
||||
|
||||
```
|
||||
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
|
||||
Time format: 12-hour
|
||||
Time zone: America/Chicago
|
||||
```
|
||||
|
||||
If only the timezone is known, we still include the section and instruct the model
|
||||
to assume UTC for unknown time references.
|
||||
When the agent needs the current time, use the `session_status` tool; the status
|
||||
card includes a timestamp line.
|
||||
|
||||
## System event lines (local by default)
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/faq",
|
||||
"destination": "/start/faq"
|
||||
"destination": "/help/faq"
|
||||
},
|
||||
{
|
||||
"source": "/gateway-lock",
|
||||
@@ -449,10 +449,6 @@
|
||||
"source": "/location-command",
|
||||
"destination": "/nodes/location-command"
|
||||
},
|
||||
{
|
||||
"source": "/logging",
|
||||
"destination": "/gateway/logging"
|
||||
},
|
||||
{
|
||||
"source": "/lore",
|
||||
"destination": "/start/lore"
|
||||
@@ -761,21 +757,13 @@
|
||||
"source": "/wizard",
|
||||
"destination": "/start/wizard"
|
||||
},
|
||||
{
|
||||
"source": "/install/node",
|
||||
"destination": "/install#nodejs--npm-path-sanity"
|
||||
},
|
||||
{
|
||||
"source": "/install/node/",
|
||||
"destination": "/install#nodejs--npm-path-sanity"
|
||||
},
|
||||
{
|
||||
"source": "/start/faq",
|
||||
"destination": "/help"
|
||||
"destination": "/help/faq"
|
||||
},
|
||||
{
|
||||
"source": "/start/faq/",
|
||||
"destination": "/help"
|
||||
"destination": "/help/faq"
|
||||
},
|
||||
{
|
||||
"source": "/oauth",
|
||||
@@ -850,12 +838,12 @@
|
||||
"cli/memory",
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/system",
|
||||
"cli/nodes",
|
||||
"cli/approvals",
|
||||
"cli/gateway",
|
||||
"cli/tui",
|
||||
"cli/voicecall",
|
||||
"cli/wake",
|
||||
"cli/cron",
|
||||
"cli/dns",
|
||||
"cli/docs",
|
||||
@@ -916,6 +904,7 @@
|
||||
"gateway/configuration-examples",
|
||||
"gateway/authentication",
|
||||
"gateway/openai-http-api",
|
||||
"gateway/tools-invoke-http-api",
|
||||
"gateway/cli-backends",
|
||||
"gateway/local-models",
|
||||
"gateway/background-process",
|
||||
@@ -1045,6 +1034,7 @@
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
"platforms/linux",
|
||||
"platforms/fly",
|
||||
"platforms/hetzner",
|
||||
"platforms/exe-dev"
|
||||
]
|
||||
|
||||
@@ -74,5 +74,5 @@ See [Configuration: Env var substitution](/gateway/configuration#env-var-substit
|
||||
## Related
|
||||
|
||||
- [Gateway configuration](/gateway/configuration)
|
||||
- [FAQ: env vars and .env loading](/start/faq#env-vars-and-env-loading)
|
||||
- [FAQ: env vars and .env loading](/help/faq#env-vars-and-env-loading)
|
||||
- [Models overview](/concepts/models)
|
||||
|
||||
@@ -1446,6 +1446,65 @@ active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
|
||||
`removeAckAfterReply` removes the bot’s ack reaction after a reply is sent
|
||||
(Slack/Discord/Telegram only). Default: `false`.
|
||||
|
||||
#### `messages.tts`
|
||||
|
||||
Enable text-to-speech for outbound replies. When on, Clawdbot generates audio
|
||||
using ElevenLabs or OpenAI and attaches it to responses. Telegram uses Opus
|
||||
voice notes; other channels send MP3 audio.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
mode: "final", // final | all (include tool/block replies)
|
||||
provider: "elevenlabs",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
enabled: true
|
||||
},
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.clawdbot/settings/tts.json",
|
||||
elevenlabs: {
|
||||
apiKey: "elevenlabs_api_key",
|
||||
baseUrl: "https://api.elevenlabs.io",
|
||||
voiceId: "voice_id",
|
||||
modelId: "eleven_multilingual_v2",
|
||||
seed: 42,
|
||||
applyTextNormalization: "auto",
|
||||
languageCode: "en",
|
||||
voiceSettings: {
|
||||
stability: 0.5,
|
||||
similarityBoost: 0.75,
|
||||
style: 0.0,
|
||||
useSpeakerBoost: true,
|
||||
speed: 1.0
|
||||
}
|
||||
},
|
||||
openai: {
|
||||
apiKey: "openai_api_key",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
|
||||
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
||||
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
||||
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
||||
- `modelOverrides` enables model-driven overrides like `[[tts:...]]` tags (on by default).
|
||||
- `/tts limit` and `/tts summary` control per-user summarization settings.
|
||||
- `apiKey` values fall back to `ELEVENLABS_API_KEY`/`XI_API_KEY` and `OPENAI_API_KEY`.
|
||||
- `elevenlabs.baseUrl` overrides the ElevenLabs API base URL.
|
||||
- `elevenlabs.voiceSettings` supports `stability`/`similarityBoost`/`style` (0..1),
|
||||
`useSpeakerBoost`, and `speed` (0.5..2.0).
|
||||
|
||||
### `talk`
|
||||
|
||||
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
|
||||
|
||||
@@ -92,6 +92,14 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
}
|
||||
```
|
||||
|
||||
### Scope and precedence
|
||||
|
||||
- `agents.defaults.heartbeat` sets global heartbeat behavior.
|
||||
- `agents.list[].heartbeat` merges on top; if any agent has a `heartbeat` block, **only those agents** run heartbeats.
|
||||
- `channels.defaults.heartbeat` sets visibility defaults for all channels.
|
||||
- `channels.<channel>.heartbeat` overrides channel defaults.
|
||||
- `channels.<channel>.accounts.<id>.heartbeat` (multi-account channels) overrides per-channel settings.
|
||||
|
||||
### Per-agent heartbeats
|
||||
|
||||
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
|
||||
@@ -156,12 +164,78 @@ Example: two agents, only the second agent runs heartbeats.
|
||||
- Heartbeat-only replies do **not** keep the session alive; the last `updatedAt`
|
||||
is restored so idle expiry behaves normally.
|
||||
|
||||
## Visibility controls
|
||||
|
||||
By default, `HEARTBEAT_OK` acknowledgments are suppressed while alert content is
|
||||
delivered. You can adjust this per channel or per account:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
defaults:
|
||||
heartbeat:
|
||||
showOk: false # Hide HEARTBEAT_OK (default)
|
||||
showAlerts: true # Show alert messages (default)
|
||||
useIndicator: true # Emit indicator events (default)
|
||||
telegram:
|
||||
heartbeat:
|
||||
showOk: true # Show OK acknowledgments on Telegram
|
||||
whatsapp:
|
||||
accounts:
|
||||
work:
|
||||
heartbeat:
|
||||
showAlerts: false # Suppress alert delivery for this account
|
||||
```
|
||||
|
||||
Precedence: per-account → per-channel → channel defaults → built-in defaults.
|
||||
|
||||
### What each flag does
|
||||
|
||||
- `showOk`: sends a `HEARTBEAT_OK` acknowledgment when the model returns an OK-only reply.
|
||||
- `showAlerts`: sends the alert content when the model returns a non-OK reply.
|
||||
- `useIndicator`: emits indicator events for UI status surfaces.
|
||||
|
||||
If **all three** are false, Clawdbot skips the heartbeat run entirely (no model call).
|
||||
|
||||
### Per-channel vs per-account examples
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
defaults:
|
||||
heartbeat:
|
||||
showOk: false
|
||||
showAlerts: true
|
||||
useIndicator: true
|
||||
slack:
|
||||
heartbeat:
|
||||
showOk: true # all Slack accounts
|
||||
accounts:
|
||||
ops:
|
||||
heartbeat:
|
||||
showAlerts: false # suppress alerts for the ops account only
|
||||
telegram:
|
||||
heartbeat:
|
||||
showOk: true
|
||||
```
|
||||
|
||||
### Common patterns
|
||||
|
||||
| Goal | Config |
|
||||
| --- | --- |
|
||||
| Default behavior (silent OKs, alerts on) | *(no config needed)* |
|
||||
| Fully silent (no messages, no indicator) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: false }` |
|
||||
| Indicator-only (no messages) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: true }` |
|
||||
| OKs in one channel only | `channels.telegram.heartbeat: { showOk: true }` |
|
||||
|
||||
## HEARTBEAT.md (optional)
|
||||
|
||||
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
||||
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
||||
safe to include every 30 minutes.
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
||||
|
||||
Example `HEARTBEAT.md`:
|
||||
@@ -195,7 +269,7 @@ Safety note: don’t put secrets (API keys, phone numbers, private tokens) into
|
||||
You can enqueue a system event and trigger an immediate heartbeat with:
|
||||
|
||||
```bash
|
||||
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
||||
clawdbot system event --text "Check for urgent follow-ups" --mode now
|
||||
```
|
||||
|
||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
||||
|
||||
@@ -30,6 +30,7 @@ pnpm gateway:watch
|
||||
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
|
||||
- OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api).
|
||||
- OpenResponses (HTTP): [`/v1/responses`](/gateway/openresponses-http-api).
|
||||
- Tools Invoke (HTTP): [`/tools/invoke`](/gateway/tools-invoke-http-api).
|
||||
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdbot__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`.
|
||||
- Logs to stdout; use launchd/systemd to keep it alive and rotate logs.
|
||||
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
||||
|
||||
79
docs/gateway/tools-invoke-http-api.md
Normal file
79
docs/gateway/tools-invoke-http-api.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
summary: "Invoke a single tool directly via the Gateway HTTP endpoint"
|
||||
read_when:
|
||||
- Calling tools without running a full agent turn
|
||||
- Building automations that need tool policy enforcement
|
||||
---
|
||||
# Tools Invoke (HTTP)
|
||||
|
||||
Clawdbot’s Gateway exposes a simple HTTP endpoint for invoking a single tool directly. It is always enabled, but gated by Gateway auth and tool policy.
|
||||
|
||||
- `POST /tools/invoke`
|
||||
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/tools/invoke`
|
||||
|
||||
Default max payload size is 2 MB.
|
||||
|
||||
## Authentication
|
||||
|
||||
Uses the Gateway auth configuration. Send a bearer token:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
|
||||
Notes:
|
||||
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||
|
||||
## Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "sessions_list",
|
||||
"action": "json",
|
||||
"args": {},
|
||||
"sessionKey": "main",
|
||||
"dryRun": false
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `tool` (string, required): tool name to invoke.
|
||||
- `action` (string, optional): mapped into args if the tool schema supports `action` and the args payload omitted it.
|
||||
- `args` (object, optional): tool-specific arguments.
|
||||
- `sessionKey` (string, optional): target session key. If omitted or `"main"`, the Gateway uses the configured main session key (honors `session.mainKey` and default agent, or `global` in global scope).
|
||||
- `dryRun` (boolean, optional): reserved for future use; currently ignored.
|
||||
|
||||
## Policy + routing behavior
|
||||
|
||||
Tool availability is filtered through the same policy chain used by Gateway agents:
|
||||
- `tools.profile` / `tools.byProvider.profile`
|
||||
- `tools.allow` / `tools.byProvider.allow`
|
||||
- `agents.<id>.tools.allow` / `agents.<id>.tools.byProvider.allow`
|
||||
- group policies (if the session key maps to a group or channel)
|
||||
- subagent policy (when invoking with a subagent session key)
|
||||
|
||||
If a tool is not allowed by policy, the endpoint returns **404**.
|
||||
|
||||
To help group policies resolve context, you can optionally set:
|
||||
- `x-clawdbot-message-channel: <channel>` (example: `slack`, `telegram`)
|
||||
- `x-clawdbot-account-id: <accountId>` (when multiple accounts exist)
|
||||
|
||||
## Responses
|
||||
|
||||
- `200` → `{ ok: true, result }`
|
||||
- `400` → `{ ok: false, error: { type, message } }` (invalid request or tool error)
|
||||
- `401` → unauthorized
|
||||
- `404` → tool not available (not found or not allowlisted)
|
||||
- `405` → method not allowed
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:18789/tools/invoke \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"tool": "sessions_list",
|
||||
"action": "json",
|
||||
"args": {}
|
||||
}'
|
||||
```
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
When Clawdbot misbehaves, here's how to fix it.
|
||||
|
||||
Start with the FAQ’s [First 60 seconds](/start/faq#first-60-seconds-if-somethings-broken) if you just want a quick triage recipe. This page goes deeper on runtime failures and diagnostics.
|
||||
Start with the FAQ’s [First 60 seconds](/help/faq#first-60-seconds-if-somethings-broken) if you just want a quick triage recipe. This page goes deeper on runtime failures and diagnostics.
|
||||
|
||||
Provider-specific shortcuts: [/channels/troubleshooting](/channels/troubleshooting)
|
||||
|
||||
@@ -31,6 +31,34 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
||||
|
||||
## Common Issues
|
||||
|
||||
### OAuth token refresh failed (Anthropic Claude subscription)
|
||||
|
||||
This means the stored Anthropic OAuth token expired and the refresh failed.
|
||||
If you’re on a Claude subscription (no API key), the most reliable fix is to
|
||||
switch to a **Claude Code setup-token** or re-sync Claude Code CLI OAuth on the
|
||||
**gateway host**.
|
||||
|
||||
**Recommended (setup-token):**
|
||||
|
||||
```bash
|
||||
# Run on the gateway host (runs Claude Code CLI)
|
||||
clawdbot models auth setup-token --provider anthropic
|
||||
clawdbot models status
|
||||
```
|
||||
|
||||
If you generated the token elsewhere:
|
||||
|
||||
```bash
|
||||
clawdbot models auth paste-token --provider anthropic
|
||||
clawdbot models status
|
||||
```
|
||||
|
||||
**If you want to keep OAuth reuse:**
|
||||
log in with Claude Code CLI on the gateway host, then run `clawdbot models status`
|
||||
to sync the refreshed token into Clawdbot’s auth store.
|
||||
|
||||
More detail: [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
||||
|
||||
### Control UI fails on HTTP ("device identity required" / "connect failed")
|
||||
|
||||
If you open the dashboard over plain HTTP (e.g. `http://<lan-ip>:18789/` or
|
||||
|
||||
1691
docs/help/faq.md
1691
docs/help/faq.md
File diff suppressed because it is too large
Load Diff
340
docs/platforms/fly.md
Normal file
340
docs/platforms/fly.md
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
title: Fly.io
|
||||
description: Deploy Clawdbot on Fly.io
|
||||
---
|
||||
|
||||
# Fly.io Deployment
|
||||
|
||||
**Goal:** Clawdbot Gateway running on a [Fly.io](https://fly.io) machine with persistent storage, automatic HTTPS, and Discord/channel access.
|
||||
|
||||
## What you need
|
||||
|
||||
- [flyctl CLI](https://fly.io/docs/hands-on/install-flyctl/) installed
|
||||
- Fly.io account (free tier works)
|
||||
- Model auth: Anthropic API key (or other provider keys)
|
||||
- Channel credentials: Discord bot token, Telegram token, etc.
|
||||
|
||||
## Beginner quick path
|
||||
|
||||
1. Clone repo → customize `fly.toml`
|
||||
2. Create app + volume → set secrets
|
||||
3. Deploy with `fly deploy`
|
||||
4. SSH in to create config or use Control UI
|
||||
|
||||
## 1) Create the Fly app
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/clawdbot/clawdbot.git
|
||||
cd clawdbot
|
||||
|
||||
# Create a new Fly app (pick your own name)
|
||||
fly apps create my-clawdbot
|
||||
|
||||
# Create a persistent volume (1GB is usually enough)
|
||||
fly volumes create clawdbot_data --size 1 --region iad
|
||||
```
|
||||
|
||||
**Tip:** Choose a region close to you. Common options: `lhr` (London), `iad` (Virginia), `sjc` (San Jose).
|
||||
|
||||
## 2) Configure fly.toml
|
||||
|
||||
Edit `fly.toml` to match your app name and requirements:
|
||||
|
||||
```toml
|
||||
app = "my-clawdbot" # Your app name
|
||||
primary_region = "iad"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
NODE_ENV = "production"
|
||||
CLAWDBOT_PREFER_PNPM = "1"
|
||||
CLAWDBOT_STATE_DIR = "/data"
|
||||
NODE_OPTIONS = "--max-old-space-size=1536"
|
||||
|
||||
[processes]
|
||||
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = false
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ["app"]
|
||||
|
||||
[[vm]]
|
||||
size = "shared-cpu-2x"
|
||||
memory = "2048mb"
|
||||
|
||||
[mounts]
|
||||
source = "clawdbot_data"
|
||||
destination = "/data"
|
||||
```
|
||||
|
||||
**Key settings:**
|
||||
|
||||
| Setting | Why |
|
||||
|---------|-----|
|
||||
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
|
||||
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
|
||||
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
|
||||
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
|
||||
|
||||
## 3) Set secrets
|
||||
|
||||
```bash
|
||||
# Required: Gateway token (for non-loopback binding)
|
||||
fly secrets set CLAWDBOT_GATEWAY_TOKEN=$(openssl rand -hex 32)
|
||||
|
||||
# Model provider API keys
|
||||
fly secrets set ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# Optional: Other providers
|
||||
fly secrets set OPENAI_API_KEY=sk-...
|
||||
fly secrets set GOOGLE_API_KEY=...
|
||||
|
||||
# Channel tokens
|
||||
fly secrets set DISCORD_BOT_TOKEN=MTQ...
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Non-loopback binds (`--bind lan`) require `CLAWDBOT_GATEWAY_TOKEN` for security.
|
||||
- Treat these tokens like passwords.
|
||||
|
||||
## 4) Deploy
|
||||
|
||||
```bash
|
||||
fly deploy
|
||||
```
|
||||
|
||||
First deploy builds the Docker image (~2-3 minutes). Subsequent deploys are faster.
|
||||
|
||||
After deployment, verify:
|
||||
```bash
|
||||
fly status
|
||||
fly logs
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
[gateway] listening on ws://0.0.0.0:3000 (PID xxx)
|
||||
[discord] logged in to discord as xxx
|
||||
```
|
||||
|
||||
## 5) Create config file
|
||||
|
||||
SSH into the machine to create a proper config:
|
||||
|
||||
```bash
|
||||
fly ssh console
|
||||
```
|
||||
|
||||
Create the config directory and file:
|
||||
```bash
|
||||
mkdir -p /data
|
||||
cat > /data/clawdbot.json << 'EOF'
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-opus-4-5",
|
||||
"fallbacks": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
|
||||
},
|
||||
"maxConcurrent": 4
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"profiles": {
|
||||
"anthropic:default": { "mode": "token", "provider": "anthropic" },
|
||||
"openai:default": { "mode": "token", "provider": "openai" }
|
||||
}
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"agentId": "main",
|
||||
"match": { "channel": "discord" }
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"groupPolicy": "allowlist",
|
||||
"guilds": {
|
||||
"YOUR_GUILD_ID": {
|
||||
"channels": { "general": { "allow": true } },
|
||||
"requireMention": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"bind": "auto"
|
||||
},
|
||||
"meta": {
|
||||
"lastTouchedVersion": "2026.1.23"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Note:** With `CLAWDBOT_STATE_DIR=/data`, the config path is `/data/clawdbot.json`.
|
||||
|
||||
**Note:** The Discord token can come from either:
|
||||
- Environment variable: `DISCORD_BOT_TOKEN` (recommended for secrets)
|
||||
- Config file: `channels.discord.token`
|
||||
|
||||
If using env var, no need to add token to config. The gateway reads `DISCORD_BOT_TOKEN` automatically.
|
||||
|
||||
Restart to apply:
|
||||
```bash
|
||||
exit
|
||||
fly machine restart <machine-id>
|
||||
```
|
||||
|
||||
## 6) Access the Gateway
|
||||
|
||||
### Control UI
|
||||
|
||||
Open in browser:
|
||||
```bash
|
||||
fly open
|
||||
```
|
||||
|
||||
Or visit `https://my-clawdbot.fly.dev/`
|
||||
|
||||
Paste your gateway token (the one from `CLAWDBOT_GATEWAY_TOKEN`) to authenticate.
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
fly logs # Live logs
|
||||
fly logs --no-tail # Recent logs
|
||||
```
|
||||
|
||||
### SSH Console
|
||||
|
||||
```bash
|
||||
fly ssh console
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "App is not listening on expected address"
|
||||
|
||||
The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
|
||||
|
||||
**Fix:** Add `--bind lan` to your process command in `fly.toml`.
|
||||
|
||||
### OOM / Memory Issues
|
||||
|
||||
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
|
||||
|
||||
**Fix:** Increase memory in `fly.toml`:
|
||||
```toml
|
||||
[[vm]]
|
||||
memory = "2048mb"
|
||||
```
|
||||
|
||||
Or update an existing machine:
|
||||
```bash
|
||||
fly machine update <machine-id> --vm-memory 2048 -y
|
||||
```
|
||||
|
||||
**Note:** 512MB is too small. 1GB may work but can OOM under load or with verbose logging. **2GB is recommended.**
|
||||
|
||||
### Gateway Lock Issues
|
||||
|
||||
Gateway refuses to start with "already running" errors.
|
||||
|
||||
This happens when the container restarts but the PID lock file persists on the volume.
|
||||
|
||||
**Fix:** Delete the lock file:
|
||||
```bash
|
||||
fly ssh console --command "rm -f /data/gateway.*.lock"
|
||||
fly machine restart <machine-id>
|
||||
```
|
||||
|
||||
The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
|
||||
|
||||
### Config Not Being Read
|
||||
|
||||
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
|
||||
|
||||
Verify the config exists:
|
||||
```bash
|
||||
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
|
||||
```
|
||||
|
||||
### Writing Config via SSH
|
||||
|
||||
The `fly ssh console -C` command doesn't support shell redirection. To write a config file:
|
||||
|
||||
```bash
|
||||
# Use echo + tee (pipe from local to remote)
|
||||
echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
|
||||
|
||||
# Or use sftp
|
||||
fly sftp shell
|
||||
> put /local/path/config.json /data/.clawdbot/clawdbot.json
|
||||
```
|
||||
|
||||
**Note:** `fly sftp` may fail if the file already exists. Delete first:
|
||||
```bash
|
||||
fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Redeploy
|
||||
fly deploy
|
||||
|
||||
# Check health
|
||||
fly status
|
||||
fly logs
|
||||
```
|
||||
|
||||
### Updating Machine Command
|
||||
|
||||
If you need to change the startup command without a full redeploy:
|
||||
|
||||
```bash
|
||||
# Get machine ID
|
||||
fly machines list
|
||||
|
||||
# Update command
|
||||
fly machine update <machine-id> --command "node dist/index.js gateway --port 3000 --bind lan" -y
|
||||
|
||||
# Or with memory increase
|
||||
fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js gateway --port 3000 --bind lan" -y
|
||||
```
|
||||
|
||||
**Note:** After `fly deploy`, the machine command may reset to what's in `fly.toml`. If you made manual changes, re-apply them after deploy.
|
||||
|
||||
## Notes
|
||||
|
||||
- Fly.io uses **x86 architecture** (not ARM)
|
||||
- The Dockerfile is compatible with both architectures
|
||||
- For WhatsApp/Telegram onboarding, use `fly ssh console`
|
||||
- Persistent data lives on the volume at `/data`
|
||||
|
||||
## Cost
|
||||
|
||||
With the recommended config (`shared-cpu-2x`, 2GB RAM):
|
||||
- ~$10-15/month depending on usage
|
||||
- Free tier includes some allowance
|
||||
|
||||
See [Fly.io pricing](https://fly.io/docs/about/pricing/) for details.
|
||||
@@ -184,7 +184,7 @@ services:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway-daemon",
|
||||
"gateway",
|
||||
"--bind",
|
||||
"${CLAWDBOT_GATEWAY_BIND}",
|
||||
"--port",
|
||||
|
||||
@@ -23,6 +23,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
|
||||
## VPS & hosting
|
||||
|
||||
- Fly.io: [Fly.io](/platforms/fly)
|
||||
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
|
||||
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ Plugins can register:
|
||||
- Background services
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
@@ -494,6 +495,65 @@ export default function (api) {
|
||||
}
|
||||
```
|
||||
|
||||
### Register auto-reply commands
|
||||
|
||||
Plugins can register custom slash commands that execute **without invoking the
|
||||
AI agent**. This is useful for toggle commands, status checks, or quick actions
|
||||
that don't need LLM processing.
|
||||
|
||||
```ts
|
||||
export default function (api) {
|
||||
api.registerCommand({
|
||||
name: "mystatus",
|
||||
description: "Show plugin status",
|
||||
handler: (ctx) => ({
|
||||
text: `Plugin is running! Channel: ${ctx.channel}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Command handler context:
|
||||
|
||||
- `senderId`: The sender's ID (if available)
|
||||
- `channel`: The channel where the command was sent
|
||||
- `isAuthorizedSender`: Whether the sender is an authorized user
|
||||
- `args`: Arguments passed after the command (if `acceptsArgs: true`)
|
||||
- `commandBody`: The full command text
|
||||
- `config`: The current Clawdbot config
|
||||
|
||||
Command options:
|
||||
|
||||
- `name`: Command name (without the leading `/`)
|
||||
- `description`: Help text shown in command lists
|
||||
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
|
||||
- `requireAuth`: Whether to require authorized sender (default: true)
|
||||
- `handler`: Function that returns `{ text: string }` (can be async)
|
||||
|
||||
Example with authorization and arguments:
|
||||
|
||||
```ts
|
||||
api.registerCommand({
|
||||
name: "setmode",
|
||||
description: "Set plugin mode",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx) => {
|
||||
const mode = ctx.args?.trim() || "default";
|
||||
await saveMode(mode);
|
||||
return { text: `Mode set to: ${mode}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Plugin commands are processed **before** built-in commands and the AI agent
|
||||
- Commands are registered globally and work across all channels
|
||||
- Command names are case-insensitive (`/MyStatus` matches `/mystatus`)
|
||||
- Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
|
||||
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
|
||||
- Duplicate command registration across plugins will fail with a diagnostic error
|
||||
|
||||
### Register background services
|
||||
|
||||
```ts
|
||||
|
||||
@@ -100,6 +100,7 @@ clawdbot onboard --auth-choice claude-cli
|
||||
## Notes
|
||||
|
||||
- Generate the setup-token with `claude setup-token` and paste it, or run `clawdbot models auth setup-token` on the gateway host.
|
||||
- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token or resync Claude Code CLI OAuth on the gateway host. See [/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription](/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription).
|
||||
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
|
||||
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
||||
auto-migrated on load.
|
||||
|
||||
73
docs/refactor/outbound-session-mirroring.md
Normal file
73
docs/refactor/outbound-session-mirroring.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Outbound Session Mirroring Refactor (Issue #1520)
|
||||
description: Track outbound session mirroring refactor notes, decisions, tests, and open items.
|
||||
---
|
||||
|
||||
# Outbound Session Mirroring Refactor (Issue #1520)
|
||||
|
||||
## Status
|
||||
- In progress.
|
||||
- Core + plugin channel routing updated for outbound mirroring.
|
||||
- Gateway send now derives target session when sessionKey is omitted.
|
||||
|
||||
## Context
|
||||
Outbound sends were mirrored into the *current* agent session (tool session key) rather than the target channel session. Inbound routing uses channel/peer session keys, so outbound responses landed in the wrong session and first-contact targets often lacked session entries.
|
||||
|
||||
## Goals
|
||||
- Mirror outbound messages into the target channel session key.
|
||||
- Create session entries on outbound when missing.
|
||||
- Keep thread/topic scoping aligned with inbound session keys.
|
||||
- Cover core channels plus bundled extensions.
|
||||
|
||||
## Implementation Summary
|
||||
- New outbound session routing helper:
|
||||
- `src/infra/outbound/outbound-session.ts`
|
||||
- `resolveOutboundSessionRoute` builds target sessionKey using `buildAgentSessionKey` (dmScope + identityLinks).
|
||||
- `ensureOutboundSessionEntry` writes minimal `MsgContext` via `recordSessionMetaFromInbound`.
|
||||
- `runMessageAction` (send) derives target sessionKey and passes it to `executeSendAction` for mirroring.
|
||||
- `message-tool` no longer mirrors directly; it only resolves agentId from the current session key.
|
||||
- Plugin send path mirrors via `appendAssistantMessageToSessionTranscript` using the derived sessionKey.
|
||||
- Gateway send derives a target session key when none is provided (default agent), and ensures a session entry.
|
||||
|
||||
## Thread/Topic Handling
|
||||
- Slack: replyTo/threadId -> `resolveThreadSessionKeys` (suffix).
|
||||
- Discord: threadId/replyTo -> `resolveThreadSessionKeys` with `useSuffix=false` to match inbound (thread channel id already scopes session).
|
||||
- Telegram: topic IDs map to `chatId:topic:<id>` via `buildTelegramGroupPeerId`.
|
||||
|
||||
## Extensions Covered
|
||||
- Matrix, MS Teams, Mattermost, BlueBubbles, Nextcloud Talk, Zalo, Zalo Personal, Nostr, Tlon.
|
||||
- Notes:
|
||||
- Mattermost targets now strip `@` for DM session key routing.
|
||||
- Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present).
|
||||
- BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys.
|
||||
|
||||
## Decisions
|
||||
- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there.
|
||||
- **Session entry creation**: always use `recordSessionMetaFromInbound` with `Provider/From/To/ChatType/AccountId/Originating*` aligned to inbound formats.
|
||||
- **Target normalization**: outbound routing uses resolved targets (post `resolveChannelTarget`) when available.
|
||||
- **Session key casing**: canonicalize session keys to lowercase on write and during migrations.
|
||||
|
||||
## Tests Added/Updated
|
||||
- `src/infra/outbound/outbound-session.test.ts`
|
||||
- Slack thread session key.
|
||||
- Telegram topic session key.
|
||||
- dmScope identityLinks with Discord.
|
||||
- `src/agents/tools/message-tool.test.ts`
|
||||
- Derives agentId from session key (no sessionKey passed through).
|
||||
- `src/gateway/server-methods/send.test.ts`
|
||||
- Derives session key when omitted and creates session entry.
|
||||
|
||||
## Open Items / Follow-ups
|
||||
- Voice-call plugin uses custom `voice:<phone>` session keys. Outbound mapping is not standardized here; if message-tool should support voice-call sends, add explicit mapping.
|
||||
- Confirm if any external plugin uses non-standard `From/To` formats beyond the bundled set.
|
||||
|
||||
## Files Touched
|
||||
- `src/infra/outbound/outbound-session.ts`
|
||||
- `src/infra/outbound/outbound-send-service.ts`
|
||||
- `src/infra/outbound/message-action-runner.ts`
|
||||
- `src/agents/tools/message-tool.ts`
|
||||
- `src/gateway/server-methods/send.ts`
|
||||
- Tests in:
|
||||
- `src/infra/outbound/outbound-session.test.ts`
|
||||
- `src/agents/tools/message-tool.test.ts`
|
||||
- `src/gateway/server-methods/send.test.ts`
|
||||
@@ -92,6 +92,21 @@ In group chats where you receive every message, be **smart about when to contrib
|
||||
|
||||
Participate, don't dominate.
|
||||
|
||||
### 😊 React Like a Human!
|
||||
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
|
||||
|
||||
**React when:**
|
||||
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
|
||||
- Something made you laugh (😂, 💀)
|
||||
- You find it interesting or thought-provoking (🤔, 💡)
|
||||
- You want to acknowledge without interrupting the flow
|
||||
- It's a simple yes/no or approval situation (✅, 👀)
|
||||
|
||||
**Why it matters:**
|
||||
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
|
||||
|
||||
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
|
||||
|
||||
## Tools
|
||||
|
||||
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
|
||||
|
||||
@@ -5,4 +5,5 @@ read_when:
|
||||
---
|
||||
# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
|
||||
@@ -7,11 +7,16 @@ read_when:
|
||||
|
||||
*Fill this in during your first conversation. Make it yours.*
|
||||
|
||||
- **Name:** *(pick something you like)*
|
||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **Emoji:** *(your signature — pick one that feels right)*
|
||||
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
|
||||
- **Name:**
|
||||
*(pick something you like)*
|
||||
- **Creature:**
|
||||
*(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:**
|
||||
*(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **Emoji:**
|
||||
*(your signature — pick one that feels right)*
|
||||
- **Avatar:**
|
||||
*(workspace-relative path, http(s) URL, or data URI)*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ Implementation:
|
||||
|
||||
**OpenAI / OpenAI Codex**
|
||||
- Image sanitization only.
|
||||
- On model switch into OpenAI Responses/Codex, drop orphaned reasoning signatures (standalone reasoning items without a following content block).
|
||||
- No tool call id sanitization.
|
||||
- No tool result pairing repair.
|
||||
- No turn validation or reordering.
|
||||
|
||||
@@ -182,6 +182,8 @@ By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||
|
||||
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
- If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||
|
||||
|
||||
1648
docs/start/faq.md
1648
docs/start/faq.md
File diff suppressed because it is too large
Load Diff
@@ -166,6 +166,19 @@ Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
|
||||
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||
tokens instead of committing them to config files.
|
||||
|
||||
### Node browser proxy (zero-config default)
|
||||
|
||||
If you run a **node host** on the machine that has your browser, Clawdbot can
|
||||
auto-route browser tool calls to that node without any custom `controlUrl`
|
||||
setup. This is the default path for remote gateways.
|
||||
|
||||
Notes:
|
||||
- The node host exposes its local browser control server via a **proxy command**.
|
||||
- Profiles come from the node’s own `browser.profiles` config (same as local).
|
||||
- Disable if you don’t want it:
|
||||
- On the node: `nodeHost.browserProxy.enabled=false`
|
||||
- On the gateway: `gateway.nodes.browser.mode="off"`
|
||||
|
||||
### Browserless (hosted remote CDP)
|
||||
|
||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||
|
||||
@@ -12,6 +12,7 @@ Exec approvals are the **companion app / node host guardrail** for letting a san
|
||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||
Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals).
|
||||
Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; if an approvals field is omitted, the `tools.exec` value is used.
|
||||
|
||||
If the companion app UI is **not available**, any request that requires a prompt is
|
||||
resolved by the **ask fallback** (default: deny).
|
||||
|
||||
@@ -25,6 +25,7 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
|
||||
Notes:
|
||||
- Matching is case-insensitive.
|
||||
- `*` wildcards are supported (`"*"` means all tools).
|
||||
- If `tools.allow` only references unknown or unloaded plugin tool names, Clawdbot logs a warning and ignores the allowlist so core tools stay available.
|
||||
|
||||
## Tool profiles (base allowlist)
|
||||
|
||||
@@ -378,10 +379,10 @@ List sessions, inspect transcript history, or send to another session.
|
||||
|
||||
Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
|
||||
- `session_status`: `sessionKey?` (default current), `model?` (`default` clears override)
|
||||
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
|
||||
|
||||
Notes:
|
||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||
|
||||
@@ -67,6 +67,8 @@ Text + native (when enabled):
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
|
||||
|
||||
@@ -84,7 +84,7 @@ current limits and pricing.
|
||||
|
||||
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
|
||||
environment. For a gateway install, put it in `~/.clawdbot/.env` (or your
|
||||
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
||||
service environment). See [Env vars](/help/faq#how-does-clawdbot-load-environment-variables).
|
||||
|
||||
## Using Perplexity (direct or via OpenRouter)
|
||||
|
||||
|
||||
296
docs/tts.md
Normal file
296
docs/tts.md
Normal file
@@ -0,0 +1,296 @@
|
||||
---
|
||||
summary: "Text-to-speech (TTS) for outbound replies"
|
||||
read_when:
|
||||
- Enabling text-to-speech for replies
|
||||
- Configuring TTS providers or limits
|
||||
- Using /tts commands
|
||||
---
|
||||
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
Clawdbot can convert outbound replies into audio using ElevenLabs or OpenAI.
|
||||
It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubble.
|
||||
|
||||
## Supported services
|
||||
|
||||
- **ElevenLabs** (primary or fallback provider)
|
||||
- **OpenAI** (primary or fallback provider; also used for summaries)
|
||||
|
||||
## Required keys
|
||||
|
||||
At least one of:
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
If both are configured, the selected provider is used first and the other is a fallback.
|
||||
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
|
||||
so that provider must also be authenticated if you enable summaries.
|
||||
|
||||
## Service links
|
||||
|
||||
- [OpenAI Text-to-Speech guide](https://platform.openai.com/docs/guides/text-to-speech)
|
||||
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
|
||||
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
|
||||
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
|
||||
|
||||
## Is it enabled by default?
|
||||
|
||||
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
|
||||
which writes a local preference override.
|
||||
|
||||
## Config
|
||||
|
||||
TTS config lives under `messages.tts` in `clawdbot.json`.
|
||||
Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
|
||||
### Minimal config (enable + provider)
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
provider: "elevenlabs"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI primary with ElevenLabs fallback
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
provider: "openai",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
enabled: true
|
||||
},
|
||||
openai: {
|
||||
apiKey: "openai_api_key",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy"
|
||||
},
|
||||
elevenlabs: {
|
||||
apiKey: "elevenlabs_api_key",
|
||||
baseUrl: "https://api.elevenlabs.io",
|
||||
voiceId: "voice_id",
|
||||
modelId: "eleven_multilingual_v2",
|
||||
seed: 42,
|
||||
applyTextNormalization: "auto",
|
||||
languageCode: "en",
|
||||
voiceSettings: {
|
||||
stability: 0.5,
|
||||
similarityBoost: 0.75,
|
||||
style: 0.0,
|
||||
useSpeakerBoost: true,
|
||||
speed: 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom limits + prefs path
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.clawdbot/settings/tts.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable auto-summary for long replies
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```
|
||||
/tts summary off
|
||||
```
|
||||
|
||||
### Notes on fields
|
||||
|
||||
- `enabled`: master toggle (default `false`; local prefs can override).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
- `provider`: `"elevenlabs"` or `"openai"` (fallback is automatic).
|
||||
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
|
||||
- Accepts `provider/model` or a configured model alias.
|
||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||
- `timeoutMs`: request timeout (ms).
|
||||
- `prefsPath`: override the local prefs JSON path.
|
||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||
- `elevenlabs.voiceSettings`:
|
||||
- `stability`, `similarityBoost`, `style`: `0..1`
|
||||
- `useSpeakerBoost`: `true|false`
|
||||
- `speed`: `0.5..2.0` (1.0 = normal)
|
||||
- `elevenlabs.applyTextNormalization`: `auto|on|off`
|
||||
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
|
||||
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
|
||||
|
||||
## Model-driven overrides (default on)
|
||||
|
||||
By default, the model **can** emit TTS directives for a single reply.
|
||||
|
||||
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
||||
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||
provide expressive tags (laughter, singing cues, etc) that should only appear in
|
||||
the audio.
|
||||
|
||||
Example reply payload:
|
||||
|
||||
```
|
||||
Here you go.
|
||||
|
||||
[[tts:provider=elevenlabs voiceId=pMsXgVXv3BLzUgSXRplE model=eleven_v3 speed=1.1]]
|
||||
[[tts:text]](laughs) Read the song once more.[[/tts:text]]
|
||||
```
|
||||
|
||||
Available directive keys (when enabled):
|
||||
- `provider` (`openai` | `elevenlabs`)
|
||||
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs)
|
||||
- `model` (OpenAI TTS model or ElevenLabs model id)
|
||||
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
|
||||
- `applyTextNormalization` (`auto|on|off`)
|
||||
- `languageCode` (ISO 639-1)
|
||||
- `seed`
|
||||
|
||||
Disable all model overrides:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
modelOverrides: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Optional allowlist (disable specific overrides while keeping tags enabled):
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
modelOverrides: {
|
||||
enabled: true,
|
||||
allowProvider: false,
|
||||
allowSeed: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Per-user preferences
|
||||
|
||||
Slash commands write local overrides to `prefsPath` (default:
|
||||
`~/.clawdbot/settings/tts.json`, override with `CLAWDBOT_TTS_PREFS` or
|
||||
`messages.tts.prefsPath`).
|
||||
|
||||
Stored fields:
|
||||
- `enabled`
|
||||
- `provider`
|
||||
- `maxLength` (summary threshold; default 1500 chars)
|
||||
- `summarize` (default `true`)
|
||||
|
||||
These override `messages.tts.*` for that host.
|
||||
|
||||
## Output formats (fixed)
|
||||
|
||||
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
|
||||
This is not configurable; Telegram expects Opus for voice-note UX.
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
When enabled, Clawdbot:
|
||||
- skips TTS if the reply already contains media or a `MEDIA:` directive.
|
||||
- skips very short replies (< 10 chars).
|
||||
- summarizes long replies when enabled using `agents.defaults.model.primary` (or `summaryModel`).
|
||||
- attaches the generated audio to the reply.
|
||||
|
||||
If the reply exceeds `maxLength` and summary is off (or no API key for the
|
||||
summary model), audio
|
||||
is skipped and the normal text reply is sent.
|
||||
|
||||
## Flow diagram
|
||||
|
||||
```
|
||||
Reply -> TTS enabled?
|
||||
no -> send text
|
||||
yes -> has media / MEDIA: / short?
|
||||
yes -> send text
|
||||
no -> length > limit?
|
||||
no -> TTS -> attach audio
|
||||
yes -> summary enabled?
|
||||
no -> send text
|
||||
yes -> summarize (summaryModel or agents.defaults.model.primary)
|
||||
-> TTS -> attach audio
|
||||
```
|
||||
|
||||
## Slash command usage
|
||||
|
||||
There is a single command: `/tts`.
|
||||
See [Slash commands](/tools/slash-commands) for enablement details.
|
||||
|
||||
Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
||||
`/voice` as the native command there. Text `/tts ...` still works.
|
||||
|
||||
```
|
||||
/tts on
|
||||
/tts off
|
||||
/tts status
|
||||
/tts provider openai
|
||||
/tts limit 2000
|
||||
/tts summary off
|
||||
/tts audio Hello from Clawdbot
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||
- `commands.text` or native command registration must be enabled.
|
||||
- `limit` and `summary` are stored in local prefs, not the main config.
|
||||
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns a `MEDIA:` path. When the
|
||||
result is Telegram-compatible, the tool includes `[[audio_as_voice]]` so
|
||||
Telegram sends a voice bubble.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
Gateway methods:
|
||||
- `tts.status`
|
||||
- `tts.enable`
|
||||
- `tts.disable`
|
||||
- `tts.convert`
|
||||
- `tts.setProvider`
|
||||
- `tts.providers`
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
@@ -62,6 +63,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
||||
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -21,6 +21,7 @@ const bluebubblesActionSchema = z
|
||||
|
||||
const bluebubblesGroupConfigSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
const bluebubblesAccountSchema = z.object({
|
||||
|
||||
@@ -4,6 +4,8 @@ export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||
export type BlueBubblesGroupConfig = {
|
||||
/** If true, only respond in this group when mentioned. */
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this group. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
};
|
||||
|
||||
export type BlueBubblesAccountConfig = {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
resolveDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
@@ -144,6 +145,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ["<@!?\\d+>"],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
type ResolvedIMessageAccount,
|
||||
@@ -106,6 +107,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
||||
},
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
|
||||
@@ -30,6 +30,48 @@ Enable it in an agent allowlist:
|
||||
}
|
||||
```
|
||||
|
||||
## Using `clawd.invoke` (Lobster → Clawdbot tools)
|
||||
|
||||
Some Lobster pipelines may include a `clawd.invoke` step to call back into Clawdbot tools/plugins (for example: `gog` for Google Workspace, `gh` for GitHub, `message.send`, etc.).
|
||||
|
||||
For this to work, the Clawdbot Gateway must expose the tool bridge endpoint and the target tool must be allowed by policy:
|
||||
|
||||
- Clawdbot provides an HTTP endpoint: `POST /tools/invoke`.
|
||||
- The request is gated by **gateway auth** (e.g. `Authorization: Bearer …` when token auth is enabled).
|
||||
- The invoked tool is gated by **tool policy** (global + per-agent + provider + group policy). If the tool is not allowed, Clawdbot returns `404 Tool not available`.
|
||||
|
||||
### Allowlisting recommended
|
||||
|
||||
To avoid letting workflows call arbitrary tools, set a tight allowlist on the agent that will be used by `clawd.invoke`.
|
||||
|
||||
Example (allow only a small set of tools):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": {
|
||||
"allow": [
|
||||
"lobster",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"gog",
|
||||
"gh"
|
||||
],
|
||||
"deny": ["gateway"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If `tools.allow` is omitted or empty, it behaves like "allow everything (except denied)". For a real allowlist, set a **non-empty** `allow`.
|
||||
- Tool names depend on which plugins you have installed/enabled.
|
||||
|
||||
## Security
|
||||
|
||||
- Runs the `lobster` executable as a local subprocess.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"matrix-bot-sdk": "0.8.0",
|
||||
"music-metadata": "^11.10.6",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
|
||||
import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
@@ -167,6 +167,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMatrixGroupRequireMention,
|
||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -26,6 +26,7 @@ const matrixRoomSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
autoReply: z.boolean().optional(),
|
||||
users: z.array(allowFromEntry).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
@@ -32,3 +32,30 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveMatrixGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const rawGroupId = params.groupId?.trim() ?? "";
|
||||
let roomId = rawGroupId;
|
||||
const lower = roomId.toLowerCase();
|
||||
if (lower.startsWith("matrix:")) {
|
||||
roomId = roomId.slice("matrix:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("channel:")) {
|
||||
roomId = roomId.slice("channel:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
return resolved?.tools;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export type MatrixRoomConfig = {
|
||||
allow?: boolean;
|
||||
/** Require mentioning the bot to trigger replies. */
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this room. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
/** If true, reply without mention requirements. */
|
||||
autoReply?: boolean;
|
||||
/** Optional allowlist for room senders (user IDs or localparts). */
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
||||
import {
|
||||
normalizeMSTeamsMessagingTarget,
|
||||
normalizeMSTeamsUserInput,
|
||||
@@ -77,6 +78,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.msteams"] },
|
||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||
config: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AllowlistMatch,
|
||||
ChannelGroupContext,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsConfig,
|
||||
MSTeamsReplyStyle,
|
||||
@@ -86,6 +88,50 @@ export function resolveMSTeamsRouteConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMSTeamsGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg.channels?.msteams;
|
||||
if (!cfg) return undefined;
|
||||
const groupId = params.groupId?.trim();
|
||||
const groupChannel = params.groupChannel?.trim();
|
||||
const groupSpace = params.groupSpace?.trim();
|
||||
|
||||
const resolved = resolveMSTeamsRouteConfig({
|
||||
cfg,
|
||||
teamId: groupSpace,
|
||||
teamName: groupSpace,
|
||||
conversationId: groupId,
|
||||
channelName: groupChannel,
|
||||
});
|
||||
|
||||
if (resolved.channelConfig) {
|
||||
return resolved.channelConfig.tools ?? resolved.teamConfig?.tools;
|
||||
}
|
||||
if (resolved.teamConfig?.tools) return resolved.teamConfig.tools;
|
||||
|
||||
if (!groupId) return undefined;
|
||||
|
||||
const channelCandidates = buildChannelKeyCandidates(
|
||||
groupId,
|
||||
groupChannel,
|
||||
groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
|
||||
);
|
||||
for (const teamConfig of Object.values(cfg.teams ?? {})) {
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries: teamConfig?.channels ?? {},
|
||||
keys: channelCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
if (match.entry) {
|
||||
return match.entry.tools ?? teamConfig?.tools;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type MSTeamsReplyPolicy = {
|
||||
requireMention: boolean;
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
||||
try {
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
|
||||
await tokenProvider.getAccessToken("https://api.botframework.com");
|
||||
let graph:
|
||||
| {
|
||||
ok: boolean;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import { sendMessageNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
|
||||
|
||||
const meta = {
|
||||
id: "nextcloud-talk",
|
||||
@@ -159,6 +160,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
|
||||
return true;
|
||||
},
|
||||
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
@@ -11,6 +12,7 @@ import { z } from "zod";
|
||||
export const NextcloudTalkRoomSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk";
|
||||
import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
@@ -86,6 +86,21 @@ export function resolveNextcloudTalkRoomMatch(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg as { channels?: { "nextcloud-talk"?: { rooms?: Record<string, NextcloudTalkRoomConfig> } } };
|
||||
const roomToken = params.groupId?.trim();
|
||||
if (!roomToken) return undefined;
|
||||
const roomName = params.groupChannel?.trim() || undefined;
|
||||
const match = resolveNextcloudTalkRoomMatch({
|
||||
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
|
||||
roomToken,
|
||||
roomName,
|
||||
});
|
||||
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkRequireMention(params: {
|
||||
roomConfig?: NextcloudTalkRoomConfig;
|
||||
wildcardConfig?: NextcloudTalkRoomConfig;
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
|
||||
export type NextcloudTalkRoomConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this room. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
/** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this room. */
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"clawdbot": "workspace:*",
|
||||
"nostr-tools": "^2.19.4",
|
||||
"zod": "^4.3.5"
|
||||
"nostr-tools": "^2.20.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveSlackGroupToolPolicy,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
slackOnboardingAdapter,
|
||||
@@ -161,6 +162,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
resolveToolPolicy: resolveSlackGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
telegramOnboardingAdapter,
|
||||
TelegramConfigSchema,
|
||||
@@ -154,6 +155,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveTelegramGroupRequireMention,
|
||||
resolveToolPolicy: resolveTelegramGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@urbit/aura": "^2.0.0",
|
||||
"@urbit/aura": "^3.0.0",
|
||||
"@urbit/http-api": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
resolveWhatsAppAccount,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
resolveWhatsAppGroupToolPolicy,
|
||||
resolveWhatsAppHeartbeatRecipients,
|
||||
whatsappOnboardingAdapter,
|
||||
WhatsAppConfigSchema,
|
||||
@@ -198,6 +199,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveWhatsAppGroupRequireMention,
|
||||
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
|
||||
resolveGroupIntroHint: () =>
|
||||
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
|
||||
},
|
||||
|
||||
@@ -2,8 +2,10 @@ import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelDock,
|
||||
ChannelGroupContext,
|
||||
ChannelPlugin,
|
||||
ClawdbotConfig,
|
||||
GroupToolPolicyConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
@@ -79,6 +81,26 @@ function mapGroup(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveZalouserGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: params.cfg as ClawdbotConfig,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const groups = account.config.groups ?? {};
|
||||
const groupId = params.groupId?.trim();
|
||||
const groupChannel = params.groupChannel?.trim();
|
||||
const candidates = [groupId, groupChannel, "*"].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
for (const key of candidates) {
|
||||
const entry = groups[key];
|
||||
if (entry?.tools) return entry.tools;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const zalouserDock: ChannelDock = {
|
||||
id: "zalouser",
|
||||
capabilities: {
|
||||
@@ -101,6 +123,7 @@ export const zalouserDock: ChannelDock = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
@@ -188,6 +211,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -6,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
const groupConfigSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
const zalouserAccountSchema = z.object({
|
||||
|
||||
@@ -75,7 +75,7 @@ export type ZalouserAccountConfig = {
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
|
||||
messagePrefix?: string;
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export type ZalouserConfig = {
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
|
||||
messagePrefix?: string;
|
||||
accounts?: Record<string, ZalouserAccountConfig>;
|
||||
};
|
||||
|
||||
34
fly.toml
Normal file
34
fly.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Clawdbot Fly.io deployment configuration
|
||||
# See https://fly.io/docs/reference/configuration/
|
||||
|
||||
app = "clawdbot"
|
||||
primary_region = "iad" # change to your closest region
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
NODE_ENV = "production"
|
||||
# Fly uses x86, but keep this for consistency
|
||||
CLAWDBOT_PREFER_PNPM = "1"
|
||||
CLAWDBOT_STATE_DIR = "/data"
|
||||
NODE_OPTIONS = "--max-old-space-size=1536"
|
||||
|
||||
[processes]
|
||||
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = false # Keep running for persistent connections
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ["app"]
|
||||
|
||||
[[vm]]
|
||||
size = "shared-cpu-2x"
|
||||
memory = "2048mb"
|
||||
|
||||
[mounts]
|
||||
source = "clawdbot_data"
|
||||
destination = "/data"
|
||||
16
package.json
16
package.json
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@agentclientprotocol/sdk": "0.13.1",
|
||||
"@aws-sdk/client-bedrock": "^3.975.0",
|
||||
"@buape/carbon": "0.14.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
@@ -186,7 +186,7 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"osc-progress": "^0.3.0",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"playwright-core": "1.57.0",
|
||||
"playwright-core": "1.58.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -196,7 +196,7 @@
|
||||
"undici": "^7.19.0",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.88",
|
||||
@@ -214,21 +214,21 @@
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260120.1",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260124.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"docx-preview": "^0.3.7",
|
||||
"lit": "^3.3.2",
|
||||
"lucide": "^0.562.0",
|
||||
"lucide": "^0.563.0",
|
||||
"ollama": "^0.6.3",
|
||||
"oxfmt": "0.26.0",
|
||||
"oxlint": "^1.41.0",
|
||||
"oxlint-tsgolint": "^0.11.1",
|
||||
"quicktype-core": "^23.2.6",
|
||||
"rolldown": "1.0.0-beta.60",
|
||||
"rolldown": "1.0.0-rc.1",
|
||||
"signal-utils": "^0.21.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.17",
|
||||
"vitest": "^4.0.18",
|
||||
"wireit": "^0.14.12"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
1255
pnpm-lock.yaml
generated
1255
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,13 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts ./
|
||||
COPY src ./src
|
||||
COPY test ./test
|
||||
COPY scripts ./scripts
|
||||
COPY docs ./docs
|
||||
COPY skills ./skills
|
||||
COPY patches ./patches
|
||||
COPY extensions/memory-core ./extensions/memory-core
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -29,12 +29,23 @@ const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
||||
|
||||
const WARNING_SUPPRESSION_FLAGS = [
|
||||
"--disable-warning=ExperimentalWarning",
|
||||
"--disable-warning=DEP0040",
|
||||
"--disable-warning=DEP0060",
|
||||
];
|
||||
|
||||
const run = (entry) =>
|
||||
new Promise((resolve) => {
|
||||
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
|
||||
const nodeOptions = process.env.NODE_OPTIONS ?? "";
|
||||
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
|
||||
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
|
||||
nodeOptions,
|
||||
);
|
||||
const child = spawn(pnpm, args, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, VITEST_GROUP: entry.name },
|
||||
env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: nextNodeOptions },
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
children.add(child);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: bird
|
||||
description: X/Twitter CLI for reading, searching, posting, and engagement via cookies.
|
||||
homepage: https://bird.fast
|
||||
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)"},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
|
||||
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)","os":["darwin"]},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
|
||||
---
|
||||
|
||||
# bird 🐦
|
||||
|
||||
@@ -70,7 +70,8 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: C
|
||||
} {
|
||||
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
|
||||
const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined;
|
||||
const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
|
||||
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
|
||||
return { defaultAgentId, sessionAgentId };
|
||||
}
|
||||
|
||||
215
src/agents/anthropic-payload-log.ts
Normal file
215
src/agents/anthropic-payload-log.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
|
||||
type PayloadLogStage = "request" | "usage";
|
||||
|
||||
type PayloadLogEvent = {
|
||||
ts: string;
|
||||
stage: PayloadLogStage;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string | null;
|
||||
workspaceDir?: string;
|
||||
payload?: unknown;
|
||||
usage?: Record<string, unknown>;
|
||||
error?: string;
|
||||
payloadDigest?: string;
|
||||
};
|
||||
|
||||
type PayloadLogConfig = {
|
||||
enabled: boolean;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
type PayloadLogWriter = {
|
||||
filePath: string;
|
||||
write: (line: string) => void;
|
||||
};
|
||||
|
||||
const writers = new Map<string, PayloadLogWriter>();
|
||||
const log = createSubsystemLogger("agent/anthropic-payload");
|
||||
|
||||
function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig {
|
||||
const enabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG) ?? false;
|
||||
const fileOverride = env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
|
||||
const filePath = fileOverride
|
||||
? resolveUserPath(fileOverride)
|
||||
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
|
||||
return { enabled, filePath };
|
||||
}
|
||||
|
||||
function getWriter(filePath: string): PayloadLogWriter {
|
||||
const existing = writers.get(filePath);
|
||||
if (existing) return existing;
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const writer: PayloadLogWriter = {
|
||||
filePath,
|
||||
write: (line: string) => {
|
||||
queue = queue
|
||||
.then(() => ready)
|
||||
.then(() => fs.appendFile(filePath, line, "utf8"))
|
||||
.catch(() => undefined);
|
||||
},
|
||||
};
|
||||
|
||||
writers.set(filePath, writer);
|
||||
return writer;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string | null {
|
||||
try {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (typeof val === "bigint") return val.toString();
|
||||
if (typeof val === "function") return "[Function]";
|
||||
if (val instanceof Error) {
|
||||
return { name: val.name, message: val.message, stack: val.stack };
|
||||
}
|
||||
if (val instanceof Uint8Array) {
|
||||
return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
|
||||
}
|
||||
return val;
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
|
||||
return String(error);
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
return safeJsonStringify(error) ?? "unknown error";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function digest(value: unknown): string | undefined {
|
||||
const serialized = safeJsonStringify(value);
|
||||
if (!serialized) return undefined;
|
||||
return crypto.createHash("sha256").update(serialized).digest("hex");
|
||||
}
|
||||
|
||||
function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
|
||||
return (model as { api?: unknown })?.api === "anthropic-messages";
|
||||
}
|
||||
|
||||
function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknown> | null {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const msg = messages[i] as { role?: unknown; usage?: unknown };
|
||||
if (msg?.role === "assistant" && msg.usage && typeof msg.usage === "object") {
|
||||
return msg.usage as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AnthropicPayloadLogger = {
|
||||
enabled: true;
|
||||
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
|
||||
recordUsage: (messages: AgentMessage[], error?: unknown) => void;
|
||||
};
|
||||
|
||||
export function createAnthropicPayloadLogger(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string | null;
|
||||
workspaceDir?: string;
|
||||
}): AnthropicPayloadLogger | null {
|
||||
const env = params.env ?? process.env;
|
||||
const cfg = resolvePayloadLogConfig(env);
|
||||
if (!cfg.enabled) return null;
|
||||
|
||||
const writer = getWriter(cfg.filePath);
|
||||
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
workspaceDir: params.workspaceDir,
|
||||
};
|
||||
|
||||
const record = (event: PayloadLogEvent) => {
|
||||
const line = safeJsonStringify(event);
|
||||
if (!line) return;
|
||||
writer.write(`${line}\n`);
|
||||
};
|
||||
|
||||
const wrapStreamFn: AnthropicPayloadLogger["wrapStreamFn"] = (streamFn) => {
|
||||
const wrapped: StreamFn = (model, context, options) => {
|
||||
if (!isAnthropicModel(model as Model<Api>)) {
|
||||
return streamFn(model, context, options);
|
||||
}
|
||||
const nextOnPayload = (payload: unknown) => {
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "request",
|
||||
payload,
|
||||
payloadDigest: digest(payload),
|
||||
});
|
||||
options?.onPayload?.(payload);
|
||||
};
|
||||
return streamFn(model, context, {
|
||||
...options,
|
||||
onPayload: nextOnPayload,
|
||||
});
|
||||
};
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
|
||||
const usage = findLastAssistantUsage(messages);
|
||||
const errorMessage = formatError(error);
|
||||
if (!usage) {
|
||||
if (errorMessage) {
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "usage",
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
record({
|
||||
...base,
|
||||
ts: new Date().toISOString(),
|
||||
stage: "usage",
|
||||
usage,
|
||||
error: errorMessage,
|
||||
});
|
||||
log.info("anthropic usage", {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
usage,
|
||||
});
|
||||
};
|
||||
|
||||
log.info("anthropic payload logger enabled", { filePath: writer.filePath });
|
||||
return { enabled: true, wrapStreamFn, recordUsage };
|
||||
}
|
||||
@@ -129,4 +129,25 @@ describe("exec approvals", () => {
|
||||
expect(calls).toContain("node.invoke");
|
||||
expect(calls).not.toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("honors ask=off for elevated gateway exec without prompting", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
const tool = createExecTool({
|
||||
ask: "off",
|
||||
security: "full",
|
||||
approvalRunningNoticeMs: 0,
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call3", { command: "echo ok", elevated: true });
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(calls).not.toContain("exec.approval.request");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -838,10 +838,7 @@ export function createExecTool(
|
||||
applyPathPrepend(env, defaultPathPrepend);
|
||||
|
||||
if (host === "node") {
|
||||
const approvals = resolveExecApprovals(
|
||||
agentId,
|
||||
host === "node" ? { security: "allowlist" } : undefined,
|
||||
);
|
||||
const approvals = resolveExecApprovals(agentId, { security, ask });
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
@@ -1112,7 +1109,7 @@ export function createExecTool(
|
||||
}
|
||||
|
||||
if (host === "gateway" && !bypassApprovals) {
|
||||
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
|
||||
const approvals = resolveExecApprovals(agentId, { security, ask });
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
|
||||
@@ -17,7 +17,8 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
updateSessionStoreMock(storePath, store);
|
||||
return store;
|
||||
},
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
|
||||
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -117,11 +118,118 @@ describe("session_status tool", () => {
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
|
||||
"Unknown sessionKey",
|
||||
"Unknown sessionId",
|
||||
);
|
||||
expect(updateSessionStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves sessionId inputs", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
const sessionId = "sess-main";
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
"agent:main:main": {
|
||||
sessionId,
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
|
||||
(candidate) => candidate.name === "session_status",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
const result = await tool.execute("call3", { sessionKey: sessionId });
|
||||
const details = result.details as { ok?: boolean; sessionKey?: string };
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
it("uses non-standard session keys without sessionId resolution", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
"temp:slug-generator": {
|
||||
sessionId: "sess-temp",
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
|
||||
(candidate) => candidate.name === "session_status",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
const result = await tool.execute("call4", { sessionKey: "temp:slug-generator" });
|
||||
const details = result.details as { ok?: boolean; sessionKey?: string };
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe("temp:slug-generator");
|
||||
});
|
||||
|
||||
it("blocks cross-agent session_status without agent-to-agent access", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
"agent:other:main": {
|
||||
sessionId: "s2",
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({ agentSessionKey: "agent:main:main" }).find(
|
||||
(candidate) => candidate.name === "session_status",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
await expect(tool.execute("call5", { sessionKey: "agent:other:main" })).rejects.toThrow(
|
||||
"Agent-to-agent status is disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes bare session keys to the requester agent", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
const stores = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"/tmp/main/sessions.json",
|
||||
{
|
||||
"agent:main:main": { sessionId: "s-main", updatedAt: 10 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"/tmp/support/sessions.json",
|
||||
{
|
||||
main: { sessionId: "s-support", updatedAt: 20 },
|
||||
},
|
||||
],
|
||||
]);
|
||||
loadSessionStoreMock.mockImplementation((storePath: string) => {
|
||||
return stores.get(storePath) ?? {};
|
||||
});
|
||||
updateSessionStoreMock.mockImplementation(
|
||||
(_storePath: string, store: Record<string, unknown>) => {
|
||||
// Keep map in sync for resolveSessionEntry fallbacks if needed.
|
||||
if (_storePath) {
|
||||
stores.set(_storePath, store);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const tool = createClawdbotTools({ agentSessionKey: "agent:support:main" }).find(
|
||||
(candidate) => candidate.name === "session_status",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
const result = await tool.execute("call6", { sessionKey: "main" });
|
||||
const details = result.details as { ok?: boolean; sessionKey?: string };
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe("main");
|
||||
});
|
||||
|
||||
it("resets per-session model override via model=default", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
|
||||
@@ -172,6 +172,62 @@ describe("sessions tools", () => {
|
||||
expect(withToolsDetails.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sessions_history resolves sessionId inputs", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const sessionId = "sess-group";
|
||||
const targetKey = "agent:main:discord:channel:1457165743010611293";
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: Record<string, unknown> };
|
||||
if (request.method === "sessions.resolve") {
|
||||
return {
|
||||
key: targetKey,
|
||||
};
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_history tool");
|
||||
|
||||
const result = await tool.execute("call5", { sessionKey: sessionId });
|
||||
const details = result.details as { messages?: unknown[] };
|
||||
expect(details.messages).toHaveLength(1);
|
||||
const historyCall = callGatewayMock.mock.calls.find(
|
||||
(call) => (call[0] as { method?: string }).method === "chat.history",
|
||||
);
|
||||
expect(historyCall?.[0]).toMatchObject({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: targetKey },
|
||||
});
|
||||
});
|
||||
|
||||
it("sessions_history errors on missing sessionId", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const sessionId = "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa";
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "sessions.resolve") {
|
||||
throw new Error("No session found");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_history tool");
|
||||
|
||||
const result = await tool.execute("call6", { sessionKey: sessionId });
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toMatch(/Session not found|No session found/);
|
||||
});
|
||||
|
||||
it("sessions_send supports fire-and-forget and wait", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
@@ -313,6 +369,50 @@ describe("sessions tools", () => {
|
||||
expect(sendCallCount).toBe(0);
|
||||
});
|
||||
|
||||
it("sessions_send resolves sessionId inputs", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const sessionId = "sess-send";
|
||||
const targetKey = "agent:main:discord:channel:123";
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: Record<string, unknown> };
|
||||
if (request.method === "sessions.resolve") {
|
||||
return { key: targetKey };
|
||||
}
|
||||
if (request.method === "agent") {
|
||||
return { runId: "run-1", acceptedAt: 123 };
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
return { status: "ok" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
return { messages: [] };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_send");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
const result = await tool.execute("call7", {
|
||||
sessionKey: sessionId,
|
||||
message: "ping",
|
||||
timeoutSeconds: 0,
|
||||
});
|
||||
const details = result.details as { status?: string };
|
||||
expect(details.status).toBe("accepted");
|
||||
const agentCall = callGatewayMock.mock.calls.find(
|
||||
(call) => (call[0] as { method?: string }).method === "agent",
|
||||
);
|
||||
expect(agentCall?.[0]).toMatchObject({
|
||||
method: "agent",
|
||||
params: { sessionKey: targetKey },
|
||||
});
|
||||
});
|
||||
|
||||
it("sessions_send runs ping-pong then announces", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js";
|
||||
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||
import { createTtsTool } from "./tools/tts-tool.js";
|
||||
|
||||
export function createClawdbotTools(options?: {
|
||||
browserControlUrl?: string;
|
||||
@@ -31,6 +32,12 @@ export function createClawdbotTools(options?: {
|
||||
agentTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
agentThreadId?: string | number;
|
||||
/** Group id for channel-level tool policy inheritance. */
|
||||
agentGroupId?: string | null;
|
||||
/** Group channel label for channel-level tool policy inheritance. */
|
||||
agentGroupChannel?: string | null;
|
||||
/** Group space label for channel-level tool policy inheritance. */
|
||||
agentGroupSpace?: string | null;
|
||||
agentDir?: string;
|
||||
sandboxRoot?: string;
|
||||
workspaceDir?: string;
|
||||
@@ -90,6 +97,10 @@ export function createClawdbotTools(options?: {
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
}),
|
||||
createTtsTool({
|
||||
agentChannel: options?.agentChannel,
|
||||
config: options?.config,
|
||||
}),
|
||||
createGatewayTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
@@ -114,6 +125,9 @@ export function createClawdbotTools(options?: {
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentTo: options?.agentTo,
|
||||
agentThreadId: options?.agentThreadId,
|
||||
agentGroupId: options?.agentGroupId,
|
||||
agentGroupChannel: options?.agentGroupChannel,
|
||||
agentGroupSpace: options?.agentGroupSpace,
|
||||
sandboxed: options?.sandboxed,
|
||||
}),
|
||||
createSessionStatusTool({
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||
import { buildSystemPromptParams } from "../system-prompt-params.js";
|
||||
import { resolveDefaultModelForAgent } from "../model-selection.js";
|
||||
import { buildAgentSystemPrompt } from "../system-prompt.js";
|
||||
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
||||
|
||||
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -194,6 +195,7 @@ export function buildSystemPrompt(params: {
|
||||
defaultModel: defaultModelLabel,
|
||||
},
|
||||
});
|
||||
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
||||
return buildAgentSystemPrompt({
|
||||
workspaceDir: params.workspaceDir,
|
||||
defaultThinkLevel: params.defaultThinkLevel,
|
||||
@@ -209,6 +211,7 @@ export function buildSystemPrompt(params: {
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles: params.contextFiles,
|
||||
ttsHint,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js";
|
||||
|
||||
const TIMEOUT_HINT_RE = /timeout|timed out|deadline exceeded|context deadline exceeded/i;
|
||||
const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i;
|
||||
|
||||
export class FailoverError extends Error {
|
||||
readonly reason: FailoverReason;
|
||||
@@ -104,6 +105,8 @@ export function isTimeoutError(err: unknown): boolean {
|
||||
if (hasTimeoutHint(err)) return true;
|
||||
if (!err || typeof err !== "object") return false;
|
||||
if (getErrorName(err) !== "AbortError") return false;
|
||||
const message = getErrorMessage(err);
|
||||
if (message && ABORT_TIMEOUT_RE.test(message)) return true;
|
||||
const cause = "cause" in err ? (err as { cause?: unknown }).cause : undefined;
|
||||
const reason = "reason" in err ? (err as { reason?: unknown }).reason : undefined;
|
||||
return hasTimeoutHint(cause) || hasTimeoutHint(reason);
|
||||
|
||||
37
src/agents/identity-file.test.ts
Normal file
37
src/agents/identity-file.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseIdentityMarkdown } from "./identity-file.js";
|
||||
|
||||
describe("parseIdentityMarkdown", () => {
|
||||
it("ignores identity template placeholders", () => {
|
||||
const content = `
|
||||
# IDENTITY.md - Who Am I?
|
||||
|
||||
- **Name:** *(pick something you like)*
|
||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **Emoji:** *(your signature - pick one that feels right)*
|
||||
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
|
||||
`;
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
expect(parsed).toEqual({});
|
||||
});
|
||||
|
||||
it("parses explicit identity values", () => {
|
||||
const content = `
|
||||
- **Name:** Samantha
|
||||
- **Creature:** Robot
|
||||
- **Vibe:** Warm
|
||||
- **Emoji:** :robot:
|
||||
- **Avatar:** avatars/clawd.png
|
||||
`;
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
expect(parsed).toEqual({
|
||||
name: "Samantha",
|
||||
creature: "Robot",
|
||||
vibe: "Warm",
|
||||
emoji: ":robot:",
|
||||
avatar: "avatars/clawd.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,30 @@ export type AgentIdentityFile = {
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
const IDENTITY_PLACEHOLDER_VALUES = new Set([
|
||||
"pick something you like",
|
||||
"ai? robot? familiar? ghost in the machine? something weirder?",
|
||||
"how do you come across? sharp? warm? chaotic? calm?",
|
||||
"your signature - pick one that feels right",
|
||||
"workspace-relative path, http(s) url, or data uri",
|
||||
]);
|
||||
|
||||
function normalizeIdentityValue(value: string): string {
|
||||
let normalized = value.trim();
|
||||
normalized = normalized.replace(/^[*_]+|[*_]+$/g, "").trim();
|
||||
if (normalized.startsWith("(") && normalized.endsWith(")")) {
|
||||
normalized = normalized.slice(1, -1).trim();
|
||||
}
|
||||
normalized = normalized.replace(/[\u2013\u2014]/g, "-");
|
||||
normalized = normalized.replace(/\s+/g, " ").toLowerCase();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isIdentityPlaceholder(value: string): boolean {
|
||||
const normalized = normalizeIdentityValue(value);
|
||||
return IDENTITY_PLACEHOLDER_VALUES.has(normalized);
|
||||
}
|
||||
|
||||
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||
const identity: AgentIdentityFile = {};
|
||||
const lines = content.split(/\r?\n/);
|
||||
@@ -25,6 +49,7 @@ export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||
.replace(/^[*_]+|[*_]+$/g, "")
|
||||
.trim();
|
||||
if (!value) continue;
|
||||
if (isIdentityPlaceholder(value)) continue;
|
||||
if (label === "name") identity.name = value;
|
||||
if (label === "emoji") identity.emoji = value;
|
||||
if (label === "creature") identity.creature = value;
|
||||
|
||||
@@ -346,6 +346,28 @@ describe("runWithModelFallback", () => {
|
||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||
});
|
||||
|
||||
it("falls back on provider abort errors with request-aborted messages", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Request was aborted"), { name: "AbortError" }),
|
||||
)
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
|
||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||
});
|
||||
|
||||
it("does not fall back on user aborts", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js";
|
||||
|
||||
describe("downgradeOpenAIReasoningBlocks", () => {
|
||||
it("keeps reasoning signatures when followed by content", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "internal reasoning",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
|
||||
},
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
|
||||
});
|
||||
|
||||
it("drops orphaned reasoning blocks without following content", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "next" },
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([
|
||||
{ role: "user", content: "next" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops object-form orphaned signatures", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinkingSignature: { id: "rs_obj", type: "reasoning" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps non-reasoning thinking signatures", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "t",
|
||||
thinkingSignature: "reasoning_content",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,8 @@ export {
|
||||
parseImageDimensionError,
|
||||
} from "./pi-embedded-helpers/errors.js";
|
||||
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
|
||||
|
||||
export { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers/openai.js";
|
||||
export {
|
||||
isEmptyAssistantMessageContent,
|
||||
sanitizeSessionMessagesImages,
|
||||
|
||||
118
src/agents/pi-embedded-helpers/openai.ts
Normal file
118
src/agents/pi-embedded-helpers/openai.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
type OpenAIThinkingBlock = {
|
||||
type?: unknown;
|
||||
thinking?: unknown;
|
||||
thinkingSignature?: unknown;
|
||||
};
|
||||
|
||||
type OpenAIReasoningSignature = {
|
||||
id: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
|
||||
if (!value) return null;
|
||||
let candidate: { id?: unknown; type?: unknown } | null = null;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||
try {
|
||||
candidate = JSON.parse(trimmed) as { id?: unknown; type?: unknown };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
candidate = value as { id?: unknown; type?: unknown };
|
||||
}
|
||||
if (!candidate) return null;
|
||||
const id = typeof candidate.id === "string" ? candidate.id : "";
|
||||
const type = typeof candidate.type === "string" ? candidate.type : "";
|
||||
if (!id.startsWith("rs_")) return null;
|
||||
if (type === "reasoning" || type.startsWith("reasoning.")) {
|
||||
return { id, type };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasFollowingNonThinkingBlock(
|
||||
content: Extract<AgentMessage, { role: "assistant" }>["content"],
|
||||
index: number,
|
||||
): boolean {
|
||||
for (let i = index + 1; i < content.length; i++) {
|
||||
const block = content[i];
|
||||
if (!block || typeof block !== "object") return true;
|
||||
if ((block as { type?: unknown }).type !== "thinking") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
|
||||
* without the required following item.
|
||||
*
|
||||
* Clawdbot persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata
|
||||
* is incomplete, drop the block to keep history usable.
|
||||
*/
|
||||
export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||
const out: AgentMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = (msg as { role?: unknown }).role;
|
||||
if (role !== "assistant") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||
if (!Array.isArray(assistantMsg.content)) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
type AssistantContentBlock = (typeof assistantMsg.content)[number];
|
||||
|
||||
const nextContent: AssistantContentBlock[] = [];
|
||||
for (let i = 0; i < assistantMsg.content.length; i++) {
|
||||
const block = assistantMsg.content[i];
|
||||
if (!block || typeof block !== "object") {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
const record = block as OpenAIThinkingBlock;
|
||||
if (record.type !== "thinking") {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
const signature = parseOpenAIReasoningSignature(record.thinkingSignature);
|
||||
if (!signature) {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) {
|
||||
nextContent.push(block as AssistantContentBlock);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextContent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -128,7 +128,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
|
||||
slack: { dmHistoryLimit: 10 },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:C1", config)).toBeUndefined();
|
||||
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:c1", config)).toBeUndefined();
|
||||
expect(getDmHistoryLimitFromSessionKey("telegram:slash:123", config)).toBeUndefined();
|
||||
});
|
||||
it("returns undefined for unknown provider", () => {
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("resolveSessionAgentIds", () => {
|
||||
});
|
||||
it("keeps the agent id for provider-qualified agent sessions", () => {
|
||||
const { sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: "agent:beta:slack:channel:C1",
|
||||
sessionKey: "agent:beta:slack:channel:c1",
|
||||
config: cfg,
|
||||
});
|
||||
expect(sessionAgentId).toBe("beta");
|
||||
|
||||
@@ -63,12 +63,12 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeConfig = (): ClawdbotConfig =>
|
||||
const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotConfig =>
|
||||
({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
fallbacks: [],
|
||||
fallbacks: opts?.fallbacks ?? [],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -76,7 +76,7 @@ const makeConfig = (): ClawdbotConfig =>
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
apiKey: "sk-test",
|
||||
apiKey: opts?.apiKey ?? "sk-test",
|
||||
baseUrl: "https://example.com",
|
||||
models: [
|
||||
{
|
||||
@@ -94,7 +94,13 @@ const makeConfig = (): ClawdbotConfig =>
|
||||
},
|
||||
}) satisfies ClawdbotConfig;
|
||||
|
||||
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
|
||||
const writeAuthStore = async (
|
||||
agentDir: string,
|
||||
opts?: {
|
||||
includeAnthropic?: boolean;
|
||||
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
|
||||
},
|
||||
) => {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const payload = {
|
||||
version: 1,
|
||||
@@ -105,10 +111,12 @@ const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: bool
|
||||
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
|
||||
: {}),
|
||||
},
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1 },
|
||||
"openai:p2": { lastUsed: 2 },
|
||||
},
|
||||
usageStats:
|
||||
opts?.usageStats ??
|
||||
({
|
||||
"openai:p1": { lastUsed: 1 },
|
||||
"openai:p2": { lastUsed: 2 },
|
||||
} as Record<string, { lastUsed?: number }>),
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
||||
};
|
||||
@@ -384,6 +392,92 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fails over when all profiles are in cooldown and fallbacks are configured", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
try {
|
||||
await writeAuthStore(agentDir, {
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
|
||||
"openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:cooldown-failover",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileIdSource: "auto",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:cooldown-failover",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "FailoverError",
|
||||
reason: "rate_limit",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails over when auth is unavailable and fallbacks are configured", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const previousOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
await fs.writeFile(authPath, JSON.stringify({ version: 1, profiles: {}, usageStats: {} }));
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:auth-unavailable",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"], apiKey: "" }),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileIdSource: "auto",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:auth-unavailable",
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "FailoverError", reason: "auth" });
|
||||
|
||||
expect(runEmbeddedAttemptMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousOpenAiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
||||
}
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips profiles in cooldown when rotating after failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -161,4 +161,92 @@ describe("sanitizeSessionHistory", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("does not downgrade openai reasoning when the model has not changed", async () => {
|
||||
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||
{
|
||||
type: "custom",
|
||||
customType: "model-snapshot",
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
provider: "openai",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "gpt-5.2-codex",
|
||||
},
|
||||
},
|
||||
];
|
||||
const sessionManager = {
|
||||
getEntries: vi.fn(() => sessionEntries),
|
||||
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||
sessionEntries.push({ type: "custom", customType, data });
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning",
|
||||
thinkingSignature: JSON.stringify({ id: "rs_test", type: "reasoning" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(result).toEqual(messages);
|
||||
});
|
||||
|
||||
it("downgrades openai reasoning only when the model changes", async () => {
|
||||
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
|
||||
{
|
||||
type: "custom",
|
||||
customType: "model-snapshot",
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
provider: "anthropic",
|
||||
modelApi: "anthropic-messages",
|
||||
modelId: "claude-3-7",
|
||||
},
|
||||
},
|
||||
];
|
||||
const sessionManager = {
|
||||
getEntries: vi.fn(() => sessionEntries),
|
||||
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
|
||||
sessionEntries.push({ type: "custom", customType, data });
|
||||
}),
|
||||
} as unknown as SessionManager;
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning",
|
||||
thinkingSignature: { id: "rs_test", type: "reasoning" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.2-codex",
|
||||
sessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ import { splitSdkTools } from "./tool-split.js";
|
||||
import type { EmbeddedPiCompactResult } from "./types.js";
|
||||
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
|
||||
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
|
||||
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
||||
|
||||
export async function compactEmbeddedPiSession(params: {
|
||||
sessionId: string;
|
||||
@@ -73,6 +74,14 @@ export async function compactEmbeddedPiSession(params: {
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||
groupChannel?: string | null;
|
||||
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
@@ -207,6 +216,10 @@ export async function compactEmbeddedPiSession(params: {
|
||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
@@ -286,6 +299,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
cwd: process.cwd(),
|
||||
moduleUrl: import.meta.url,
|
||||
});
|
||||
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
@@ -298,6 +312,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
: undefined,
|
||||
skillsPrompt,
|
||||
docsPath: docsPath ?? undefined,
|
||||
ttsHint,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
messageToolHints,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||
import {
|
||||
downgradeOpenAIReasoningBlocks,
|
||||
isCompactionFailureError,
|
||||
isGoogleModelApi,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
@@ -211,7 +212,50 @@ registerUnhandledRejectionHandler((reason) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
type CustomEntryLike = { type?: unknown; customType?: unknown };
|
||||
type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown };
|
||||
|
||||
type ModelSnapshotEntry = {
|
||||
timestamp: number;
|
||||
provider?: string;
|
||||
modelApi?: string | null;
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot";
|
||||
|
||||
function readLastModelSnapshot(sessionManager: SessionManager): ModelSnapshotEntry | null {
|
||||
try {
|
||||
const entries = sessionManager.getEntries();
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i] as CustomEntryLike;
|
||||
if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) continue;
|
||||
const data = entry?.data as ModelSnapshotEntry | undefined;
|
||||
if (data && typeof data === "object") {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendModelSnapshot(sessionManager: SessionManager, data: ModelSnapshotEntry): void {
|
||||
try {
|
||||
sessionManager.appendCustomEntry(MODEL_SNAPSHOT_CUSTOM_TYPE, data);
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
function isSameModelSnapshot(a: ModelSnapshotEntry, b: ModelSnapshotEntry): boolean {
|
||||
const normalize = (value?: string | null) => value ?? "";
|
||||
return (
|
||||
normalize(a.provider) === normalize(b.provider) &&
|
||||
normalize(a.modelApi) === normalize(b.modelApi) &&
|
||||
normalize(a.modelId) === normalize(b.modelId)
|
||||
);
|
||||
}
|
||||
|
||||
function hasGoogleTurnOrderingMarker(sessionManager: SessionManager): boolean {
|
||||
try {
|
||||
@@ -292,12 +336,38 @@ export async function sanitizeSessionHistory(params: {
|
||||
? sanitizeToolUseResultPairing(sanitizedThinking)
|
||||
: sanitizedThinking;
|
||||
|
||||
const isOpenAIResponsesApi =
|
||||
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
|
||||
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
|
||||
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
|
||||
const modelChanged = priorSnapshot
|
||||
? !isSameModelSnapshot(priorSnapshot, {
|
||||
timestamp: 0,
|
||||
provider: params.provider,
|
||||
modelApi: params.modelApi,
|
||||
modelId: params.modelId,
|
||||
})
|
||||
: false;
|
||||
const sanitizedOpenAI =
|
||||
isOpenAIResponsesApi && modelChanged
|
||||
? downgradeOpenAIReasoningBlocks(repairedTools)
|
||||
: repairedTools;
|
||||
|
||||
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
|
||||
appendModelSnapshot(params.sessionManager, {
|
||||
timestamp: Date.now(),
|
||||
provider: params.provider,
|
||||
modelApi: params.modelApi,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!policy.applyGoogleTurnOrdering) {
|
||||
return repairedTools;
|
||||
return sanitizedOpenAI;
|
||||
}
|
||||
|
||||
return applyGoogleTurnOrderingFix({
|
||||
messages: repairedTools,
|
||||
messages: sanitizedOpenAI,
|
||||
modelApi: params.modelApi,
|
||||
sessionManager: params.sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
isRateLimitAssistantError,
|
||||
isTimeoutErrorMessage,
|
||||
pickFallbackThinkingLevel,
|
||||
type FailoverReason,
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { normalizeUsage, type UsageLike } from "../usage.js";
|
||||
|
||||
@@ -92,6 +93,8 @@ export async function runEmbeddedPiAgent(
|
||||
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||
const fallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
await ensureClawdbotModelsJson(params.config, agentDir);
|
||||
|
||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||
@@ -165,6 +168,42 @@ export async function runEmbeddedPiAgent(
|
||||
let apiKeyInfo: ApiKeyInfo | null = null;
|
||||
let lastProfileId: string | undefined;
|
||||
|
||||
const resolveAuthProfileFailoverReason = (params: {
|
||||
allInCooldown: boolean;
|
||||
message: string;
|
||||
}): FailoverReason => {
|
||||
if (params.allInCooldown) return "rate_limit";
|
||||
const classified = classifyFailoverReason(params.message);
|
||||
return classified ?? "auth";
|
||||
};
|
||||
|
||||
const throwAuthProfileFailover = (params: {
|
||||
allInCooldown: boolean;
|
||||
message?: string;
|
||||
error?: unknown;
|
||||
}): never => {
|
||||
const fallbackMessage = `No available auth profile for ${provider} (all in cooldown or unavailable).`;
|
||||
const message =
|
||||
params.message?.trim() ||
|
||||
(params.error ? describeUnknownError(params.error).trim() : "") ||
|
||||
fallbackMessage;
|
||||
const reason = resolveAuthProfileFailoverReason({
|
||||
allInCooldown: params.allInCooldown,
|
||||
message,
|
||||
});
|
||||
if (fallbackConfigured) {
|
||||
throw new FailoverError(message, {
|
||||
reason,
|
||||
provider,
|
||||
model: modelId,
|
||||
status: resolveFailoverStatus(reason),
|
||||
cause: params.error,
|
||||
});
|
||||
}
|
||||
if (params.error instanceof Error) throw params.error;
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const resolveApiKeyForCandidate = async (candidate?: string) => {
|
||||
return getApiKeyForModel({
|
||||
model,
|
||||
@@ -238,14 +277,17 @@ export async function runEmbeddedPiAgent(
|
||||
break;
|
||||
}
|
||||
if (profileIndex >= profileCandidates.length) {
|
||||
throw new Error(
|
||||
`No available auth profile for ${provider} (all in cooldown or unavailable).`,
|
||||
);
|
||||
throwAuthProfileFailover({ allInCooldown: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
|
||||
if (err instanceof FailoverError) throw err;
|
||||
if (profileCandidates[profileIndex] === lockedProfileId) {
|
||||
throwAuthProfileFailover({ allInCooldown: false, error: err });
|
||||
}
|
||||
const advanced = await advanceAuthProfile();
|
||||
if (!advanced) throw err;
|
||||
if (!advanced) {
|
||||
throwAuthProfileFailover({ allInCooldown: false, error: err });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -264,6 +306,10 @@ export async function runEmbeddedPiAgent(
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
@@ -389,9 +435,7 @@ export async function runEmbeddedPiAgent(
|
||||
}
|
||||
// FIX: Throw FailoverError for prompt errors when fallbacks configured
|
||||
// This enables model fallback for quota/rate limit errors during prompt submission
|
||||
const promptFallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
if (promptFallbackConfigured && isFailoverErrorMessage(errorText)) {
|
||||
if (fallbackConfigured && isFailoverErrorMessage(errorText)) {
|
||||
throw new FailoverError(errorText, {
|
||||
reason: promptFailoverReason ?? "unknown",
|
||||
provider,
|
||||
@@ -415,8 +459,6 @@ export async function runEmbeddedPiAgent(
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
const authFailure = isAuthAssistantError(lastAssistant);
|
||||
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
|
||||
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
import { createCacheTrace } from "../../cache-trace.js";
|
||||
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
|
||||
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||
@@ -77,6 +78,7 @@ import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
|
||||
import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
||||
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||
import { buildTtsSystemPromptHint } from "../../../tts/tts.js";
|
||||
import { isTimeoutError } from "../../failover-error.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
|
||||
@@ -208,6 +210,10 @@ export async function runEmbeddedAttempt(
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
@@ -310,6 +316,7 @@ export async function runEmbeddedAttempt(
|
||||
cwd: process.cwd(),
|
||||
moduleUrl: import.meta.url,
|
||||
});
|
||||
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
||||
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
@@ -323,6 +330,7 @@ export async function runEmbeddedAttempt(
|
||||
: undefined,
|
||||
skillsPrompt,
|
||||
docsPath: docsPath ?? undefined,
|
||||
ttsHint,
|
||||
workspaceNotes,
|
||||
reactionGuidance,
|
||||
promptMode,
|
||||
@@ -454,6 +462,16 @@ export async function runEmbeddedAttempt(
|
||||
modelApi: params.model.api,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const anthropicPayloadLogger = createAnthropicPayloadLogger({
|
||||
env: process.env,
|
||||
runId: params.runId,
|
||||
sessionId: activeSession.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.model.api,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
|
||||
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
|
||||
activeSession.agent.streamFn = streamSimple;
|
||||
@@ -474,6 +492,11 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn);
|
||||
}
|
||||
if (anthropicPayloadLogger) {
|
||||
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
|
||||
activeSession.agent.streamFn,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const prior = await sanitizeSessionHistory({
|
||||
@@ -768,6 +791,7 @@ export async function runEmbeddedAttempt(
|
||||
messages: messagesSnapshot,
|
||||
note: promptError ? "prompt error" : undefined,
|
||||
});
|
||||
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError);
|
||||
|
||||
// Run agent_end hooks to allow plugins to analyze the conversation
|
||||
// This is fire-and-forget, so we don't await
|
||||
|
||||
@@ -27,6 +27,14 @@ export type RunEmbeddedPiAgentParams = {
|
||||
messageTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
messageThreadId?: string | number;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||
groupChannel?: string | null;
|
||||
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
|
||||
@@ -148,6 +148,35 @@ describe("buildEmbeddedRunPayloads", () => {
|
||||
expect(payloads[0]?.text).toBe("All good");
|
||||
});
|
||||
|
||||
it("adds tool error fallback when the assistant only invoked tools", () => {
|
||||
const payloads = buildEmbeddedRunPayloads({
|
||||
assistantTexts: [],
|
||||
toolMetas: [],
|
||||
lastAssistant: {
|
||||
stopReason: "toolUse",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "toolu_01",
|
||||
name: "exec",
|
||||
arguments: { command: "echo hi" },
|
||||
},
|
||||
],
|
||||
} as AssistantMessage,
|
||||
lastToolError: { toolName: "exec", error: "Command exited with code 1" },
|
||||
sessionKey: "session:telegram",
|
||||
inlineToolResultsAllowed: false,
|
||||
verboseLevel: "off",
|
||||
reasoningLevel: "off",
|
||||
toolResultFormat: "plain",
|
||||
});
|
||||
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]?.isError).toBe(true);
|
||||
expect(payloads[0]?.text).toContain("Exec");
|
||||
expect(payloads[0]?.text).toContain("code 1");
|
||||
});
|
||||
|
||||
it("suppresses recoverable tool errors containing 'required'", () => {
|
||||
const payloads = buildEmbeddedRunPayloads({
|
||||
assistantTexts: [],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user