Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
885fc2bb30 | ||
|
|
970d0430aa |
No files matched your search
@@ -77,7 +77,6 @@
|
|||||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
||||||
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
|
|
||||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||||
|
|||||||
+2
-32
@@ -2,61 +2,31 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.23 (Unreleased)
|
## 2026.1.23
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- 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).
|
|
||||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
|
||||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
- 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.
|
- 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.
|
- Docs: remove the misplaced Google Docs Editor entry from the showcase. (#1547) Thanks @aj47.
|
||||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
|
||||||
- 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.
|
- 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.
|
- 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: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
- 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: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||||
- CLI: suppress diagnostic session/run noise during auth probes.
|
- CLI: suppress diagnostic session/run noise during auth probes.
|
||||||
- CLI: hide auth probe timeout warnings from embedded runs.
|
- CLI: hide auth probe timeout warnings from embedded runs.
|
||||||
- CLI: render auth probe results as a table in `clawdbot models status`.
|
- CLI: render auth probe results as a table in `clawdbot models status`.
|
||||||
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
|
|
||||||
- CLI: move auth probe errors below the table to reduce wrapping.
|
|
||||||
- CLI: prevent ANSI color bleed when table cells wrap.
|
|
||||||
- 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.
|
- 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`).
|
- 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.
|
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||||
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
|
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) 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
|
## 2026.1.22
|
||||||
|
|
||||||
|
|||||||
@@ -478,30 +478,28 @@ Thanks to all clawtributors:
|
|||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
|
||||||
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/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/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
||||||
<a href="https://github.com/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/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
|
||||||
<a href="https://github.com/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/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
||||||
<a href="https://github.com/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/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
||||||
<a href="https://github.com/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/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/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/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
||||||
<a href="https://github.com/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/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||||
<a href="https://github.com/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/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/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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||||
<a href="https://github.com/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/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/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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
||||||
<a href="https://github.com/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/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
|
||||||
<a href="https://github.com/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/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
|
||||||
<a href="https://github.com/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/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
||||||
<a href="https://github.com/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/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/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/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/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/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/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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
||||||
<a href="https://github.com/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/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/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/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
||||||
<a href="https://github.com/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/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||||
<a href="https://github.com/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/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/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
|
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
||||||
<a href="https://github.com/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>
|
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -385,7 +385,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let to: String
|
public let to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
public let mediaurls: [String]?
|
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
@@ -396,7 +395,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
to: String,
|
to: String,
|
||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
mediaurls: [String]?,
|
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
@@ -406,7 +404,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.to = to
|
self.to = to
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
self.mediaurls = mediaurls
|
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
@@ -417,7 +414,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case to
|
case to
|
||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
case mediaurls = "mediaUrls"
|
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
@@ -482,9 +478,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
public let threadid: String?
|
public let threadid: String?
|
||||||
public let groupid: String?
|
|
||||||
public let groupchannel: String?
|
|
||||||
public let groupspace: String?
|
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -507,9 +500,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
threadid: String?,
|
threadid: String?,
|
||||||
groupid: String?,
|
|
||||||
groupchannel: String?,
|
|
||||||
groupspace: String?,
|
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -531,9 +521,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
self.threadid = threadid
|
self.threadid = threadid
|
||||||
self.groupid = groupid
|
|
||||||
self.groupchannel = groupchannel
|
|
||||||
self.groupspace = groupspace
|
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -556,9 +543,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
case threadid = "threadId"
|
case threadid = "threadId"
|
||||||
case groupid = "groupId"
|
|
||||||
case groupchannel = "groupChannel"
|
|
||||||
case groupspace = "groupSpace"
|
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -1989,25 +1973,25 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
public let id: String?
|
public let id: String?
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: AnyCodable?
|
public let cwd: String?
|
||||||
public let host: AnyCodable?
|
public let host: String?
|
||||||
public let security: AnyCodable?
|
public let security: String?
|
||||||
public let ask: AnyCodable?
|
public let ask: String?
|
||||||
public let agentid: AnyCodable?
|
public let agentid: String?
|
||||||
public let resolvedpath: AnyCodable?
|
public let resolvedpath: String?
|
||||||
public let sessionkey: AnyCodable?
|
public let sessionkey: String?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String?,
|
id: String?,
|
||||||
command: String,
|
command: String,
|
||||||
cwd: AnyCodable?,
|
cwd: String?,
|
||||||
host: AnyCodable?,
|
host: String?,
|
||||||
security: AnyCodable?,
|
security: String?,
|
||||||
ask: AnyCodable?,
|
ask: String?,
|
||||||
agentid: AnyCodable?,
|
agentid: String?,
|
||||||
resolvedpath: AnyCodable?,
|
resolvedpath: String?,
|
||||||
sessionkey: AnyCodable?,
|
sessionkey: String?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
|||||||
@@ -385,7 +385,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let to: String
|
public let to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
public let mediaurls: [String]?
|
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
@@ -396,7 +395,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
to: String,
|
to: String,
|
||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
mediaurls: [String]?,
|
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
@@ -406,7 +404,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.to = to
|
self.to = to
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
self.mediaurls = mediaurls
|
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
@@ -417,7 +414,6 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case to
|
case to
|
||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
case mediaurls = "mediaUrls"
|
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
@@ -482,9 +478,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
public let threadid: String?
|
public let threadid: String?
|
||||||
public let groupid: String?
|
|
||||||
public let groupchannel: String?
|
|
||||||
public let groupspace: String?
|
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@@ -507,9 +500,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
threadid: String?,
|
threadid: String?,
|
||||||
groupid: String?,
|
|
||||||
groupchannel: String?,
|
|
||||||
groupspace: String?,
|
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -531,9 +521,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
self.threadid = threadid
|
self.threadid = threadid
|
||||||
self.groupid = groupid
|
|
||||||
self.groupchannel = groupchannel
|
|
||||||
self.groupspace = groupspace
|
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -556,9 +543,6 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
case threadid = "threadId"
|
case threadid = "threadId"
|
||||||
case groupid = "groupId"
|
|
||||||
case groupchannel = "groupChannel"
|
|
||||||
case groupspace = "groupSpace"
|
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -1989,25 +1973,25 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
public let id: String?
|
public let id: String?
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: AnyCodable?
|
public let cwd: String?
|
||||||
public let host: AnyCodable?
|
public let host: String?
|
||||||
public let security: AnyCodable?
|
public let security: String?
|
||||||
public let ask: AnyCodable?
|
public let ask: String?
|
||||||
public let agentid: AnyCodable?
|
public let agentid: String?
|
||||||
public let resolvedpath: AnyCodable?
|
public let resolvedpath: String?
|
||||||
public let sessionkey: AnyCodable?
|
public let sessionkey: String?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String?,
|
id: String?,
|
||||||
command: String,
|
command: String,
|
||||||
cwd: AnyCodable?,
|
cwd: String?,
|
||||||
host: AnyCodable?,
|
host: String?,
|
||||||
security: AnyCodable?,
|
security: String?,
|
||||||
ask: AnyCodable?,
|
ask: String?,
|
||||||
agentid: AnyCodable?,
|
agentid: String?,
|
||||||
resolvedpath: AnyCodable?,
|
resolvedpath: String?,
|
||||||
sessionkey: AnyCodable?,
|
sessionkey: String?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ services:
|
|||||||
[
|
[
|
||||||
"node",
|
"node",
|
||||||
"dist/index.js",
|
"dist/index.js",
|
||||||
"gateway",
|
"gateway-daemon",
|
||||||
"--bind",
|
"--bind",
|
||||||
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
||||||
"--port",
|
"--port",
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ summary: "Cron jobs + wakeups for the Gateway scheduler"
|
|||||||
read_when:
|
read_when:
|
||||||
- Scheduling background jobs or wakeups
|
- Scheduling background jobs or wakeups
|
||||||
- Wiring automation that should run with or alongside heartbeats
|
- Wiring automation that should run with or alongside heartbeats
|
||||||
- Deciding between heartbeat and cron for scheduled tasks
|
|
||||||
---
|
---
|
||||||
# Cron jobs (Gateway scheduler)
|
# Cron jobs (Gateway scheduler)
|
||||||
|
|
||||||
> **Cron vs Heartbeat?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
|
|
||||||
|
|
||||||
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at
|
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at
|
||||||
the right time, and can optionally deliver output back to a chat.
|
the right time, and can optionally deliver output back to a chat.
|
||||||
|
|
||||||
@@ -263,15 +260,15 @@ Run history:
|
|||||||
clawdbot cron runs --id <jobId> --limit 50
|
clawdbot cron runs --id <jobId> --limit 50
|
||||||
```
|
```
|
||||||
|
|
||||||
Immediate system event without creating a job:
|
Immediate wake without creating a job:
|
||||||
```bash
|
```bash
|
||||||
clawdbot system event --mode now --text "Next heartbeat: check battery."
|
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gateway API surface
|
## Gateway API surface
|
||||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||||
- `cron.run` (force or due), `cron.runs`
|
- `cron.run` (force or due), `cron.runs`
|
||||||
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
|
- `wake` (enqueue system event + optional heartbeat)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Guidance for choosing between heartbeat and cron jobs for automation"
|
|
||||||
read_when:
|
|
||||||
- Deciding how to schedule recurring tasks
|
|
||||||
- Setting up background monitoring or notifications
|
|
||||||
- Optimizing token usage for periodic checks
|
|
||||||
---
|
|
||||||
# Cron vs Heartbeat: When to Use Each
|
|
||||||
|
|
||||||
Both heartbeats and cron jobs let you run tasks on a schedule. This guide helps you choose the right mechanism for your use case.
|
|
||||||
|
|
||||||
## Quick Decision Guide
|
|
||||||
|
|
||||||
| Use Case | Recommended | Why |
|
|
||||||
|----------|-------------|-----|
|
|
||||||
| Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware |
|
|
||||||
| Send daily report at 9am sharp | Cron (isolated) | Exact timing needed |
|
|
||||||
| Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness |
|
|
||||||
| Run weekly deep analysis | Cron (isolated) | Standalone task, can use different model |
|
|
||||||
| Remind me in 20 minutes | Cron (main, `--at`) | One-shot with precise timing |
|
|
||||||
| Background project health check | Heartbeat | Piggybacks on existing cycle |
|
|
||||||
|
|
||||||
## Heartbeat: Periodic Awareness
|
|
||||||
|
|
||||||
Heartbeats run in the **main session** at a regular interval (default: 30 min). They're designed for the agent to check on things and surface anything important.
|
|
||||||
|
|
||||||
### When to use heartbeat
|
|
||||||
|
|
||||||
- **Multiple periodic checks**: Instead of 5 separate cron jobs checking inbox, calendar, weather, notifications, and project status, a single heartbeat can batch all of these.
|
|
||||||
- **Context-aware decisions**: The agent has full main-session context, so it can make smart decisions about what's urgent vs. what can wait.
|
|
||||||
- **Conversational continuity**: Heartbeat runs share the same session, so the agent remembers recent conversations and can follow up naturally.
|
|
||||||
- **Low-overhead monitoring**: One heartbeat replaces many small polling tasks.
|
|
||||||
|
|
||||||
### Heartbeat advantages
|
|
||||||
|
|
||||||
- **Batches multiple checks**: One agent turn can review inbox, calendar, and notifications together.
|
|
||||||
- **Reduces API calls**: A single heartbeat is cheaper than 5 isolated cron jobs.
|
|
||||||
- **Context-aware**: The agent knows what you've been working on and can prioritize accordingly.
|
|
||||||
- **Smart suppression**: If nothing needs attention, the agent replies `HEARTBEAT_OK` and no message is delivered.
|
|
||||||
- **Natural timing**: Drifts slightly based on queue load, which is fine for most monitoring.
|
|
||||||
|
|
||||||
### Heartbeat example: HEARTBEAT.md checklist
|
|
||||||
|
|
||||||
```md
|
|
||||||
# Heartbeat checklist
|
|
||||||
|
|
||||||
- Check email for urgent messages
|
|
||||||
- Review calendar for events in next 2 hours
|
|
||||||
- If a background task finished, summarize results
|
|
||||||
- If idle for 8+ hours, send a brief check-in
|
|
||||||
```
|
|
||||||
|
|
||||||
The agent reads this on each heartbeat and handles all items in one turn.
|
|
||||||
|
|
||||||
### Configuring heartbeat
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
heartbeat: {
|
|
||||||
every: "30m", // interval
|
|
||||||
target: "last", // where to deliver alerts
|
|
||||||
activeHours: { start: "08:00", end: "22:00" } // optional
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Heartbeat](/gateway/heartbeat) for full configuration.
|
|
||||||
|
|
||||||
## Cron: Precise Scheduling
|
|
||||||
|
|
||||||
Cron jobs run at **exact times** and can run in isolated sessions without affecting main context.
|
|
||||||
|
|
||||||
### When to use cron
|
|
||||||
|
|
||||||
- **Exact timing required**: "Send this at 9:00 AM every Monday" (not "sometime around 9").
|
|
||||||
- **Standalone tasks**: Tasks that don't need conversational context.
|
|
||||||
- **Different model/thinking**: Heavy analysis that warrants a more powerful model.
|
|
||||||
- **One-shot reminders**: "Remind me in 20 minutes" with `--at`.
|
|
||||||
- **Noisy/frequent tasks**: Tasks that would clutter main session history.
|
|
||||||
- **External triggers**: Tasks that should run independently of whether the agent is otherwise active.
|
|
||||||
|
|
||||||
### Cron advantages
|
|
||||||
|
|
||||||
- **Exact timing**: 5-field cron expressions with timezone support.
|
|
||||||
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
|
|
||||||
- **Model overrides**: Use a cheaper or more powerful model per job.
|
|
||||||
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
|
|
||||||
- **No agent context needed**: Runs even if main session is idle or compacted.
|
|
||||||
- **One-shot support**: `--at` for precise future timestamps.
|
|
||||||
|
|
||||||
### Cron example: Daily morning briefing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot cron add \
|
|
||||||
--name "Morning briefing" \
|
|
||||||
--cron "0 7 * * *" \
|
|
||||||
--tz "America/New_York" \
|
|
||||||
--session isolated \
|
|
||||||
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
|
||||||
--model opus \
|
|
||||||
--deliver \
|
|
||||||
--channel whatsapp \
|
|
||||||
--to "+15551234567"
|
|
||||||
```
|
|
||||||
|
|
||||||
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
|
|
||||||
|
|
||||||
### Cron example: One-shot reminder
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot cron add \
|
|
||||||
--name "Meeting reminder" \
|
|
||||||
--at "20m" \
|
|
||||||
--session main \
|
|
||||||
--system-event "Reminder: standup meeting starts in 10 minutes." \
|
|
||||||
--wake now \
|
|
||||||
--delete-after-run
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Cron jobs](/automation/cron-jobs) for full CLI reference.
|
|
||||||
|
|
||||||
## Decision Flowchart
|
|
||||||
|
|
||||||
```
|
|
||||||
Does the task need to run at an EXACT time?
|
|
||||||
YES -> Use cron
|
|
||||||
NO -> Continue...
|
|
||||||
|
|
||||||
Does the task need isolation from main session?
|
|
||||||
YES -> Use cron (isolated)
|
|
||||||
NO -> Continue...
|
|
||||||
|
|
||||||
Can this task be batched with other periodic checks?
|
|
||||||
YES -> Use heartbeat (add to HEARTBEAT.md)
|
|
||||||
NO -> Use cron
|
|
||||||
|
|
||||||
Is this a one-shot reminder?
|
|
||||||
YES -> Use cron with --at
|
|
||||||
NO -> Continue...
|
|
||||||
|
|
||||||
Does it need a different model or thinking level?
|
|
||||||
YES -> Use cron (isolated) with --model/--thinking
|
|
||||||
NO -> Use heartbeat
|
|
||||||
```
|
|
||||||
|
|
||||||
## Combining Both
|
|
||||||
|
|
||||||
The most efficient setup uses **both**:
|
|
||||||
|
|
||||||
1. **Heartbeat** handles routine monitoring (inbox, calendar, notifications) in one batched turn every 30 minutes.
|
|
||||||
2. **Cron** handles precise schedules (daily reports, weekly reviews) and one-shot reminders.
|
|
||||||
|
|
||||||
### Example: Efficient automation setup
|
|
||||||
|
|
||||||
**HEARTBEAT.md** (checked every 30 min):
|
|
||||||
```md
|
|
||||||
# Heartbeat checklist
|
|
||||||
- Scan inbox for urgent emails
|
|
||||||
- Check calendar for events in next 2h
|
|
||||||
- Review any pending tasks
|
|
||||||
- Light check-in if quiet for 8+ hours
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cron jobs** (precise timing):
|
|
||||||
```bash
|
|
||||||
# Daily morning briefing at 7am
|
|
||||||
clawdbot cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
|
|
||||||
|
|
||||||
# Weekly project review on Mondays at 9am
|
|
||||||
clawdbot cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
|
||||||
|
|
||||||
# One-shot reminder
|
|
||||||
clawdbot cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
## Lobster: Deterministic workflows with approvals
|
|
||||||
|
|
||||||
Lobster is the workflow runtime for **multi-step tool pipelines** that need deterministic execution and explicit approvals.
|
|
||||||
Use it when the task is more than a single agent turn, and you want a resumable workflow with human checkpoints.
|
|
||||||
|
|
||||||
### When Lobster fits
|
|
||||||
|
|
||||||
- **Multi-step automation**: You need a fixed pipeline of tool calls, not a one-off prompt.
|
|
||||||
- **Approval gates**: Side effects should pause until you approve, then resume.
|
|
||||||
- **Resumable runs**: Continue a paused workflow without re-running earlier steps.
|
|
||||||
|
|
||||||
### How it pairs with heartbeat and cron
|
|
||||||
|
|
||||||
- **Heartbeat/cron** decide *when* a run happens.
|
|
||||||
- **Lobster** defines *what steps* happen once the run starts.
|
|
||||||
|
|
||||||
For scheduled workflows, use cron or heartbeat to trigger an agent turn that calls Lobster.
|
|
||||||
For ad-hoc workflows, call Lobster directly.
|
|
||||||
|
|
||||||
### Operational notes (from the code)
|
|
||||||
|
|
||||||
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
|
|
||||||
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
|
|
||||||
- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
|
|
||||||
- If you pass `lobsterPath`, it must be an **absolute path**.
|
|
||||||
|
|
||||||
See [Lobster](/tools/lobster) for full usage and examples.
|
|
||||||
|
|
||||||
## Main Session vs Isolated Session
|
|
||||||
|
|
||||||
Both heartbeat and cron can interact with the main session, but differently:
|
|
||||||
|
|
||||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Session | Main | Main (via system event) | `cron:<jobId>` |
|
|
||||||
| History | Shared | Shared | Fresh each run |
|
|
||||||
| Context | Full | Full | None (starts clean) |
|
|
||||||
| Model | Main session model | Main session model | Can override |
|
|
||||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
|
|
||||||
|
|
||||||
### When to use main session cron
|
|
||||||
|
|
||||||
Use `--session main` with `--system-event` when you want:
|
|
||||||
- The reminder/event to appear in main session context
|
|
||||||
- The agent to handle it during the next heartbeat with full context
|
|
||||||
- No separate isolated run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot cron add \
|
|
||||||
--name "Check project" \
|
|
||||||
--every "4h" \
|
|
||||||
--session main \
|
|
||||||
--system-event "Time for a project health check" \
|
|
||||||
--wake now
|
|
||||||
```
|
|
||||||
|
|
||||||
### When to use isolated cron
|
|
||||||
|
|
||||||
Use `--session isolated` when you want:
|
|
||||||
- A clean slate without prior context
|
|
||||||
- Different model or thinking settings
|
|
||||||
- Output delivered directly to a channel (summary still posts to main by default)
|
|
||||||
- History that doesn't clutter main session
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot cron add \
|
|
||||||
--name "Deep analysis" \
|
|
||||||
--cron "0 6 * * 0" \
|
|
||||||
--session isolated \
|
|
||||||
--message "Weekly codebase analysis..." \
|
|
||||||
--model opus \
|
|
||||||
--thinking high \
|
|
||||||
--deliver
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cost Considerations
|
|
||||||
|
|
||||||
| Mechanism | Cost Profile |
|
|
||||||
|-----------|--------------|
|
|
||||||
| Heartbeat | One turn every N minutes; scales with HEARTBEAT.md size |
|
|
||||||
| Cron (main) | Adds event to next heartbeat (no isolated turn) |
|
|
||||||
| Cron (isolated) | Full agent turn per job; can use cheaper model |
|
|
||||||
|
|
||||||
**Tips**:
|
|
||||||
- Keep `HEARTBEAT.md` small to minimize token overhead.
|
|
||||||
- Batch similar checks into heartbeat instead of multiple cron jobs.
|
|
||||||
- Use `target: "none"` on heartbeat if you only want internal processing.
|
|
||||||
- Use isolated cron with a cheaper model for routine tasks.
|
|
||||||
|
|
||||||
## Related
|
|
||||||
|
|
||||||
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
|
|
||||||
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
|
|
||||||
- [System](/cli/system) - system events + heartbeat controls
|
|
||||||
@@ -17,37 +17,6 @@ not an API key.
|
|||||||
- Auth: AWS credentials (env vars, shared config, or instance role)
|
- Auth: AWS credentials (env vars, shared config, or instance role)
|
||||||
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
|
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
|
||||||
|
|
||||||
## Automatic model discovery
|
|
||||||
|
|
||||||
If AWS credentials are detected, Clawdbot can automatically discover Bedrock
|
|
||||||
models that support **streaming** and **text output**. Discovery uses
|
|
||||||
`bedrock:ListFoundationModels` and is cached (default: 1 hour).
|
|
||||||
|
|
||||||
Config options live under `models.bedrockDiscovery`:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
models: {
|
|
||||||
bedrockDiscovery: {
|
|
||||||
enabled: true,
|
|
||||||
region: "us-east-1",
|
|
||||||
providerFilter: ["anthropic", "amazon"],
|
|
||||||
refreshInterval: 3600,
|
|
||||||
defaultContextWindow: 32000,
|
|
||||||
defaultMaxTokens: 4096
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `enabled` defaults to `true` when AWS credentials are present.
|
|
||||||
- `region` defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`, then `us-east-1`.
|
|
||||||
- `providerFilter` matches Bedrock provider names (for example `anthropic`).
|
|
||||||
- `refreshInterval` is seconds; set to `0` to disable caching.
|
|
||||||
- `defaultContextWindow` (default: `32000`) and `defaultMaxTokens` (default: `4096`)
|
|
||||||
are used for discovered models (override if you know your model limits).
|
|
||||||
|
|
||||||
## Setup (manual)
|
## Setup (manual)
|
||||||
|
|
||||||
1) Ensure AWS credentials are available on the **gateway host**:
|
1) Ensure AWS credentials are available on the **gateway host**:
|
||||||
@@ -98,7 +67,6 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||||
- Automatic discovery needs the `bedrock:ListFoundationModels` permission.
|
|
||||||
- If you use profiles, set `AWS_PROFILE` on the gateway host.
|
- If you use profiles, set `AWS_PROFILE` on the gateway host.
|
||||||
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
|
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
|
||||||
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
|
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
||||||
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
|
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Tlon/Urbit support status, capabilities, and configuration"
|
|
||||||
read_when:
|
|
||||||
- Working on Tlon/Urbit channel features
|
|
||||||
---
|
|
||||||
# Tlon (plugin)
|
|
||||||
|
|
||||||
Tlon is a decentralized messenger built on Urbit. Clawdbot connects to your Urbit ship and can
|
|
||||||
respond to DMs and group chat messages. Group replies require an @ mention by default and can
|
|
||||||
be further restricted via allowlists.
|
|
||||||
|
|
||||||
Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
|
|
||||||
(URL appended to caption). Reactions, polls, and native media uploads are not supported.
|
|
||||||
|
|
||||||
## Plugin required
|
|
||||||
|
|
||||||
Tlon ships as a plugin and is not bundled with the core install.
|
|
||||||
|
|
||||||
Install via CLI (npm registry):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot plugins install @clawdbot/tlon
|
|
||||||
```
|
|
||||||
|
|
||||||
Local checkout (when running from a git repo):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot plugins install ./extensions/tlon
|
|
||||||
```
|
|
||||||
|
|
||||||
Details: [Plugins](/plugin)
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1) Install the Tlon plugin.
|
|
||||||
2) Gather your ship URL and login code.
|
|
||||||
3) Configure `channels.tlon`.
|
|
||||||
4) Restart the gateway.
|
|
||||||
5) DM the bot or mention it in a group channel.
|
|
||||||
|
|
||||||
Minimal config (single account):
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
tlon: {
|
|
||||||
enabled: true,
|
|
||||||
ship: "~sampel-palnet",
|
|
||||||
url: "https://your-ship-host",
|
|
||||||
code: "lidlut-tabwed-pillex-ridrup"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Group channels
|
|
||||||
|
|
||||||
Auto-discovery is enabled by default. You can also pin channels manually:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
tlon: {
|
|
||||||
groupChannels: [
|
|
||||||
"chat/~host-ship/general",
|
|
||||||
"chat/~host-ship/support"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Disable auto-discovery:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
tlon: {
|
|
||||||
autoDiscoverChannels: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Access control
|
|
||||||
|
|
||||||
DM allowlist (empty = allow all):
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
tlon: {
|
|
||||||
dmAllowlist: ["~zod", "~nec"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Group authorization (restricted by default):
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
tlon: {
|
|
||||||
defaultAuthorizedShips: ["~zod"],
|
|
||||||
authorization: {
|
|
||||||
channelRules: {
|
|
||||||
"chat/~host-ship/general": {
|
|
||||||
mode: "restricted",
|
|
||||||
allowedShips: ["~zod", "~nec"]
|
|
||||||
},
|
|
||||||
"chat/~host-ship/announcements": {
|
|
||||||
mode: "open"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Delivery targets (CLI/cron)
|
|
||||||
|
|
||||||
Use these with `clawdbot message send` or cron delivery:
|
|
||||||
|
|
||||||
- DM: `~sampel-palnet` or `dm/~sampel-palnet`
|
|
||||||
- Group: `chat/~host-ship/channel` or `group:~host-ship/channel`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Group replies require a mention (e.g. `~your-bot-ship`) to respond.
|
|
||||||
- Thread replies: if the inbound message is in a thread, Clawdbot replies in-thread.
|
|
||||||
- Media: `sendMedia` falls back to text + URL (no native upload).
|
|
||||||
@@ -44,7 +44,6 @@ clawdbot channels logout --channel whatsapp
|
|||||||
|
|
||||||
- Run `clawdbot status --deep` for a broad probe.
|
- Run `clawdbot status --deep` for a broad probe.
|
||||||
- Use `clawdbot doctor` for guided fixes.
|
- 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
|
## Capabilities probe
|
||||||
|
|
||||||
|
|||||||
+5
-22
@@ -29,7 +29,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
- [`system`](/cli/system)
|
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
@@ -39,6 +38,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
|
- [`wake`](/cli/wake)
|
||||||
- [`cron`](/cli/cron)
|
- [`cron`](/cli/cron)
|
||||||
- [`dns`](/cli/dns)
|
- [`dns`](/cli/dns)
|
||||||
- [`docs`](/cli/docs)
|
- [`docs`](/cli/docs)
|
||||||
@@ -145,10 +145,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
restart
|
restart
|
||||||
run
|
run
|
||||||
logs
|
logs
|
||||||
system
|
|
||||||
event
|
|
||||||
heartbeat last|enable|disable
|
|
||||||
presence
|
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
status
|
status
|
||||||
@@ -164,6 +160,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
recreate
|
recreate
|
||||||
explain
|
explain
|
||||||
|
wake
|
||||||
cron
|
cron
|
||||||
status
|
status
|
||||||
list
|
list
|
||||||
@@ -766,9 +763,9 @@ Options:
|
|||||||
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
|
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
|
||||||
- `clear`: `--provider <name>`, `--agent <id>`
|
- `clear`: `--provider <name>`, `--agent <id>`
|
||||||
|
|
||||||
## System
|
## Cron + wake
|
||||||
|
|
||||||
### `system event`
|
### `wake`
|
||||||
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
|
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
@@ -779,21 +776,7 @@ Options:
|
|||||||
- `--json`
|
- `--json`
|
||||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||||
|
|
||||||
### `system heartbeat last|enable|disable`
|
### `cron`
|
||||||
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).
|
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
|
|||||||
@@ -23,24 +23,6 @@ Common use cases:
|
|||||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||||
node host, so you can keep command access scoped and explicit.
|
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)
|
## Run (foreground)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -66,12 +66,12 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
|
|||||||
|
|
||||||
## Time handling
|
## Time handling
|
||||||
|
|
||||||
The system prompt includes a dedicated **Current Date & Time** section when the
|
The system prompt includes a dedicated **Current Date & Time** section when user
|
||||||
user timezone is known. To keep the prompt cache-stable, it now only includes
|
time or timezone is known. It is explicit about:
|
||||||
the **time zone** (no dynamic clock or time format).
|
|
||||||
|
|
||||||
Use `session_status` when the agent needs the current time; the status card
|
- The user’s **local time** (already converted).
|
||||||
includes a timestamp line.
|
- The **time zone** used for the conversion.
|
||||||
|
- The **time format** (12-hour / 24-hour).
|
||||||
|
|
||||||
Configure with:
|
Configure with:
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -7,8 +7,8 @@ read_when:
|
|||||||
|
|
||||||
# Date & Time
|
# Date & Time
|
||||||
|
|
||||||
Clawdbot defaults to **host-local time for transport timestamps** and **user timezone only in the system prompt**.
|
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 (current time is available via `session_status`).
|
Provider timestamps are preserved so tools keep their native semantics.
|
||||||
|
|
||||||
## Message envelopes (local by default)
|
## Message envelopes (local by default)
|
||||||
|
|
||||||
@@ -63,16 +63,16 @@ You can override this behavior:
|
|||||||
|
|
||||||
## System prompt: Current Date & Time
|
## System prompt: Current Date & Time
|
||||||
|
|
||||||
If the user timezone is known, the system prompt includes a dedicated
|
If the user timezone or local time is known, the system prompt includes a dedicated
|
||||||
**Current Date & Time** section with the **time zone only** (no clock/time format)
|
**Current Date & Time** section:
|
||||||
to keep prompt caching stable:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Time zone: America/Chicago
|
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
|
||||||
|
Time format: 12-hour
|
||||||
```
|
```
|
||||||
|
|
||||||
When the agent needs the current time, use the `session_status` tool; the status
|
If only the timezone is known, we still include the section and instruct the model
|
||||||
card includes a timestamp line.
|
to assume UTC for unknown time references.
|
||||||
|
|
||||||
## System event lines (local by default)
|
## System event lines (local by default)
|
||||||
|
|
||||||
|
|||||||
+1
-12
@@ -349,14 +349,6 @@
|
|||||||
"source": "/cron-jobs",
|
"source": "/cron-jobs",
|
||||||
"destination": "/automation/cron-jobs"
|
"destination": "/automation/cron-jobs"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"source": "/cron-vs-heartbeat",
|
|
||||||
"destination": "/automation/cron-vs-heartbeat"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "/cron-vs-heartbeat/",
|
|
||||||
"destination": "/automation/cron-vs-heartbeat"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"source": "/dashboard",
|
"source": "/dashboard",
|
||||||
"destination": "/web/dashboard"
|
"destination": "/web/dashboard"
|
||||||
@@ -850,12 +842,12 @@
|
|||||||
"cli/memory",
|
"cli/memory",
|
||||||
"cli/models",
|
"cli/models",
|
||||||
"cli/logs",
|
"cli/logs",
|
||||||
"cli/system",
|
|
||||||
"cli/nodes",
|
"cli/nodes",
|
||||||
"cli/approvals",
|
"cli/approvals",
|
||||||
"cli/gateway",
|
"cli/gateway",
|
||||||
"cli/tui",
|
"cli/tui",
|
||||||
"cli/voicecall",
|
"cli/voicecall",
|
||||||
|
"cli/wake",
|
||||||
"cli/cron",
|
"cli/cron",
|
||||||
"cli/dns",
|
"cli/dns",
|
||||||
"cli/docs",
|
"cli/docs",
|
||||||
@@ -992,7 +984,6 @@
|
|||||||
"automation/webhook",
|
"automation/webhook",
|
||||||
"automation/gmail-pubsub",
|
"automation/gmail-pubsub",
|
||||||
"automation/cron-jobs",
|
"automation/cron-jobs",
|
||||||
"automation/cron-vs-heartbeat",
|
|
||||||
"automation/poll"
|
"automation/poll"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1000,8 +991,6 @@
|
|||||||
"group": "Tools & Skills",
|
"group": "Tools & Skills",
|
||||||
"pages": [
|
"pages": [
|
||||||
"tools",
|
"tools",
|
||||||
"tools/lobster",
|
|
||||||
"tools/llm-task",
|
|
||||||
"plugin",
|
"plugin",
|
||||||
"plugins/voice-call",
|
"plugins/voice-call",
|
||||||
"plugins/zalouser",
|
"plugins/zalouser",
|
||||||
|
|||||||
@@ -1970,7 +1970,6 @@ Example (provider/model-specific allowlist):
|
|||||||
```
|
```
|
||||||
|
|
||||||
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
|
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
|
||||||
Matching is case-insensitive and supports `*` wildcards (`"*"` means all tools).
|
|
||||||
This is applied even when the Docker sandbox is **off**.
|
This is applied even when the Docker sandbox is **off**.
|
||||||
|
|
||||||
Example (disable browser/canvas everywhere):
|
Example (disable browser/canvas everywhere):
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
summary: "Heartbeat polling messages and notification rules"
|
summary: "Heartbeat polling messages and notification rules"
|
||||||
read_when:
|
read_when:
|
||||||
- Adjusting heartbeat cadence or messaging
|
- Adjusting heartbeat cadence or messaging
|
||||||
- Deciding between heartbeat and cron for scheduled tasks
|
|
||||||
---
|
---
|
||||||
# Heartbeat (Gateway)
|
# Heartbeat (Gateway)
|
||||||
|
|
||||||
> **Heartbeat vs Cron?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
|
|
||||||
|
|
||||||
Heartbeat runs **periodic agent turns** in the main session so the model can
|
Heartbeat runs **periodic agent turns** in the main session so the model can
|
||||||
surface anything that needs attention without spamming you.
|
surface anything that needs attention without spamming you.
|
||||||
|
|
||||||
@@ -162,10 +159,6 @@ 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
|
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
||||||
safe to include every 30 minutes.
|
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.
|
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
||||||
|
|
||||||
Example `HEARTBEAT.md`:
|
Example `HEARTBEAT.md`:
|
||||||
@@ -199,7 +192,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:
|
You can enqueue a system event and trigger an immediate heartbeat with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot system event --text "Check for urgent follow-ups" --mode now
|
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
||||||
```
|
```
|
||||||
|
|
||||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ services:
|
|||||||
[
|
[
|
||||||
"node",
|
"node",
|
||||||
"dist/index.js",
|
"dist/index.js",
|
||||||
"gateway",
|
"gateway-daemon",
|
||||||
"--bind",
|
"--bind",
|
||||||
"${CLAWDBOT_GATEWAY_BIND}",
|
"${CLAWDBOT_GATEWAY_BIND}",
|
||||||
"--port",
|
"--port",
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ Plugins can register:
|
|||||||
- Background services
|
- Background services
|
||||||
- Optional config validation
|
- Optional config validation
|
||||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
- **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.
|
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||||
@@ -146,16 +145,6 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Clawdbot can also merge **external channel catalogs** (for example, an MPM
|
|
||||||
registry export). Drop a JSON file at one of:
|
|
||||||
- `~/.clawdbot/mpm/plugins.json`
|
|
||||||
- `~/.clawdbot/mpm/catalog.json`
|
|
||||||
- `~/.clawdbot/plugins/catalog.json`
|
|
||||||
|
|
||||||
Or point `CLAWDBOT_PLUGIN_CATALOG_PATHS` (or `CLAWDBOT_MPM_CATALOG_PATHS`) at
|
|
||||||
one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should
|
|
||||||
contain `{ "entries": [ { "name": "@scope/pkg", "clawdbot": { "channel": {...}, "install": {...} } } ] }`.
|
|
||||||
|
|
||||||
## Plugin IDs
|
## Plugin IDs
|
||||||
|
|
||||||
Default plugin ids:
|
Default plugin ids:
|
||||||
@@ -495,65 +484,6 @@ 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
|
### Register background services
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -112,23 +112,6 @@ Default heartbeat prompt:
|
|||||||
|
|
||||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||||
|
|
||||||
### Heartbeat vs Cron: When to Use Each
|
|
||||||
|
|
||||||
**Use heartbeat when:**
|
|
||||||
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
|
|
||||||
- You need conversational context from recent messages
|
|
||||||
- Timing can drift slightly (every ~30 min is fine, not exact)
|
|
||||||
- You want to reduce API calls by combining periodic checks
|
|
||||||
|
|
||||||
**Use cron when:**
|
|
||||||
- Exact timing matters ("9:00 AM sharp every Monday")
|
|
||||||
- Task needs isolation from main session history
|
|
||||||
- You want a different model or thinking level for the task
|
|
||||||
- One-shot reminders ("remind me in 20 minutes")
|
|
||||||
- Output should deliver directly to a channel without main session involvement
|
|
||||||
|
|
||||||
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
|
|
||||||
|
|
||||||
**Things to check (rotate through these, 2-4 times per day):**
|
**Things to check (rotate through these, 2-4 times per day):**
|
||||||
- **Emails** - Any urgent unread messages?
|
- **Emails** - Any urgent unread messages?
|
||||||
- **Calendar** - Upcoming events in next 24-48h?
|
- **Calendar** - Upcoming events in next 24-48h?
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ read_when:
|
|||||||
---
|
---
|
||||||
# HEARTBEAT.md
|
# HEARTBEAT.md
|
||||||
|
|
||||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||||
# Add tasks below when you want the agent to check something periodically.
|
|
||||||
@@ -7,16 +7,11 @@ read_when:
|
|||||||
|
|
||||||
*Fill this in during your first conversation. Make it yours.*
|
*Fill this in during your first conversation. Make it yours.*
|
||||||
|
|
||||||
- **Name:**
|
- **Name:** *(pick something you like)*
|
||||||
*(pick something you like)*
|
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||||
- **Creature:**
|
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||||
*(AI? robot? familiar? ghost in the machine? something weirder?)*
|
- **Emoji:** *(your signature — pick one that feels right)*
|
||||||
- **Vibe:**
|
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
|
||||||
*(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)*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -182,8 +182,6 @@ 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.`
|
`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.
|
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.
|
- 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.
|
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||||
|
|
||||||
|
|||||||
@@ -971,10 +971,6 @@ Heartbeats run every **30m** by default. Tune or disable them:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
||||||
|
|
||||||
### Do I need to add a “bot account” to a WhatsApp group?
|
### Do I need to add a “bot account” to a WhatsApp group?
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
|||||||
- [Exec tool](/tools/exec)
|
- [Exec tool](/tools/exec)
|
||||||
- [Elevated mode](/tools/elevated)
|
- [Elevated mode](/tools/elevated)
|
||||||
- [Cron jobs](/automation/cron-jobs)
|
- [Cron jobs](/automation/cron-jobs)
|
||||||
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat)
|
|
||||||
- [Thinking + verbose](/tools/thinking)
|
- [Thinking + verbose](/tools/thinking)
|
||||||
- [Models](/concepts/models)
|
- [Models](/concepts/models)
|
||||||
- [Sub-agents](/tools/subagents)
|
- [Sub-agents](/tools/subagents)
|
||||||
|
|||||||
@@ -166,19 +166,6 @@ Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
|
|||||||
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||||
tokens instead of committing them to config files.
|
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 (hosted remote CDP)
|
||||||
|
|
||||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ 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 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.
|
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).
|
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
|
If the companion app UI is **not available**, any request that requires a prompt is
|
||||||
resolved by the **ask fallback** (default: deny).
|
resolved by the **ask fallback** (default: deny).
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ 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)
|
## Tool profiles (base allowlist)
|
||||||
|
|
||||||
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
|
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
|
||||||
@@ -161,7 +156,6 @@ alongside tools (for example, the voice-call plugin).
|
|||||||
|
|
||||||
Optional plugin tools:
|
Optional plugin tools:
|
||||||
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
||||||
- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation).
|
|
||||||
|
|
||||||
## Tool inventory
|
## Tool inventory
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "JSON-only LLM tasks for workflows (optional plugin tool)"
|
|
||||||
read_when:
|
|
||||||
- You want a JSON-only LLM step inside workflows
|
|
||||||
- You need schema-validated LLM output for automation
|
|
||||||
---
|
|
||||||
|
|
||||||
# LLM Task
|
|
||||||
|
|
||||||
`llm-task` is an **optional plugin tool** that runs a JSON-only LLM task and
|
|
||||||
returns structured output (optionally validated against JSON Schema).
|
|
||||||
|
|
||||||
This is ideal for workflow engines like Lobster: you can add a single LLM step
|
|
||||||
without writing custom Clawdbot code for each workflow.
|
|
||||||
|
|
||||||
## Enable the plugin
|
|
||||||
|
|
||||||
1) Enable the plugin:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plugins": {
|
|
||||||
"entries": {
|
|
||||||
"llm-task": { "enabled": true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2) Allowlist the tool (it is registered with `optional: true`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": "main",
|
|
||||||
"tools": { "allow": ["llm-task"] }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Config (optional)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plugins": {
|
|
||||||
"entries": {
|
|
||||||
"llm-task": {
|
|
||||||
"enabled": true,
|
|
||||||
"config": {
|
|
||||||
"defaultProvider": "openai-codex",
|
|
||||||
"defaultModel": "gpt-5.2",
|
|
||||||
"defaultAuthProfileId": "main",
|
|
||||||
"allowedModels": ["openai-codex/gpt-5.2"],
|
|
||||||
"maxTokens": 800,
|
|
||||||
"timeoutMs": 30000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`allowedModels` is an allowlist of `provider/model` strings. If set, any request
|
|
||||||
outside the list is rejected.
|
|
||||||
|
|
||||||
## Tool parameters
|
|
||||||
|
|
||||||
- `prompt` (string, required)
|
|
||||||
- `input` (any, optional)
|
|
||||||
- `schema` (object, optional JSON Schema)
|
|
||||||
- `provider` (string, optional)
|
|
||||||
- `model` (string, optional)
|
|
||||||
- `authProfileId` (string, optional)
|
|
||||||
- `temperature` (number, optional)
|
|
||||||
- `maxTokens` (number, optional)
|
|
||||||
- `timeoutMs` (number, optional)
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Returns `details.json` containing the parsed JSON (and validates against
|
|
||||||
`schema` when provided).
|
|
||||||
|
|
||||||
## Example: Lobster workflow step
|
|
||||||
|
|
||||||
```lobster
|
|
||||||
clawd.invoke --tool llm-task --action json --args-json '{
|
|
||||||
"prompt": "Given the input email, return intent and draft.",
|
|
||||||
"input": {
|
|
||||||
"subject": "Hello",
|
|
||||||
"body": "Can you help?"
|
|
||||||
},
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"intent": { "type": "string" },
|
|
||||||
"draft": { "type": "string" }
|
|
||||||
},
|
|
||||||
"required": ["intent", "draft"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Safety notes
|
|
||||||
|
|
||||||
- The tool is **JSON-only** and instructs the model to output only JSON (no
|
|
||||||
code fences, no commentary).
|
|
||||||
- No tools are exposed to the model for this run.
|
|
||||||
- Treat output as untrusted unless you validate with `schema`.
|
|
||||||
- Put approvals before any side-effecting step (send, post, exec).
|
|
||||||
@@ -65,52 +65,6 @@ gog.gmail.search --query 'newer_than:1d' \
|
|||||||
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
|
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## JSON-only LLM steps (llm-task)
|
|
||||||
|
|
||||||
For workflows that need a **structured LLM step**, enable the optional
|
|
||||||
`llm-task` plugin tool and call it from Lobster. This keeps the workflow
|
|
||||||
deterministic while still letting you classify/summarize/draft with a model.
|
|
||||||
|
|
||||||
Enable the tool:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plugins": {
|
|
||||||
"entries": {
|
|
||||||
"llm-task": { "enabled": true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": "main",
|
|
||||||
"tools": { "allow": ["llm-task"] }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use it in a pipeline:
|
|
||||||
|
|
||||||
```lobster
|
|
||||||
clawd.invoke --tool llm-task --action json --args-json '{
|
|
||||||
"prompt": "Given the input email, return intent and draft.",
|
|
||||||
"input": { "subject": "Hello", "body": "Can you help?" },
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"intent": { "type": "string" },
|
|
||||||
"draft": { "type": "string" }
|
|
||||||
},
|
|
||||||
"required": ["intent", "draft"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
See [LLM Task](/tools/llm-task) for details and configuration options.
|
|
||||||
|
|
||||||
## Workflow files (.lobster)
|
## Workflow files (.lobster)
|
||||||
|
|
||||||
Lobster can run YAML/JSON workflow files with `name`, `args`, `steps`, `env`, `condition`, and `approval` fields. In Clawdbot tool calls, set `pipeline` to the file path.
|
Lobster can run YAML/JSON workflow files with `name`, `args`, `steps`, `env`, `condition`, and `approval` fields. In Clawdbot tool calls, set `pipeline` to the file path.
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
resolveBlueBubblesGroupRequireMention,
|
resolveBlueBubblesGroupRequireMention,
|
||||||
resolveBlueBubblesGroupToolPolicy,
|
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
@@ -63,7 +62,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
||||||
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -21,7 +21,6 @@ const bluebubblesActionSchema = z
|
|||||||
|
|
||||||
const bluebubblesGroupConfigSchema = z.object({
|
const bluebubblesGroupConfigSchema = z.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const bluebubblesAccountSchema = z.object({
|
const bluebubblesAccountSchema = z.object({
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ export type GroupPolicy = "open" | "disabled" | "allowlist";
|
|||||||
export type BlueBubblesGroupConfig = {
|
export type BlueBubblesGroupConfig = {
|
||||||
/** If true, only respond in this group when mentioned. */
|
/** If true, only respond in this group when mentioned. */
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this group. */
|
|
||||||
tools?: { allow?: string[]; deny?: string[] };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BlueBubblesAccountConfig = {
|
export type BlueBubblesAccountConfig = {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
resolveDiscordAccount,
|
resolveDiscordAccount,
|
||||||
resolveDefaultDiscordAccountId,
|
resolveDefaultDiscordAccountId,
|
||||||
resolveDiscordGroupRequireMention,
|
resolveDiscordGroupRequireMention,
|
||||||
resolveDiscordGroupToolPolicy,
|
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
type ChannelMessageActionAdapter,
|
type ChannelMessageActionAdapter,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
@@ -145,7 +144,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||||
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
mentions: {
|
mentions: {
|
||||||
stripPatterns: () => ["<@!?\\d+>"],
|
stripPatterns: () => ["<@!?\\d+>"],
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
resolveDefaultIMessageAccountId,
|
resolveDefaultIMessageAccountId,
|
||||||
resolveIMessageAccount,
|
resolveIMessageAccount,
|
||||||
resolveIMessageGroupRequireMention,
|
resolveIMessageGroupRequireMention,
|
||||||
resolveIMessageGroupToolPolicy,
|
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
type ResolvedIMessageAccount,
|
type ResolvedIMessageAccount,
|
||||||
@@ -107,7 +106,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
# LLM Task (plugin)
|
|
||||||
|
|
||||||
Adds an **optional** agent tool `llm-task` for running **JSON-only** LLM tasks
|
|
||||||
(drafting, summarizing, classifying) with optional JSON Schema validation.
|
|
||||||
|
|
||||||
Designed to be called from workflow engines (for example, Lobster via
|
|
||||||
`clawd.invoke --each`) without adding new Clawdbot code per workflow.
|
|
||||||
|
|
||||||
## Enable
|
|
||||||
|
|
||||||
1) Enable the plugin:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plugins": {
|
|
||||||
"entries": {
|
|
||||||
"llm-task": { "enabled": true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2) Allowlist the tool (it is registered with `optional: true`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": "main",
|
|
||||||
"tools": { "allow": ["llm-task"] }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Config (optional)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plugins": {
|
|
||||||
"entries": {
|
|
||||||
"llm-task": {
|
|
||||||
"enabled": true,
|
|
||||||
"config": {
|
|
||||||
"defaultProvider": "openai-codex",
|
|
||||||
"defaultModel": "gpt-5.2",
|
|
||||||
"defaultAuthProfileId": "main",
|
|
||||||
"allowedModels": ["openai-codex/gpt-5.2"],
|
|
||||||
"maxTokens": 800,
|
|
||||||
"timeoutMs": 30000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`allowedModels` is an allowlist of `provider/model` strings. If set, any request
|
|
||||||
outside the list is rejected.
|
|
||||||
|
|
||||||
## Tool API
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
|
|
||||||
- `prompt` (string, required)
|
|
||||||
- `input` (any, optional)
|
|
||||||
- `schema` (object, optional JSON Schema)
|
|
||||||
- `provider` (string, optional)
|
|
||||||
- `model` (string, optional)
|
|
||||||
- `authProfileId` (string, optional)
|
|
||||||
- `temperature` (number, optional)
|
|
||||||
- `maxTokens` (number, optional)
|
|
||||||
- `timeoutMs` (number, optional)
|
|
||||||
|
|
||||||
### Output
|
|
||||||
|
|
||||||
Returns `details.json` containing the parsed JSON (and validates against
|
|
||||||
`schema` when provided).
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The tool is **JSON-only** and instructs the model to output only JSON
|
|
||||||
(no code fences, no commentary).
|
|
||||||
- No tools are exposed to the model for this run.
|
|
||||||
- Side effects should be handled outside this tool (for example, approvals in
|
|
||||||
Lobster) before calling tools that send messages/emails.
|
|
||||||
|
|
||||||
## Bundled extension note
|
|
||||||
|
|
||||||
This extension depends on Clawdbot internal modules (the embedded agent runner).
|
|
||||||
It is intended to ship as a **bundled** Clawdbot extension (like `lobster`) and
|
|
||||||
be enabled via `plugins.entries` + tool allowlists.
|
|
||||||
|
|
||||||
It is **not** currently designed to be copied into
|
|
||||||
`~/.clawdbot/extensions` as a standalone plugin directory.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "llm-task",
|
|
||||||
"name": "LLM Task",
|
|
||||||
"description": "Generic JSON-only LLM tool for structured tasks callable from workflows.",
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"defaultProvider": { "type": "string" },
|
|
||||||
"defaultModel": { "type": "string" },
|
|
||||||
"defaultAuthProfileId": { "type": "string" },
|
|
||||||
"allowedModels": {
|
|
||||||
"type": "array",
|
|
||||||
"items": { "type": "string" },
|
|
||||||
"description": "Allowlist of provider/model keys like openai-codex/gpt-5.2."
|
|
||||||
},
|
|
||||||
"maxTokens": { "type": "number" },
|
|
||||||
"timeoutMs": { "type": "number" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
|
||||||
|
|
||||||
import { createLlmTaskTool } from "./src/llm-task-tool.js";
|
|
||||||
|
|
||||||
export default function register(api: ClawdbotPluginApi) {
|
|
||||||
api.registerTool(createLlmTaskTool(api), { optional: true });
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@clawdbot/llm-task",
|
|
||||||
"version": "2026.1.23",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Clawdbot JSON-only LLM task plugin",
|
|
||||||
"clawdbot": {
|
|
||||||
"extensions": [
|
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
|
|
||||||
return {
|
|
||||||
runEmbeddedPiAgent: vi.fn(async () => ({
|
|
||||||
meta: { startedAt: Date.now() },
|
|
||||||
payloads: [{ text: "{}" }],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js";
|
|
||||||
import { createLlmTaskTool } from "./llm-task-tool.js";
|
|
||||||
|
|
||||||
function fakeApi(overrides: any = {}) {
|
|
||||||
return {
|
|
||||||
id: "llm-task",
|
|
||||||
name: "llm-task",
|
|
||||||
source: "test",
|
|
||||||
config: { agents: { defaults: { workspace: "/tmp", model: { primary: "openai-codex/gpt-5.2" } } } },
|
|
||||||
pluginConfig: {},
|
|
||||||
runtime: { version: "test" },
|
|
||||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
|
||||||
registerTool() {},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("llm-task tool (json-only)", () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
it("returns parsed json", async () => {
|
|
||||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
|
||||||
meta: {},
|
|
||||||
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
|
||||||
});
|
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
|
||||||
const res = await tool.execute("id", { prompt: "return foo" });
|
|
||||||
expect((res as any).details.json).toEqual({ foo: "bar" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips fenced json", async () => {
|
|
||||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
|
||||||
meta: {},
|
|
||||||
payloads: [{ text: "```json\n{\"ok\":true}\n```" }],
|
|
||||||
});
|
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
|
||||||
const res = await tool.execute("id", { prompt: "return ok" });
|
|
||||||
expect((res as any).details.json).toEqual({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates schema", async () => {
|
|
||||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
|
||||||
meta: {},
|
|
||||||
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
|
||||||
});
|
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
|
||||||
const schema = {
|
|
||||||
type: "object",
|
|
||||||
properties: { foo: { type: "string" } },
|
|
||||||
required: ["foo"],
|
|
||||||
additionalProperties: false,
|
|
||||||
};
|
|
||||||
const res = await tool.execute("id", { prompt: "return foo", schema });
|
|
||||||
expect((res as any).details.json).toEqual({ foo: "bar" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on invalid json", async () => {
|
|
||||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: "not-json" }] });
|
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
|
||||||
await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on schema mismatch", async () => {
|
|
||||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
|
||||||
meta: {},
|
|
||||||
payloads: [{ text: JSON.stringify({ foo: 1 }) }],
|
|
||||||
});
|
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
|
||||||
const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] };
|
|
||||||
await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes provider/model overrides to embedded runner", async () => {
|
|
||||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
|
||||||
meta: {},
|
|
||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
|
||||||
});
|
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
|
||||||
await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" });
|
|
||||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
|
||||||
expect(call.provider).toBe("anthropic");
|
|
||||||
expect(call.model).toBe("claude-4-sonnet");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enforces allowedModels", async () => {
|
|
||||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
|
||||||
meta: {},
|
|
||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
|
||||||
});
|
|
||||||
const tool = createLlmTaskTool(fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }) as any);
|
|
||||||
await expect(tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" })).rejects.toThrow(
|
|
||||||
/not allowed/i,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables tools for embedded run", async () => {
|
|
||||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
|
||||||
meta: {},
|
|
||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
|
||||||
});
|
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
|
||||||
await tool.execute("id", { prompt: "x" });
|
|
||||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
|
||||||
expect(call.disableTools).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
|
|
||||||
import Ajv from "ajv";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
// NOTE: This extension is intended to be bundled with Clawdbot.
|
|
||||||
// When running from source (tests/dev), Clawdbot internals live under src/.
|
|
||||||
// When running from a built install, internals live under dist/ (no src/ tree).
|
|
||||||
// So we resolve internal imports dynamically with src-first, dist-fallback.
|
|
||||||
|
|
||||||
import type { ClawdbotPluginApi } from "../../../src/plugins/types.js";
|
|
||||||
|
|
||||||
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
|
|
||||||
|
|
||||||
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
|
|
||||||
// Source checkout (tests/dev)
|
|
||||||
try {
|
|
||||||
const mod = await import("../../../src/agents/pi-embedded-runner.js");
|
|
||||||
if (typeof (mod as any).runEmbeddedPiAgent === "function") return (mod as any).runEmbeddedPiAgent;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundled install (built)
|
|
||||||
const mod = await import("../../../agents/pi-embedded-runner.js");
|
|
||||||
if (typeof (mod as any).runEmbeddedPiAgent !== "function") {
|
|
||||||
throw new Error("Internal error: runEmbeddedPiAgent not available");
|
|
||||||
}
|
|
||||||
return (mod as any).runEmbeddedPiAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripCodeFences(s: string): string {
|
|
||||||
const trimmed = s.trim();
|
|
||||||
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
||||||
if (m) return (m[1] ?? "").trim();
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectText(payloads: Array<{ text?: string; isError?: boolean }> | undefined): string {
|
|
||||||
const texts = (payloads ?? [])
|
|
||||||
.filter((p) => !p.isError && typeof p.text === "string")
|
|
||||||
.map((p) => p.text ?? "");
|
|
||||||
return texts.join("\n").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toModelKey(provider?: string, model?: string): string | undefined {
|
|
||||||
const p = provider?.trim();
|
|
||||||
const m = model?.trim();
|
|
||||||
if (!p || !m) return undefined;
|
|
||||||
return `${p}/${m}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PluginCfg = {
|
|
||||||
defaultProvider?: string;
|
|
||||||
defaultModel?: string;
|
|
||||||
defaultAuthProfileId?: string;
|
|
||||||
allowedModels?: string[];
|
|
||||||
maxTokens?: number;
|
|
||||||
timeoutMs?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createLlmTaskTool(api: ClawdbotPluginApi) {
|
|
||||||
return {
|
|
||||||
name: "llm-task",
|
|
||||||
description:
|
|
||||||
"Run a generic JSON-only LLM task and return schema-validated JSON. Designed for orchestration from Lobster workflows via clawd.invoke.",
|
|
||||||
parameters: Type.Object({
|
|
||||||
prompt: Type.String({ description: "Task instruction for the LLM." }),
|
|
||||||
input: Type.Optional(Type.Unknown({ description: "Optional input payload for the task." })),
|
|
||||||
schema: Type.Optional(Type.Unknown({ description: "Optional JSON Schema to validate the returned JSON." })),
|
|
||||||
provider: Type.Optional(Type.String({ description: "Provider override (e.g. openai-codex, anthropic)." })),
|
|
||||||
model: Type.Optional(Type.String({ description: "Model id override." })),
|
|
||||||
authProfileId: Type.Optional(Type.String({ description: "Auth profile override." })),
|
|
||||||
temperature: Type.Optional(Type.Number({ description: "Best-effort temperature override." })),
|
|
||||||
maxTokens: Type.Optional(Type.Number({ description: "Best-effort maxTokens override." })),
|
|
||||||
timeoutMs: Type.Optional(Type.Number({ description: "Timeout for the LLM run." })),
|
|
||||||
}),
|
|
||||||
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
|
||||||
const prompt = String(params.prompt ?? "");
|
|
||||||
if (!prompt.trim()) throw new Error("prompt required");
|
|
||||||
|
|
||||||
const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg;
|
|
||||||
|
|
||||||
const primary = api.config?.agents?.defaults?.model?.primary;
|
|
||||||
const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined;
|
|
||||||
const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
|
|
||||||
|
|
||||||
const provider =
|
|
||||||
(typeof params.provider === "string" && params.provider.trim()) ||
|
|
||||||
(typeof pluginCfg.defaultProvider === "string" && pluginCfg.defaultProvider.trim()) ||
|
|
||||||
primaryProvider ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
const model =
|
|
||||||
(typeof params.model === "string" && params.model.trim()) ||
|
|
||||||
(typeof pluginCfg.defaultModel === "string" && pluginCfg.defaultModel.trim()) ||
|
|
||||||
primaryModel ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
const authProfileId =
|
|
||||||
(typeof (params as any).authProfileId === "string" && (params as any).authProfileId.trim()) ||
|
|
||||||
(typeof pluginCfg.defaultAuthProfileId === "string" && pluginCfg.defaultAuthProfileId.trim()) ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
const modelKey = toModelKey(provider, model);
|
|
||||||
if (!provider || !model || !modelKey) {
|
|
||||||
throw new Error(
|
|
||||||
`provider/model could not be resolved (provider=${String(provider ?? "")}, model=${String(model ?? "")})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = Array.isArray(pluginCfg.allowedModels) ? pluginCfg.allowedModels : undefined;
|
|
||||||
if (allowed && allowed.length > 0 && !allowed.includes(modelKey)) {
|
|
||||||
throw new Error(
|
|
||||||
`Model not allowed by llm-task plugin config: ${modelKey}. Allowed models: ${allowed.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutMs =
|
|
||||||
(typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : undefined) ||
|
|
||||||
(typeof pluginCfg.timeoutMs === "number" && pluginCfg.timeoutMs > 0 ? pluginCfg.timeoutMs : undefined) ||
|
|
||||||
30_000;
|
|
||||||
|
|
||||||
const streamParams = {
|
|
||||||
temperature: typeof params.temperature === "number" ? params.temperature : undefined,
|
|
||||||
maxTokens:
|
|
||||||
typeof params.maxTokens === "number"
|
|
||||||
? params.maxTokens
|
|
||||||
: typeof pluginCfg.maxTokens === "number"
|
|
||||||
? pluginCfg.maxTokens
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const input = (params as any).input as unknown;
|
|
||||||
let inputJson: string;
|
|
||||||
try {
|
|
||||||
inputJson = JSON.stringify(input ?? null, null, 2);
|
|
||||||
} catch {
|
|
||||||
throw new Error("input must be JSON-serializable");
|
|
||||||
}
|
|
||||||
|
|
||||||
const system = [
|
|
||||||
"You are a JSON-only function.",
|
|
||||||
"Return ONLY a valid JSON value.",
|
|
||||||
"Do not wrap in markdown fences.",
|
|
||||||
"Do not include commentary.",
|
|
||||||
"Do not call tools.",
|
|
||||||
].join(" ");
|
|
||||||
|
|
||||||
const fullPrompt = `${system}\n\nTASK:\n${prompt}\n\nINPUT_JSON:\n${inputJson}\n`;
|
|
||||||
|
|
||||||
let tmpDir: string | null = null;
|
|
||||||
try {
|
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-llm-task-"));
|
|
||||||
const sessionId = `llm-task-${Date.now()}`;
|
|
||||||
const sessionFile = path.join(tmpDir, "session.json");
|
|
||||||
|
|
||||||
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent();
|
|
||||||
|
|
||||||
const result = await runEmbeddedPiAgent({
|
|
||||||
sessionId,
|
|
||||||
sessionFile,
|
|
||||||
workspaceDir: api.config?.agents?.defaults?.workspace ?? process.cwd(),
|
|
||||||
config: api.config,
|
|
||||||
prompt: fullPrompt,
|
|
||||||
timeoutMs,
|
|
||||||
runId: `llm-task-${Date.now()}`,
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
authProfileId,
|
|
||||||
authProfileIdSource: authProfileId ? "user" : "auto",
|
|
||||||
streamParams,
|
|
||||||
disableTools: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = collectText((result as any).payloads);
|
|
||||||
if (!text) throw new Error("LLM returned empty output");
|
|
||||||
|
|
||||||
const raw = stripCodeFences(text);
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
throw new Error("LLM returned invalid JSON");
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = (params as any).schema as unknown;
|
|
||||||
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
|
|
||||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
||||||
const validate = ajv.compile(schema as any);
|
|
||||||
const ok = validate(parsed);
|
|
||||||
if (!ok) {
|
|
||||||
const msg =
|
|
||||||
validate.errors?.map((e) => `${e.instancePath || "<root>"} ${e.message || "invalid"}`).join("; ") ??
|
|
||||||
"invalid";
|
|
||||||
throw new Error(`LLM JSON did not match schema: ${msg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }],
|
|
||||||
details: { json: parsed, provider, model },
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
if (tmpDir) {
|
|
||||||
try {
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -25,12 +25,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||||
|
"clawdbot": "workspace:*",
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"matrix-bot-sdk": "0.8.0",
|
"matrix-bot-sdk": "0.8.0",
|
||||||
"music-metadata": "^11.10.6",
|
"music-metadata": "^11.10.6"
|
||||||
"zod": "^4.3.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"clawdbot": "workspace:*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
|
|
||||||
import { matrixMessageActions } from "./actions.js";
|
import { matrixMessageActions } from "./actions.js";
|
||||||
import { MatrixConfigSchema } from "./config-schema.js";
|
import { MatrixConfigSchema } from "./config-schema.js";
|
||||||
import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
|
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
import {
|
import {
|
||||||
listMatrixAccountIds,
|
listMatrixAccountIds,
|
||||||
@@ -167,7 +167,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveMatrixGroupRequireMention,
|
resolveRequireMention: resolveMatrixGroupRequireMention,
|
||||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg }) =>
|
resolveReplyToMode: ({ cfg }) =>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -26,7 +26,6 @@ const matrixRoomSchema = z
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
allow: z.boolean().optional(),
|
allow: z.boolean().optional(),
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
|
||||||
autoReply: z.boolean().optional(),
|
autoReply: z.boolean().optional(),
|
||||||
users: z.array(allowFromEntry).optional(),
|
users: z.array(allowFromEntry).optional(),
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
|
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
@@ -32,30 +32,3 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
|||||||
}
|
}
|
||||||
return true;
|
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,8 +18,6 @@ export type MatrixRoomConfig = {
|
|||||||
allow?: boolean;
|
allow?: boolean;
|
||||||
/** Require mentioning the bot to trigger replies. */
|
/** Require mentioning the bot to trigger replies. */
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
/** Optional tool policy overrides for this room. */
|
|
||||||
tools?: { allow?: string[]; deny?: string[] };
|
|
||||||
/** If true, reply without mention requirements. */
|
/** If true, reply without mention requirements. */
|
||||||
autoReply?: boolean;
|
autoReply?: boolean;
|
||||||
/** Optional allowlist for room senders (user IDs or localparts). */
|
/** Optional allowlist for room senders (user IDs or localparts). */
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||||
import { msteamsOutbound } from "./outbound.js";
|
import { msteamsOutbound } from "./outbound.js";
|
||||||
import { probeMSTeams } from "./probe.js";
|
import { probeMSTeams } from "./probe.js";
|
||||||
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
|
||||||
import {
|
import {
|
||||||
normalizeMSTeamsMessagingTarget,
|
normalizeMSTeamsMessagingTarget,
|
||||||
normalizeMSTeamsUserInput,
|
normalizeMSTeamsUserInput,
|
||||||
@@ -78,9 +77,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
groups: {
|
|
||||||
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
|
|
||||||
},
|
|
||||||
reload: { configPrefixes: ["channels.msteams"] },
|
reload: { configPrefixes: ["channels.msteams"] },
|
||||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AllowlistMatch,
|
AllowlistMatch,
|
||||||
ChannelGroupContext,
|
|
||||||
GroupPolicy,
|
GroupPolicy,
|
||||||
GroupToolPolicyConfig,
|
|
||||||
MSTeamsChannelConfig,
|
MSTeamsChannelConfig,
|
||||||
MSTeamsConfig,
|
MSTeamsConfig,
|
||||||
MSTeamsReplyStyle,
|
MSTeamsReplyStyle,
|
||||||
@@ -88,50 +86,6 @@ 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 = {
|
export type MSTeamsReplyPolicy = {
|
||||||
requireMention: boolean;
|
requireMention: boolean;
|
||||||
replyStyle: MSTeamsReplyStyle;
|
replyStyle: MSTeamsReplyStyle;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
|
|||||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||||
import { sendMessageNextcloudTalk } from "./send.js";
|
import { sendMessageNextcloudTalk } from "./send.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
|
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
id: "nextcloud-talk",
|
id: "nextcloud-talk",
|
||||||
@@ -160,7 +159,6 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
DmPolicySchema,
|
DmPolicySchema,
|
||||||
GroupPolicySchema,
|
GroupPolicySchema,
|
||||||
MarkdownConfigSchema,
|
MarkdownConfigSchema,
|
||||||
ToolPolicySchema,
|
|
||||||
requireOpenAllowFrom,
|
requireOpenAllowFrom,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -12,7 +11,6 @@ import { z } from "zod";
|
|||||||
export const NextcloudTalkRoomSchema = z
|
export const NextcloudTalkRoomSchema = z
|
||||||
.object({
|
.object({
|
||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
|
||||||
skills: z.array(z.string()).optional(),
|
skills: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
allowFrom: z.array(z.string()).optional(),
|
allowFrom: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
|
import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
buildChannelKeyCandidates,
|
buildChannelKeyCandidates,
|
||||||
normalizeChannelSlug,
|
normalizeChannelSlug,
|
||||||
@@ -86,21 +86,6 @@ 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: {
|
export function resolveNextcloudTalkRequireMention(params: {
|
||||||
roomConfig?: NextcloudTalkRoomConfig;
|
roomConfig?: NextcloudTalkRoomConfig;
|
||||||
wildcardConfig?: NextcloudTalkRoomConfig;
|
wildcardConfig?: NextcloudTalkRoomConfig;
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import type {
|
|||||||
|
|
||||||
export type NextcloudTalkRoomConfig = {
|
export type NextcloudTalkRoomConfig = {
|
||||||
requireMention?: boolean;
|
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. */
|
/** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
/** If false, disable the bot for this room. */
|
/** If false, disable the bot for this room. */
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
resolveSlackReplyToMode,
|
resolveSlackReplyToMode,
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
resolveSlackGroupToolPolicy,
|
|
||||||
buildSlackThreadingToolContext,
|
buildSlackThreadingToolContext,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
slackOnboardingAdapter,
|
slackOnboardingAdapter,
|
||||||
@@ -162,7 +161,6 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||||
resolveToolPolicy: resolveSlackGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
resolveDefaultTelegramAccountId,
|
resolveDefaultTelegramAccountId,
|
||||||
resolveTelegramAccount,
|
resolveTelegramAccount,
|
||||||
resolveTelegramGroupRequireMention,
|
resolveTelegramGroupRequireMention,
|
||||||
resolveTelegramGroupToolPolicy,
|
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
telegramOnboardingAdapter,
|
telegramOnboardingAdapter,
|
||||||
TelegramConfigSchema,
|
TelegramConfigSchema,
|
||||||
@@ -155,7 +154,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveTelegramGroupRequireMention,
|
resolveRequireMention: resolveTelegramGroupRequireMention,
|
||||||
resolveToolPolicy: resolveTelegramGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
|
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Tlon (Clawdbot plugin)
|
|
||||||
|
|
||||||
Tlon/Urbit channel plugin for Clawdbot. Supports DMs, group mentions, and thread replies.
|
|
||||||
|
|
||||||
Docs: https://docs.clawd.bot/channels/tlon
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "tlon",
|
|
||||||
"channels": [
|
|
||||||
"tlon"
|
|
||||||
],
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
||||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
import { tlonPlugin } from "./src/channel.js";
|
|
||||||
import { setTlonRuntime } from "./src/runtime.js";
|
|
||||||
|
|
||||||
const plugin = {
|
|
||||||
id: "tlon",
|
|
||||||
name: "Tlon",
|
|
||||||
description: "Tlon/Urbit channel plugin",
|
|
||||||
configSchema: emptyPluginConfigSchema(),
|
|
||||||
register(api: ClawdbotPluginApi) {
|
|
||||||
setTlonRuntime(api.runtime);
|
|
||||||
api.registerChannel({ plugin: tlonPlugin });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default plugin;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@clawdbot/tlon",
|
|
||||||
"version": "2026.1.22",
|
|
||||||
"type": "module",
|
|
||||||
"description": "Clawdbot Tlon/Urbit channel plugin",
|
|
||||||
"clawdbot": {
|
|
||||||
"extensions": [
|
|
||||||
"./index.ts"
|
|
||||||
],
|
|
||||||
"channel": {
|
|
||||||
"id": "tlon",
|
|
||||||
"label": "Tlon",
|
|
||||||
"selectionLabel": "Tlon (Urbit)",
|
|
||||||
"docsPath": "/channels/tlon",
|
|
||||||
"docsLabel": "tlon",
|
|
||||||
"blurb": "decentralized messaging on Urbit; install the plugin to enable.",
|
|
||||||
"order": 90,
|
|
||||||
"quickstartAllowFrom": true
|
|
||||||
},
|
|
||||||
"install": {
|
|
||||||
"npmSpec": "@clawdbot/tlon",
|
|
||||||
"localPath": "extensions/tlon",
|
|
||||||
"defaultChoice": "npm"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@urbit/aura": "^2.0.0",
|
|
||||||
"@urbit/http-api": "^3.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
import type {
|
|
||||||
ChannelOutboundAdapter,
|
|
||||||
ChannelPlugin,
|
|
||||||
ChannelSetupInput,
|
|
||||||
ClawdbotConfig,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
import {
|
|
||||||
applyAccountNameToChannelSection,
|
|
||||||
DEFAULT_ACCOUNT_ID,
|
|
||||||
normalizeAccountId,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
|
|
||||||
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
|
|
||||||
import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js";
|
|
||||||
import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js";
|
|
||||||
import { monitorTlonProvider } from "./monitor/index.js";
|
|
||||||
import { tlonChannelConfigSchema } from "./config-schema.js";
|
|
||||||
import { tlonOnboardingAdapter } from "./onboarding.js";
|
|
||||||
|
|
||||||
const TLON_CHANNEL_ID = "tlon" as const;
|
|
||||||
|
|
||||||
type TlonSetupInput = ChannelSetupInput & {
|
|
||||||
ship?: string;
|
|
||||||
url?: string;
|
|
||||||
code?: string;
|
|
||||||
groupChannels?: string[];
|
|
||||||
dmAllowlist?: string[];
|
|
||||||
autoDiscoverChannels?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyTlonSetupConfig(params: {
|
|
||||||
cfg: ClawdbotConfig;
|
|
||||||
accountId: string;
|
|
||||||
input: TlonSetupInput;
|
|
||||||
}): ClawdbotConfig {
|
|
||||||
const { cfg, accountId, input } = params;
|
|
||||||
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
||||||
const namedConfig = applyAccountNameToChannelSection({
|
|
||||||
cfg,
|
|
||||||
channelKey: "tlon",
|
|
||||||
accountId,
|
|
||||||
name: input.name,
|
|
||||||
});
|
|
||||||
const base = namedConfig.channels?.tlon ?? {};
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
...(input.ship ? { ship: input.ship } : {}),
|
|
||||||
...(input.url ? { url: input.url } : {}),
|
|
||||||
...(input.code ? { code: input.code } : {}),
|
|
||||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
|
||||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
|
||||||
...(typeof input.autoDiscoverChannels === "boolean"
|
|
||||||
? { autoDiscoverChannels: input.autoDiscoverChannels }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (useDefault) {
|
|
||||||
return {
|
|
||||||
...namedConfig,
|
|
||||||
channels: {
|
|
||||||
...namedConfig.channels,
|
|
||||||
tlon: {
|
|
||||||
...base,
|
|
||||||
enabled: true,
|
|
||||||
...payload,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...namedConfig,
|
|
||||||
channels: {
|
|
||||||
...namedConfig.channels,
|
|
||||||
tlon: {
|
|
||||||
...base,
|
|
||||||
enabled: base.enabled ?? true,
|
|
||||||
accounts: {
|
|
||||||
...(base as { accounts?: Record<string, unknown> }).accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...((base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
|
|
||||||
accountId
|
|
||||||
] ?? {}),
|
|
||||||
enabled: true,
|
|
||||||
...payload,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tlonOutbound: ChannelOutboundAdapter = {
|
|
||||||
deliveryMode: "direct",
|
|
||||||
textChunkLimit: 10000,
|
|
||||||
resolveTarget: ({ to }) => {
|
|
||||||
const parsed = parseTlonTarget(to ?? "");
|
|
||||||
if (!parsed) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (parsed.kind === "dm") {
|
|
||||||
return { ok: true, to: parsed.ship };
|
|
||||||
}
|
|
||||||
return { ok: true, to: parsed.nest };
|
|
||||||
},
|
|
||||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
|
||||||
const account = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined);
|
|
||||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
|
||||||
throw new Error("Tlon account not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseTlonTarget(to);
|
|
||||||
if (!parsed) {
|
|
||||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureUrbitConnectPatched();
|
|
||||||
const api = await Urbit.authenticate({
|
|
||||||
ship: account.ship.replace(/^~/, ""),
|
|
||||||
url: account.url,
|
|
||||||
code: account.code,
|
|
||||||
verbose: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fromShip = normalizeShip(account.ship);
|
|
||||||
if (parsed.kind === "dm") {
|
|
||||||
return await sendDm({
|
|
||||||
api,
|
|
||||||
fromShip,
|
|
||||||
toShip: parsed.ship,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
|
|
||||||
return await sendGroupMessage({
|
|
||||||
api,
|
|
||||||
fromShip,
|
|
||||||
hostShip: parsed.hostShip,
|
|
||||||
channelName: parsed.channelName,
|
|
||||||
text,
|
|
||||||
replyToId: replyId,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await api.delete();
|
|
||||||
} catch {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
|
||||||
const mergedText = buildMediaText(text, mediaUrl);
|
|
||||||
return await tlonOutbound.sendText({
|
|
||||||
cfg,
|
|
||||||
to,
|
|
||||||
text: mergedText,
|
|
||||||
accountId,
|
|
||||||
replyToId,
|
|
||||||
threadId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tlonPlugin: ChannelPlugin = {
|
|
||||||
id: TLON_CHANNEL_ID,
|
|
||||||
meta: {
|
|
||||||
id: TLON_CHANNEL_ID,
|
|
||||||
label: "Tlon",
|
|
||||||
selectionLabel: "Tlon (Urbit)",
|
|
||||||
docsPath: "/channels/tlon",
|
|
||||||
docsLabel: "tlon",
|
|
||||||
blurb: "Decentralized messaging on Urbit",
|
|
||||||
aliases: ["urbit"],
|
|
||||||
order: 90,
|
|
||||||
},
|
|
||||||
capabilities: {
|
|
||||||
chatTypes: ["direct", "group", "thread"],
|
|
||||||
media: false,
|
|
||||||
reply: true,
|
|
||||||
threads: true,
|
|
||||||
},
|
|
||||||
onboarding: tlonOnboardingAdapter,
|
|
||||||
reload: { configPrefixes: ["channels.tlon"] },
|
|
||||||
configSchema: tlonChannelConfigSchema,
|
|
||||||
config: {
|
|
||||||
listAccountIds: (cfg) => listTlonAccountIds(cfg as ClawdbotConfig),
|
|
||||||
resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined),
|
|
||||||
defaultAccountId: () => "default",
|
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
||||||
const useDefault = !accountId || accountId === "default";
|
|
||||||
if (useDefault) {
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
tlon: {
|
|
||||||
...(cfg.channels?.tlon ?? {}),
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
tlon: {
|
|
||||||
...(cfg.channels?.tlon ?? {}),
|
|
||||||
accounts: {
|
|
||||||
...(cfg.channels?.tlon?.accounts ?? {}),
|
|
||||||
[accountId]: {
|
|
||||||
...(cfg.channels?.tlon?.accounts?.[accountId] ?? {}),
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
},
|
|
||||||
deleteAccount: ({ cfg, accountId }) => {
|
|
||||||
const useDefault = !accountId || accountId === "default";
|
|
||||||
if (useDefault) {
|
|
||||||
const { ship, code, url, name, ...rest } = cfg.channels?.tlon ?? {};
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
tlon: rest,
|
|
||||||
},
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
}
|
|
||||||
const { [accountId]: removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {};
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
tlon: {
|
|
||||||
...(cfg.channels?.tlon ?? {}),
|
|
||||||
accounts: remainingAccounts,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as ClawdbotConfig;
|
|
||||||
},
|
|
||||||
isConfigured: (account) => account.configured,
|
|
||||||
describeAccount: (account) => ({
|
|
||||||
accountId: account.accountId,
|
|
||||||
name: account.name,
|
|
||||||
enabled: account.enabled,
|
|
||||||
configured: account.configured,
|
|
||||||
ship: account.ship,
|
|
||||||
url: account.url,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
setup: {
|
|
||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
|
||||||
applyAccountName: ({ cfg, accountId, name }) =>
|
|
||||||
applyAccountNameToChannelSection({
|
|
||||||
cfg: cfg as ClawdbotConfig,
|
|
||||||
channelKey: "tlon",
|
|
||||||
accountId,
|
|
||||||
name,
|
|
||||||
}),
|
|
||||||
validateInput: ({ cfg, accountId, input }) => {
|
|
||||||
const setupInput = input as TlonSetupInput;
|
|
||||||
const resolved = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined);
|
|
||||||
const ship = setupInput.ship?.trim() || resolved.ship;
|
|
||||||
const url = setupInput.url?.trim() || resolved.url;
|
|
||||||
const code = setupInput.code?.trim() || resolved.code;
|
|
||||||
if (!ship) return "Tlon requires --ship.";
|
|
||||||
if (!url) return "Tlon requires --url.";
|
|
||||||
if (!code) return "Tlon requires --code.";
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
applyAccountConfig: ({ cfg, accountId, input }) =>
|
|
||||||
applyTlonSetupConfig({
|
|
||||||
cfg: cfg as ClawdbotConfig,
|
|
||||||
accountId,
|
|
||||||
input: input as TlonSetupInput,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
messaging: {
|
|
||||||
normalizeTarget: (target) => {
|
|
||||||
const parsed = parseTlonTarget(target);
|
|
||||||
if (!parsed) return target.trim();
|
|
||||||
if (parsed.kind === "dm") return parsed.ship;
|
|
||||||
return parsed.nest;
|
|
||||||
},
|
|
||||||
targetResolver: {
|
|
||||||
looksLikeId: (target) => Boolean(parseTlonTarget(target)),
|
|
||||||
hint: formatTargetHint(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
outbound: tlonOutbound,
|
|
||||||
status: {
|
|
||||||
defaultRuntime: {
|
|
||||||
accountId: "default",
|
|
||||||
running: false,
|
|
||||||
lastStartAt: null,
|
|
||||||
lastStopAt: null,
|
|
||||||
lastError: null,
|
|
||||||
},
|
|
||||||
collectStatusIssues: (accounts) => {
|
|
||||||
return accounts.flatMap((account) => {
|
|
||||||
if (!account.configured) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
channel: TLON_CHANNEL_ID,
|
|
||||||
accountId: account.accountId,
|
|
||||||
kind: "config",
|
|
||||||
message: "Account not configured (missing ship, code, or url)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
buildChannelSummary: ({ snapshot }) => ({
|
|
||||||
configured: snapshot.configured ?? false,
|
|
||||||
ship: snapshot.ship ?? null,
|
|
||||||
url: snapshot.url ?? null,
|
|
||||||
}),
|
|
||||||
probeAccount: async ({ account }) => {
|
|
||||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
|
||||||
return { ok: false, error: "Not configured" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ensureUrbitConnectPatched();
|
|
||||||
const api = await Urbit.authenticate({
|
|
||||||
ship: account.ship.replace(/^~/, ""),
|
|
||||||
url: account.url,
|
|
||||||
code: account.code,
|
|
||||||
verbose: false,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await api.getOurName();
|
|
||||||
return { ok: true };
|
|
||||||
} finally {
|
|
||||||
await api.delete();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
return { ok: false, error: error?.message ?? String(error) };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
||||||
accountId: account.accountId,
|
|
||||||
name: account.name,
|
|
||||||
enabled: account.enabled,
|
|
||||||
configured: account.configured,
|
|
||||||
ship: account.ship,
|
|
||||||
url: account.url,
|
|
||||||
running: runtime?.running ?? false,
|
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
|
||||||
lastError: runtime?.lastError ?? null,
|
|
||||||
probe,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
gateway: {
|
|
||||||
startAccount: async (ctx) => {
|
|
||||||
const account = ctx.account;
|
|
||||||
ctx.setStatus({
|
|
||||||
accountId: account.accountId,
|
|
||||||
ship: account.ship,
|
|
||||||
url: account.url,
|
|
||||||
});
|
|
||||||
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
|
|
||||||
return monitorTlonProvider({
|
|
||||||
runtime: ctx.runtime,
|
|
||||||
abortSignal: ctx.abortSignal,
|
|
||||||
accountId: account.accountId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
const ShipSchema = z.string().min(1);
|
|
||||||
const ChannelNestSchema = z.string().min(1);
|
|
||||||
|
|
||||||
export const TlonChannelRuleSchema = z.object({
|
|
||||||
mode: z.enum(["restricted", "open"]).optional(),
|
|
||||||
allowedShips: z.array(ShipSchema).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TlonAuthorizationSchema = z.object({
|
|
||||||
channelRules: z.record(TlonChannelRuleSchema).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TlonAccountSchema = z.object({
|
|
||||||
name: z.string().optional(),
|
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
ship: ShipSchema.optional(),
|
|
||||||
url: z.string().optional(),
|
|
||||||
code: z.string().optional(),
|
|
||||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
|
||||||
dmAllowlist: z.array(ShipSchema).optional(),
|
|
||||||
autoDiscoverChannels: z.boolean().optional(),
|
|
||||||
showModelSignature: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const TlonConfigSchema = z.object({
|
|
||||||
name: z.string().optional(),
|
|
||||||
enabled: z.boolean().optional(),
|
|
||||||
ship: ShipSchema.optional(),
|
|
||||||
url: z.string().optional(),
|
|
||||||
code: z.string().optional(),
|
|
||||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
|
||||||
dmAllowlist: z.array(ShipSchema).optional(),
|
|
||||||
autoDiscoverChannels: z.boolean().optional(),
|
|
||||||
showModelSignature: z.boolean().optional(),
|
|
||||||
authorization: TlonAuthorizationSchema.optional(),
|
|
||||||
defaultAuthorizedShips: z.array(ShipSchema).optional(),
|
|
||||||
accounts: z.record(TlonAccountSchema).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
import { formatChangesDate } from "./utils.js";
|
|
||||||
|
|
||||||
export async function fetchGroupChanges(
|
|
||||||
api: { scry: (path: string) => Promise<unknown> },
|
|
||||||
runtime: RuntimeEnv,
|
|
||||||
daysAgo = 5,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const changeDate = formatChangesDate(daysAgo);
|
|
||||||
runtime.log?.(`[tlon] Fetching group changes since ${daysAgo} days ago (${changeDate})...`);
|
|
||||||
const changes = await api.scry(`/groups-ui/v5/changes/${changeDate}.json`);
|
|
||||||
if (changes) {
|
|
||||||
runtime.log?.("[tlon] Successfully fetched changes data");
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error?.message ?? String(error)}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAllChannels(
|
|
||||||
api: { scry: (path: string) => Promise<unknown> },
|
|
||||||
runtime: RuntimeEnv,
|
|
||||||
): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
runtime.log?.("[tlon] Attempting auto-discovery of group channels...");
|
|
||||||
const changes = await fetchGroupChanges(api, runtime, 5);
|
|
||||||
|
|
||||||
let initData: any;
|
|
||||||
if (changes) {
|
|
||||||
runtime.log?.("[tlon] Changes data received, using full init for channel extraction");
|
|
||||||
initData = await api.scry("/groups-ui/v6/init.json");
|
|
||||||
} else {
|
|
||||||
initData = await api.scry("/groups-ui/v6/init.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
const channels: string[] = [];
|
|
||||||
if (initData && initData.groups) {
|
|
||||||
for (const groupData of Object.values(initData.groups as Record<string, any>)) {
|
|
||||||
if (groupData && typeof groupData === "object" && groupData.channels) {
|
|
||||||
for (const channelNest of Object.keys(groupData.channels)) {
|
|
||||||
if (channelNest.startsWith("chat/")) {
|
|
||||||
channels.push(channelNest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channels.length > 0) {
|
|
||||||
runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
|
|
||||||
runtime.log?.(
|
|
||||||
`[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
runtime.log?.("[tlon] No chat channels found via auto-discovery");
|
|
||||||
runtime.log?.("[tlon] Add channels manually to config: channels.tlon.groupChannels");
|
|
||||||
}
|
|
||||||
|
|
||||||
return channels;
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.log?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
|
|
||||||
runtime.log?.("[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels");
|
|
||||||
runtime.log?.("[tlon] Example: [\"chat/~host-ship/channel-name\"]");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
import { extractMessageText } from "./utils.js";
|
|
||||||
|
|
||||||
export type TlonHistoryEntry = {
|
|
||||||
author: string;
|
|
||||||
content: string;
|
|
||||||
timestamp: number;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageCache = new Map<string, TlonHistoryEntry[]>();
|
|
||||||
const MAX_CACHED_MESSAGES = 100;
|
|
||||||
|
|
||||||
export function cacheMessage(channelNest: string, message: TlonHistoryEntry) {
|
|
||||||
if (!messageCache.has(channelNest)) {
|
|
||||||
messageCache.set(channelNest, []);
|
|
||||||
}
|
|
||||||
const cache = messageCache.get(channelNest);
|
|
||||||
if (!cache) return;
|
|
||||||
cache.unshift(message);
|
|
||||||
if (cache.length > MAX_CACHED_MESSAGES) {
|
|
||||||
cache.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchChannelHistory(
|
|
||||||
api: { scry: (path: string) => Promise<unknown> },
|
|
||||||
channelNest: string,
|
|
||||||
count = 50,
|
|
||||||
runtime?: RuntimeEnv,
|
|
||||||
): Promise<TlonHistoryEntry[]> {
|
|
||||||
try {
|
|
||||||
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
|
|
||||||
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
|
|
||||||
|
|
||||||
const data: any = await api.scry(scryPath);
|
|
||||||
if (!data) return [];
|
|
||||||
|
|
||||||
let posts: any[] = [];
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
posts = data;
|
|
||||||
} else if (data.posts && typeof data.posts === "object") {
|
|
||||||
posts = Object.values(data.posts);
|
|
||||||
} else if (typeof data === "object") {
|
|
||||||
posts = Object.values(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = posts
|
|
||||||
.map((item) => {
|
|
||||||
const essay = item.essay || item["r-post"]?.set?.essay;
|
|
||||||
const seal = item.seal || item["r-post"]?.set?.seal;
|
|
||||||
|
|
||||||
return {
|
|
||||||
author: essay?.author || "unknown",
|
|
||||||
content: extractMessageText(essay?.content || []),
|
|
||||||
timestamp: essay?.sent || Date.now(),
|
|
||||||
id: seal?.id,
|
|
||||||
} as TlonHistoryEntry;
|
|
||||||
})
|
|
||||||
.filter((msg) => msg.content);
|
|
||||||
|
|
||||||
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
|
|
||||||
return messages;
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime?.log?.(`[tlon] Error fetching channel history: ${error?.message ?? String(error)}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getChannelHistory(
|
|
||||||
api: { scry: (path: string) => Promise<unknown> },
|
|
||||||
channelNest: string,
|
|
||||||
count = 50,
|
|
||||||
runtime?: RuntimeEnv,
|
|
||||||
): Promise<TlonHistoryEntry[]> {
|
|
||||||
const cache = messageCache.get(channelNest) ?? [];
|
|
||||||
if (cache.length >= count) {
|
|
||||||
runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`);
|
|
||||||
return cache.slice(0, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime?.log?.(
|
|
||||||
`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`,
|
|
||||||
);
|
|
||||||
return await fetchChannelHistory(api, channelNest, count, runtime);
|
|
||||||
}
|
|
||||||
@@ -1,501 +0,0 @@
|
|||||||
import { format } from "node:util";
|
|
||||||
|
|
||||||
import type { RuntimeEnv, ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
import { getTlonRuntime } from "../runtime.js";
|
|
||||||
import { resolveTlonAccount } from "../types.js";
|
|
||||||
import { normalizeShip, parseChannelNest } from "../targets.js";
|
|
||||||
import { authenticate } from "../urbit/auth.js";
|
|
||||||
import { UrbitSSEClient } from "../urbit/sse-client.js";
|
|
||||||
import { sendDm, sendGroupMessage } from "../urbit/send.js";
|
|
||||||
import { cacheMessage, getChannelHistory } from "./history.js";
|
|
||||||
import { createProcessedMessageTracker } from "./processed-messages.js";
|
|
||||||
import {
|
|
||||||
extractMessageText,
|
|
||||||
formatModelName,
|
|
||||||
isBotMentioned,
|
|
||||||
isDmAllowed,
|
|
||||||
isSummarizationRequest,
|
|
||||||
} from "./utils.js";
|
|
||||||
import { fetchAllChannels } from "./discovery.js";
|
|
||||||
|
|
||||||
export type MonitorTlonOpts = {
|
|
||||||
runtime?: RuntimeEnv;
|
|
||||||
abortSignal?: AbortSignal;
|
|
||||||
accountId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ChannelAuthorization = {
|
|
||||||
mode?: "restricted" | "open";
|
|
||||||
allowedShips?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveChannelAuthorization(
|
|
||||||
cfg: ClawdbotConfig,
|
|
||||||
channelNest: string,
|
|
||||||
): { mode: "restricted" | "open"; allowedShips: string[] } {
|
|
||||||
const tlonConfig = cfg.channels?.tlon as
|
|
||||||
| {
|
|
||||||
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
|
|
||||||
defaultAuthorizedShips?: string[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
const rules = tlonConfig?.authorization?.channelRules ?? {};
|
|
||||||
const rule = rules[channelNest];
|
|
||||||
const allowedShips = rule?.allowedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
|
|
||||||
const mode = rule?.mode ?? "restricted";
|
|
||||||
return { mode, allowedShips };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<void> {
|
|
||||||
const core = getTlonRuntime();
|
|
||||||
const cfg = core.config.loadConfig() as ClawdbotConfig;
|
|
||||||
if (cfg.channels?.tlon?.enabled === false) return;
|
|
||||||
|
|
||||||
const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
|
|
||||||
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
|
||||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
|
||||||
log: (...args) => {
|
|
||||||
logger.info(formatRuntimeMessage(...args));
|
|
||||||
},
|
|
||||||
error: (...args) => {
|
|
||||||
logger.error(formatRuntimeMessage(...args));
|
|
||||||
},
|
|
||||||
exit: (code: number): never => {
|
|
||||||
throw new Error(`exit ${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const account = resolveTlonAccount(cfg, opts.accountId ?? undefined);
|
|
||||||
if (!account.enabled) return;
|
|
||||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
|
||||||
throw new Error("Tlon account not configured (ship/url/code required)");
|
|
||||||
}
|
|
||||||
|
|
||||||
const botShipName = normalizeShip(account.ship);
|
|
||||||
runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
|
|
||||||
|
|
||||||
let api: UrbitSSEClient | null = null;
|
|
||||||
try {
|
|
||||||
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
|
|
||||||
const cookie = await authenticate(account.url, account.code);
|
|
||||||
api = new UrbitSSEClient(account.url, cookie, {
|
|
||||||
ship: botShipName,
|
|
||||||
logger: {
|
|
||||||
log: (message) => runtime.log?.(message),
|
|
||||||
error: (message) => runtime.error?.(message),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Failed to authenticate: ${error?.message ?? String(error)}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const processedTracker = createProcessedMessageTracker(2000);
|
|
||||||
let groupChannels: string[] = [];
|
|
||||||
|
|
||||||
if (account.autoDiscoverChannels !== false) {
|
|
||||||
try {
|
|
||||||
const discoveredChannels = await fetchAllChannels(api, runtime);
|
|
||||||
if (discoveredChannels.length > 0) {
|
|
||||||
groupChannels = discoveredChannels;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupChannels.length === 0 && account.groupChannels.length > 0) {
|
|
||||||
groupChannels = account.groupChannels;
|
|
||||||
runtime.log?.(`[tlon] Using manual groupChannels config: ${groupChannels.join(", ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupChannels.length > 0) {
|
|
||||||
runtime.log?.(
|
|
||||||
`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIncomingDM = async (update: any) => {
|
|
||||||
try {
|
|
||||||
const memo = update?.response?.add?.memo;
|
|
||||||
if (!memo) return;
|
|
||||||
|
|
||||||
const messageId = update.id as string | undefined;
|
|
||||||
if (!processedTracker.mark(messageId)) return;
|
|
||||||
|
|
||||||
const senderShip = normalizeShip(memo.author ?? "");
|
|
||||||
if (!senderShip || senderShip === botShipName) return;
|
|
||||||
|
|
||||||
const messageText = extractMessageText(memo.content);
|
|
||||||
if (!messageText) return;
|
|
||||||
|
|
||||||
if (!isDmAllowed(senderShip, account.dmAllowlist)) {
|
|
||||||
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await processMessage({
|
|
||||||
messageId: messageId ?? "",
|
|
||||||
senderShip,
|
|
||||||
messageText,
|
|
||||||
isGroup: false,
|
|
||||||
timestamp: memo.sent || Date.now(),
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Error handling DM: ${error?.message ?? String(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleIncomingGroupMessage = (channelNest: string) => async (update: any) => {
|
|
||||||
try {
|
|
||||||
const parsed = parseChannelNest(channelNest);
|
|
||||||
if (!parsed) return;
|
|
||||||
|
|
||||||
const essay = update?.response?.post?.["r-post"]?.set?.essay;
|
|
||||||
const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
|
|
||||||
if (!essay && !memo) return;
|
|
||||||
|
|
||||||
const content = memo || essay;
|
|
||||||
const isThreadReply = Boolean(memo);
|
|
||||||
const messageId = isThreadReply
|
|
||||||
? update?.response?.post?.["r-post"]?.reply?.id
|
|
||||||
: update?.response?.post?.id;
|
|
||||||
|
|
||||||
if (!processedTracker.mark(messageId)) return;
|
|
||||||
|
|
||||||
const senderShip = normalizeShip(content.author ?? "");
|
|
||||||
if (!senderShip || senderShip === botShipName) return;
|
|
||||||
|
|
||||||
const messageText = extractMessageText(content.content);
|
|
||||||
if (!messageText) return;
|
|
||||||
|
|
||||||
cacheMessage(channelNest, {
|
|
||||||
author: senderShip,
|
|
||||||
content: messageText,
|
|
||||||
timestamp: content.sent || Date.now(),
|
|
||||||
id: messageId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mentioned = isBotMentioned(messageText, botShipName);
|
|
||||||
if (!mentioned) return;
|
|
||||||
|
|
||||||
const { mode, allowedShips } = resolveChannelAuthorization(cfg, channelNest);
|
|
||||||
if (mode === "restricted") {
|
|
||||||
if (allowedShips.length === 0) {
|
|
||||||
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${channelNest} (no allowlist)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const normalizedAllowed = allowedShips.map(normalizeShip);
|
|
||||||
if (!normalizedAllowed.includes(senderShip)) {
|
|
||||||
runtime.log?.(
|
|
||||||
`[tlon] Access denied: ${senderShip} in ${channelNest} (allowed: ${allowedShips.join(", ")})`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seal = isThreadReply
|
|
||||||
? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
|
|
||||||
: update?.response?.post?.["r-post"]?.set?.seal;
|
|
||||||
|
|
||||||
const parentId = seal?.["parent-id"] || seal?.parent || null;
|
|
||||||
|
|
||||||
await processMessage({
|
|
||||||
messageId: messageId ?? "",
|
|
||||||
senderShip,
|
|
||||||
messageText,
|
|
||||||
isGroup: true,
|
|
||||||
groupChannel: channelNest,
|
|
||||||
groupName: `${parsed.hostShip}/${parsed.channelName}`,
|
|
||||||
timestamp: content.sent || Date.now(),
|
|
||||||
parentId,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Error handling group message: ${error?.message ?? String(error)}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processMessage = async (params: {
|
|
||||||
messageId: string;
|
|
||||||
senderShip: string;
|
|
||||||
messageText: string;
|
|
||||||
isGroup: boolean;
|
|
||||||
groupChannel?: string;
|
|
||||||
groupName?: string;
|
|
||||||
timestamp: number;
|
|
||||||
parentId?: string | null;
|
|
||||||
}) => {
|
|
||||||
const { messageId, senderShip, isGroup, groupChannel, groupName, timestamp, parentId } = params;
|
|
||||||
let messageText = params.messageText;
|
|
||||||
|
|
||||||
if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
|
|
||||||
try {
|
|
||||||
const history = await getChannelHistory(api!, groupChannel, 50, runtime);
|
|
||||||
if (history.length === 0) {
|
|
||||||
const noHistoryMsg =
|
|
||||||
"I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
|
|
||||||
if (isGroup) {
|
|
||||||
const parsed = parseChannelNest(groupChannel);
|
|
||||||
if (parsed) {
|
|
||||||
await sendGroupMessage({
|
|
||||||
api: api!,
|
|
||||||
fromShip: botShipName,
|
|
||||||
hostShip: parsed.hostShip,
|
|
||||||
channelName: parsed.channelName,
|
|
||||||
text: noHistoryMsg,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: noHistoryMsg });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyText = history
|
|
||||||
.map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
messageText =
|
|
||||||
`Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
|
|
||||||
"Provide a concise summary highlighting:\n" +
|
|
||||||
"1. Main topics discussed\n" +
|
|
||||||
"2. Key decisions or conclusions\n" +
|
|
||||||
"3. Action items if any\n" +
|
|
||||||
"4. Notable participants";
|
|
||||||
} catch (error: any) {
|
|
||||||
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
|
|
||||||
if (isGroup && groupChannel) {
|
|
||||||
const parsed = parseChannelNest(groupChannel);
|
|
||||||
if (parsed) {
|
|
||||||
await sendGroupMessage({
|
|
||||||
api: api!,
|
|
||||||
fromShip: botShipName,
|
|
||||||
hostShip: parsed.hostShip,
|
|
||||||
channelName: parsed.channelName,
|
|
||||||
text: errorMsg,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: errorMsg });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = core.channel.routing.resolveAgentRoute({
|
|
||||||
cfg,
|
|
||||||
channel: "tlon",
|
|
||||||
accountId: opts.accountId ?? undefined,
|
|
||||||
peer: {
|
|
||||||
kind: isGroup ? "group" : "dm",
|
|
||||||
id: isGroup ? groupChannel ?? senderShip : senderShip,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fromLabel = isGroup ? `${senderShip} in ${groupName}` : senderShip;
|
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
|
||||||
channel: "Tlon",
|
|
||||||
from: fromLabel,
|
|
||||||
timestamp,
|
|
||||||
body: messageText,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
||||||
Body: body,
|
|
||||||
RawBody: messageText,
|
|
||||||
CommandBody: messageText,
|
|
||||||
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
|
|
||||||
To: `tlon:${botShipName}`,
|
|
||||||
SessionKey: route.sessionKey,
|
|
||||||
AccountId: route.accountId,
|
|
||||||
ChatType: isGroup ? "group" : "direct",
|
|
||||||
ConversationLabel: fromLabel,
|
|
||||||
SenderName: senderShip,
|
|
||||||
SenderId: senderShip,
|
|
||||||
Provider: "tlon",
|
|
||||||
Surface: "tlon",
|
|
||||||
MessageSid: messageId,
|
|
||||||
OriginatingChannel: "tlon",
|
|
||||||
OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dispatchStartTime = Date.now();
|
|
||||||
|
|
||||||
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
|
||||||
.responsePrefix;
|
|
||||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
|
||||||
|
|
||||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcherOptions: {
|
|
||||||
responsePrefix,
|
|
||||||
humanDelay,
|
|
||||||
deliver: async (payload: ReplyPayload) => {
|
|
||||||
let replyText = payload.text;
|
|
||||||
if (!replyText) return;
|
|
||||||
|
|
||||||
const showSignature = account.showModelSignature ?? cfg.channels?.tlon?.showModelSignature ?? false;
|
|
||||||
if (showSignature) {
|
|
||||||
const modelInfo =
|
|
||||||
payload.metadata?.model || payload.model || route.model || cfg.agents?.defaults?.model?.primary;
|
|
||||||
replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGroup && groupChannel) {
|
|
||||||
const parsed = parseChannelNest(groupChannel);
|
|
||||||
if (!parsed) return;
|
|
||||||
await sendGroupMessage({
|
|
||||||
api: api!,
|
|
||||||
fromShip: botShipName,
|
|
||||||
hostShip: parsed.hostShip,
|
|
||||||
channelName: parsed.channelName,
|
|
||||||
text: replyText,
|
|
||||||
replyToId: parentId ?? undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: replyText });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (err, info) => {
|
|
||||||
const dispatchDuration = Date.now() - dispatchStartTime;
|
|
||||||
runtime.error?.(
|
|
||||||
`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const subscribedChannels = new Set<string>();
|
|
||||||
const subscribedDMs = new Set<string>();
|
|
||||||
|
|
||||||
async function subscribeToChannel(channelNest: string) {
|
|
||||||
if (subscribedChannels.has(channelNest)) return;
|
|
||||||
const parsed = parseChannelNest(channelNest);
|
|
||||||
if (!parsed) {
|
|
||||||
runtime.error?.(`[tlon] Invalid channel format: ${channelNest}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api!.subscribe({
|
|
||||||
app: "channels",
|
|
||||||
path: `/${channelNest}`,
|
|
||||||
event: handleIncomingGroupMessage(channelNest),
|
|
||||||
err: (error) => {
|
|
||||||
runtime.error?.(`[tlon] Group subscription error for ${channelNest}: ${String(error)}`);
|
|
||||||
},
|
|
||||||
quit: () => {
|
|
||||||
runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`);
|
|
||||||
subscribedChannels.delete(channelNest);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
subscribedChannels.add(channelNest);
|
|
||||||
runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`);
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error?.message ?? String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function subscribeToDM(dmShip: string) {
|
|
||||||
if (subscribedDMs.has(dmShip)) return;
|
|
||||||
try {
|
|
||||||
await api!.subscribe({
|
|
||||||
app: "chat",
|
|
||||||
path: `/dm/${dmShip}`,
|
|
||||||
event: handleIncomingDM,
|
|
||||||
err: (error) => {
|
|
||||||
runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${String(error)}`);
|
|
||||||
},
|
|
||||||
quit: () => {
|
|
||||||
runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`);
|
|
||||||
subscribedDMs.delete(dmShip);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
subscribedDMs.add(dmShip);
|
|
||||||
runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`);
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error?.message ?? String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshChannelSubscriptions() {
|
|
||||||
try {
|
|
||||||
const dmShips = await api!.scry("/chat/dm.json");
|
|
||||||
if (Array.isArray(dmShips)) {
|
|
||||||
for (const dmShip of dmShips) {
|
|
||||||
await subscribeToDM(dmShip);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.autoDiscoverChannels !== false) {
|
|
||||||
const discoveredChannels = await fetchAllChannels(api!, runtime);
|
|
||||||
for (const channelNest of discoveredChannels) {
|
|
||||||
await subscribeToChannel(channelNest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Channel refresh failed: ${error?.message ?? String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
runtime.log?.("[tlon] Subscribing to updates...");
|
|
||||||
|
|
||||||
let dmShips: string[] = [];
|
|
||||||
try {
|
|
||||||
const dmList = await api!.scry("/chat/dm.json");
|
|
||||||
if (Array.isArray(dmList)) {
|
|
||||||
dmShips = dmList;
|
|
||||||
runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Failed to fetch DM list: ${error?.message ?? String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const dmShip of dmShips) {
|
|
||||||
await subscribeToDM(dmShip);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const channelNest of groupChannels) {
|
|
||||||
await subscribeToChannel(channelNest);
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
|
|
||||||
await api!.connect();
|
|
||||||
runtime.log?.("[tlon] Connected! All subscriptions active");
|
|
||||||
|
|
||||||
const pollInterval = setInterval(() => {
|
|
||||||
if (!opts.abortSignal?.aborted) {
|
|
||||||
refreshChannelSubscriptions().catch((error) => {
|
|
||||||
runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 2 * 60 * 1000);
|
|
||||||
|
|
||||||
if (opts.abortSignal) {
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
opts.abortSignal.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
clearInterval(pollInterval);
|
|
||||||
resolve(null);
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await new Promise(() => {});
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await api?.close();
|
|
||||||
} catch (error: any) {
|
|
||||||
runtime.error?.(`[tlon] Cleanup error: ${error?.message ?? String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { createProcessedMessageTracker } from "./processed-messages.js";
|
|
||||||
|
|
||||||
describe("createProcessedMessageTracker", () => {
|
|
||||||
it("dedupes and evicts oldest entries", () => {
|
|
||||||
const tracker = createProcessedMessageTracker(3);
|
|
||||||
|
|
||||||
expect(tracker.mark("a")).toBe(true);
|
|
||||||
expect(tracker.mark("a")).toBe(false);
|
|
||||||
expect(tracker.has("a")).toBe(true);
|
|
||||||
|
|
||||||
tracker.mark("b");
|
|
||||||
tracker.mark("c");
|
|
||||||
expect(tracker.size()).toBe(3);
|
|
||||||
|
|
||||||
tracker.mark("d");
|
|
||||||
expect(tracker.size()).toBe(3);
|
|
||||||
expect(tracker.has("a")).toBe(false);
|
|
||||||
expect(tracker.has("b")).toBe(true);
|
|
||||||
expect(tracker.has("c")).toBe(true);
|
|
||||||
expect(tracker.has("d")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export type ProcessedMessageTracker = {
|
|
||||||
mark: (id?: string | null) => boolean;
|
|
||||||
has: (id?: string | null) => boolean;
|
|
||||||
size: () => number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const order: string[] = [];
|
|
||||||
|
|
||||||
const mark = (id?: string | null) => {
|
|
||||||
const trimmed = id?.trim();
|
|
||||||
if (!trimmed) return true;
|
|
||||||
if (seen.has(trimmed)) return false;
|
|
||||||
seen.add(trimmed);
|
|
||||||
order.push(trimmed);
|
|
||||||
if (order.length > limit) {
|
|
||||||
const overflow = order.length - limit;
|
|
||||||
for (let i = 0; i < overflow; i += 1) {
|
|
||||||
const oldest = order.shift();
|
|
||||||
if (oldest) seen.delete(oldest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const has = (id?: string | null) => {
|
|
||||||
const trimmed = id?.trim();
|
|
||||||
if (!trimmed) return false;
|
|
||||||
return seen.has(trimmed);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
mark,
|
|
||||||
has,
|
|
||||||
size: () => seen.size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { normalizeShip } from "../targets.js";
|
|
||||||
|
|
||||||
export function formatModelName(modelString?: string | null): string {
|
|
||||||
if (!modelString) return "AI";
|
|
||||||
const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString;
|
|
||||||
const modelMappings: Record<string, string> = {
|
|
||||||
"claude-opus-4-5": "Claude Opus 4.5",
|
|
||||||
"claude-sonnet-4-5": "Claude Sonnet 4.5",
|
|
||||||
"claude-sonnet-3-5": "Claude Sonnet 3.5",
|
|
||||||
"gpt-4o": "GPT-4o",
|
|
||||||
"gpt-4-turbo": "GPT-4 Turbo",
|
|
||||||
"gpt-4": "GPT-4",
|
|
||||||
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
|
||||||
"gemini-pro": "Gemini Pro",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (modelMappings[modelName]) return modelMappings[modelName];
|
|
||||||
return modelName
|
|
||||||
.replace(/-/g, " ")
|
|
||||||
.split(" ")
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isBotMentioned(messageText: string, botShipName: string): boolean {
|
|
||||||
if (!messageText || !botShipName) return false;
|
|
||||||
const normalizedBotShip = normalizeShip(botShipName);
|
|
||||||
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
|
|
||||||
return mentionPattern.test(messageText);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean {
|
|
||||||
if (!allowlist || allowlist.length === 0) return true;
|
|
||||||
const normalizedSender = normalizeShip(senderShip);
|
|
||||||
return allowlist
|
|
||||||
.map((ship) => normalizeShip(ship))
|
|
||||||
.some((ship) => ship === normalizedSender);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractMessageText(content: unknown): string {
|
|
||||||
if (!content || !Array.isArray(content)) return "";
|
|
||||||
|
|
||||||
return content
|
|
||||||
.map((block: any) => {
|
|
||||||
if (block.inline && Array.isArray(block.inline)) {
|
|
||||||
return block.inline
|
|
||||||
.map((item: any) => {
|
|
||||||
if (typeof item === "string") return item;
|
|
||||||
if (item && typeof item === "object") {
|
|
||||||
if (item.ship) return item.ship;
|
|
||||||
if (item.break !== undefined) return "\n";
|
|
||||||
if (item.link && item.link.href) return item.link.href;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
})
|
|
||||||
.join("\n")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSummarizationRequest(messageText: string): boolean {
|
|
||||||
const patterns = [
|
|
||||||
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
|
|
||||||
/what\s+did\s+i\s+miss/i,
|
|
||||||
/catch\s+me\s+up/i,
|
|
||||||
/channel\s+summary/i,
|
|
||||||
/tldr/i,
|
|
||||||
];
|
|
||||||
return patterns.some((pattern) => pattern.test(messageText));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatChangesDate(daysAgo = 5): string {
|
|
||||||
const now = new Date();
|
|
||||||
const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
|
|
||||||
const year = targetDate.getFullYear();
|
|
||||||
const month = targetDate.getMonth() + 1;
|
|
||||||
const day = targetDate.getDate();
|
|
||||||
return `~${year}.${month}.${day}..20.19.51..9b9d`;
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import {
|
|
||||||
formatDocsLink,
|
|
||||||
promptAccountId,
|
|
||||||
DEFAULT_ACCOUNT_ID,
|
|
||||||
normalizeAccountId,
|
|
||||||
type ChannelOnboardingAdapter,
|
|
||||||
type WizardPrompter,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
|
|
||||||
import type { TlonResolvedAccount } from "./types.js";
|
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
const channel = "tlon" as const;
|
|
||||||
|
|
||||||
function isConfigured(account: TlonResolvedAccount): boolean {
|
|
||||||
return Boolean(account.ship && account.url && account.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAccountConfig(params: {
|
|
||||||
cfg: ClawdbotConfig;
|
|
||||||
accountId: string;
|
|
||||||
input: {
|
|
||||||
name?: string;
|
|
||||||
ship?: string;
|
|
||||||
url?: string;
|
|
||||||
code?: string;
|
|
||||||
groupChannels?: string[];
|
|
||||||
dmAllowlist?: string[];
|
|
||||||
autoDiscoverChannels?: boolean;
|
|
||||||
};
|
|
||||||
}): ClawdbotConfig {
|
|
||||||
const { cfg, accountId, input } = params;
|
|
||||||
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
|
|
||||||
const base = cfg.channels?.tlon ?? {};
|
|
||||||
|
|
||||||
if (useDefault) {
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
tlon: {
|
|
||||||
...base,
|
|
||||||
enabled: true,
|
|
||||||
...(input.name ? { name: input.name } : {}),
|
|
||||||
...(input.ship ? { ship: input.ship } : {}),
|
|
||||||
...(input.url ? { url: input.url } : {}),
|
|
||||||
...(input.code ? { code: input.code } : {}),
|
|
||||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
|
||||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
|
||||||
...(typeof input.autoDiscoverChannels === "boolean"
|
|
||||||
? { autoDiscoverChannels: input.autoDiscoverChannels }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
tlon: {
|
|
||||||
...base,
|
|
||||||
enabled: base.enabled ?? true,
|
|
||||||
accounts: {
|
|
||||||
...(base as { accounts?: Record<string, unknown> }).accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...((base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[accountId] ?? {}),
|
|
||||||
enabled: true,
|
|
||||||
...(input.name ? { name: input.name } : {}),
|
|
||||||
...(input.ship ? { ship: input.ship } : {}),
|
|
||||||
...(input.url ? { url: input.url } : {}),
|
|
||||||
...(input.code ? { code: input.code } : {}),
|
|
||||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
|
||||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
|
||||||
...(typeof input.autoDiscoverChannels === "boolean"
|
|
||||||
? { autoDiscoverChannels: input.autoDiscoverChannels }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function noteTlonHelp(prompter: WizardPrompter): Promise<void> {
|
|
||||||
await prompter.note(
|
|
||||||
[
|
|
||||||
"You need your Urbit ship URL and login code.",
|
|
||||||
"Example URL: https://your-ship-host",
|
|
||||||
"Example ship: ~sampel-palnet",
|
|
||||||
`Docs: ${formatDocsLink("/channels/tlon", "channels/tlon")}`,
|
|
||||||
].join("\n"),
|
|
||||||
"Tlon setup",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseList(value: string): string[] {
|
|
||||||
return value
|
|
||||||
.split(/[\n,;]+/g)
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tlonOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
||||||
channel,
|
|
||||||
getStatus: async ({ cfg }) => {
|
|
||||||
const accountIds = listTlonAccountIds(cfg);
|
|
||||||
const configured =
|
|
||||||
accountIds.length > 0
|
|
||||||
? accountIds.some((accountId) => isConfigured(resolveTlonAccount(cfg, accountId)))
|
|
||||||
: isConfigured(resolveTlonAccount(cfg, DEFAULT_ACCOUNT_ID));
|
|
||||||
|
|
||||||
return {
|
|
||||||
channel,
|
|
||||||
configured,
|
|
||||||
statusLines: [`Tlon: ${configured ? "configured" : "needs setup"}`],
|
|
||||||
selectionHint: configured ? "configured" : "urbit messenger",
|
|
||||||
quickstartScore: configured ? 1 : 4,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
|
||||||
const override = accountOverrides[channel]?.trim();
|
|
||||||
const defaultAccountId = DEFAULT_ACCOUNT_ID;
|
|
||||||
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
|
|
||||||
|
|
||||||
if (shouldPromptAccountIds && !override) {
|
|
||||||
accountId = await promptAccountId({
|
|
||||||
cfg,
|
|
||||||
prompter,
|
|
||||||
label: "Tlon",
|
|
||||||
currentId: accountId,
|
|
||||||
listAccountIds: listTlonAccountIds,
|
|
||||||
defaultAccountId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = resolveTlonAccount(cfg, accountId);
|
|
||||||
await noteTlonHelp(prompter);
|
|
||||||
|
|
||||||
const ship = await prompter.text({
|
|
||||||
message: "Ship name",
|
|
||||||
placeholder: "~sampel-palnet",
|
|
||||||
initialValue: resolved.ship ?? undefined,
|
|
||||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = await prompter.text({
|
|
||||||
message: "Ship URL",
|
|
||||||
placeholder: "https://your-ship-host",
|
|
||||||
initialValue: resolved.url ?? undefined,
|
|
||||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const code = await prompter.text({
|
|
||||||
message: "Login code",
|
|
||||||
placeholder: "lidlut-tabwed-pillex-ridrup",
|
|
||||||
initialValue: resolved.code ?? undefined,
|
|
||||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const wantsGroupChannels = await prompter.confirm({
|
|
||||||
message: "Add group channels manually? (optional)",
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let groupChannels: string[] | undefined;
|
|
||||||
if (wantsGroupChannels) {
|
|
||||||
const entry = await prompter.text({
|
|
||||||
message: "Group channels (comma-separated)",
|
|
||||||
placeholder: "chat/~host-ship/general, chat/~host-ship/support",
|
|
||||||
});
|
|
||||||
const parsed = parseList(String(entry ?? ""));
|
|
||||||
groupChannels = parsed.length > 0 ? parsed : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wantsAllowlist = await prompter.confirm({
|
|
||||||
message: "Restrict DMs with an allowlist?",
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let dmAllowlist: string[] | undefined;
|
|
||||||
if (wantsAllowlist) {
|
|
||||||
const entry = await prompter.text({
|
|
||||||
message: "DM allowlist (comma-separated ship names)",
|
|
||||||
placeholder: "~zod, ~nec",
|
|
||||||
});
|
|
||||||
const parsed = parseList(String(entry ?? ""));
|
|
||||||
dmAllowlist = parsed.length > 0 ? parsed : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoDiscoverChannels = await prompter.confirm({
|
|
||||||
message: "Enable auto-discovery of group channels?",
|
|
||||||
initialValue: resolved.autoDiscoverChannels ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const next = applyAccountConfig({
|
|
||||||
cfg,
|
|
||||||
accountId,
|
|
||||||
input: {
|
|
||||||
ship: String(ship).trim(),
|
|
||||||
url: String(url).trim(),
|
|
||||||
code: String(code).trim(),
|
|
||||||
groupChannels,
|
|
||||||
dmAllowlist,
|
|
||||||
autoDiscoverChannels,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { cfg: next, accountId };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
let runtime: PluginRuntime | null = null;
|
|
||||||
|
|
||||||
export function setTlonRuntime(next: PluginRuntime) {
|
|
||||||
runtime = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTlonRuntime(): PluginRuntime {
|
|
||||||
if (!runtime) {
|
|
||||||
throw new Error("Tlon runtime not initialized");
|
|
||||||
}
|
|
||||||
return runtime;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
export type TlonTarget =
|
|
||||||
| { kind: "dm"; ship: string }
|
|
||||||
| { kind: "group"; nest: string; hostShip: string; channelName: string };
|
|
||||||
|
|
||||||
const SHIP_RE = /^~?[a-z-]+$/i;
|
|
||||||
const NEST_RE = /^chat\/([^/]+)\/([^/]+)$/i;
|
|
||||||
|
|
||||||
export function normalizeShip(raw: string): string {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) return trimmed;
|
|
||||||
return trimmed.startsWith("~") ? trimmed : `~${trimmed}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseChannelNest(raw: string): { hostShip: string; channelName: string } | null {
|
|
||||||
const match = NEST_RE.exec(raw.trim());
|
|
||||||
if (!match) return null;
|
|
||||||
const hostShip = normalizeShip(match[1]);
|
|
||||||
const channelName = match[2];
|
|
||||||
return { hostShip, channelName };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTlonTarget(raw?: string | null): TlonTarget | null {
|
|
||||||
const trimmed = raw?.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
const withoutPrefix = trimmed.replace(/^tlon:/i, "");
|
|
||||||
|
|
||||||
const dmPrefix = withoutPrefix.match(/^dm[/:](.+)$/i);
|
|
||||||
if (dmPrefix) {
|
|
||||||
return { kind: "dm", ship: normalizeShip(dmPrefix[1]) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupPrefix = withoutPrefix.match(/^(group|room)[/:](.+)$/i);
|
|
||||||
if (groupPrefix) {
|
|
||||||
const groupTarget = groupPrefix[2].trim();
|
|
||||||
if (groupTarget.startsWith("chat/")) {
|
|
||||||
const parsed = parseChannelNest(groupTarget);
|
|
||||||
if (!parsed) return null;
|
|
||||||
return {
|
|
||||||
kind: "group",
|
|
||||||
nest: `chat/${parsed.hostShip}/${parsed.channelName}`,
|
|
||||||
hostShip: parsed.hostShip,
|
|
||||||
channelName: parsed.channelName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const parts = groupTarget.split("/");
|
|
||||||
if (parts.length === 2) {
|
|
||||||
const hostShip = normalizeShip(parts[0]);
|
|
||||||
const channelName = parts[1];
|
|
||||||
return {
|
|
||||||
kind: "group",
|
|
||||||
nest: `chat/${hostShip}/${channelName}`,
|
|
||||||
hostShip,
|
|
||||||
channelName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withoutPrefix.startsWith("chat/")) {
|
|
||||||
const parsed = parseChannelNest(withoutPrefix);
|
|
||||||
if (!parsed) return null;
|
|
||||||
return {
|
|
||||||
kind: "group",
|
|
||||||
nest: `chat/${parsed.hostShip}/${parsed.channelName}`,
|
|
||||||
hostShip: parsed.hostShip,
|
|
||||||
channelName: parsed.channelName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SHIP_RE.test(withoutPrefix)) {
|
|
||||||
return { kind: "dm", ship: normalizeShip(withoutPrefix) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTargetHint(): string {
|
|
||||||
return "dm/~sampel-palnet | ~sampel-palnet | chat/~host-ship/channel | group:~host-ship/channel";
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
|
||||||
|
|
||||||
export type TlonResolvedAccount = {
|
|
||||||
accountId: string;
|
|
||||||
name: string | null;
|
|
||||||
enabled: boolean;
|
|
||||||
configured: boolean;
|
|
||||||
ship: string | null;
|
|
||||||
url: string | null;
|
|
||||||
code: string | null;
|
|
||||||
groupChannels: string[];
|
|
||||||
dmAllowlist: string[];
|
|
||||||
autoDiscoverChannels: boolean | null;
|
|
||||||
showModelSignature: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function resolveTlonAccount(cfg: ClawdbotConfig, accountId?: string | null): TlonResolvedAccount {
|
|
||||||
const base = cfg.channels?.tlon as
|
|
||||||
| {
|
|
||||||
name?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
ship?: string;
|
|
||||||
url?: string;
|
|
||||||
code?: string;
|
|
||||||
groupChannels?: string[];
|
|
||||||
dmAllowlist?: string[];
|
|
||||||
autoDiscoverChannels?: boolean;
|
|
||||||
showModelSignature?: boolean;
|
|
||||||
accounts?: Record<string, Record<string, unknown>>;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!base) {
|
|
||||||
return {
|
|
||||||
accountId: accountId || "default",
|
|
||||||
name: null,
|
|
||||||
enabled: false,
|
|
||||||
configured: false,
|
|
||||||
ship: null,
|
|
||||||
url: null,
|
|
||||||
code: null,
|
|
||||||
groupChannels: [],
|
|
||||||
dmAllowlist: [],
|
|
||||||
autoDiscoverChannels: null,
|
|
||||||
showModelSignature: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const useDefault = !accountId || accountId === "default";
|
|
||||||
const account = useDefault ? base : (base.accounts?.[accountId] as Record<string, unknown> | undefined);
|
|
||||||
|
|
||||||
const ship = (account?.ship ?? base.ship ?? null) as string | null;
|
|
||||||
const url = (account?.url ?? base.url ?? null) as string | null;
|
|
||||||
const code = (account?.code ?? base.code ?? null) as string | null;
|
|
||||||
const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[];
|
|
||||||
const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[];
|
|
||||||
const autoDiscoverChannels =
|
|
||||||
(account?.autoDiscoverChannels ?? base.autoDiscoverChannels ?? null) as boolean | null;
|
|
||||||
const showModelSignature =
|
|
||||||
(account?.showModelSignature ?? base.showModelSignature ?? null) as boolean | null;
|
|
||||||
const configured = Boolean(ship && url && code);
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountId: accountId || "default",
|
|
||||||
name: (account?.name ?? base.name ?? null) as string | null,
|
|
||||||
enabled: (account?.enabled ?? base.enabled ?? true) !== false,
|
|
||||||
configured,
|
|
||||||
ship,
|
|
||||||
url,
|
|
||||||
code,
|
|
||||||
groupChannels,
|
|
||||||
dmAllowlist,
|
|
||||||
autoDiscoverChannels,
|
|
||||||
showModelSignature,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listTlonAccountIds(cfg: ClawdbotConfig): string[] {
|
|
||||||
const base = cfg.channels?.tlon as
|
|
||||||
| { ship?: string; accounts?: Record<string, Record<string, unknown>> }
|
|
||||||
| undefined;
|
|
||||||
if (!base) return [];
|
|
||||||
const accounts = base.accounts ?? {};
|
|
||||||
return [...(base.ship ? ["default"] : []), ...Object.keys(accounts)];
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export async function authenticate(url: string, code: string): Promise<string> {
|
|
||||||
const resp = await fetch(`${url}/~/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: `password=${code}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error(`Login failed with status ${resp.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await resp.text();
|
|
||||||
const cookie = resp.headers.get("set-cookie");
|
|
||||||
if (!cookie) {
|
|
||||||
throw new Error("No authentication cookie received");
|
|
||||||
}
|
|
||||||
return cookie;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Urbit } from "@urbit/http-api";
|
|
||||||
|
|
||||||
let patched = false;
|
|
||||||
|
|
||||||
export function ensureUrbitConnectPatched() {
|
|
||||||
if (patched) return;
|
|
||||||
patched = true;
|
|
||||||
Urbit.prototype.connect = async function patchedConnect() {
|
|
||||||
const resp = await fetch(`${this.url}/~/login`, {
|
|
||||||
method: "POST",
|
|
||||||
body: `password=${this.code}`,
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resp.status >= 400) {
|
|
||||||
throw new Error(`Login failed with status ${resp.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookie = resp.headers.get("set-cookie");
|
|
||||||
if (cookie) {
|
|
||||||
const match = /urbauth-~([\w-]+)/.exec(cookie);
|
|
||||||
if (match) {
|
|
||||||
if (!(this as unknown as { ship?: string | null }).ship) {
|
|
||||||
(this as unknown as { ship?: string | null }).ship = match[1];
|
|
||||||
}
|
|
||||||
(this as unknown as { nodeId?: string }).nodeId = match[1];
|
|
||||||
}
|
|
||||||
(this as unknown as { cookie?: string }).cookie = cookie;
|
|
||||||
}
|
|
||||||
|
|
||||||
await (this as typeof Urbit.prototype).getShipName();
|
|
||||||
await (this as typeof Urbit.prototype).getOurName();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Urbit };
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { unixToDa, formatUd } from "@urbit/aura";
|
|
||||||
|
|
||||||
export type TlonPokeApi = {
|
|
||||||
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SendTextParams = {
|
|
||||||
api: TlonPokeApi;
|
|
||||||
fromShip: string;
|
|
||||||
toShip: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
|
|
||||||
const story = [{ inline: [text] }];
|
|
||||||
const sentAt = Date.now();
|
|
||||||
const idUd = formatUd(unixToDa(sentAt));
|
|
||||||
const id = `${fromShip}/${idUd}`;
|
|
||||||
|
|
||||||
const delta = {
|
|
||||||
add: {
|
|
||||||
memo: {
|
|
||||||
content: story,
|
|
||||||
author: fromShip,
|
|
||||||
sent: sentAt,
|
|
||||||
},
|
|
||||||
kind: null,
|
|
||||||
time: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const action = {
|
|
||||||
ship: toShip,
|
|
||||||
diff: { id, delta },
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.poke({
|
|
||||||
app: "chat",
|
|
||||||
mark: "chat-dm-action",
|
|
||||||
json: action,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { channel: "tlon", messageId: id };
|
|
||||||
}
|
|
||||||
|
|
||||||
type SendGroupParams = {
|
|
||||||
api: TlonPokeApi;
|
|
||||||
fromShip: string;
|
|
||||||
hostShip: string;
|
|
||||||
channelName: string;
|
|
||||||
text: string;
|
|
||||||
replyToId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function sendGroupMessage({
|
|
||||||
api,
|
|
||||||
fromShip,
|
|
||||||
hostShip,
|
|
||||||
channelName,
|
|
||||||
text,
|
|
||||||
replyToId,
|
|
||||||
}: SendGroupParams) {
|
|
||||||
const story = [{ inline: [text] }];
|
|
||||||
const sentAt = Date.now();
|
|
||||||
|
|
||||||
const action = {
|
|
||||||
channel: {
|
|
||||||
nest: `chat/${hostShip}/${channelName}`,
|
|
||||||
action: replyToId
|
|
||||||
? {
|
|
||||||
reply: {
|
|
||||||
id: replyToId,
|
|
||||||
delta: {
|
|
||||||
add: {
|
|
||||||
memo: {
|
|
||||||
content: story,
|
|
||||||
author: fromShip,
|
|
||||||
sent: sentAt,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
post: {
|
|
||||||
add: {
|
|
||||||
content: story,
|
|
||||||
author: fromShip,
|
|
||||||
sent: sentAt,
|
|
||||||
kind: "/chat",
|
|
||||||
blob: null,
|
|
||||||
meta: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.poke({
|
|
||||||
app: "channels",
|
|
||||||
mark: "channel-action-1",
|
|
||||||
json: action,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { channel: "tlon", messageId: `${fromShip}/${sentAt}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMediaText(text: string | undefined, mediaUrl: string | undefined): string {
|
|
||||||
const cleanText = text?.trim() ?? "";
|
|
||||||
const cleanUrl = mediaUrl?.trim() ?? "";
|
|
||||||
if (cleanText && cleanUrl) return `${cleanText}\n${cleanUrl}`;
|
|
||||||
if (cleanUrl) return cleanUrl;
|
|
||||||
return cleanText;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { UrbitSSEClient } from "./sse-client.js";
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
|
|
||||||
describe("UrbitSSEClient", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("fetch", mockFetch);
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends subscriptions added after connect", async () => {
|
|
||||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
|
||||||
|
|
||||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
|
||||||
(client as { isConnected: boolean }).isConnected = true;
|
|
||||||
|
|
||||||
await client.subscribe({
|
|
||||||
app: "chat",
|
|
||||||
path: "/dm/~zod",
|
|
||||||
event: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
||||||
const [url, init] = mockFetch.mock.calls[0];
|
|
||||||
expect(url).toBe(client.channelUrl);
|
|
||||||
expect(init.method).toBe("PUT");
|
|
||||||
const body = JSON.parse(init.body as string);
|
|
||||||
expect(body).toHaveLength(1);
|
|
||||||
expect(body[0]).toMatchObject({
|
|
||||||
action: "subscribe",
|
|
||||||
app: "chat",
|
|
||||||
path: "/dm/~zod",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
import { Readable } from "node:stream";
|
|
||||||
|
|
||||||
export type UrbitSseLogger = {
|
|
||||||
log?: (message: string) => void;
|
|
||||||
error?: (message: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UrbitSseOptions = {
|
|
||||||
ship?: string;
|
|
||||||
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
|
|
||||||
autoReconnect?: boolean;
|
|
||||||
maxReconnectAttempts?: number;
|
|
||||||
reconnectDelay?: number;
|
|
||||||
maxReconnectDelay?: number;
|
|
||||||
logger?: UrbitSseLogger;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class UrbitSSEClient {
|
|
||||||
url: string;
|
|
||||||
cookie: string;
|
|
||||||
ship: string;
|
|
||||||
channelId: string;
|
|
||||||
channelUrl: string;
|
|
||||||
subscriptions: Array<{
|
|
||||||
id: number;
|
|
||||||
action: "subscribe";
|
|
||||||
ship: string;
|
|
||||||
app: string;
|
|
||||||
path: string;
|
|
||||||
}> = [];
|
|
||||||
eventHandlers = new Map<
|
|
||||||
number,
|
|
||||||
{ event?: (data: unknown) => void; err?: (error: unknown) => void; quit?: () => void }
|
|
||||||
>();
|
|
||||||
aborted = false;
|
|
||||||
streamController: AbortController | null = null;
|
|
||||||
onReconnect: UrbitSseOptions["onReconnect"] | null;
|
|
||||||
autoReconnect: boolean;
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
maxReconnectAttempts: number;
|
|
||||||
reconnectDelay: number;
|
|
||||||
maxReconnectDelay: number;
|
|
||||||
isConnected = false;
|
|
||||||
logger: UrbitSseLogger;
|
|
||||||
|
|
||||||
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
|
|
||||||
this.url = url;
|
|
||||||
this.cookie = cookie.split(";")[0];
|
|
||||||
this.ship = options.ship?.replace(/^~/, "") ?? this.resolveShipFromUrl(url);
|
|
||||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
|
|
||||||
this.channelUrl = `${url}/~/channel/${this.channelId}`;
|
|
||||||
this.onReconnect = options.onReconnect ?? null;
|
|
||||||
this.autoReconnect = options.autoReconnect !== false;
|
|
||||||
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
|
|
||||||
this.reconnectDelay = options.reconnectDelay ?? 1000;
|
|
||||||
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
|
|
||||||
this.logger = options.logger ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveShipFromUrl(url: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const host = parsed.hostname;
|
|
||||||
if (host.includes(".")) {
|
|
||||||
return host.split(".")[0] ?? host;
|
|
||||||
}
|
|
||||||
return host;
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async subscribe(params: {
|
|
||||||
app: string;
|
|
||||||
path: string;
|
|
||||||
event?: (data: unknown) => void;
|
|
||||||
err?: (error: unknown) => void;
|
|
||||||
quit?: () => void;
|
|
||||||
}) {
|
|
||||||
const subId = this.subscriptions.length + 1;
|
|
||||||
const subscription = {
|
|
||||||
id: subId,
|
|
||||||
action: "subscribe",
|
|
||||||
ship: this.ship,
|
|
||||||
app: params.app,
|
|
||||||
path: params.path,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
this.subscriptions.push(subscription);
|
|
||||||
this.eventHandlers.set(subId, { event: params.event, err: params.err, quit: params.quit });
|
|
||||||
|
|
||||||
if (this.isConnected) {
|
|
||||||
try {
|
|
||||||
await this.sendSubscription(subscription);
|
|
||||||
} catch (error) {
|
|
||||||
const handler = this.eventHandlers.get(subId);
|
|
||||||
handler?.err?.(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return subId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendSubscription(subscription: {
|
|
||||||
id: number;
|
|
||||||
action: "subscribe";
|
|
||||||
ship: string;
|
|
||||||
app: string;
|
|
||||||
path: string;
|
|
||||||
}) {
|
|
||||||
const response = await fetch(this.channelUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Cookie: this.cookie,
|
|
||||||
},
|
|
||||||
body: JSON.stringify([subscription]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok && response.status !== 204) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Subscribe failed: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
const createResp = await fetch(this.channelUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Cookie: this.cookie,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(this.subscriptions),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createResp.ok && createResp.status !== 204) {
|
|
||||||
throw new Error(`Channel creation failed: ${createResp.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pokeResp = await fetch(this.channelUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Cookie: this.cookie,
|
|
||||||
},
|
|
||||||
body: JSON.stringify([
|
|
||||||
{
|
|
||||||
id: Date.now(),
|
|
||||||
action: "poke",
|
|
||||||
ship: this.ship,
|
|
||||||
app: "hood",
|
|
||||||
mark: "helm-hi",
|
|
||||||
json: "Opening API channel",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!pokeResp.ok && pokeResp.status !== 204) {
|
|
||||||
throw new Error(`Channel activation failed: ${pokeResp.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.openStream();
|
|
||||||
this.isConnected = true;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async openStream() {
|
|
||||||
const response = await fetch(this.channelUrl, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
Cookie: this.cookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Stream connection failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processStream(response.body).catch((error) => {
|
|
||||||
if (!this.aborted) {
|
|
||||||
this.logger.error?.(`Stream error: ${String(error)}`);
|
|
||||||
for (const { err } of this.eventHandlers.values()) {
|
|
||||||
if (err) err(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async processStream(body: ReadableStream<Uint8Array> | Readable | null) {
|
|
||||||
if (!body) return;
|
|
||||||
const stream = body instanceof ReadableStream ? Readable.fromWeb(body) : body;
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
if (this.aborted) break;
|
|
||||||
buffer += chunk.toString();
|
|
||||||
let eventEnd;
|
|
||||||
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
|
|
||||||
const eventData = buffer.substring(0, eventEnd);
|
|
||||||
buffer = buffer.substring(eventEnd + 2);
|
|
||||||
this.processEvent(eventData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!this.aborted && this.autoReconnect) {
|
|
||||||
this.isConnected = false;
|
|
||||||
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
|
|
||||||
await this.attemptReconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processEvent(eventData: string) {
|
|
||||||
const lines = eventData.split("\n");
|
|
||||||
let data: string | null = null;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith("data: ")) {
|
|
||||||
data = line.substring(6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string };
|
|
||||||
|
|
||||||
if (parsed.response === "quit") {
|
|
||||||
if (parsed.id) {
|
|
||||||
const handlers = this.eventHandlers.get(parsed.id);
|
|
||||||
if (handlers?.quit) handlers.quit();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.id && this.eventHandlers.has(parsed.id)) {
|
|
||||||
const { event } = this.eventHandlers.get(parsed.id) ?? {};
|
|
||||||
if (event && parsed.json) {
|
|
||||||
event(parsed.json);
|
|
||||||
}
|
|
||||||
} else if (parsed.json) {
|
|
||||||
for (const { event } of this.eventHandlers.values()) {
|
|
||||||
if (event) event(parsed.json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async poke(params: { app: string; mark: string; json: unknown }) {
|
|
||||||
const pokeId = Date.now();
|
|
||||||
const pokeData = {
|
|
||||||
id: pokeId,
|
|
||||||
action: "poke",
|
|
||||||
ship: this.ship,
|
|
||||||
app: params.app,
|
|
||||||
mark: params.mark,
|
|
||||||
json: params.json,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(this.channelUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Cookie: this.cookie,
|
|
||||||
},
|
|
||||||
body: JSON.stringify([pokeData]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok && response.status !== 204) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pokeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async scry(path: string) {
|
|
||||||
const scryUrl = `${this.url}/~/scry${path}`;
|
|
||||||
const response = await fetch(scryUrl, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Cookie: this.cookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Scry failed: ${response.status} for path ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async attemptReconnect() {
|
|
||||||
if (this.aborted || !this.autoReconnect) {
|
|
||||||
this.logger.log?.("[SSE] Reconnection aborted or disabled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
||||||
this.logger.error?.(
|
|
||||||
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.reconnectAttempts += 1;
|
|
||||||
const delay = Math.min(
|
|
||||||
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
|
|
||||||
this.maxReconnectDelay,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log?.(
|
|
||||||
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.channelId = `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(2, 8)}`;
|
|
||||||
this.channelUrl = `${this.url}/~/channel/${this.channelId}`;
|
|
||||||
|
|
||||||
if (this.onReconnect) {
|
|
||||||
await this.onReconnect(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.connect();
|
|
||||||
this.logger.log?.("[SSE] Reconnection successful!");
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
|
|
||||||
await this.attemptReconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
|
||||||
this.aborted = true;
|
|
||||||
this.isConnected = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const unsubscribes = this.subscriptions.map((sub) => ({
|
|
||||||
id: sub.id,
|
|
||||||
action: "unsubscribe",
|
|
||||||
subscription: sub.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await fetch(this.channelUrl, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Cookie: this.cookie,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(unsubscribes),
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetch(this.channelUrl, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
Cookie: this.cookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error?.(`Error closing channel: ${String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
resolveDefaultWhatsAppAccountId,
|
resolveDefaultWhatsAppAccountId,
|
||||||
resolveWhatsAppAccount,
|
resolveWhatsAppAccount,
|
||||||
resolveWhatsAppGroupRequireMention,
|
resolveWhatsAppGroupRequireMention,
|
||||||
resolveWhatsAppGroupToolPolicy,
|
|
||||||
resolveWhatsAppHeartbeatRecipients,
|
resolveWhatsAppHeartbeatRecipients,
|
||||||
whatsappOnboardingAdapter,
|
whatsappOnboardingAdapter,
|
||||||
WhatsAppConfigSchema,
|
WhatsAppConfigSchema,
|
||||||
@@ -199,7 +198,6 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: resolveWhatsAppGroupRequireMention,
|
resolveRequireMention: resolveWhatsAppGroupRequireMention,
|
||||||
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
|
|
||||||
resolveGroupIntroHint: () =>
|
resolveGroupIntroHint: () =>
|
||||||
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
|
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import type {
|
|||||||
ChannelAccountSnapshot,
|
ChannelAccountSnapshot,
|
||||||
ChannelDirectoryEntry,
|
ChannelDirectoryEntry,
|
||||||
ChannelDock,
|
ChannelDock,
|
||||||
ChannelGroupContext,
|
|
||||||
ChannelPlugin,
|
ChannelPlugin,
|
||||||
ClawdbotConfig,
|
ClawdbotConfig,
|
||||||
GroupToolPolicyConfig,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
@@ -81,26 +79,6 @@ 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 = {
|
export const zalouserDock: ChannelDock = {
|
||||||
id: "zalouser",
|
id: "zalouser",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
@@ -123,7 +101,6 @@ export const zalouserDock: ChannelDock = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: () => true,
|
resolveRequireMention: () => true,
|
||||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: () => "off",
|
resolveReplyToMode: () => "off",
|
||||||
@@ -211,7 +188,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
},
|
},
|
||||||
groups: {
|
groups: {
|
||||||
resolveRequireMention: () => true,
|
resolveRequireMention: () => true,
|
||||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: () => "off",
|
resolveReplyToMode: () => "off",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||||
@@ -6,7 +6,6 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
|||||||
const groupConfigSchema = z.object({
|
const groupConfigSchema = z.object({
|
||||||
allow: z.boolean().optional(),
|
allow: z.boolean().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
tools: ToolPolicySchema,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const zalouserAccountSchema = z.object({
|
const zalouserAccountSchema = z.object({
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export type ZalouserAccountConfig = {
|
|||||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||||
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
|
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||||
messagePrefix?: string;
|
messagePrefix?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export type ZalouserConfig = {
|
|||||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||||
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
|
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||||
messagePrefix?: string;
|
messagePrefix?: string;
|
||||||
accounts?: Record<string, ZalouserAccountConfig>;
|
accounts?: Record<string, ZalouserAccountConfig>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -147,7 +147,6 @@
|
|||||||
"packageManager": "pnpm@10.23.0",
|
"packageManager": "pnpm@10.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.13.0",
|
"@agentclientprotocol/sdk": "0.13.0",
|
||||||
"@aws-sdk/client-bedrock": "^3.975.0",
|
|
||||||
"@buape/carbon": "0.14.0",
|
"@buape/carbon": "0.14.0",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@grammyjs/runner": "^2.0.3",
|
"@grammyjs/runner": "^2.0.3",
|
||||||
|
|||||||
Generated
+5
-620
File diff suppressed because it is too large.
Load diff
@@ -1,215 +0,0 @@
|
|||||||
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,25 +129,4 @@ describe("exec approvals", () => {
|
|||||||
expect(calls).toContain("node.invoke");
|
expect(calls).toContain("node.invoke");
|
||||||
expect(calls).not.toContain("exec.approval.request");
|
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,7 +838,10 @@ export function createExecTool(
|
|||||||
applyPathPrepend(env, defaultPathPrepend);
|
applyPathPrepend(env, defaultPathPrepend);
|
||||||
|
|
||||||
if (host === "node") {
|
if (host === "node") {
|
||||||
const approvals = resolveExecApprovals(agentId, { security, ask });
|
const approvals = resolveExecApprovals(
|
||||||
|
agentId,
|
||||||
|
host === "node" ? { security: "allowlist" } : undefined,
|
||||||
|
);
|
||||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||||
const askFallback = approvals.agent.askFallback;
|
const askFallback = approvals.agent.askFallback;
|
||||||
@@ -1109,7 +1112,7 @@ export function createExecTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (host === "gateway" && !bypassApprovals) {
|
if (host === "gateway" && !bypassApprovals) {
|
||||||
const approvals = resolveExecApprovals(agentId, { security, ask });
|
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
|
||||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||||
const askFallback = approvals.agent.askFallback;
|
const askFallback = approvals.agent.askFallback;
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import type { BedrockClient } from "@aws-sdk/client-bedrock";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const sendMock = vi.fn();
|
|
||||||
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
|
|
||||||
|
|
||||||
describe("bedrock discovery", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
sendMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters to active streaming text models and maps modalities", async () => {
|
|
||||||
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
|
|
||||||
await import("./bedrock-discovery.js");
|
|
||||||
resetBedrockDiscoveryCacheForTest();
|
|
||||||
|
|
||||||
sendMock.mockResolvedValueOnce({
|
|
||||||
modelSummaries: [
|
|
||||||
{
|
|
||||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
modelName: "Claude 3.7 Sonnet",
|
|
||||||
providerName: "anthropic",
|
|
||||||
inputModalities: ["TEXT", "IMAGE"],
|
|
||||||
outputModalities: ["TEXT"],
|
|
||||||
responseStreamingSupported: true,
|
|
||||||
modelLifecycle: { status: "ACTIVE" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
|
|
||||||
modelName: "Claude 3 Haiku",
|
|
||||||
providerName: "anthropic",
|
|
||||||
inputModalities: ["TEXT"],
|
|
||||||
outputModalities: ["TEXT"],
|
|
||||||
responseStreamingSupported: false,
|
|
||||||
modelLifecycle: { status: "ACTIVE" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
modelId: "meta.llama3-8b-instruct-v1:0",
|
|
||||||
modelName: "Llama 3 8B",
|
|
||||||
providerName: "meta",
|
|
||||||
inputModalities: ["TEXT"],
|
|
||||||
outputModalities: ["TEXT"],
|
|
||||||
responseStreamingSupported: true,
|
|
||||||
modelLifecycle: { status: "INACTIVE" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
modelId: "amazon.titan-embed-text-v1",
|
|
||||||
modelName: "Titan Embed",
|
|
||||||
providerName: "amazon",
|
|
||||||
inputModalities: ["TEXT"],
|
|
||||||
outputModalities: ["EMBEDDING"],
|
|
||||||
responseStreamingSupported: true,
|
|
||||||
modelLifecycle: { status: "ACTIVE" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
|
||||||
expect(models).toHaveLength(1);
|
|
||||||
expect(models[0]).toMatchObject({
|
|
||||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
name: "Claude 3.7 Sonnet",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text", "image"],
|
|
||||||
contextWindow: 32000,
|
|
||||||
maxTokens: 4096,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies provider filter", async () => {
|
|
||||||
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
|
|
||||||
await import("./bedrock-discovery.js");
|
|
||||||
resetBedrockDiscoveryCacheForTest();
|
|
||||||
|
|
||||||
sendMock.mockResolvedValueOnce({
|
|
||||||
modelSummaries: [
|
|
||||||
{
|
|
||||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
modelName: "Claude 3.7 Sonnet",
|
|
||||||
providerName: "anthropic",
|
|
||||||
inputModalities: ["TEXT"],
|
|
||||||
outputModalities: ["TEXT"],
|
|
||||||
responseStreamingSupported: true,
|
|
||||||
modelLifecycle: { status: "ACTIVE" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const models = await discoverBedrockModels({
|
|
||||||
region: "us-east-1",
|
|
||||||
config: { providerFilter: ["amazon"] },
|
|
||||||
clientFactory,
|
|
||||||
});
|
|
||||||
expect(models).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses configured defaults for context and max tokens", async () => {
|
|
||||||
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
|
|
||||||
await import("./bedrock-discovery.js");
|
|
||||||
resetBedrockDiscoveryCacheForTest();
|
|
||||||
|
|
||||||
sendMock.mockResolvedValueOnce({
|
|
||||||
modelSummaries: [
|
|
||||||
{
|
|
||||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
modelName: "Claude 3.7 Sonnet",
|
|
||||||
providerName: "anthropic",
|
|
||||||
inputModalities: ["TEXT"],
|
|
||||||
outputModalities: ["TEXT"],
|
|
||||||
responseStreamingSupported: true,
|
|
||||||
modelLifecycle: { status: "ACTIVE" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const models = await discoverBedrockModels({
|
|
||||||
region: "us-east-1",
|
|
||||||
config: { defaultContextWindow: 64000, defaultMaxTokens: 8192 },
|
|
||||||
clientFactory,
|
|
||||||
});
|
|
||||||
expect(models[0]).toMatchObject({ contextWindow: 64000, maxTokens: 8192 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caches results when refreshInterval is enabled", async () => {
|
|
||||||
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
|
|
||||||
await import("./bedrock-discovery.js");
|
|
||||||
resetBedrockDiscoveryCacheForTest();
|
|
||||||
|
|
||||||
sendMock.mockResolvedValueOnce({
|
|
||||||
modelSummaries: [
|
|
||||||
{
|
|
||||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
modelName: "Claude 3.7 Sonnet",
|
|
||||||
providerName: "anthropic",
|
|
||||||
inputModalities: ["TEXT"],
|
|
||||||
outputModalities: ["TEXT"],
|
|
||||||
responseStreamingSupported: true,
|
|
||||||
modelLifecycle: { status: "ACTIVE" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
|
||||||
await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips cache when refreshInterval is 0", async () => {
|
|
||||||
const { discoverBedrockModels, resetBedrockDiscoveryCacheForTest } =
|
|
||||||
await import("./bedrock-discovery.js");
|
|
||||||
resetBedrockDiscoveryCacheForTest();
|
|
||||||
|
|
||||||
sendMock
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
modelSummaries: [
|
|
||||||
{
|
|
||||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
modelName: "Claude 3.7 Sonnet",
|
|
||||||
providerName: "anthropic",
|
|
||||||
inputModalities: ["TEXT"],
|
|
||||||
outputModalities: ["TEXT"],
|
|
||||||
responseStreamingSupported: true,
|
|
||||||
modelLifecycle: { status: "ACTIVE" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
modelSummaries: [
|
|
||||||
{
|
|
||||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
|
||||||
modelName: "Claude 3.7 Sonnet",
|
|
||||||
providerName: "anthropic",
|
|
||||||
inputModalities: ["TEXT"],
|
|
||||||
outputModalities: ["TEXT"],
|
|
||||||
responseStreamingSupported: true,
|
|
||||||
modelLifecycle: { status: "ACTIVE" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await discoverBedrockModels({
|
|
||||||
region: "us-east-1",
|
|
||||||
config: { refreshInterval: 0 },
|
|
||||||
clientFactory,
|
|
||||||
});
|
|
||||||
await discoverBedrockModels({
|
|
||||||
region: "us-east-1",
|
|
||||||
config: { refreshInterval: 0 },
|
|
||||||
clientFactory,
|
|
||||||
});
|
|
||||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import {
|
|
||||||
BedrockClient,
|
|
||||||
ListFoundationModelsCommand,
|
|
||||||
type ListFoundationModelsCommandOutput,
|
|
||||||
} from "@aws-sdk/client-bedrock";
|
|
||||||
|
|
||||||
import type { BedrockDiscoveryConfig, ModelDefinitionConfig } from "../config/types.js";
|
|
||||||
|
|
||||||
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
|
|
||||||
const DEFAULT_CONTEXT_WINDOW = 32000;
|
|
||||||
const DEFAULT_MAX_TOKENS = 4096;
|
|
||||||
const DEFAULT_COST = {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
type BedrockModelSummary = NonNullable<ListFoundationModelsCommandOutput["modelSummaries"]>[number];
|
|
||||||
|
|
||||||
type BedrockDiscoveryCacheEntry = {
|
|
||||||
expiresAt: number;
|
|
||||||
value?: ModelDefinitionConfig[];
|
|
||||||
inFlight?: Promise<ModelDefinitionConfig[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const discoveryCache = new Map<string, BedrockDiscoveryCacheEntry>();
|
|
||||||
let hasLoggedBedrockError = false;
|
|
||||||
|
|
||||||
function normalizeProviderFilter(filter?: string[]): string[] {
|
|
||||||
if (!filter || filter.length === 0) return [];
|
|
||||||
const normalized = new Set(
|
|
||||||
filter.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0),
|
|
||||||
);
|
|
||||||
return Array.from(normalized).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCacheKey(params: {
|
|
||||||
region: string;
|
|
||||||
providerFilter: string[];
|
|
||||||
refreshIntervalSeconds: number;
|
|
||||||
defaultContextWindow: number;
|
|
||||||
defaultMaxTokens: number;
|
|
||||||
}): string {
|
|
||||||
return JSON.stringify(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
function includesTextModalities(modalities?: Array<string>): boolean {
|
|
||||||
return (modalities ?? []).some((entry) => entry.toLowerCase() === "text");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isActive(summary: BedrockModelSummary): boolean {
|
|
||||||
const status = summary.modelLifecycle?.status;
|
|
||||||
return typeof status === "string" ? status.toUpperCase() === "ACTIVE" : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image"> {
|
|
||||||
const inputs = summary.inputModalities ?? [];
|
|
||||||
const mapped = new Set<"text" | "image">();
|
|
||||||
for (const modality of inputs) {
|
|
||||||
const lower = modality.toLowerCase();
|
|
||||||
if (lower === "text") mapped.add("text");
|
|
||||||
if (lower === "image") mapped.add("image");
|
|
||||||
}
|
|
||||||
if (mapped.size === 0) mapped.add("text");
|
|
||||||
return Array.from(mapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
|
|
||||||
const haystack = `${summary.modelId ?? ""} ${summary.modelName ?? ""}`.toLowerCase();
|
|
||||||
return haystack.includes("reasoning") || haystack.includes("thinking");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultContextWindow(config?: BedrockDiscoveryConfig): number {
|
|
||||||
const value = Math.floor(config?.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW);
|
|
||||||
return value > 0 ? value : DEFAULT_CONTEXT_WINDOW;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDefaultMaxTokens(config?: BedrockDiscoveryConfig): number {
|
|
||||||
const value = Math.floor(config?.defaultMaxTokens ?? DEFAULT_MAX_TOKENS);
|
|
||||||
return value > 0 ? value : DEFAULT_MAX_TOKENS;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]): boolean {
|
|
||||||
if (filter.length === 0) return true;
|
|
||||||
const providerName =
|
|
||||||
summary.providerName ??
|
|
||||||
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
|
|
||||||
const normalized = providerName?.trim().toLowerCase();
|
|
||||||
if (!normalized) return false;
|
|
||||||
return filter.includes(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): boolean {
|
|
||||||
if (!summary.modelId?.trim()) return false;
|
|
||||||
if (!matchesProviderFilter(summary, filter)) return false;
|
|
||||||
if (summary.responseStreamingSupported !== true) return false;
|
|
||||||
if (!includesTextModalities(summary.outputModalities)) return false;
|
|
||||||
if (!isActive(summary)) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toModelDefinition(
|
|
||||||
summary: BedrockModelSummary,
|
|
||||||
defaults: { contextWindow: number; maxTokens: number },
|
|
||||||
): ModelDefinitionConfig {
|
|
||||||
const id = summary.modelId?.trim() ?? "";
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: summary.modelName?.trim() || id,
|
|
||||||
reasoning: inferReasoningSupport(summary),
|
|
||||||
input: mapInputModalities(summary),
|
|
||||||
cost: DEFAULT_COST,
|
|
||||||
contextWindow: defaults.contextWindow,
|
|
||||||
maxTokens: defaults.maxTokens,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetBedrockDiscoveryCacheForTest(): void {
|
|
||||||
discoveryCache.clear();
|
|
||||||
hasLoggedBedrockError = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function discoverBedrockModels(params: {
|
|
||||||
region: string;
|
|
||||||
config?: BedrockDiscoveryConfig;
|
|
||||||
now?: () => number;
|
|
||||||
clientFactory?: (region: string) => BedrockClient;
|
|
||||||
}): Promise<ModelDefinitionConfig[]> {
|
|
||||||
const refreshIntervalSeconds = Math.max(
|
|
||||||
0,
|
|
||||||
Math.floor(params.config?.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_SECONDS),
|
|
||||||
);
|
|
||||||
const providerFilter = normalizeProviderFilter(params.config?.providerFilter);
|
|
||||||
const defaultContextWindow = resolveDefaultContextWindow(params.config);
|
|
||||||
const defaultMaxTokens = resolveDefaultMaxTokens(params.config);
|
|
||||||
const cacheKey = buildCacheKey({
|
|
||||||
region: params.region,
|
|
||||||
providerFilter,
|
|
||||||
refreshIntervalSeconds,
|
|
||||||
defaultContextWindow,
|
|
||||||
defaultMaxTokens,
|
|
||||||
});
|
|
||||||
const now = params.now?.() ?? Date.now();
|
|
||||||
|
|
||||||
if (refreshIntervalSeconds > 0) {
|
|
||||||
const cached = discoveryCache.get(cacheKey);
|
|
||||||
if (cached?.value && cached.expiresAt > now) {
|
|
||||||
return cached.value;
|
|
||||||
}
|
|
||||||
if (cached?.inFlight) {
|
|
||||||
return cached.inFlight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientFactory = params.clientFactory ?? ((region: string) => new BedrockClient({ region }));
|
|
||||||
const client = clientFactory(params.region);
|
|
||||||
|
|
||||||
const discoveryPromise = (async () => {
|
|
||||||
const response = await client.send(new ListFoundationModelsCommand({}));
|
|
||||||
const discovered: ModelDefinitionConfig[] = [];
|
|
||||||
for (const summary of response.modelSummaries ?? []) {
|
|
||||||
if (!shouldIncludeSummary(summary, providerFilter)) continue;
|
|
||||||
discovered.push(
|
|
||||||
toModelDefinition(summary, {
|
|
||||||
contextWindow: defaultContextWindow,
|
|
||||||
maxTokens: defaultMaxTokens,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return discovered.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (refreshIntervalSeconds > 0) {
|
|
||||||
discoveryCache.set(cacheKey, {
|
|
||||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
|
||||||
inFlight: discoveryPromise,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const value = await discoveryPromise;
|
|
||||||
if (refreshIntervalSeconds > 0) {
|
|
||||||
discoveryCache.set(cacheKey, {
|
|
||||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
} catch (error) {
|
|
||||||
if (refreshIntervalSeconds > 0) {
|
|
||||||
discoveryCache.delete(cacheKey);
|
|
||||||
}
|
|
||||||
if (!hasLoggedBedrockError) {
|
|
||||||
hasLoggedBedrockError = true;
|
|
||||||
console.warn(`[bedrock-discovery] Failed to list models: ${String(error)}`);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,12 +31,6 @@ export function createClawdbotTools(options?: {
|
|||||||
agentTo?: string;
|
agentTo?: string;
|
||||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||||
agentThreadId?: string | number;
|
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;
|
agentDir?: string;
|
||||||
sandboxRoot?: string;
|
sandboxRoot?: string;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
@@ -120,9 +114,6 @@ export function createClawdbotTools(options?: {
|
|||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
agentTo: options?.agentTo,
|
agentTo: options?.agentTo,
|
||||||
agentThreadId: options?.agentThreadId,
|
agentThreadId: options?.agentThreadId,
|
||||||
agentGroupId: options?.agentGroupId,
|
|
||||||
agentGroupChannel: options?.agentGroupChannel,
|
|
||||||
agentGroupSpace: options?.agentGroupSpace,
|
|
||||||
sandboxed: options?.sandboxed,
|
sandboxed: options?.sandboxed,
|
||||||
}),
|
}),
|
||||||
createSessionStatusTool({
|
createSessionStatusTool({
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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,30 +12,6 @@ export type AgentIdentityFile = {
|
|||||||
avatar?: string;
|
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 {
|
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||||
const identity: AgentIdentityFile = {};
|
const identity: AgentIdentityFile = {};
|
||||||
const lines = content.split(/\r?\n/);
|
const lines = content.split(/\r?\n/);
|
||||||
@@ -49,7 +25,6 @@ export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
|||||||
.replace(/^[*_]+|[*_]+$/g, "")
|
.replace(/^[*_]+|[*_]+$/g, "")
|
||||||
.trim();
|
.trim();
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
if (isIdentityPlaceholder(value)) continue;
|
|
||||||
if (label === "name") identity.name = value;
|
if (label === "name") identity.name = value;
|
||||||
if (label === "emoji") identity.emoji = value;
|
if (label === "emoji") identity.emoji = value;
|
||||||
if (label === "creature") identity.creature = value;
|
if (label === "creature") identity.creature = value;
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ function resolveEnvSourceLabel(params: {
|
|||||||
return `${prefix}${params.label}`;
|
return `${prefix}${params.label}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAwsSdkEnvVarName(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
export function resolveAwsSdkEnvVarName(): string | undefined {
|
||||||
if (env[AWS_BEARER_ENV]?.trim()) return AWS_BEARER_ENV;
|
if (process.env[AWS_BEARER_ENV]?.trim()) return AWS_BEARER_ENV;
|
||||||
if (env[AWS_ACCESS_KEY_ENV]?.trim() && env[AWS_SECRET_KEY_ENV]?.trim()) {
|
if (process.env[AWS_ACCESS_KEY_ENV]?.trim() && process.env[AWS_SECRET_KEY_ENV]?.trim()) {
|
||||||
return AWS_ACCESS_KEY_ENV;
|
return AWS_ACCESS_KEY_ENV;
|
||||||
}
|
}
|
||||||
if (env[AWS_PROFILE_ENV]?.trim()) return AWS_PROFILE_ENV;
|
if (process.env[AWS_PROFILE_ENV]?.trim()) return AWS_PROFILE_ENV;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "../providers/github-copilot-token.js";
|
} from "../providers/github-copilot-token.js";
|
||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||||
import { discoverBedrockModels } from "./bedrock-discovery.js";
|
|
||||||
import {
|
import {
|
||||||
buildSyntheticModelDefinition,
|
buildSyntheticModelDefinition,
|
||||||
SYNTHETIC_BASE_URL,
|
SYNTHETIC_BASE_URL,
|
||||||
@@ -376,27 +375,3 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
models: [],
|
models: [],
|
||||||
} satisfies ProviderConfig;
|
} satisfies ProviderConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveImplicitBedrockProvider(params: {
|
|
||||||
agentDir: string;
|
|
||||||
config?: ClawdbotConfig;
|
|
||||||
env?: NodeJS.ProcessEnv;
|
|
||||||
}): Promise<ProviderConfig | null> {
|
|
||||||
const env = params.env ?? process.env;
|
|
||||||
const discoveryConfig = params.config?.models?.bedrockDiscovery;
|
|
||||||
const enabled = discoveryConfig?.enabled;
|
|
||||||
const hasAwsCreds = resolveAwsSdkEnvVarName(env) !== undefined;
|
|
||||||
if (enabled === false) return null;
|
|
||||||
if (enabled !== true && !hasAwsCreds) return null;
|
|
||||||
|
|
||||||
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
|
||||||
const models = await discoverBedrockModels({ region, config: discoveryConfig });
|
|
||||||
if (models.length === 0) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseUrl: `https://bedrock-runtime.${region}.amazonaws.com`,
|
|
||||||
api: "bedrock-converse-stream",
|
|
||||||
auth: "aws-sdk",
|
|
||||||
models,
|
|
||||||
} satisfies ProviderConfig;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
|||||||
import {
|
import {
|
||||||
normalizeProviders,
|
normalizeProviders,
|
||||||
type ProviderConfig,
|
type ProviderConfig,
|
||||||
resolveImplicitBedrockProvider,
|
|
||||||
resolveImplicitCopilotProvider,
|
resolveImplicitCopilotProvider,
|
||||||
resolveImplicitProviders,
|
resolveImplicitProviders,
|
||||||
} from "./models-config.providers.js";
|
} from "./models-config.providers.js";
|
||||||
@@ -85,13 +84,6 @@ export async function ensureClawdbotModelsJson(
|
|||||||
implicit: implicitProviders,
|
implicit: implicitProviders,
|
||||||
explicit: explicitProviders,
|
explicit: explicitProviders,
|
||||||
});
|
});
|
||||||
const implicitBedrock = await resolveImplicitBedrockProvider({ agentDir, config: cfg });
|
|
||||||
if (implicitBedrock) {
|
|
||||||
const existing = providers["amazon-bedrock"];
|
|
||||||
providers["amazon-bedrock"] = existing
|
|
||||||
? mergeProviderModels(implicitBedrock, existing)
|
|
||||||
: implicitBedrock;
|
|
||||||
}
|
|
||||||
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
|
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
|
||||||
if (implicitCopilot && !providers["github-copilot"]) {
|
if (implicitCopilot && !providers["github-copilot"]) {
|
||||||
providers["github-copilot"] = implicitCopilot;
|
providers["github-copilot"] = implicitCopilot;
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
|
|||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotConfig =>
|
const makeConfig = (): ClawdbotConfig =>
|
||||||
({
|
({
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
model: {
|
model: {
|
||||||
fallbacks: opts?.fallbacks ?? [],
|
fallbacks: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -76,7 +76,7 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotC
|
|||||||
providers: {
|
providers: {
|
||||||
openai: {
|
openai: {
|
||||||
api: "openai-responses",
|
api: "openai-responses",
|
||||||
apiKey: opts?.apiKey ?? "sk-test",
|
apiKey: "sk-test",
|
||||||
baseUrl: "https://example.com",
|
baseUrl: "https://example.com",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -94,13 +94,7 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotC
|
|||||||
},
|
},
|
||||||
}) satisfies ClawdbotConfig;
|
}) satisfies ClawdbotConfig;
|
||||||
|
|
||||||
const writeAuthStore = async (
|
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
|
||||||
agentDir: string,
|
|
||||||
opts?: {
|
|
||||||
includeAnthropic?: boolean;
|
|
||||||
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
const payload = {
|
const payload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -111,12 +105,10 @@ const writeAuthStore = async (
|
|||||||
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
|
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
usageStats:
|
usageStats: {
|
||||||
opts?.usageStats ??
|
"openai:p1": { lastUsed: 1 },
|
||||||
({
|
"openai:p2": { lastUsed: 2 },
|
||||||
"openai:p1": { lastUsed: 1 },
|
},
|
||||||
"openai:p2": { lastUsed: 2 },
|
|
||||||
} as Record<string, { lastUsed?: number }>),
|
|
||||||
};
|
};
|
||||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
await fs.writeFile(authPath, JSON.stringify(payload));
|
||||||
};
|
};
|
||||||
@@ -392,92 +384,6 @@ 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 () => {
|
it("skips profiles in cooldown when rotating after failure", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
},
|
},
|
||||||
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
||||||
const stream = new actual.AssistantMessageEventStream();
|
const stream = new actual.AssistantMessageEventStream();
|
||||||
setTimeout(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({
|
stream.push({
|
||||||
type: "done",
|
type: "done",
|
||||||
reason: "stop",
|
reason: "stop",
|
||||||
@@ -80,7 +80,7 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
: buildAssistantMessage(model),
|
: buildAssistantMessage(model),
|
||||||
});
|
});
|
||||||
stream.end();
|
stream.end();
|
||||||
}, 0);
|
});
|
||||||
return stream;
|
return stream;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -165,7 +165,6 @@ const readSessionMessages = async (sessionFile: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("runEmbeddedPiAgent", () => {
|
describe("runEmbeddedPiAgent", () => {
|
||||||
const itIfNotWin32 = process.platform === "win32" ? it.skip : it;
|
|
||||||
it("writes models.json into the provided agentDir", async () => {
|
it("writes models.json into the provided agentDir", async () => {
|
||||||
const sessionFile = nextSessionFile();
|
const sessionFile = nextSessionFile();
|
||||||
|
|
||||||
@@ -211,39 +210,35 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
|
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
itIfNotWin32(
|
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
|
||||||
"persists the first user message before assistant output",
|
const sessionFile = nextSessionFile();
|
||||||
{ timeout: 60_000 },
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
async () => {
|
await ensureModels(cfg);
|
||||||
const sessionFile = nextSessionFile();
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
|
||||||
await ensureModels(cfg);
|
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
sessionKey: testSessionKey,
|
sessionKey: testSessionKey,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
prompt: "hello",
|
prompt: "hello",
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "mock-1",
|
model: "mock-1",
|
||||||
timeoutMs: 5_000,
|
timeoutMs: 5_000,
|
||||||
agentDir,
|
agentDir,
|
||||||
enqueue: immediateEnqueue,
|
enqueue: immediateEnqueue,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messages = await readSessionMessages(sessionFile);
|
const messages = await readSessionMessages(sessionFile);
|
||||||
const firstUserIndex = messages.findIndex(
|
const firstUserIndex = messages.findIndex(
|
||||||
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
|
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
|
||||||
);
|
);
|
||||||
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
|
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
|
||||||
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
||||||
if (firstAssistantIndex !== -1) {
|
if (firstAssistantIndex !== -1) {
|
||||||
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
|
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it("persists the user message when prompt fails before assistant output", async () => {
|
it("persists the user message when prompt fails before assistant output", async () => {
|
||||||
const sessionFile = nextSessionFile();
|
const sessionFile = nextSessionFile();
|
||||||
|
|||||||
@@ -73,14 +73,6 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
messageChannel?: string;
|
messageChannel?: string;
|
||||||
messageProvider?: string;
|
messageProvider?: string;
|
||||||
agentAccountId?: 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;
|
sessionFile: string;
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
@@ -215,10 +207,6 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||||
agentAccountId: params.agentAccountId,
|
agentAccountId: params.agentAccountId,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
groupId: params.groupId,
|
|
||||||
groupChannel: params.groupChannel,
|
|
||||||
groupSpace: params.groupSpace,
|
|
||||||
spawnedBy: params.spawnedBy,
|
|
||||||
agentDir,
|
agentDir,
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
isRateLimitAssistantError,
|
isRateLimitAssistantError,
|
||||||
isTimeoutErrorMessage,
|
isTimeoutErrorMessage,
|
||||||
pickFallbackThinkingLevel,
|
pickFallbackThinkingLevel,
|
||||||
type FailoverReason,
|
|
||||||
} from "../pi-embedded-helpers.js";
|
} from "../pi-embedded-helpers.js";
|
||||||
import { normalizeUsage, type UsageLike } from "../usage.js";
|
import { normalizeUsage, type UsageLike } from "../usage.js";
|
||||||
|
|
||||||
@@ -72,8 +71,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
const globalLane = resolveGlobalLane(params.lane);
|
const globalLane = resolveGlobalLane(params.lane);
|
||||||
const enqueueGlobal =
|
const enqueueGlobal =
|
||||||
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
||||||
const enqueueSession =
|
|
||||||
params.enqueue ?? ((task, opts) => enqueueCommandInLane(sessionLane, task, opts));
|
|
||||||
const channelHint = params.messageChannel ?? params.messageProvider;
|
const channelHint = params.messageChannel ?? params.messageProvider;
|
||||||
const resolvedToolResultFormat =
|
const resolvedToolResultFormat =
|
||||||
params.toolResultFormat ??
|
params.toolResultFormat ??
|
||||||
@@ -84,7 +81,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
: "markdown");
|
: "markdown");
|
||||||
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
|
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
|
||||||
|
|
||||||
return enqueueSession(() =>
|
return enqueueCommandInLane(sessionLane, () =>
|
||||||
enqueueGlobal(async () => {
|
enqueueGlobal(async () => {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||||
@@ -93,8 +90,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||||
const fallbackConfigured =
|
|
||||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
|
||||||
await ensureClawdbotModelsJson(params.config, agentDir);
|
await ensureClawdbotModelsJson(params.config, agentDir);
|
||||||
|
|
||||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||||
@@ -168,42 +163,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
let apiKeyInfo: ApiKeyInfo | null = null;
|
let apiKeyInfo: ApiKeyInfo | null = null;
|
||||||
let lastProfileId: string | undefined;
|
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) => {
|
const resolveApiKeyForCandidate = async (candidate?: string) => {
|
||||||
return getApiKeyForModel({
|
return getApiKeyForModel({
|
||||||
model,
|
model,
|
||||||
@@ -277,17 +236,14 @@ export async function runEmbeddedPiAgent(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (profileIndex >= profileCandidates.length) {
|
if (profileIndex >= profileCandidates.length) {
|
||||||
throwAuthProfileFailover({ allInCooldown: true });
|
throw new Error(
|
||||||
|
`No available auth profile for ${provider} (all in cooldown or unavailable).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof FailoverError) throw err;
|
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
|
||||||
if (profileCandidates[profileIndex] === lockedProfileId) {
|
|
||||||
throwAuthProfileFailover({ allInCooldown: false, error: err });
|
|
||||||
}
|
|
||||||
const advanced = await advanceAuthProfile();
|
const advanced = await advanceAuthProfile();
|
||||||
if (!advanced) {
|
if (!advanced) throw err;
|
||||||
throwAuthProfileFailover({ allInCooldown: false, error: err });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -306,10 +262,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
agentAccountId: params.agentAccountId,
|
agentAccountId: params.agentAccountId,
|
||||||
messageTo: params.messageTo,
|
messageTo: params.messageTo,
|
||||||
messageThreadId: params.messageThreadId,
|
messageThreadId: params.messageThreadId,
|
||||||
groupId: params.groupId,
|
|
||||||
groupChannel: params.groupChannel,
|
|
||||||
groupSpace: params.groupSpace,
|
|
||||||
spawnedBy: params.spawnedBy,
|
|
||||||
currentChannelId: params.currentChannelId,
|
currentChannelId: params.currentChannelId,
|
||||||
currentThreadTs: params.currentThreadTs,
|
currentThreadTs: params.currentThreadTs,
|
||||||
replyToMode: params.replyToMode,
|
replyToMode: params.replyToMode,
|
||||||
@@ -321,7 +273,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
skillsSnapshot: params.skillsSnapshot,
|
skillsSnapshot: params.skillsSnapshot,
|
||||||
prompt,
|
prompt,
|
||||||
images: params.images,
|
images: params.images,
|
||||||
disableTools: params.disableTools,
|
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
model,
|
model,
|
||||||
@@ -435,7 +386,9 @@ export async function runEmbeddedPiAgent(
|
|||||||
}
|
}
|
||||||
// FIX: Throw FailoverError for prompt errors when fallbacks configured
|
// FIX: Throw FailoverError for prompt errors when fallbacks configured
|
||||||
// This enables model fallback for quota/rate limit errors during prompt submission
|
// This enables model fallback for quota/rate limit errors during prompt submission
|
||||||
if (fallbackConfigured && isFailoverErrorMessage(errorText)) {
|
const promptFallbackConfigured =
|
||||||
|
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||||
|
if (promptFallbackConfigured && isFailoverErrorMessage(errorText)) {
|
||||||
throw new FailoverError(errorText, {
|
throw new FailoverError(errorText, {
|
||||||
reason: promptFailoverReason ?? "unknown",
|
reason: promptFailoverReason ?? "unknown",
|
||||||
provider,
|
provider,
|
||||||
@@ -459,6 +412,8 @@ export async function runEmbeddedPiAgent(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackConfigured =
|
||||||
|
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||||
const authFailure = isAuthAssistantError(lastAssistant);
|
const authFailure = isAuthAssistantError(lastAssistant);
|
||||||
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
|
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
|
||||||
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
||||||
|
|||||||
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user