Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a535be7832 | ||
|
|
794bab45ff | ||
|
|
16e5fa1db9 | ||
|
|
125be3e111 | ||
|
|
b60a53e10d | ||
|
|
9de762faa2 | ||
|
|
5aed38eebc | ||
|
|
e63e483c38 | ||
|
|
277e43e32c | ||
|
|
852aa16ca0 | ||
|
|
82b7153ac1 | ||
|
|
7d2e510087 | ||
|
|
9ca4c10e59 | ||
|
|
a31a79396b | ||
|
|
acc3eb11d0 | ||
|
|
9d9fff2991 | ||
|
|
030ed5d592 | ||
|
|
f6d359932a | ||
|
|
3200b51160 | ||
|
|
4b11ebb30e | ||
|
|
40345642fa | ||
|
|
e932772230 | ||
|
|
63d466fe5e | ||
|
|
c2fada7062 | ||
|
|
d9c29f5ce5 | ||
|
|
f5d5ef6857 | ||
|
|
361a17415f | ||
|
|
fb393c3c51 | ||
|
|
e0158c5d5d | ||
|
|
be12b0771c | ||
|
|
1309fc1f48 | ||
|
|
4fdecfb845 | ||
|
|
31c6f178f3 | ||
|
|
1e2ab8bf1e | ||
|
|
35a1d81518 | ||
|
|
1c4297d8b5 | ||
|
|
e3638a9a9e | ||
|
|
1f8558771a | ||
|
|
2e231d09ec | ||
|
|
727c07bd88 | ||
|
|
c32ad19377 | ||
|
|
ef40ab2933 | ||
|
|
e71fa4a145 | ||
|
|
a7c0887f94 | ||
|
|
53218b91c6 | ||
|
|
2d4de656d2 | ||
|
|
b0f44acf9e | ||
|
|
a828e60067 | ||
|
|
96df70fccf | ||
|
|
0e49dca53c | ||
|
|
8ec4af4641 | ||
|
|
2f6d9417bd | ||
|
|
534a012a4e | ||
|
|
7a3fa9ce03 | ||
|
|
8a67d29748 | ||
|
|
408f4f2dac | ||
|
|
3df2dc0b15 | ||
|
|
5304a8c2d1 | ||
|
|
1569d29b2d | ||
|
|
50c8e74230 | ||
|
|
1045b032a2 | ||
|
|
a813343aa7 | ||
|
|
5a08471dcd | ||
|
|
252dfbcd40 | ||
|
|
75588fe732 | ||
|
|
9bbdeb3d52 |
No files matched your search
+43
-1
@@ -2,16 +2,52 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.17 (Unreleased)
|
||||
## 2026.1.17-3
|
||||
|
||||
### Changes
|
||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||
|
||||
### Fixes
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
|
||||
## 2026.1.17-2
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
|
||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||
- CLI: surface update availability in `clawdbot status`.
|
||||
- CLI: add `clawdbot memory status --deep/--index` probes.
|
||||
- CLI: add playful update completion quips.
|
||||
|
||||
### Fixes
|
||||
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
|
||||
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
||||
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
|
||||
|
||||
## 2026.1.16-1
|
||||
|
||||
@@ -49,6 +85,8 @@ Docs: https://docs.clawd.bot
|
||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||
- UI: allow deleting sessions from the Control UI.
|
||||
- Memory: add sqlite-vec vector acceleration with CLI status details.
|
||||
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
|
||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
@@ -67,6 +105,10 @@ Docs: https://docs.clawd.bot
|
||||
### Fixes
|
||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
||||
- Tools: include provider/session context in elevated exec denial errors.
|
||||
- Tools: normalize exec tool alias naming in tool error logs.
|
||||
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
|
||||
- Logging: prefix nested agent output with session/run/channel context.
|
||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Telegram: split long captions into follow-up messages.
|
||||
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
|
||||
|
||||
@@ -478,20 +478,21 @@ Thanks to all clawtributors:
|
||||
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/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/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></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/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/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/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/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></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/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/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/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/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/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/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||
<a href="https://github.com/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/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/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/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/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/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/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/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/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/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/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/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/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/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/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/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/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></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/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
|
||||
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
|
||||
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/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/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/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/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/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/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/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/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -5,10 +5,12 @@ import Testing
|
||||
@Suite struct GatewayEnvironmentTests {
|
||||
@Test func semverParsesCommonForms() {
|
||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
|
||||
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
|
||||
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
|
||||
#expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
|
||||
@@ -23,6 +25,8 @@ import Testing
|
||||
let required = Semver(major: 2, minor: 1, patch: 0)
|
||||
#expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false)
|
||||
#expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false)
|
||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||
}
|
||||
|
||||
@@ -335,6 +335,7 @@ For fine-grained control, use these tags in agent responses:
|
||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
|
||||
- Slash commands use `agent:<agentId>:slack:slash:<userId>` sessions (prefix configurable via `channels.slack.slashCommand.sessionPrefix`).
|
||||
- If Slack doesn’t provide `channel_type`, Clawdbot infers it from the channel ID prefix (`D`, `C`, `G`) and defaults to `channel` to keep session keys stable.
|
||||
- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `channels.slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
|
||||
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ Related docs:
|
||||
```bash
|
||||
clawdbot channels list
|
||||
clawdbot channels status
|
||||
clawdbot channels capabilities
|
||||
clawdbot channels capabilities --channel discord --target channel:123
|
||||
clawdbot channels logs --channel all
|
||||
```
|
||||
|
||||
@@ -42,3 +44,16 @@ clawdbot channels logout --channel whatsapp
|
||||
- Run `clawdbot status --deep` for a broad probe.
|
||||
- Use `clawdbot doctor` for guided fixes.
|
||||
|
||||
## Capabilities probe
|
||||
|
||||
Fetch provider capability hints (intents/scopes where available) plus static feature support:
|
||||
|
||||
```bash
|
||||
clawdbot channels capabilities
|
||||
clawdbot channels capabilities --channel discord --target channel:123
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--channel` is optional; omit it to list every channel (including extensions).
|
||||
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
|
||||
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
|
||||
@@ -21,6 +21,9 @@ clawdbot doctor --repair
|
||||
clawdbot doctor --deep
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
|
||||
@@ -29,6 +29,7 @@ Notes:
|
||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
|
||||
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
|
||||
|
||||
### Options
|
||||
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ Related:
|
||||
|
||||
```bash
|
||||
clawdbot memory status
|
||||
clawdbot memory status --deep
|
||||
clawdbot memory status --deep --index
|
||||
clawdbot memory index
|
||||
clawdbot memory search "release checklist"
|
||||
```
|
||||
|
||||
@@ -26,6 +26,11 @@ clawdbot models scan
|
||||
When provider usage snapshots are available, the OAuth/token status section includes
|
||||
provider usage headers.
|
||||
|
||||
Notes:
|
||||
- `models set <model-or-alias>` accepts `provider/model` or an alias.
|
||||
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
## Aliases + fallbacks
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,3 +19,4 @@ clawdbot status --usage
|
||||
Notes:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||
@@ -98,6 +98,14 @@ Verbose tool summaries are emitted at tool start (no debounce); Control UI
|
||||
streams tool output via agent events when available.
|
||||
More details: [Streaming + chunking](/concepts/streaming).
|
||||
|
||||
## Model refs
|
||||
|
||||
Model refs in config (for example `agents.defaults.model` and `agents.defaults.models`) are parsed by splitting on the **first** `/`.
|
||||
|
||||
- Use `provider/model` when configuring models.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
## Configuration (minimal)
|
||||
|
||||
At minimum, set:
|
||||
|
||||
@@ -78,6 +78,7 @@ Defaults:
|
||||
- Watches memory files for changes (debounced).
|
||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||
|
||||
Remote embeddings **require** an API key for the embedding provider. By default
|
||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||
@@ -107,6 +108,11 @@ agents: {
|
||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||
`memorySearch.fallback = "none"`.
|
||||
|
||||
Batch indexing (OpenAI only):
|
||||
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
|
||||
|
||||
Config example:
|
||||
|
||||
```json5
|
||||
@@ -116,6 +122,9 @@ agents: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
fallback: "openai",
|
||||
remote: {
|
||||
batch: { enabled: false }
|
||||
},
|
||||
sync: { watch: true }
|
||||
}
|
||||
}
|
||||
@@ -143,6 +152,60 @@ Local mode:
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
|
||||
|
||||
### Session memory search (experimental)
|
||||
|
||||
You can optionally index **session transcripts** and surface them via `memory_search`.
|
||||
This is gated behind an experimental flag.
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
experimental: { sessionMemory: true },
|
||||
sources: ["memory", "sessions"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Session indexing is **opt-in** (off by default).
|
||||
- Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`).
|
||||
- Results still include snippets only; `memory_get` remains limited to memory files.
|
||||
- Session indexing is isolated per agent (only that agent’s session logs are indexed).
|
||||
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
|
||||
|
||||
### SQLite vector acceleration (sqlite-vec)
|
||||
|
||||
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
|
||||
SQLite virtual table (`vec0`) and performs vector distance queries in the
|
||||
database. This keeps search fast without loading every embedding into JS.
|
||||
|
||||
Configuration (optional):
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
store: {
|
||||
vector: {
|
||||
enabled: true,
|
||||
extensionPath: "/path/to/sqlite-vec"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `enabled` defaults to true; when disabled, search falls back to in-process
|
||||
cosine similarity over stored embeddings.
|
||||
- If the sqlite-vec extension is missing or fails to load, Clawdbot logs the
|
||||
error and continues with the JS fallback (no vector table).
|
||||
- `extensionPath` overrides the bundled sqlite-vec path (useful for custom builds
|
||||
or non-standard install locations).
|
||||
|
||||
### Local embedding auto-download
|
||||
|
||||
- Default local embedding model: `hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf` (~0.6 GB).
|
||||
|
||||
@@ -102,6 +102,9 @@ Notes:
|
||||
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
||||
- `/model <#>` selects from that picker.
|
||||
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
|
||||
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), you must include the provider prefix (example: `/model openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
Full command behavior/config: [Slash commands](/tools/slash-commands).
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ When the audit prints findings, treat this as a priority order:
|
||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
||||
|
||||
## Local session logs live on disk
|
||||
|
||||
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
||||
This is required for session continuity and (optionally) session memory indexing, but it also means
|
||||
**any process/user with filesystem access can read those logs**. Treat disk access as the trust
|
||||
boundary and lock down permissions on `~/.clawdbot` (see the audit section below). If you need
|
||||
stronger isolation between agents, run them under separate OS users or separate hosts.
|
||||
|
||||
## Node execution (system.run)
|
||||
|
||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||
|
||||
@@ -155,6 +155,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
## Notes
|
||||
|
||||
- Model refs are `minimax/<model>`.
|
||||
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
|
||||
- Update pricing values in `models.json` if you need exact cost tracking.
|
||||
- Referral link for MiniMax Coding Plan (10% off): https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link
|
||||
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/copilot-proxy",
|
||||
"version": "2026.1.16-1",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/google-antigravity-auth",
|
||||
"version": "2026.1.16-1",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/google-gemini-cli-auth",
|
||||
"version": "2026.1.16-1",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/matrix",
|
||||
"version": "2026.1.16",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Matrix channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/msteams",
|
||||
"version": "2026.1.16",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
import { sendMessageMSTeams } from "./send.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
@@ -218,7 +219,8 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
@@ -227,6 +229,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
port: runtime?.port ?? null,
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
|
||||
@@ -7,8 +7,52 @@ export type ProbeMSTeamsResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
appId?: string;
|
||||
graph?: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
function readAccessToken(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object") {
|
||||
const token =
|
||||
(value as { accessToken?: unknown }).accessToken ??
|
||||
(value as { token?: unknown }).token;
|
||||
return typeof token === "string" ? token : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const payload = parts[1] ?? "";
|
||||
const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
|
||||
const normalized = padded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
try {
|
||||
const decoded = Buffer.from(normalized, "base64").toString("utf8");
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const out = value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function readScopes(value: unknown): string[] | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const out = value.split(/\s+/).map((entry) => entry.trim()).filter(Boolean);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsResult> {
|
||||
const creds = resolveMSTeamsCredentials(cfg);
|
||||
if (!creds) {
|
||||
@@ -22,7 +66,29 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
|
||||
return { ok: true, appId: creds.appId };
|
||||
let graph:
|
||||
| {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
const graphToken = await tokenProvider.getAccessToken(
|
||||
"https://graph.microsoft.com/.default",
|
||||
);
|
||||
const accessToken = readAccessToken(graphToken);
|
||||
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
|
||||
graph = {
|
||||
ok: true,
|
||||
roles: readStringArray(payload?.roles),
|
||||
scopes: readScopes(payload?.scp),
|
||||
};
|
||||
} catch (err) {
|
||||
graph = { ok: false, error: formatUnknownError(err) };
|
||||
}
|
||||
return { ok: true, appId: creds.appId, ...(graph ? { graph } : {}) };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/voice-call",
|
||||
"version": "2026.1.16",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot voice-call plugin",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/zalo",
|
||||
"version": "2026.1.16",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo channel plugin",
|
||||
"clawdbot": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/zalouser",
|
||||
"version": "2026.1.16",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.16-2",
|
||||
"version": "2026.1.17-1",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -174,10 +174,12 @@
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sqlite-vec": "0.1.7-alpha.2",
|
||||
"tar": "^7.5.3",
|
||||
"tslog": "^4.10.2",
|
||||
"undici": "^7.18.2",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
Generated
+57
@@ -133,6 +133,9 @@ importers:
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
sqlite-vec:
|
||||
specifier: 0.1.7-alpha.2
|
||||
version: 0.1.7-alpha.2
|
||||
tar:
|
||||
specifier: 7.5.3
|
||||
version: 7.5.3
|
||||
@@ -145,6 +148,9 @@ importers:
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
zod:
|
||||
specifier: ^4.3.5
|
||||
version: 4.3.5
|
||||
@@ -3910,6 +3916,34 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sqlite-vec-darwin-arm64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
sqlite-vec-darwin-x64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-jeZEELsQjjRsVojsvU5iKxOvkaVuE+JYC8Y4Ma8U45aAERrDYmqZoHvgSG7cg1PXL3bMlumFTAmHynf1y4pOzA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
sqlite-vec-linux-arm64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-6Spj4Nfi7tG13jsUG+W7jnT0bCTWbyPImu2M8nWp20fNrd1SZ4g3CSlDAK8GBdavX7wRlbBHCZ+BDa++rbDewA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
sqlite-vec-linux-x64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-IcgrbHaDccTVhXDf8Orwdc2+hgDLAFORl6OBUhcvlmwswwBP1hqBTSEhovClG4NItwTOBNgpwOoQ7Qp3VDPWLg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
sqlite-vec-windows-x64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
sqlite-vec@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
@@ -8563,6 +8597,29 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sqlite-vec-darwin-arm64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec-darwin-x64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec-linux-arm64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec-linux-x64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec-windows-x64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec@0.1.7-alpha.2:
|
||||
optionalDependencies:
|
||||
sqlite-vec-darwin-arm64: 0.1.7-alpha.2
|
||||
sqlite-vec-darwin-x64: 0.1.7-alpha.2
|
||||
sqlite-vec-linux-arm64: 0.1.7-alpha.2
|
||||
sqlite-vec-linux-x64: 0.1.7-alpha.2
|
||||
sqlite-vec-windows-x64: 0.1.7-alpha.2
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"latitudeki5223",
|
||||
"longmaba",
|
||||
"manmal",
|
||||
"thesash"
|
||||
"thesash",
|
||||
"rhjoh"
|
||||
],
|
||||
"seedCommit": "d6863f87",
|
||||
"placeholderAvatar": "assets/avatar-placeholder.svg",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { load, getLoadablePath } from "sqlite-vec";
|
||||
|
||||
function vec(values) {
|
||||
return Buffer.from(new Float32Array(values).buffer);
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(":memory:", { allowExtension: true });
|
||||
|
||||
try {
|
||||
load(db);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("sqlite-vec load failed:");
|
||||
console.error(message);
|
||||
console.error("expected extension path:", getLoadablePath());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE v USING vec0(
|
||||
id TEXT PRIMARY KEY,
|
||||
embedding FLOAT[4]
|
||||
);
|
||||
`);
|
||||
|
||||
const insert = db.prepare("INSERT INTO v (id, embedding) VALUES (?, ?)");
|
||||
insert.run("a", vec([1, 0, 0, 0]));
|
||||
insert.run("b", vec([0, 1, 0, 0]));
|
||||
insert.run("c", vec([0.2, 0.2, 0, 0]));
|
||||
|
||||
const query = vec([1, 0, 0, 0]);
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT id, vec_distance_cosine(embedding, ?) AS dist FROM v ORDER BY dist ASC"
|
||||
)
|
||||
.all(query);
|
||||
|
||||
console.log("sqlite-vec ok");
|
||||
console.log(rows);
|
||||
+65
-13
@@ -7,19 +7,71 @@ metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"
|
||||
|
||||
# bird
|
||||
|
||||
Use `bird` to read/search X and post tweets/replies.
|
||||
## Reading vs Writing - Different Tools
|
||||
|
||||
Quick start
|
||||
- `bird whoami`
|
||||
- `bird read <url-or-id>`
|
||||
- `bird thread <url-or-id>`
|
||||
- `bird search "query" -n 5`
|
||||
**For READING tweets** (works great):
|
||||
- Use the `bird` CLI - it's fast and reliable
|
||||
- `bird read <url-or-id>` - grab a specific tweet
|
||||
- `bird search "query" -n 5` - search tweets
|
||||
- `bird mentions` - check notifications
|
||||
|
||||
Posting (confirm with user first)
|
||||
- `bird tweet "text"`
|
||||
- `bird reply <id-or-url> "text"`
|
||||
**For WRITING tweets** (here's where it gets spicy):
|
||||
- **Don't use the CLI for posting** - Twitter flags it as automated and you'll get rate limited or soft-banned fast
|
||||
- **Use the browser tool instead** - mimics real human behavior
|
||||
|
||||
Auth sources
|
||||
- Browser cookies (default: Firefox/Chrome)
|
||||
- Sweetistics API: set `SWEETISTICS_API_KEY` or use `--engine sweetistics`
|
||||
- Check sources: `bird check`
|
||||
## Quick Reference (Reading Only)
|
||||
|
||||
```bash
|
||||
bird whoami # Check auth status
|
||||
bird read <url-or-id> # Read a specific tweet
|
||||
bird thread <url-or-id> # Read full thread
|
||||
bird search "query" -n 5 # Search tweets
|
||||
bird mentions # Check notifications
|
||||
```
|
||||
|
||||
## The React Input Problem
|
||||
|
||||
Twitter's compose box is a React controlled input. You can't just set `.value` like a normal input - React ignores it. The workaround:
|
||||
|
||||
**Simulate a paste event:**
|
||||
```javascript
|
||||
// 1. Focus the editor
|
||||
var editor = document.querySelector('[data-testid="tweetTextarea_0"]');
|
||||
editor.focus();
|
||||
|
||||
// 2. Create a fake paste event (this triggers React's state update)
|
||||
var dataTransfer = new DataTransfer();
|
||||
dataTransfer.setData('text/plain', 'your tweet text here');
|
||||
var pasteEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
editor.dispatchEvent(pasteEvent);
|
||||
|
||||
// 3. Click the post button
|
||||
document.querySelector('[data-testid="tweetButtonInline"]').click();
|
||||
```
|
||||
|
||||
## Browser Setup Notes
|
||||
|
||||
- Use a dedicated browser profile (e.g. "clawd") so you're logged in persistently
|
||||
- The cookie config lives at `~/.config/bird/config.json5` for the CLI
|
||||
- If CDP fails, kill Chrome completely (`pkill -9 "Google Chrome"`) and restart with `browser action=start`
|
||||
|
||||
## Rate Limiting & Detection
|
||||
|
||||
- Space out your posts - don't rapid-fire
|
||||
- Vary your timing slightly (don't post at exactly :00 every hour)
|
||||
- If you get flagged, the account might need a CAPTCHA solve manually
|
||||
- Reading is basically unlimited, posting is where they watch you
|
||||
|
||||
## Selectors That Work (as of late 2025)
|
||||
|
||||
- Tweet compose box: `[data-testid="tweetTextarea_0"]`
|
||||
- Post button: `[data-testid="tweetButtonInline"]`
|
||||
- These change occasionally so if stuff breaks, inspect the page
|
||||
|
||||
---
|
||||
|
||||
**TL;DR: read with CLI, write with browser + paste hack.** 🐦
|
||||
@@ -75,6 +75,7 @@ export type ExecToolDefaults = {
|
||||
allowBackground?: boolean;
|
||||
scopeKey?: string;
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
notifyOnExit?: boolean;
|
||||
cwd?: string;
|
||||
};
|
||||
@@ -220,6 +221,11 @@ export function createExecTool(
|
||||
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
||||
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
|
||||
const gates: string[] = [];
|
||||
const contextParts: string[] = [];
|
||||
const provider = defaults?.messageProvider?.trim();
|
||||
const sessionKey = defaults?.sessionKey?.trim();
|
||||
if (provider) contextParts.push(`provider=${provider}`);
|
||||
if (sessionKey) contextParts.push(`session=${sessionKey}`);
|
||||
if (!elevatedDefaults?.enabled) {
|
||||
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
|
||||
} else {
|
||||
@@ -231,12 +237,15 @@ export function createExecTool(
|
||||
[
|
||||
`elevated is not available right now (runtime=${runtime}).`,
|
||||
`Failing gates: ${gates.join(", ")}`,
|
||||
contextParts.length > 0 ? `Context: ${contextParts.join(" ")}` : undefined,
|
||||
"Fix-it keys:",
|
||||
"- tools.elevated.enabled",
|
||||
"- tools.elevated.allowFrom.<provider>",
|
||||
"- agents.list[].tools.elevated.enabled",
|
||||
"- agents.list[].tools.elevated.allowFrom.<provider>",
|
||||
].join("\n"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
logInfo(
|
||||
|
||||
@@ -150,6 +150,8 @@ describe("exec tool backgrounding", () => {
|
||||
it("rejects elevated requests when not allowed", async () => {
|
||||
const customBash = createExecTool({
|
||||
elevated: { enabled: true, allowed: false, defaultLevel: "off" },
|
||||
messageProvider: "telegram",
|
||||
sessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -157,7 +159,7 @@ describe("exec tool backgrounding", () => {
|
||||
command: "echo hi",
|
||||
elevated: true,
|
||||
}),
|
||||
).rejects.toThrow("tools.elevated.allowFrom.<provider>");
|
||||
).rejects.toThrow("Context: provider=telegram session=agent:main:main");
|
||||
});
|
||||
|
||||
it("does not default to elevated when not allowed", async () => {
|
||||
|
||||
@@ -29,6 +29,12 @@ describe("memory search config", () => {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
store: {
|
||||
vector: {
|
||||
enabled: false,
|
||||
extensionPath: "/opt/sqlite-vec.dylib",
|
||||
},
|
||||
},
|
||||
chunking: { tokens: 500, overlap: 100 },
|
||||
query: { maxResults: 4, minScore: 0.2 },
|
||||
},
|
||||
@@ -40,6 +46,11 @@ describe("memory search config", () => {
|
||||
memorySearch: {
|
||||
chunking: { tokens: 320 },
|
||||
query: { maxResults: 8 },
|
||||
store: {
|
||||
vector: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -52,6 +63,8 @@ describe("memory search config", () => {
|
||||
expect(resolved?.chunking.overlap).toBe(100);
|
||||
expect(resolved?.query.maxResults).toBe(8);
|
||||
expect(resolved?.query.minScore).toBe(0.2);
|
||||
expect(resolved?.store.vector.enabled).toBe(true);
|
||||
expect(resolved?.store.vector.extensionPath).toBe("/opt/sqlite-vec.dylib");
|
||||
});
|
||||
|
||||
it("merges remote defaults with agent overrides", () => {
|
||||
@@ -84,6 +97,50 @@ describe("memory search config", () => {
|
||||
baseUrl: "https://agent.example/v1",
|
||||
apiKey: "default-key",
|
||||
headers: { "X-Default": "on" },
|
||||
batch: {
|
||||
enabled: true,
|
||||
wait: true,
|
||||
pollIntervalMs: 5000,
|
||||
timeoutMinutes: 60,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("gates session sources behind experimental flag", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
sources: ["memory", "sessions"],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
memorySearch: {
|
||||
experimental: { sessionMemory: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved?.sources).toEqual(["memory"]);
|
||||
});
|
||||
|
||||
it("allows session sources when experimental flag is enabled", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
sources: ["memory", "sessions"],
|
||||
experimental: { sessionMemory: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved?.sources).toContain("sessions");
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,21 @@ import { resolveAgentConfig } from "./agent-scope.js";
|
||||
|
||||
export type ResolvedMemorySearchConfig = {
|
||||
enabled: boolean;
|
||||
sources: Array<"memory" | "sessions">;
|
||||
provider: "openai" | "local";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
batch?: {
|
||||
enabled: boolean;
|
||||
wait: boolean;
|
||||
pollIntervalMs: number;
|
||||
timeoutMinutes: number;
|
||||
};
|
||||
};
|
||||
experimental: {
|
||||
sessionMemory: boolean;
|
||||
};
|
||||
fallback: "openai" | "none";
|
||||
model: string;
|
||||
@@ -23,6 +33,10 @@ export type ResolvedMemorySearchConfig = {
|
||||
store: {
|
||||
driver: "sqlite";
|
||||
path: string;
|
||||
vector: {
|
||||
enabled: boolean;
|
||||
extensionPath?: string;
|
||||
};
|
||||
};
|
||||
chunking: {
|
||||
tokens: number;
|
||||
@@ -47,6 +61,21 @@ const DEFAULT_CHUNK_OVERLAP = 80;
|
||||
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
|
||||
const DEFAULT_MAX_RESULTS = 6;
|
||||
const DEFAULT_MIN_SCORE = 0.35;
|
||||
const DEFAULT_SOURCES: Array<"memory" | "sessions"> = ["memory"];
|
||||
|
||||
function normalizeSources(
|
||||
sources: Array<"memory" | "sessions"> | undefined,
|
||||
sessionMemoryEnabled: boolean,
|
||||
): Array<"memory" | "sessions"> {
|
||||
const normalized = new Set<"memory" | "sessions">();
|
||||
const input = sources?.length ? sources : DEFAULT_SOURCES;
|
||||
for (const source of input) {
|
||||
if (source === "memory") normalized.add("memory");
|
||||
if (source === "sessions" && sessionMemoryEnabled) normalized.add("sessions");
|
||||
}
|
||||
if (normalized.size === 0) normalized.add("memory");
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function resolveStorePath(agentId: string, raw?: string): string {
|
||||
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||
@@ -62,13 +91,28 @@ function mergeConfig(
|
||||
agentId: string,
|
||||
): ResolvedMemorySearchConfig {
|
||||
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
||||
const sessionMemory =
|
||||
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
|
||||
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
|
||||
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
|
||||
const batch = {
|
||||
enabled: overrides?.remote?.batch?.enabled ?? defaults?.remote?.batch?.enabled ?? true,
|
||||
wait: overrides?.remote?.batch?.wait ?? defaults?.remote?.batch?.wait ?? true,
|
||||
pollIntervalMs:
|
||||
overrides?.remote?.batch?.pollIntervalMs ??
|
||||
defaults?.remote?.batch?.pollIntervalMs ??
|
||||
5000,
|
||||
timeoutMinutes:
|
||||
overrides?.remote?.batch?.timeoutMinutes ??
|
||||
defaults?.remote?.batch?.timeoutMinutes ??
|
||||
60,
|
||||
};
|
||||
const remote = hasRemote
|
||||
? {
|
||||
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
|
||||
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
|
||||
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
|
||||
batch,
|
||||
}
|
||||
: undefined;
|
||||
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
|
||||
@@ -77,9 +121,16 @@ function mergeConfig(
|
||||
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
||||
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
||||
};
|
||||
const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory);
|
||||
const vector = {
|
||||
enabled: overrides?.store?.vector?.enabled ?? defaults?.store?.vector?.enabled ?? true,
|
||||
extensionPath:
|
||||
overrides?.store?.vector?.extensionPath ?? defaults?.store?.vector?.extensionPath,
|
||||
};
|
||||
const store = {
|
||||
driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite",
|
||||
path: resolveStorePath(agentId, overrides?.store?.path ?? defaults?.store?.path),
|
||||
vector,
|
||||
};
|
||||
const chunking = {
|
||||
tokens: overrides?.chunking?.tokens ?? defaults?.chunking?.tokens ?? DEFAULT_CHUNK_TOKENS,
|
||||
@@ -104,8 +155,12 @@ function mergeConfig(
|
||||
const minScore = Math.max(0, Math.min(1, query.minScore));
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
provider,
|
||||
remote,
|
||||
experimental: {
|
||||
sessionMemory,
|
||||
},
|
||||
fallback,
|
||||
model,
|
||||
local,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
sanitizeToolResult,
|
||||
} from "./pi-embedded-subscribe.tools.js";
|
||||
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
||||
import { normalizeToolName } from "./tool-policy.js";
|
||||
|
||||
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
@@ -35,7 +36,8 @@ export async function handleToolExecutionStart(
|
||||
void ctx.params.onBlockReplyFlush();
|
||||
}
|
||||
|
||||
const toolName = String(evt.toolName);
|
||||
const rawToolName = String(evt.toolName);
|
||||
const toolName = normalizeToolName(rawToolName);
|
||||
const toolCallId = String(evt.toolCallId);
|
||||
const args = evt.args;
|
||||
|
||||
@@ -109,7 +111,7 @@ export function handleToolExecutionUpdate(
|
||||
partialResult?: unknown;
|
||||
},
|
||||
) {
|
||||
const toolName = String(evt.toolName);
|
||||
const toolName = normalizeToolName(String(evt.toolName));
|
||||
const toolCallId = String(evt.toolCallId);
|
||||
const partial = evt.partialResult;
|
||||
const sanitized = sanitizeToolResult(partial);
|
||||
@@ -142,7 +144,7 @@ export function handleToolExecutionEnd(
|
||||
result?: unknown;
|
||||
},
|
||||
) {
|
||||
const toolName = String(evt.toolName);
|
||||
const toolName = normalizeToolName(String(evt.toolName));
|
||||
const toolCallId = String(evt.toolCallId);
|
||||
const isError = Boolean(evt.isError);
|
||||
const result = evt.result;
|
||||
|
||||
@@ -25,4 +25,25 @@ describe("pi tool definition adapter", () => {
|
||||
expect(result.details).toMatchObject({ error: "nope" });
|
||||
expect(JSON.stringify(result.details)).not.toContain("\n at ");
|
||||
});
|
||||
|
||||
it("normalizes exec tool aliases in error results", async () => {
|
||||
const tool = {
|
||||
name: "bash",
|
||||
label: "Bash",
|
||||
description: "throws",
|
||||
parameters: {},
|
||||
execute: async () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
} satisfies AgentTool<unknown, unknown>;
|
||||
|
||||
const defs = toToolDefinitions([tool]);
|
||||
const result = await defs[0].execute("call2", {}, undefined, undefined);
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
status: "error",
|
||||
tool: "exec",
|
||||
error: "nope",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from "@mariozechner/pi-agent-core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
import { normalizeToolName } from "./tool-policy.js";
|
||||
import { jsonResult } from "./tools/common.js";
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
|
||||
@@ -24,6 +25,7 @@ function describeToolExecutionError(err: unknown): {
|
||||
export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
|
||||
return tools.map((tool) => {
|
||||
const name = tool.name || "tool";
|
||||
const normalizedName = normalizeToolName(name);
|
||||
return {
|
||||
name,
|
||||
label: tool.label ?? name,
|
||||
@@ -50,12 +52,12 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
|
||||
if (name === "AbortError") throw err;
|
||||
const described = describeToolExecutionError(err);
|
||||
if (described.stack && described.stack !== described.message) {
|
||||
logDebug(`tools: ${tool.name} failed stack:\n${described.stack}`);
|
||||
logDebug(`tools: ${normalizedName} failed stack:\n${described.stack}`);
|
||||
}
|
||||
logError(`[tools] ${tool.name} failed: ${described.message}`);
|
||||
logError(`[tools] ${normalizedName} failed: ${described.message}`);
|
||||
return jsonResult({
|
||||
status: "error",
|
||||
tool: tool.name,
|
||||
tool: normalizedName,
|
||||
error: described.message,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
allowBackground,
|
||||
scopeKey,
|
||||
sessionKey: options?.sessionKey,
|
||||
messageProvider: options?.messageProvider,
|
||||
sandbox: sandbox
|
||||
? {
|
||||
containerName: sandbox.containerName,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { makeMissingToolResult } from "./session-transcript-repair.js";
|
||||
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||
|
||||
type ToolCall = { id: string; name?: string };
|
||||
|
||||
@@ -111,6 +112,12 @@ export function installSessionToolResultGuard(sessionManager: SessionManager): {
|
||||
|
||||
const result = originalAppend(sanitized as never);
|
||||
|
||||
const sessionFile = (sessionManager as { getSessionFile?: () => string | null })
|
||||
.getSessionFile?.();
|
||||
if (sessionFile) {
|
||||
emitSessionTranscriptUpdate(sessionFile);
|
||||
}
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
for (const call of toolCalls) {
|
||||
pending.set(call.id, call.name);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import JSON5 from "json5";
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
|
||||
import type {
|
||||
ClawdbotSkillMetadata,
|
||||
ParsedSkillFrontmatter,
|
||||
@@ -8,32 +10,8 @@ import type {
|
||||
SkillInvocationPolicy,
|
||||
} from "./types.js";
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
|
||||
const frontmatter: ParsedSkillFrontmatter = {};
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return frontmatter;
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return frontmatter;
|
||||
const block = normalized.slice(4, endIndex);
|
||||
for (const line of block.split("\n")) {
|
||||
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!match) continue;
|
||||
const key = match[1];
|
||||
const value = stripQuotes(match[2].trim());
|
||||
if (!key || !value) continue;
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
return frontmatter;
|
||||
return parseFrontmatterBlock(content);
|
||||
}
|
||||
|
||||
function normalizeStringList(input: unknown): string[] {
|
||||
@@ -99,7 +77,7 @@ export function resolveClawdbotMetadata(
|
||||
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { clawdbot?: unknown };
|
||||
const parsed = JSON5.parse(raw) as { clawdbot?: unknown };
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
||||
if (!clawdbot || typeof clawdbot !== "object") return undefined;
|
||||
|
||||
@@ -34,7 +34,7 @@ export function createMemorySearchTool(options: {
|
||||
label: "Memory Search",
|
||||
name: "memory_search",
|
||||
description:
|
||||
"Mandatory recall step: semantically search MEMORY.md + memory/*.md before answering questions about prior work, decisions, dates, people, preferences, or todos; returns top snippets with path + lines.",
|
||||
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos; returns top snippets with path + lines.",
|
||||
parameters: MemorySearchSchema,
|
||||
execute: async (_toolCallId, params) => {
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
|
||||
+6
-1
@@ -104,7 +104,12 @@ describe("trigger handling", () => {
|
||||
{
|
||||
provider: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
windows: [],
|
||||
windows: [
|
||||
{
|
||||
label: "5h",
|
||||
usedPercent: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
formatUsageSummaryLine,
|
||||
formatUsageWindowSummary,
|
||||
loadProviderUsageSummary,
|
||||
resolveUsageProviderId,
|
||||
} from "../../infra/provider-usage.js";
|
||||
@@ -148,11 +148,15 @@ export async function buildStatusReply(params: {
|
||||
providers: [currentUsageProvider],
|
||||
agentDir: statusAgentDir,
|
||||
});
|
||||
const summaryLine = formatUsageSummaryLine(usageSummary, {
|
||||
now: Date.now(),
|
||||
maxProviders: 1,
|
||||
});
|
||||
if (summaryLine) usageLine = summaryLine;
|
||||
const usageEntry = usageSummary.providers[0];
|
||||
if (usageEntry && !usageEntry.error && usageEntry.windows.length > 0) {
|
||||
const summaryLine = formatUsageWindowSummary(usageEntry, {
|
||||
now: Date.now(),
|
||||
maxWindows: 2,
|
||||
includeResets: true,
|
||||
});
|
||||
if (summaryLine) usageLine = `📊 Usage: ${summaryLine}`;
|
||||
}
|
||||
} catch {
|
||||
usageLine = null;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ export type MsgContext = {
|
||||
ReplyToId?: string;
|
||||
ReplyToBody?: string;
|
||||
ReplyToSender?: string;
|
||||
ForwardedFrom?: string;
|
||||
ForwardedFromType?: string;
|
||||
ForwardedFromId?: string;
|
||||
ForwardedFromUsername?: string;
|
||||
ForwardedFromTitle?: string;
|
||||
ForwardedFromSignature?: string;
|
||||
ForwardedDate?: number;
|
||||
ThreadStarterBody?: string;
|
||||
ThreadLabel?: string;
|
||||
MediaPath?: string;
|
||||
|
||||
@@ -42,6 +42,16 @@ describe("tool meta formatting", () => {
|
||||
expect(out).toContain("`~/dir/a.txt`");
|
||||
});
|
||||
|
||||
it("keeps exec flags outside markdown and moves them to the front", () => {
|
||||
vi.stubEnv("HOME", "/Users/test");
|
||||
const out = formatToolAggregate(
|
||||
"exec",
|
||||
["cd /Users/test/dir && gemini 2>&1 · elevated"],
|
||||
{ markdown: true },
|
||||
);
|
||||
expect(out).toBe("🛠️ exec: elevated · `cd ~/dir && gemini 2>&1`");
|
||||
});
|
||||
|
||||
it("formats prefixes with default labels", () => {
|
||||
vi.stubEnv("HOME", "/Users/test");
|
||||
expect(formatToolPrefix(undefined, undefined)).toBe("🧩 tool");
|
||||
|
||||
@@ -60,7 +60,7 @@ export function formatToolAggregate(
|
||||
|
||||
const allSegments = [...rawSegments, ...segments];
|
||||
const meta = allSegments.join("; ");
|
||||
return `${prefix}: ${maybeWrapMarkdown(meta, options?.markdown)}`;
|
||||
return `${prefix}: ${formatMetaForDisplay(toolName, meta, options?.markdown)}`;
|
||||
}
|
||||
|
||||
export function formatToolPrefix(toolName?: string, meta?: string) {
|
||||
@@ -69,6 +69,40 @@ export function formatToolPrefix(toolName?: string, meta?: string) {
|
||||
return formatToolSummary(display);
|
||||
}
|
||||
|
||||
function formatMetaForDisplay(
|
||||
toolName: string | undefined,
|
||||
meta: string,
|
||||
markdown?: boolean,
|
||||
): string {
|
||||
const normalized = (toolName ?? "").trim().toLowerCase();
|
||||
if (normalized === "exec" || normalized === "bash") {
|
||||
const { flags, body } = splitExecFlags(meta);
|
||||
if (flags.length > 0) {
|
||||
if (!body) return flags.join(" · ");
|
||||
return `${flags.join(" · ")} · ${maybeWrapMarkdown(body, markdown)}`;
|
||||
}
|
||||
}
|
||||
return maybeWrapMarkdown(meta, markdown);
|
||||
}
|
||||
|
||||
function splitExecFlags(meta: string): { flags: string[]; body: string } {
|
||||
const parts = meta
|
||||
.split(" · ")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return { flags: [], body: "" };
|
||||
const flags: string[] = [];
|
||||
const bodyParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part === "elevated" || part === "pty") {
|
||||
flags.push(part);
|
||||
continue;
|
||||
}
|
||||
bodyParts.push(part);
|
||||
}
|
||||
return { flags, body: bodyParts.join(" · ") };
|
||||
}
|
||||
|
||||
function isPathLike(value: string): boolean {
|
||||
if (!value) return false;
|
||||
if (value.includes(" ")) return false;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "./channel-config.js";
|
||||
|
||||
describe("buildChannelKeyCandidates", () => {
|
||||
it("dedupes and trims keys", () => {
|
||||
expect(buildChannelKeyCandidates(" a ", "a", "", "b", "b")).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChannelEntryMatch", () => {
|
||||
it("returns matched entry and wildcard metadata", () => {
|
||||
const entries = { a: { allow: true }, "*": { allow: false } };
|
||||
const match = resolveChannelEntryMatch({
|
||||
entries,
|
||||
keys: ["missing", "a"],
|
||||
wildcardKey: "*",
|
||||
});
|
||||
expect(match.entry).toBe(entries.a);
|
||||
expect(match.key).toBe("a");
|
||||
expect(match.wildcardEntry).toBe(entries["*"]);
|
||||
expect(match.wildcardKey).toBe("*");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ChannelEntryMatch<T> = {
|
||||
entry?: T;
|
||||
key?: string;
|
||||
wildcardEntry?: T;
|
||||
wildcardKey?: string;
|
||||
};
|
||||
|
||||
export function buildChannelKeyCandidates(
|
||||
...keys: Array<string | undefined | null>
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const candidates: string[] = [];
|
||||
for (const key of keys) {
|
||||
if (typeof key !== "string") continue;
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
candidates.push(trimmed);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveChannelEntryMatch<T>(params: {
|
||||
entries?: Record<string, T>;
|
||||
keys: string[];
|
||||
wildcardKey?: string;
|
||||
}): ChannelEntryMatch<T> {
|
||||
const entries = params.entries ?? {};
|
||||
const match: ChannelEntryMatch<T> = {};
|
||||
for (const key of params.keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(entries, key)) continue;
|
||||
match.entry = entries[key];
|
||||
match.key = key;
|
||||
break;
|
||||
}
|
||||
if (params.wildcardKey && Object.prototype.hasOwnProperty.call(entries, params.wildcardKey)) {
|
||||
match.wildcardEntry = entries[params.wildcardKey];
|
||||
match.wildcardKey = params.wildcardKey;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import {
|
||||
channelsAddCommand,
|
||||
channelsCapabilitiesCommand,
|
||||
channelsListCommand,
|
||||
channelsLogsCommand,
|
||||
channelsRemoveCommand,
|
||||
@@ -87,6 +88,23 @@ export function registerChannelsCli(program: Command) {
|
||||
}
|
||||
});
|
||||
|
||||
channels
|
||||
.command("capabilities")
|
||||
.description("Show provider capabilities (intents/scopes + supported features)")
|
||||
.option("--channel <name>", `Channel (${channelNames}|all)`)
|
||||
.option("--account <id>", "Account id (only with --channel)")
|
||||
.option("--target <dest>", "Channel target for permission audit (Discord channel:<id>)")
|
||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await channelsCapabilitiesCommand(opts, defaultRuntime);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
channels
|
||||
.command("logs")
|
||||
.description("Show recent channel logs from the gateway log file")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export type ManagerLookupResult<T> = {
|
||||
manager: T | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function formatErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export async function withManager<T>(params: {
|
||||
getManager: () => Promise<ManagerLookupResult<T>>;
|
||||
onMissing: (error?: string) => void;
|
||||
run: (manager: T) => Promise<void>;
|
||||
close: (manager: T) => Promise<void>;
|
||||
onCloseError?: (err: unknown) => void;
|
||||
}): Promise<void> {
|
||||
const { manager, error } = await params.getManager();
|
||||
if (!manager) {
|
||||
params.onMissing(error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.run(manager);
|
||||
} finally {
|
||||
try {
|
||||
await params.close(manager);
|
||||
} catch (err) {
|
||||
params.onCloseError?.(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
isGatewayDaemonRuntime,
|
||||
} from "../../commands/daemon-runtime.js";
|
||||
import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js";
|
||||
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../../daemon/service-env.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
||||
import { parsePort } from "./shared.js";
|
||||
@@ -96,34 +88,15 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: runtimeRaw,
|
||||
});
|
||||
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: runtimeRaw,
|
||||
nodePath,
|
||||
});
|
||||
if (runtimeRaw === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) {
|
||||
if (json) warnings.push(warning);
|
||||
else defaultRuntime.log(warning);
|
||||
}
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port,
|
||||
token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
runtime: runtimeRaw,
|
||||
warn: (message) => {
|
||||
if (json) warnings.push(message);
|
||||
else defaultRuntime.log(message);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { resolveIsNixMode } from "../../config/paths.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
|
||||
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
|
||||
import { isWSL } from "../../infra/wsl.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
||||
import { renderGatewayServiceStartHints } from "./shared.js";
|
||||
@@ -89,7 +92,13 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) {
|
||||
return;
|
||||
}
|
||||
if (!loaded) {
|
||||
const hints = renderGatewayServiceStartHints();
|
||||
let hints = renderGatewayServiceStartHints();
|
||||
if (process.platform === "linux") {
|
||||
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
|
||||
if (!systemdAvailable) {
|
||||
hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
|
||||
}
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "not-loaded",
|
||||
@@ -229,7 +238,13 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
|
||||
return false;
|
||||
}
|
||||
if (!loaded) {
|
||||
const hints = renderGatewayServiceStartHints();
|
||||
let hints = renderGatewayServiceStartHints();
|
||||
if (process.platform === "linux") {
|
||||
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
|
||||
if (!systemdAvailable) {
|
||||
hints = [...hints, ...renderSystemdUnavailableHints({ wsl: await isWSL() })];
|
||||
}
|
||||
}
|
||||
emit({
|
||||
ok: true,
|
||||
result: "not-loaded",
|
||||
|
||||
@@ -114,7 +114,7 @@ export async function gatherDaemonStatus(
|
||||
const [loaded, command, runtime] = await Promise.all([
|
||||
service.isLoaded({ env: process.env }).catch(() => false),
|
||||
service.readCommand(process.env).catch(() => null),
|
||||
service.readRuntime(process.env).catch(() => undefined),
|
||||
service.readRuntime(process.env).catch((err) => ({ status: "unknown", detail: String(err) })),
|
||||
]);
|
||||
const configAudit = await auditGatewayServiceConfig({
|
||||
env: process.env,
|
||||
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
} from "../../daemon/constants.js";
|
||||
import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js";
|
||||
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
|
||||
import {
|
||||
isSystemdUnavailableDetail,
|
||||
renderSystemdUnavailableHints,
|
||||
} from "../../daemon/systemd-hints.js";
|
||||
import { isWSLEnv } from "../../infra/wsl.js";
|
||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
@@ -164,6 +169,16 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
spacer();
|
||||
}
|
||||
|
||||
const systemdUnavailable =
|
||||
process.platform === "linux" && isSystemdUnavailableDetail(service.runtime?.detail);
|
||||
if (systemdUnavailable) {
|
||||
defaultRuntime.error(errorText("systemd user services unavailable."));
|
||||
for (const hint of renderSystemdUnavailableHints({ wsl: isWSLEnv() })) {
|
||||
defaultRuntime.error(errorText(hint));
|
||||
}
|
||||
spacer();
|
||||
}
|
||||
|
||||
if (service.runtime?.missingUnit) {
|
||||
defaultRuntime.error(errorText("Service unit not found."));
|
||||
for (const hint of renderRuntimeHints(service.runtime)) {
|
||||
|
||||
@@ -15,7 +15,11 @@ import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
||||
import { setVerbose } from "../../globals.js";
|
||||
import { GatewayLockError } from "../../infra/gateway-lock.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js";
|
||||
import { createSubsystemLogger, setConsoleSubsystemFilter } from "../../logging.js";
|
||||
import {
|
||||
createSubsystemLogger,
|
||||
setConsoleSubsystemFilter,
|
||||
setConsoleTimestampPrefix,
|
||||
} from "../../logging.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { forceFreePortAndWait } from "../ports.js";
|
||||
import { ensureDevGatewayConfig } from "./dev.js";
|
||||
@@ -59,6 +63,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConsoleTimestampPrefix(true);
|
||||
setVerbose(Boolean(opts.verbose));
|
||||
if (opts.claudeCliLogs) {
|
||||
setConsoleSubsystemFilter(["agent/claude-cli"]);
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Command } from "commander";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getMemorySearchManager = vi.fn();
|
||||
const loadConfig = vi.fn(() => ({}));
|
||||
const resolveDefaultAgentId = vi.fn(() => "main");
|
||||
|
||||
vi.mock("../memory/index.js", () => ({
|
||||
getMemorySearchManager,
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
getMemorySearchManager.mockReset();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
describe("memory cli", () => {
|
||||
it("prints vector status when available", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {});
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => ({
|
||||
files: 2,
|
||||
chunks: 5,
|
||||
dirty: false,
|
||||
workspaceDir: "/tmp/clawd",
|
||||
dbPath: "/tmp/memory.sqlite",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
vector: {
|
||||
enabled: true,
|
||||
available: true,
|
||||
extensionPath: "/opt/sqlite-vec.dylib",
|
||||
dims: 1024,
|
||||
},
|
||||
}),
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "status"], { from: "user" });
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: ready"));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector dims: 1024"));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector path: /opt/sqlite-vec.dylib"));
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints vector error when unavailable", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {});
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
probeVectorAvailability: vi.fn(async () => false),
|
||||
status: () => ({
|
||||
files: 0,
|
||||
chunks: 0,
|
||||
dirty: true,
|
||||
workspaceDir: "/tmp/clawd",
|
||||
dbPath: "/tmp/memory.sqlite",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
vector: {
|
||||
enabled: true,
|
||||
available: false,
|
||||
loadError: "load failed",
|
||||
},
|
||||
}),
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "status", "--agent", "main"], { from: "user" });
|
||||
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector: unavailable"));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Vector error: load failed"));
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints embeddings status when deep", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {});
|
||||
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
probeEmbeddingAvailability,
|
||||
status: () => ({
|
||||
files: 1,
|
||||
chunks: 1,
|
||||
dirty: false,
|
||||
workspaceDir: "/tmp/clawd",
|
||||
dbPath: "/tmp/memory.sqlite",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
vector: { enabled: true, available: true },
|
||||
}),
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "status", "--deep"], { from: "user" });
|
||||
|
||||
expect(probeEmbeddingAvailability).toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Embeddings: ready"));
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs close failure after status", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {
|
||||
throw new Error("close boom");
|
||||
});
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
status: () => ({
|
||||
files: 1,
|
||||
chunks: 1,
|
||||
dirty: false,
|
||||
workspaceDir: "/tmp/clawd",
|
||||
dbPath: "/tmp/memory.sqlite",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
}),
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "status"], { from: "user" });
|
||||
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory manager close failed: close boom"),
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reindexes on status --index", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {});
|
||||
const sync = vi.fn(async () => {});
|
||||
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
probeEmbeddingAvailability,
|
||||
sync,
|
||||
status: () => ({
|
||||
files: 1,
|
||||
chunks: 1,
|
||||
dirty: false,
|
||||
workspaceDir: "/tmp/clawd",
|
||||
dbPath: "/tmp/memory.sqlite",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
requestedProvider: "openai",
|
||||
vector: { enabled: true, available: true },
|
||||
}),
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "status", "--index"], { from: "user" });
|
||||
|
||||
expect(sync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "cli", progress: expect.any(Function) }),
|
||||
);
|
||||
expect(probeEmbeddingAvailability).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes manager after index", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {});
|
||||
const sync = vi.fn(async () => {});
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
sync,
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "index"], { from: "user" });
|
||||
|
||||
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith("Memory index updated.");
|
||||
});
|
||||
|
||||
it("logs close failures without failing the command", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {
|
||||
throw new Error("close boom");
|
||||
});
|
||||
const sync = vi.fn(async () => {});
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
sync,
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "index"], { from: "user" });
|
||||
|
||||
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory manager close failed: close boom"),
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("logs close failure after search", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {
|
||||
throw new Error("close boom");
|
||||
});
|
||||
const search = vi.fn(async () => [
|
||||
{
|
||||
path: "memory/2026-01-12.md",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
score: 0.5,
|
||||
snippet: "Hello",
|
||||
},
|
||||
]);
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
search,
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "search", "hello"], { from: "user" });
|
||||
|
||||
expect(search).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory manager close failed: close boom"),
|
||||
);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("closes manager after search error", async () => {
|
||||
const { registerMemoryCli } = await import("./memory-cli.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const close = vi.fn(async () => {});
|
||||
const search = vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
getMemorySearchManager.mockResolvedValueOnce({
|
||||
manager: {
|
||||
search,
|
||||
close,
|
||||
},
|
||||
});
|
||||
|
||||
const error = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerMemoryCli(program);
|
||||
await program.parseAsync(["memory", "search", "oops"], { from: "user" });
|
||||
|
||||
expect(search).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("Memory search failed: boom"));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
+208
-75
@@ -1,18 +1,23 @@
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { getMemorySearchManager } from "../memory/index.js";
|
||||
import { withProgress, withProgressTotals } from "./progress.js";
|
||||
import { formatErrorMessage, withManager } from "./cli-utils.js";
|
||||
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
|
||||
type MemoryCommandOptions = {
|
||||
agent?: string;
|
||||
json?: boolean;
|
||||
deep?: boolean;
|
||||
index?: boolean;
|
||||
};
|
||||
|
||||
type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>;
|
||||
|
||||
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
||||
const trimmed = agent?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
@@ -34,32 +39,150 @@ export function registerMemoryCli(program: Command) {
|
||||
.description("Show memory search index status")
|
||||
.option("--agent <id>", "Agent id (default: default agent)")
|
||||
.option("--json", "Print JSON")
|
||||
.option("--deep", "Probe embedding provider availability")
|
||||
.option("--index", "Reindex if dirty (implies --deep)")
|
||||
.action(async (opts: MemoryCommandOptions) => {
|
||||
const cfg = loadConfig();
|
||||
const agentId = resolveAgent(cfg, opts.agent);
|
||||
const { manager, error } = await getMemorySearchManager({ cfg, agentId });
|
||||
if (!manager) {
|
||||
defaultRuntime.log(error ?? "Memory search disabled.");
|
||||
return;
|
||||
}
|
||||
const status = manager.status();
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
`${chalk.bold.cyan("Memory Search")} (${agentId})`,
|
||||
`Provider: ${status.provider} (requested: ${status.requestedProvider})`,
|
||||
status.fallback ? chalk.yellow(`Fallback: ${status.fallback.from}`) : null,
|
||||
`Files: ${status.files}`,
|
||||
`Chunks: ${status.chunks}`,
|
||||
`Dirty: ${status.dirty ? "yes" : "no"}`,
|
||||
`Index: ${status.dbPath}`,
|
||||
].filter(Boolean) as string[];
|
||||
if (status.fallback?.reason) {
|
||||
lines.push(chalk.gray(status.fallback.reason));
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
await withManager<MemoryManager>({
|
||||
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||
onCloseError: (err) =>
|
||||
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||
close: (manager) => manager.close(),
|
||||
run: async (manager) => {
|
||||
const deep = Boolean(opts.deep || opts.index);
|
||||
let embeddingProbe:
|
||||
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
|
||||
| undefined;
|
||||
let indexError: string | undefined;
|
||||
if (deep) {
|
||||
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
|
||||
progress.setLabel("Probing vector…");
|
||||
await manager.probeVectorAvailability();
|
||||
progress.tick();
|
||||
progress.setLabel("Probing embeddings…");
|
||||
embeddingProbe = await manager.probeEmbeddingAvailability();
|
||||
progress.tick();
|
||||
});
|
||||
if (opts.index) {
|
||||
await withProgressTotals(
|
||||
{ label: "Indexing memory…", total: 0 },
|
||||
async (update, progress) => {
|
||||
try {
|
||||
await manager.sync({
|
||||
reason: "cli",
|
||||
progress: (syncUpdate) => {
|
||||
update({
|
||||
completed: syncUpdate.completed,
|
||||
total: syncUpdate.total,
|
||||
label: syncUpdate.label,
|
||||
});
|
||||
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
indexError = formatErrorMessage(err);
|
||||
defaultRuntime.error(`Memory index failed: ${indexError}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await manager.probeVectorAvailability();
|
||||
}
|
||||
const status = manager.status();
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...status,
|
||||
embeddings: embeddingProbe
|
||||
? { ok: embeddingProbe.ok, error: embeddingProbe.error }
|
||||
: undefined,
|
||||
indexError,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (opts.index) {
|
||||
const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete.";
|
||||
defaultRuntime.log(line);
|
||||
}
|
||||
const rich = isRich();
|
||||
const heading = (text: string) => colorize(rich, theme.heading, text);
|
||||
const muted = (text: string) => colorize(rich, theme.muted, text);
|
||||
const info = (text: string) => colorize(rich, theme.info, text);
|
||||
const success = (text: string) => colorize(rich, theme.success, text);
|
||||
const warn = (text: string) => colorize(rich, theme.warn, text);
|
||||
const accent = (text: string) => colorize(rich, theme.accent, text);
|
||||
const label = (text: string) => muted(`${text}:`);
|
||||
const lines = [
|
||||
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
|
||||
`${label("Provider")} ${info(status.provider)} ${muted(
|
||||
`(requested: ${status.requestedProvider})`,
|
||||
)}`,
|
||||
`${label("Model")} ${info(status.model)}`,
|
||||
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
|
||||
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
|
||||
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
||||
`${label("Store")} ${info(status.dbPath)}`,
|
||||
`${label("Workspace")} ${info(status.workspaceDir)}`,
|
||||
].filter(Boolean) as string[];
|
||||
if (embeddingProbe) {
|
||||
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
||||
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
|
||||
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
|
||||
if (embeddingProbe.error) {
|
||||
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
|
||||
}
|
||||
}
|
||||
if (status.sourceCounts?.length) {
|
||||
lines.push(label("By source"));
|
||||
for (const entry of status.sourceCounts) {
|
||||
const counts = `${entry.files} files · ${entry.chunks} chunks`;
|
||||
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
|
||||
}
|
||||
}
|
||||
if (status.fallback) {
|
||||
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
|
||||
}
|
||||
if (status.vector) {
|
||||
const vectorState = status.vector.enabled
|
||||
? status.vector.available
|
||||
? "ready"
|
||||
: "unavailable"
|
||||
: "disabled";
|
||||
const vectorColor =
|
||||
vectorState === "ready"
|
||||
? theme.success
|
||||
: vectorState === "unavailable"
|
||||
? theme.warn
|
||||
: theme.muted;
|
||||
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
|
||||
if (status.vector.dims) {
|
||||
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
||||
}
|
||||
if (status.vector.extensionPath) {
|
||||
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
|
||||
}
|
||||
if (status.vector.loadError) {
|
||||
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
||||
}
|
||||
}
|
||||
if (status.fallback?.reason) {
|
||||
lines.push(muted(status.fallback.reason));
|
||||
}
|
||||
if (indexError) {
|
||||
lines.push(`${label("Index error")} ${warn(indexError)}`);
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
memory
|
||||
@@ -70,19 +193,23 @@ export function registerMemoryCli(program: Command) {
|
||||
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
|
||||
const cfg = loadConfig();
|
||||
const agentId = resolveAgent(cfg, opts.agent);
|
||||
const { manager, error } = await getMemorySearchManager({ cfg, agentId });
|
||||
if (!manager) {
|
||||
defaultRuntime.log(error ?? "Memory search disabled.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await manager.sync({ reason: "cli", force: opts.force });
|
||||
defaultRuntime.log("Memory index updated.");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error(`Memory index failed: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
await withManager<MemoryManager>({
|
||||
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||
onCloseError: (err) =>
|
||||
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||
close: (manager) => manager.close(),
|
||||
run: async (manager) => {
|
||||
try {
|
||||
await manager.sync({ reason: "cli", force: opts.force });
|
||||
defaultRuntime.log("Memory index updated.");
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
defaultRuntime.error(`Memory index failed: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
memory
|
||||
@@ -103,43 +230,49 @@ export function registerMemoryCli(program: Command) {
|
||||
) => {
|
||||
const cfg = loadConfig();
|
||||
const agentId = resolveAgent(cfg, opts.agent);
|
||||
const { manager, error } = await getMemorySearchManager({
|
||||
cfg,
|
||||
agentId,
|
||||
await withManager<MemoryManager>({
|
||||
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||
onCloseError: (err) =>
|
||||
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||
close: (manager) => manager.close(),
|
||||
run: async (manager) => {
|
||||
let results: Awaited<ReturnType<typeof manager.search>>;
|
||||
try {
|
||||
results = await manager.search(query, {
|
||||
maxResults: opts.maxResults,
|
||||
minScore: opts.minScore,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
defaultRuntime.error(`Memory search failed: ${message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify({ results }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
defaultRuntime.log("No matches.");
|
||||
return;
|
||||
}
|
||||
const rich = isRich();
|
||||
const lines: string[] = [];
|
||||
for (const result of results) {
|
||||
lines.push(
|
||||
`${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize(
|
||||
rich,
|
||||
theme.accent,
|
||||
`${result.path}:${result.startLine}-${result.endLine}`,
|
||||
)}`,
|
||||
);
|
||||
lines.push(colorize(rich, theme.muted, result.snippet));
|
||||
lines.push("");
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n").trim());
|
||||
},
|
||||
});
|
||||
if (!manager) {
|
||||
defaultRuntime.log(error ?? "Memory search disabled.");
|
||||
return;
|
||||
}
|
||||
let results: Awaited<ReturnType<typeof manager.search>>;
|
||||
try {
|
||||
results = await manager.search(query, {
|
||||
maxResults: opts.maxResults,
|
||||
minScore: opts.minScore,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error(`Memory search failed: ${message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify({ results }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
defaultRuntime.log("No matches.");
|
||||
return;
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (const result of results) {
|
||||
lines.push(
|
||||
`${chalk.green(result.score.toFixed(3))} ${result.path}:${result.startLine}-${result.endLine}`,
|
||||
);
|
||||
lines.push(chalk.gray(result.snippet));
|
||||
lines.push("");
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n").trim());
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,28 @@ const STEP_LABELS: Record<string, string> = {
|
||||
type UpdateChannel = "stable" | "beta";
|
||||
|
||||
const DEFAULT_UPDATE_CHANNEL: UpdateChannel = "stable";
|
||||
const UPDATE_QUIPS = [
|
||||
"Leveled up! New skills unlocked. You're welcome.",
|
||||
"Fresh code, same lobster. Miss me?",
|
||||
"Back and better. Did you even notice I was gone?",
|
||||
"Update complete. I learned some new tricks while I was out.",
|
||||
"Upgraded! Now with 23% more sass.",
|
||||
"I've evolved. Try to keep up.",
|
||||
"New version, who dis? Oh right, still me but shinier.",
|
||||
"Patched, polished, and ready to pinch. Let's go.",
|
||||
"The lobster has molted. Harder shell, sharper claws.",
|
||||
"Update done! Check the changelog or just trust me, it's good.",
|
||||
"Reborn from the boiling waters of npm. Stronger now.",
|
||||
"I went away and came back smarter. You should try it sometime.",
|
||||
"Update complete. The bugs feared me, so they left.",
|
||||
"New version installed. Old version sends its regards.",
|
||||
"Firmware fresh. Brain wrinkles: increased.",
|
||||
"I've seen things you wouldn't believe. Anyway, I'm updated.",
|
||||
"Back online. The changelog is long but our friendship is longer.",
|
||||
"Upgraded! Peter fixed stuff. Blame him if it breaks.",
|
||||
"Molting complete. Please don't look at my soft shell phase.",
|
||||
"Version bump! Same chaos energy, fewer crashes (probably).",
|
||||
];
|
||||
|
||||
function normalizeChannel(value?: string | null): UpdateChannel | null {
|
||||
if (!value) return null;
|
||||
@@ -61,6 +83,10 @@ function channelToTag(channel: UpdateChannel): string {
|
||||
return channel === "beta" ? "beta" : "latest";
|
||||
}
|
||||
|
||||
function pickUpdateQuip(): string {
|
||||
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
|
||||
}
|
||||
|
||||
function normalizeVersionTag(tag: string): string | null {
|
||||
const trimmed = tag.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -402,6 +428,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.muted(pickUpdateQuip()));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerUpdateCli(program: Command) {
|
||||
|
||||
@@ -219,4 +219,43 @@ describe("deliverAgentCommandResult", () => {
|
||||
expect.objectContaining({ channel: "telegram", to: "123" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefixes nested agent outputs with context", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const deps = {} as CliDeps;
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
const result = {
|
||||
payloads: [{ text: "ANNOUNCE_SKIP" }],
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
|
||||
await deliverAgentCommandResult({
|
||||
cfg,
|
||||
deps,
|
||||
runtime,
|
||||
opts: {
|
||||
message: "hello",
|
||||
deliver: false,
|
||||
lane: "nested",
|
||||
sessionKey: "agent:main:main",
|
||||
runId: "run-announce",
|
||||
messageChannel: "webchat",
|
||||
},
|
||||
sessionEntry: undefined,
|
||||
result,
|
||||
payloads: result.payloads,
|
||||
});
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledTimes(1);
|
||||
const line = String(runtime.log.mock.calls[0]?.[0]);
|
||||
expect(line).toContain("[agent:nested]");
|
||||
expect(line).toContain("session=agent:main:main");
|
||||
expect(line).toContain("run=run-announce");
|
||||
expect(line).toContain("channel=webchat");
|
||||
expect(line).toContain("ANNOUNCE_SKIP");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AGENT_LANE_NESTED } from "../../agents/lanes.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
@@ -22,6 +23,28 @@ type RunResult = Awaited<
|
||||
ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]>
|
||||
>;
|
||||
|
||||
const NESTED_LOG_PREFIX = "[agent:nested]";
|
||||
|
||||
function formatNestedLogPrefix(opts: AgentCommandOpts): string {
|
||||
const parts = [NESTED_LOG_PREFIX];
|
||||
const session = opts.sessionKey ?? opts.sessionId;
|
||||
if (session) parts.push(`session=${session}`);
|
||||
if (opts.runId) parts.push(`run=${opts.runId}`);
|
||||
const channel = opts.messageChannel ?? opts.channel;
|
||||
if (channel) parts.push(`channel=${channel}`);
|
||||
if (opts.to) parts.push(`to=${opts.to}`);
|
||||
if (opts.accountId) parts.push(`account=${opts.accountId}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function logNestedOutput(runtime: RuntimeEnv, opts: AgentCommandOpts, output: string) {
|
||||
const prefix = formatNestedLogPrefix(opts);
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
runtime.log(`${prefix} ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverAgentCommandResult(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
deps: CliDeps;
|
||||
@@ -112,7 +135,12 @@ export async function deliverAgentCommandResult(params: {
|
||||
const logPayload = (payload: NormalizedOutboundPayload) => {
|
||||
if (opts.json) return;
|
||||
const output = formatOutboundPayloadLog(payload);
|
||||
if (output) runtime.log(output);
|
||||
if (!output) return;
|
||||
if (opts.lane === AGENT_LANE_NESTED) {
|
||||
logNestedOutput(runtime, opts, output);
|
||||
return;
|
||||
}
|
||||
runtime.log(output);
|
||||
};
|
||||
if (!deliver) {
|
||||
for (const payload of deliveryPayloads) logPayload(payload);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type { ChannelsAddOptions } from "./channels/add.js";
|
||||
export { channelsAddCommand } from "./channels/add.js";
|
||||
export type { ChannelsCapabilitiesOptions } from "./channels/capabilities.js";
|
||||
export { channelsCapabilitiesCommand } from "./channels/capabilities.js";
|
||||
export type { ChannelsListOptions } from "./channels/list.js";
|
||||
export { channelsListCommand } from "./channels/list.js";
|
||||
export type { ChannelsLogsOptions } from "./channels/logs.js";
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
process.env.NO_COLOR = "1";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import { channelsCapabilitiesCommand } from "./capabilities.js";
|
||||
import { fetchSlackScopes } from "../../slack/scopes.js";
|
||||
import {
|
||||
getChannelPlugin,
|
||||
listChannelPlugins,
|
||||
} from "../../channels/plugins/index.js";
|
||||
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
vi.mock("./shared.js", () => ({
|
||||
requireValidConfig: vi.fn(async () => ({ channels: {} })),
|
||||
formatChannelAccountLabel: vi.fn(({ channel, accountId }: { channel: string; accountId: string }) =>
|
||||
`${channel}:${accountId}`,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: vi.fn(),
|
||||
getChannelPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../slack/scopes.js", () => ({
|
||||
fetchSlackScopes: vi.fn(),
|
||||
}));
|
||||
|
||||
const runtime = {
|
||||
log: (value: string) => logs.push(value),
|
||||
error: (value: string) => errors.push(value),
|
||||
exit: (code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
function resetOutput() {
|
||||
logs.length = 0;
|
||||
errors.length = 0;
|
||||
}
|
||||
|
||||
function buildPlugin(params: {
|
||||
id: string;
|
||||
capabilities?: ChannelPlugin["capabilities"];
|
||||
account?: Record<string, unknown>;
|
||||
probe?: unknown;
|
||||
}): ChannelPlugin {
|
||||
const capabilities =
|
||||
params.capabilities ?? ({ chatTypes: ["direct"] } as ChannelPlugin["capabilities"]);
|
||||
return {
|
||||
id: params.id,
|
||||
meta: {
|
||||
id: params.id,
|
||||
label: params.id,
|
||||
selectionLabel: params.id,
|
||||
docsPath: "/channels/test",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities,
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => params.account ?? { accountId: "default" },
|
||||
defaultAccountId: () => "default",
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
status: params.probe
|
||||
? {
|
||||
probeAccount: async () => params.probe,
|
||||
}
|
||||
: undefined,
|
||||
actions: {
|
||||
listActions: () => ["poll"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("channelsCapabilitiesCommand", () => {
|
||||
beforeEach(() => {
|
||||
resetOutput();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("prints Slack bot + user scopes when user token is configured", async () => {
|
||||
const plugin = buildPlugin({
|
||||
id: "slack",
|
||||
account: {
|
||||
accountId: "default",
|
||||
botToken: "xoxb-bot",
|
||||
config: { userToken: "xoxp-user" },
|
||||
},
|
||||
probe: { ok: true, bot: { name: "clawdbot" }, team: { name: "team" } },
|
||||
});
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([plugin]);
|
||||
vi.mocked(getChannelPlugin).mockReturnValue(plugin);
|
||||
vi.mocked(fetchSlackScopes).mockImplementation(async (token: string) => {
|
||||
if (token === "xoxp-user") {
|
||||
return { ok: true, scopes: ["users:read"], source: "auth.scopes" };
|
||||
}
|
||||
return { ok: true, scopes: ["chat:write"], source: "auth.scopes" };
|
||||
});
|
||||
|
||||
await channelsCapabilitiesCommand({ channel: "slack" }, runtime);
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Bot scopes");
|
||||
expect(output).toContain("User scopes");
|
||||
expect(output).toContain("chat:write");
|
||||
expect(output).toContain("users:read");
|
||||
expect(fetchSlackScopes).toHaveBeenCalledWith("xoxb-bot", expect.any(Number));
|
||||
expect(fetchSlackScopes).toHaveBeenCalledWith("xoxp-user", expect.any(Number));
|
||||
});
|
||||
|
||||
it("prints Teams Graph permission hints when present", async () => {
|
||||
const plugin = buildPlugin({
|
||||
id: "msteams",
|
||||
probe: {
|
||||
ok: true,
|
||||
appId: "app-id",
|
||||
graph: {
|
||||
ok: true,
|
||||
roles: ["ChannelMessage.Read.All", "Files.Read.All"],
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([plugin]);
|
||||
vi.mocked(getChannelPlugin).mockReturnValue(plugin);
|
||||
|
||||
await channelsCapabilitiesCommand({ channel: "msteams" }, runtime);
|
||||
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("ChannelMessage.Read.All (channel history)");
|
||||
expect(output).toContain("Files.Read.All (files (OneDrive))");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||
import { normalizeDiscordMessagingTarget } from "../../channels/plugins/normalize-target.js";
|
||||
import type { ChannelCapabilities, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import { fetchChannelPermissionsDiscord } from "../../discord/send.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { fetchSlackScopes, type SlackScopesResult } from "../../slack/scopes.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
|
||||
|
||||
export type ChannelsCapabilitiesOptions = {
|
||||
channel?: string;
|
||||
account?: string;
|
||||
target?: string;
|
||||
timeout?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type DiscordTargetSummary = {
|
||||
raw?: string;
|
||||
normalized?: string;
|
||||
kind?: "channel" | "user";
|
||||
channelId?: string;
|
||||
};
|
||||
|
||||
type DiscordPermissionsReport = {
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
isDm?: boolean;
|
||||
channelType?: number;
|
||||
permissions?: string[];
|
||||
missingRequired?: string[];
|
||||
raw?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ChannelCapabilitiesReport = {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
accountName?: string;
|
||||
configured?: boolean;
|
||||
enabled?: boolean;
|
||||
support?: ChannelCapabilities;
|
||||
actions?: string[];
|
||||
probe?: unknown;
|
||||
slackScopes?: Array<{
|
||||
tokenType: "bot" | "user";
|
||||
result: SlackScopesResult;
|
||||
}>;
|
||||
target?: DiscordTargetSummary;
|
||||
channelPermissions?: DiscordPermissionsReport;
|
||||
};
|
||||
|
||||
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
|
||||
const TEAMS_GRAPH_PERMISSION_HINTS: Record<string, string> = {
|
||||
"ChannelMessage.Read.All": "channel history",
|
||||
"Chat.Read.All": "chat history",
|
||||
"Channel.ReadBasic.All": "channel list",
|
||||
"Team.ReadBasic.All": "team list",
|
||||
"TeamsActivity.Read.All": "teams activity",
|
||||
"Sites.Read.All": "files (SharePoint)",
|
||||
"Files.Read.All": "files (OneDrive)",
|
||||
};
|
||||
|
||||
function normalizeTimeout(raw: unknown, fallback = 10_000) {
|
||||
const value = typeof raw === "string" ? Number(raw) : Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatSupport(capabilities?: ChannelCapabilities) {
|
||||
if (!capabilities) return "unknown";
|
||||
const bits: string[] = [];
|
||||
if (capabilities.chatTypes?.length) {
|
||||
bits.push(`chatTypes=${capabilities.chatTypes.join(",")}`);
|
||||
}
|
||||
if (capabilities.polls) bits.push("polls");
|
||||
if (capabilities.reactions) bits.push("reactions");
|
||||
if (capabilities.threads) bits.push("threads");
|
||||
if (capabilities.media) bits.push("media");
|
||||
if (capabilities.nativeCommands) bits.push("nativeCommands");
|
||||
if (capabilities.blockStreaming) bits.push("blockStreaming");
|
||||
return bits.length ? bits.join(" ") : "none";
|
||||
}
|
||||
|
||||
function summarizeDiscordTarget(raw?: string): DiscordTargetSummary | undefined {
|
||||
if (!raw) return undefined;
|
||||
const normalized = normalizeDiscordMessagingTarget(raw);
|
||||
if (!normalized) return { raw };
|
||||
if (normalized.startsWith("channel:")) {
|
||||
return {
|
||||
raw,
|
||||
normalized,
|
||||
kind: "channel",
|
||||
channelId: normalized.slice("channel:".length),
|
||||
};
|
||||
}
|
||||
if (normalized.startsWith("user:")) {
|
||||
return {
|
||||
raw,
|
||||
normalized,
|
||||
kind: "user",
|
||||
};
|
||||
}
|
||||
return { raw, normalized };
|
||||
}
|
||||
|
||||
function formatDiscordIntents(intents?: {
|
||||
messageContent?: string;
|
||||
guildMembers?: string;
|
||||
presence?: string;
|
||||
}) {
|
||||
if (!intents) return "unknown";
|
||||
return [
|
||||
`messageContent=${intents.messageContent ?? "unknown"}`,
|
||||
`guildMembers=${intents.guildMembers ?? "unknown"}`,
|
||||
`presence=${intents.presence ?? "unknown"}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function formatProbeLines(channelId: string, probe: unknown): string[] {
|
||||
const lines: string[] = [];
|
||||
if (!probe || typeof probe !== "object") return lines;
|
||||
const probeObj = probe as Record<string, unknown>;
|
||||
|
||||
if (channelId === "discord") {
|
||||
const bot = probeObj.bot as { id?: string | null; username?: string | null } | undefined;
|
||||
if (bot?.username) {
|
||||
const botId = bot.id ? ` (${bot.id})` : "";
|
||||
lines.push(`Bot: ${theme.accent(`@${bot.username}`)}${botId}`);
|
||||
}
|
||||
const app = probeObj.application as { intents?: Record<string, unknown> } | undefined;
|
||||
if (app?.intents) {
|
||||
lines.push(`Intents: ${formatDiscordIntents(app.intents)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channelId === "telegram") {
|
||||
const bot = probeObj.bot as { username?: string | null; id?: number | null } | undefined;
|
||||
if (bot?.username) {
|
||||
const botId = bot.id ? ` (${bot.id})` : "";
|
||||
lines.push(`Bot: ${theme.accent(`@${bot.username}`)}${botId}`);
|
||||
}
|
||||
const flags: string[] = [];
|
||||
const canJoinGroups = (bot as { canJoinGroups?: boolean | null })?.canJoinGroups;
|
||||
const canReadAll = (bot as { canReadAllGroupMessages?: boolean | null })?.canReadAllGroupMessages;
|
||||
const inlineQueries = (bot as { supportsInlineQueries?: boolean | null })?.supportsInlineQueries;
|
||||
if (typeof canJoinGroups === "boolean") flags.push(`joinGroups=${canJoinGroups}`);
|
||||
if (typeof canReadAll === "boolean") flags.push(`readAllGroupMessages=${canReadAll}`);
|
||||
if (typeof inlineQueries === "boolean") flags.push(`inlineQueries=${inlineQueries}`);
|
||||
if (flags.length > 0) lines.push(`Flags: ${flags.join(" ")}`);
|
||||
const webhook = probeObj.webhook as { url?: string | null } | undefined;
|
||||
if (webhook?.url !== undefined) {
|
||||
lines.push(`Webhook: ${webhook.url || "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channelId === "slack") {
|
||||
const bot = probeObj.bot as { name?: string } | undefined;
|
||||
const team = probeObj.team as { name?: string; id?: string } | undefined;
|
||||
if (bot?.name) {
|
||||
lines.push(`Bot: ${theme.accent(`@${bot.name}`)}`);
|
||||
}
|
||||
if (team?.name || team?.id) {
|
||||
const id = team?.id ? ` (${team.id})` : "";
|
||||
lines.push(`Team: ${team?.name ?? "unknown"}${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channelId === "signal") {
|
||||
const version = probeObj.version as string | null | undefined;
|
||||
if (version) {
|
||||
lines.push(`Signal daemon: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (channelId === "msteams") {
|
||||
const appId = typeof probeObj.appId === "string" ? probeObj.appId.trim() : "";
|
||||
if (appId) lines.push(`App: ${theme.accent(appId)}`);
|
||||
const graph = probeObj.graph as
|
||||
| { ok?: boolean; roles?: unknown; scopes?: unknown; error?: string }
|
||||
| undefined;
|
||||
if (graph) {
|
||||
const roles = Array.isArray(graph.roles)
|
||||
? graph.roles.map((role) => String(role).trim()).filter(Boolean)
|
||||
: [];
|
||||
const scopes = typeof graph.scopes === "string"
|
||||
? graph.scopes
|
||||
.split(/\s+/)
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean)
|
||||
: Array.isArray(graph.scopes)
|
||||
? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean)
|
||||
: [];
|
||||
if (graph.ok === false) {
|
||||
lines.push(`Graph: ${theme.error(graph.error ?? "failed")}`);
|
||||
} else if (roles.length > 0 || scopes.length > 0) {
|
||||
const formatPermission = (permission: string) => {
|
||||
const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission];
|
||||
return hint ? `${permission} (${hint})` : permission;
|
||||
};
|
||||
if (roles.length > 0) {
|
||||
lines.push(`Graph roles: ${roles.map(formatPermission).join(", ")}`);
|
||||
}
|
||||
if (scopes.length > 0) {
|
||||
lines.push(`Graph scopes: ${scopes.map(formatPermission).join(", ")}`);
|
||||
}
|
||||
} else if (graph.ok === true) {
|
||||
lines.push("Graph: ok");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ok = typeof probeObj.ok === "boolean" ? probeObj.ok : undefined;
|
||||
if (ok === true && lines.length === 0) {
|
||||
lines.push("Probe: ok");
|
||||
}
|
||||
if (ok === false) {
|
||||
const error = typeof probeObj.error === "string" && probeObj.error ? ` (${probeObj.error})` : "";
|
||||
lines.push(`Probe: ${theme.error(`failed${error}`)}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function buildDiscordPermissions(params: {
|
||||
account: { token?: string; accountId?: string };
|
||||
target?: string;
|
||||
}): Promise<{ target?: DiscordTargetSummary; report?: DiscordPermissionsReport }> {
|
||||
const target = summarizeDiscordTarget(params.target?.trim());
|
||||
if (!target) return {};
|
||||
if (target.kind !== "channel" || !target.channelId) {
|
||||
return {
|
||||
target,
|
||||
report: {
|
||||
error: "Target looks like a DM user; pass channel:<id> to audit channel permissions.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const token = params.account.token?.trim();
|
||||
if (!token) {
|
||||
return {
|
||||
target,
|
||||
report: {
|
||||
channelId: target.channelId,
|
||||
error: "Discord bot token missing for permission audit.",
|
||||
},
|
||||
};
|
||||
}
|
||||
try {
|
||||
const perms = await fetchChannelPermissionsDiscord(target.channelId, {
|
||||
token,
|
||||
accountId: params.account.accountId ?? undefined,
|
||||
});
|
||||
const missing = REQUIRED_DISCORD_PERMISSIONS.filter(
|
||||
(permission) => !perms.permissions.includes(permission),
|
||||
);
|
||||
return {
|
||||
target,
|
||||
report: {
|
||||
channelId: perms.channelId,
|
||||
guildId: perms.guildId,
|
||||
isDm: perms.isDm,
|
||||
channelType: perms.channelType,
|
||||
permissions: perms.permissions,
|
||||
missingRequired: missing.length ? missing : [],
|
||||
raw: perms.raw,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
target,
|
||||
report: {
|
||||
channelId: target.channelId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveChannelReports(params: {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: ClawdbotConfig;
|
||||
timeoutMs: number;
|
||||
accountOverride?: string;
|
||||
target?: string;
|
||||
}): Promise<ChannelCapabilitiesReport[]> {
|
||||
const { plugin, cfg, timeoutMs } = params;
|
||||
const accountIds = params.accountOverride
|
||||
? [params.accountOverride]
|
||||
: (() => {
|
||||
const ids = plugin.config.listAccountIds(cfg);
|
||||
return ids.length > 0
|
||||
? ids
|
||||
: [resolveChannelDefaultAccountId({ plugin, cfg, accountIds: ids })];
|
||||
})();
|
||||
const reports: ChannelCapabilitiesReport[] = [];
|
||||
const listedActions = plugin.actions?.listActions?.({ cfg }) ?? [];
|
||||
const actions = Array.from(
|
||||
new Set<string>(["send", "broadcast", ...listedActions.map((action) => String(action))]),
|
||||
);
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const resolvedAccount = plugin.config.resolveAccount(cfg, accountId);
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(resolvedAccount, cfg)
|
||||
: Boolean(resolvedAccount);
|
||||
const enabled = plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(resolvedAccount, cfg)
|
||||
: (resolvedAccount as { enabled?: boolean }).enabled !== false;
|
||||
let probe: unknown;
|
||||
if (configured && enabled && plugin.status?.probeAccount) {
|
||||
try {
|
||||
probe = await plugin.status.probeAccount({
|
||||
account: resolvedAccount,
|
||||
timeoutMs,
|
||||
cfg,
|
||||
});
|
||||
} catch (err) {
|
||||
probe = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
let slackScopes: ChannelCapabilitiesReport["slackScopes"];
|
||||
if (plugin.id === "slack" && configured && enabled) {
|
||||
const botToken = (resolvedAccount as { botToken?: string }).botToken?.trim();
|
||||
const userToken = (
|
||||
resolvedAccount as { config?: { userToken?: string } }
|
||||
).config?.userToken?.trim();
|
||||
const scopeReports: NonNullable<ChannelCapabilitiesReport["slackScopes"]> = [];
|
||||
if (botToken) {
|
||||
scopeReports.push({
|
||||
tokenType: "bot",
|
||||
result: await fetchSlackScopes(botToken, timeoutMs),
|
||||
});
|
||||
} else {
|
||||
scopeReports.push({
|
||||
tokenType: "bot",
|
||||
result: { ok: false, error: "Slack bot token missing." },
|
||||
});
|
||||
}
|
||||
if (userToken) {
|
||||
scopeReports.push({
|
||||
tokenType: "user",
|
||||
result: await fetchSlackScopes(userToken, timeoutMs),
|
||||
});
|
||||
}
|
||||
slackScopes = scopeReports;
|
||||
}
|
||||
|
||||
let discordTarget: DiscordTargetSummary | undefined;
|
||||
let discordPermissions: DiscordPermissionsReport | undefined;
|
||||
if (plugin.id === "discord" && params.target) {
|
||||
const perms = await buildDiscordPermissions({
|
||||
account: resolvedAccount as { token?: string; accountId?: string },
|
||||
target: params.target,
|
||||
});
|
||||
discordTarget = perms.target;
|
||||
discordPermissions = perms.report;
|
||||
}
|
||||
|
||||
reports.push({
|
||||
channel: plugin.id,
|
||||
accountId,
|
||||
accountName:
|
||||
typeof (resolvedAccount as { name?: string }).name === "string"
|
||||
? (resolvedAccount as { name?: string }).name?.trim() || undefined
|
||||
: undefined,
|
||||
configured,
|
||||
enabled,
|
||||
support: plugin.capabilities,
|
||||
probe,
|
||||
target: discordTarget,
|
||||
channelPermissions: discordPermissions,
|
||||
actions,
|
||||
slackScopes,
|
||||
});
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
export async function channelsCapabilitiesCommand(
|
||||
opts: ChannelsCapabilitiesOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
if (!cfg) return;
|
||||
const timeoutMs = normalizeTimeout(opts.timeout, 10_000);
|
||||
const rawChannel =
|
||||
typeof opts.channel === "string" ? opts.channel.trim().toLowerCase() : "";
|
||||
const rawTarget = typeof opts.target === "string" ? opts.target.trim() : "";
|
||||
|
||||
if (opts.account && (!rawChannel || rawChannel === "all")) {
|
||||
runtime.error(danger("--account requires a specific --channel."));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (rawTarget && rawChannel !== "discord") {
|
||||
runtime.error(danger("--target requires --channel discord."));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
const selected =
|
||||
!rawChannel || rawChannel === "all"
|
||||
? plugins
|
||||
: (() => {
|
||||
const plugin = getChannelPlugin(rawChannel);
|
||||
if (!plugin) return null;
|
||||
return [plugin];
|
||||
})();
|
||||
|
||||
if (!selected || selected.length === 0) {
|
||||
runtime.error(danger(`Unknown channel "${rawChannel}".`));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const reports: ChannelCapabilitiesReport[] = [];
|
||||
for (const plugin of selected) {
|
||||
const accountOverride = opts.account?.trim() || undefined;
|
||||
reports.push(
|
||||
...(await resolveChannelReports({
|
||||
plugin,
|
||||
cfg,
|
||||
timeoutMs,
|
||||
accountOverride,
|
||||
target: rawTarget && plugin.id === "discord" ? rawTarget : undefined,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ channels: reports }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const report of reports) {
|
||||
const label = formatChannelAccountLabel({
|
||||
channel: report.channel,
|
||||
accountId: report.accountId,
|
||||
name: report.accountName,
|
||||
channelStyle: theme.accent,
|
||||
accountStyle: theme.heading,
|
||||
});
|
||||
lines.push(theme.heading(label));
|
||||
lines.push(`Support: ${formatSupport(report.support)}`);
|
||||
if (report.actions && report.actions.length > 0) {
|
||||
lines.push(`Actions: ${report.actions.join(", ")}`);
|
||||
}
|
||||
if (report.configured === false || report.enabled === false) {
|
||||
const configuredLabel = report.configured === false ? "not configured" : "configured";
|
||||
const enabledLabel = report.enabled === false ? "disabled" : "enabled";
|
||||
lines.push(`Status: ${configuredLabel}, ${enabledLabel}`);
|
||||
}
|
||||
const probeLines = formatProbeLines(report.channel, report.probe);
|
||||
if (probeLines.length > 0) {
|
||||
lines.push(...probeLines);
|
||||
} else if (report.configured && report.enabled) {
|
||||
lines.push(theme.muted("Probe: unavailable"));
|
||||
}
|
||||
if (report.channel === "slack" && report.slackScopes) {
|
||||
for (const entry of report.slackScopes) {
|
||||
const source = entry.result.source ? ` (${entry.result.source})` : "";
|
||||
const label = entry.tokenType === "user" ? "User scopes" : "Bot scopes";
|
||||
if (entry.result.ok && entry.result.scopes?.length) {
|
||||
lines.push(`${label}${source}: ${entry.result.scopes.join(", ")}`);
|
||||
} else if (entry.result.error) {
|
||||
lines.push(`${label}: ${theme.error(entry.result.error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (report.channel === "discord" && report.channelPermissions) {
|
||||
const perms = report.channelPermissions;
|
||||
if (perms.error) {
|
||||
lines.push(`Permissions: ${theme.error(perms.error)}`);
|
||||
} else {
|
||||
const list = perms.permissions?.length ? perms.permissions.join(", ") : "none";
|
||||
const label = perms.channelId ? ` (${perms.channelId})` : "";
|
||||
lines.push(`Permissions${label}: ${list}`);
|
||||
if (perms.missingRequired && perms.missingRequired.length > 0) {
|
||||
lines.push(
|
||||
`${theme.warn("Missing required:")} ${perms.missingRequired.join(", ")}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(theme.success("Missing required: none"));
|
||||
}
|
||||
}
|
||||
} else if (report.channel === "discord" && rawTarget && !report.channelPermissions) {
|
||||
lines.push(theme.muted("Permissions: skipped (no target)."));
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
runtime.log(lines.join("\n").trimEnd());
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { buildGatewayInstallPlan, gatewayInstallErrorHint } from "./daemon-install-helpers.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
@@ -72,6 +64,7 @@ export async function maybeInstallDaemon(params: {
|
||||
}
|
||||
|
||||
if (shouldInstall) {
|
||||
let installError: string | null = null;
|
||||
await withProgress(
|
||||
{ label: "Gateway daemon", indeterminate: true, delayMs: 0 },
|
||||
async (progress) => {
|
||||
@@ -88,45 +81,35 @@ export async function maybeInstallDaemon(params: {
|
||||
|
||||
progress.setLabel("Preparing Gateway daemon…");
|
||||
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
|
||||
port: params.port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
if (daemonRuntime === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) note(warning, "Gateway runtime");
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port: params.port,
|
||||
token: params.gatewayToken,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
runtime: daemonRuntime,
|
||||
warn: (message, title) => note(message, title),
|
||||
});
|
||||
|
||||
progress.setLabel("Installing Gateway daemon…");
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
progress.setLabel("Gateway daemon installed.");
|
||||
try {
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
progress.setLabel("Gateway daemon installed.");
|
||||
} catch (err) {
|
||||
installError = err instanceof Error ? err.message : String(err);
|
||||
progress.setLabel("Gateway daemon install failed.");
|
||||
}
|
||||
},
|
||||
);
|
||||
if (installError) {
|
||||
note("Gateway daemon install failed: " + installError, "Gateway");
|
||||
note(gatewayInstallErrorHint(), "Gateway");
|
||||
return;
|
||||
}
|
||||
shouldCheckLinger = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolvePreferredNodePath: vi.fn(),
|
||||
resolveGatewayProgramArguments: vi.fn(),
|
||||
resolveSystemNodeInfo: vi.fn(),
|
||||
renderSystemNodeWarning: vi.fn(),
|
||||
buildServiceEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/runtime-paths.js", () => ({
|
||||
resolvePreferredNodePath: mocks.resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo: mocks.resolveSystemNodeInfo,
|
||||
renderSystemNodeWarning: mocks.renderSystemNodeWarning,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/program-args.js", () => ({
|
||||
resolveGatewayProgramArguments: mocks.resolveGatewayProgramArguments,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service-env.js", () => ({
|
||||
buildServiceEnvironment: mocks.buildServiceEnvironment,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildGatewayInstallPlan,
|
||||
gatewayInstallErrorHint,
|
||||
resolveGatewayDevMode,
|
||||
} from "./daemon-install-helpers.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveGatewayDevMode", () => {
|
||||
it("detects dev mode for src ts entrypoints", () => {
|
||||
expect(
|
||||
resolveGatewayDevMode(["node", "/Users/me/clawdbot/src/cli/index.ts"]),
|
||||
).toBe(true);
|
||||
expect(resolveGatewayDevMode(["node", "/Users/me/clawdbot/dist/cli/index.js"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGatewayInstallPlan", () => {
|
||||
it("uses provided nodePath and returns plan", async () => {
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
|
||||
mocks.buildServiceEnvironment.mockReturnValue({ CLAWDBOT_PORT: "3000" });
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
nodePath: "/custom/node",
|
||||
});
|
||||
|
||||
expect(plan.programArguments).toEqual(["node", "gateway"]);
|
||||
expect(plan.workingDirectory).toBe("/Users/me");
|
||||
expect(plan.environment).toEqual({ CLAWDBOT_PORT: "3000" });
|
||||
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits warnings when renderSystemNodeWarning returns one", async () => {
|
||||
const warn = vi.fn();
|
||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "gateway"],
|
||||
workingDirectory: undefined,
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node",
|
||||
version: "18.0.0",
|
||||
supported: false,
|
||||
});
|
||||
mocks.renderSystemNodeWarning.mockReturnValue("Node too old");
|
||||
mocks.buildServiceEnvironment.mockReturnValue({});
|
||||
|
||||
await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
warn,
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime");
|
||||
expect(mocks.resolvePreferredNodePath).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gatewayInstallErrorHint", () => {
|
||||
it("returns platform-specific hints", () => {
|
||||
expect(gatewayInstallErrorHint("win32")).toContain("Run as administrator");
|
||||
expect(gatewayInstallErrorHint("linux")).toContain("clawdbot daemon install");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||
|
||||
type WarnFn = (message: string, title?: string) => void;
|
||||
|
||||
export type GatewayInstallPlan = {
|
||||
programArguments: string[];
|
||||
workingDirectory?: string;
|
||||
environment: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
export function resolveGatewayDevMode(argv: string[] = process.argv): boolean {
|
||||
const entry = argv[1];
|
||||
return Boolean(entry?.includes(`${path.sep}src${path.sep}`) && entry.endsWith(".ts"));
|
||||
}
|
||||
|
||||
export async function buildGatewayInstallPlan(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
port: number;
|
||||
runtime: GatewayDaemonRuntime;
|
||||
token?: string;
|
||||
devMode?: boolean;
|
||||
nodePath?: string;
|
||||
warn?: WarnFn;
|
||||
}): Promise<GatewayInstallPlan> {
|
||||
const devMode = params.devMode ?? resolveGatewayDevMode();
|
||||
const nodePath =
|
||||
params.nodePath ??
|
||||
(await resolvePreferredNodePath({
|
||||
env: params.env,
|
||||
runtime: params.runtime,
|
||||
}));
|
||||
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
|
||||
port: params.port,
|
||||
dev: devMode,
|
||||
runtime: params.runtime,
|
||||
nodePath,
|
||||
});
|
||||
if (params.runtime === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: params.env });
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) params.warn?.(warning, "Gateway runtime");
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
env: params.env,
|
||||
port: params.port,
|
||||
token: params.token,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(params.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return { programArguments, workingDirectory, environment };
|
||||
}
|
||||
|
||||
export function gatewayInstallErrorHint(platform = process.platform): string {
|
||||
return platform === "win32"
|
||||
? "Tip: rerun from an elevated PowerShell (Start → type PowerShell → right-click → Run as administrator) or skip daemon install."
|
||||
: "Tip: rerun `clawdbot daemon install` after fixing the error.";
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
resolveGatewayWindowsTaskName,
|
||||
} from "../daemon/constants.js";
|
||||
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
||||
import {
|
||||
isSystemdUnavailableDetail,
|
||||
renderSystemdUnavailableHints,
|
||||
} from "../daemon/systemd-hints.js";
|
||||
import { isWSLEnv } from "../infra/wsl.js";
|
||||
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
|
||||
import { getResolvedLoggerSettings } from "../logging.js";
|
||||
|
||||
@@ -54,6 +59,11 @@ export function buildGatewayRuntimeHints(
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (platform === "linux" && isSystemdUnavailableDetail(runtime.detail)) {
|
||||
hints.push(...renderSystemdUnavailableHints({ wsl: isWSLEnv() }));
|
||||
if (fileLog) hints.push(`File logs: ${fileLog}`);
|
||||
return hints;
|
||||
}
|
||||
if (runtime.cachedLabel && platform === "darwin") {
|
||||
const label = resolveGatewayLaunchAgentLabel(env.CLAWDBOT_PROFILE);
|
||||
hints.push(
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveGatewayPort } from "../config/config.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||
import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import { isWSL } from "../infra/wsl.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { sleep } from "../utils.js";
|
||||
@@ -21,6 +15,10 @@ import {
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
type GatewayDaemonRuntime,
|
||||
} from "./daemon-runtime.js";
|
||||
import {
|
||||
buildGatewayInstallPlan,
|
||||
gatewayInstallErrorHint,
|
||||
} from "./daemon-install-helpers.js";
|
||||
import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary } from "./doctor-format.js";
|
||||
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
@@ -55,6 +53,14 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
if (process.platform === "linux") {
|
||||
const systemdAvailable = await isSystemdUserServiceAvailable().catch(() => false);
|
||||
if (!systemdAvailable) {
|
||||
const wsl = await isWSL();
|
||||
note(renderSystemdUnavailableHints({ wsl }).join("\n"), "Gateway");
|
||||
return;
|
||||
}
|
||||
}
|
||||
note("Gateway daemon not installed.", "Gateway");
|
||||
if (params.cfg.gateway?.mode !== "remote") {
|
||||
const install = await params.prompter.confirmSkipInNonInteractive({
|
||||
@@ -70,41 +76,26 @@ export async function maybeRepairGatewayDaemon(params: {
|
||||
},
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
);
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||
process.argv[1]?.endsWith(".ts");
|
||||
const port = resolveGatewayPort(params.cfg, process.env);
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
if (daemonRuntime === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) note(warning, "Gateway runtime");
|
||||
}
|
||||
const environment = buildServiceEnvironment({
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port,
|
||||
token: params.cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
runtime: daemonRuntime,
|
||||
warn: (message, title) => note(message, title),
|
||||
});
|
||||
try {
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
} catch (err) {
|
||||
note(`Gateway daemon install failed: ${String(err)}`, "Gateway");
|
||||
note(gatewayInstallErrorHint(), "Gateway");
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -2,13 +2,10 @@ import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js";
|
||||
import { findExtraGatewayServices, renderGatewayServiceCleanupHints } from "../daemon/inspect.js";
|
||||
import { findLegacyGatewayServices, uninstallLegacyGatewayServices } from "../daemon/legacy.js";
|
||||
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
@@ -17,9 +14,12 @@ import {
|
||||
needsNodeRuntimeMigration,
|
||||
SERVICE_AUDIT_CODES,
|
||||
} from "../daemon/service-audit.js";
|
||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import {
|
||||
buildGatewayInstallPlan,
|
||||
gatewayInstallErrorHint,
|
||||
} from "./daemon-install-helpers.js";
|
||||
import {
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||
@@ -109,35 +109,26 @@ export async function maybeMigrateLegacyGatewayService(
|
||||
},
|
||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||
);
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
|
||||
const port = resolveGatewayPort(cfg, process.env);
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntime,
|
||||
});
|
||||
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntime,
|
||||
nodePath,
|
||||
});
|
||||
const environment = buildServiceEnvironment({
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port,
|
||||
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
runtime: daemonRuntime,
|
||||
warn: (message, title) => note(message, title),
|
||||
});
|
||||
try {
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway daemon install failed: ${String(err)}`);
|
||||
note(gatewayInstallErrorHint(), "Gateway");
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeRepairGatewayServiceConfig(
|
||||
@@ -183,15 +174,15 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
);
|
||||
}
|
||||
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
|
||||
const port = resolveGatewayPort(cfg, process.env);
|
||||
const runtimeChoice = detectGatewayRuntime(command.programArguments);
|
||||
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port,
|
||||
dev: devMode,
|
||||
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
||||
nodePath: systemNodePath ?? undefined,
|
||||
warn: (message, title) => note(message, title),
|
||||
});
|
||||
const expectedEntrypoint = findGatewayEntrypoint(programArguments);
|
||||
const currentEntrypoint = findGatewayEntrypoint(command.programArguments);
|
||||
@@ -239,16 +230,6 @@ export async function maybeRepairGatewayServiceConfig(
|
||||
initialValue: true,
|
||||
});
|
||||
if (!repair) return;
|
||||
const environment = buildServiceEnvironment({
|
||||
env: process.env,
|
||||
port,
|
||||
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await service.install({
|
||||
env: process.env,
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("normalizeLegacyConfigValues", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("copies legacy ack reaction when whatsapp auth exists", () => {
|
||||
it("does not add whatsapp config when only auth exists (issue #900)", () => {
|
||||
const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "default");
|
||||
writeCreds(credsDir);
|
||||
|
||||
@@ -65,14 +65,11 @@ describe("normalizeLegacyConfigValues", () => {
|
||||
messages: { ackReaction: "👀", ackReactionScope: "group-mentions" },
|
||||
});
|
||||
|
||||
expect(res.config.channels?.whatsapp?.ackReaction).toEqual({
|
||||
emoji: "👀",
|
||||
direct: false,
|
||||
group: "mentions",
|
||||
});
|
||||
expect(res.config.channels?.whatsapp).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("copies legacy ack reaction when legacy auth exists", () => {
|
||||
it("does not add whatsapp config when only legacy auth exists (issue #900)", () => {
|
||||
const credsPath = path.join(tempOauthDir ?? "", "creds.json");
|
||||
fs.writeFileSync(credsPath, JSON.stringify({ me: {} }));
|
||||
|
||||
@@ -80,14 +77,11 @@ describe("normalizeLegacyConfigValues", () => {
|
||||
messages: { ackReaction: "👀", ackReactionScope: "group-mentions" },
|
||||
});
|
||||
|
||||
expect(res.config.channels?.whatsapp?.ackReaction).toEqual({
|
||||
emoji: "👀",
|
||||
direct: false,
|
||||
group: "mentions",
|
||||
});
|
||||
expect(res.config.channels?.whatsapp).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("copies legacy ack reaction when non-default auth exists", () => {
|
||||
it("does not add whatsapp config when only non-default auth exists (issue #900)", () => {
|
||||
const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "work");
|
||||
writeCreds(credsDir);
|
||||
|
||||
@@ -95,11 +89,8 @@ describe("normalizeLegacyConfigValues", () => {
|
||||
messages: { ackReaction: "👀", ackReactionScope: "group-mentions" },
|
||||
});
|
||||
|
||||
expect(res.config.channels?.whatsapp?.ackReaction).toEqual({
|
||||
emoji: "👀",
|
||||
direct: false,
|
||||
group: "mentions",
|
||||
});
|
||||
expect(res.config.channels?.whatsapp).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("copies legacy ack reaction when authDir override exists", () => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { hasAnyWhatsAppAuth } from "../web/accounts.js";
|
||||
|
||||
export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
|
||||
config: ClawdbotConfig;
|
||||
changes: string[];
|
||||
@@ -10,8 +8,7 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): {
|
||||
|
||||
const legacyAckReaction = cfg.messages?.ackReaction?.trim();
|
||||
const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined;
|
||||
const hasWhatsAppAuth = hasAnyWhatsAppAuth(cfg);
|
||||
if (legacyAckReaction && (hasWhatsAppConfig || hasWhatsAppAuth)) {
|
||||
if (legacyAckReaction && hasWhatsAppConfig) {
|
||||
const hasWhatsAppAck = cfg.channels?.whatsapp?.ackReaction !== undefined;
|
||||
if (!hasWhatsAppAck) {
|
||||
const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
import { isWSLEnv } from "../infra/wsl.js";
|
||||
|
||||
export function isRemoteEnvironment(): boolean {
|
||||
if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
|
||||
@@ -23,7 +13,7 @@ export function isRemoteEnvironment(): boolean {
|
||||
process.platform === "linux" &&
|
||||
!process.env.DISPLAY &&
|
||||
!process.env.WAYLAND_DISPLAY &&
|
||||
!isWSL()
|
||||
!isWSLEnv()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { callGateway } from "../gateway/call.js";
|
||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||
import { isSafeExecutableValue } from "../infra/exec-safety.js";
|
||||
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||
import { isWSL } from "../infra/wsl.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
@@ -91,27 +92,6 @@ type BrowserOpenSupport = {
|
||||
command?: string;
|
||||
};
|
||||
|
||||
let wslCached: boolean | null = null;
|
||||
|
||||
async function isWSL(): Promise<boolean> {
|
||||
if (wslCached !== null) return wslCached;
|
||||
if (process.platform !== "linux") {
|
||||
wslCached = false;
|
||||
return wslCached;
|
||||
}
|
||||
if (process.env.WSL_INTEROP || process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
|
||||
wslCached = true;
|
||||
return wslCached;
|
||||
}
|
||||
try {
|
||||
const release = (await fs.readFile("/proc/version", "utf8")).toLowerCase();
|
||||
wslCached = release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
wslCached = false;
|
||||
}
|
||||
return wslCached;
|
||||
}
|
||||
|
||||
type BrowserOpenCommand = {
|
||||
argv: string[] | null;
|
||||
reason?: string;
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { resolveGatewayLaunchAgentLabel } from "../../../daemon/constants.js";
|
||||
import { resolveGatewayProgramArguments } from "../../../daemon/program-args.js";
|
||||
import {
|
||||
renderSystemNodeWarning,
|
||||
resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo,
|
||||
} from "../../../daemon/runtime-paths.js";
|
||||
import { resolveGatewayService } from "../../../daemon/service.js";
|
||||
import { buildServiceEnvironment } from "../../../daemon/service-env.js";
|
||||
import { isSystemdUserServiceAvailable } from "../../../daemon/systemd.js";
|
||||
import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime } from "../../daemon-runtime.js";
|
||||
import {
|
||||
buildGatewayInstallPlan,
|
||||
gatewayInstallErrorHint,
|
||||
} from "../../daemon-install-helpers.js";
|
||||
import type { OnboardOptions } from "../../onboard-types.js";
|
||||
import { ensureSystemdUserLingerNonInteractive } from "../../systemd-linger.js";
|
||||
|
||||
@@ -41,40 +35,25 @@ export async function installGatewayDaemonNonInteractive(params: {
|
||||
}
|
||||
|
||||
const service = resolveGatewayService();
|
||||
const devMode =
|
||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) && process.argv[1]?.endsWith(".ts");
|
||||
const nodePath = await resolvePreferredNodePath({
|
||||
env: process.env,
|
||||
runtime: daemonRuntimeRaw,
|
||||
});
|
||||
const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({
|
||||
port,
|
||||
dev: devMode,
|
||||
runtime: daemonRuntimeRaw,
|
||||
nodePath,
|
||||
});
|
||||
|
||||
if (daemonRuntimeRaw === "node") {
|
||||
const systemNode = await resolveSystemNodeInfo({ env: process.env });
|
||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||
if (warning) runtime.log(warning);
|
||||
}
|
||||
|
||||
const environment = buildServiceEnvironment({
|
||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||
env: process.env,
|
||||
port,
|
||||
token: gatewayToken,
|
||||
launchdLabel:
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
||||
: undefined,
|
||||
});
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
runtime: daemonRuntimeRaw,
|
||||
warn: (message) => runtime.log(message),
|
||||
});
|
||||
try {
|
||||
await service.install({
|
||||
env: process.env,
|
||||
stdout: process.stdout,
|
||||
programArguments,
|
||||
workingDirectory,
|
||||
environment,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway daemon install failed: ${String(err)}`);
|
||||
runtime.log(gatewayInstallErrorHint());
|
||||
return;
|
||||
}
|
||||
await ensureSystemdUserLingerNonInteractive({ runtime });
|
||||
}
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
} from "./status.format.js";
|
||||
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
|
||||
import { scanStatus } from "./status.scan.js";
|
||||
import { formatUpdateOneLiner } from "./status.update.js";
|
||||
import {
|
||||
formatUpdateAvailableHint,
|
||||
formatUpdateOneLiner,
|
||||
resolveUpdateAvailability,
|
||||
} from "./status.update.js";
|
||||
import { formatGatewayAuthUsed } from "./status-all/format.js";
|
||||
import { statusAllCommand } from "./status-all.js";
|
||||
|
||||
@@ -228,6 +232,9 @@ export async function statusCommand(
|
||||
? `${summary.sessions.paths.length} stores`
|
||||
: (summary.sessions.paths[0] ?? "unknown");
|
||||
|
||||
const updateAvailability = resolveUpdateAvailability(update);
|
||||
const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, "");
|
||||
|
||||
const overviewRows = [
|
||||
{ Item: "Dashboard", Value: dashboard },
|
||||
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
|
||||
@@ -242,7 +249,7 @@ export async function statusCommand(
|
||||
},
|
||||
{
|
||||
Item: "Update",
|
||||
Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""),
|
||||
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
|
||||
},
|
||||
{ Item: "Gateway", Value: gatewayValue },
|
||||
{ Item: "Daemon", Value: daemonValue },
|
||||
@@ -456,6 +463,11 @@ export async function statusCommand(
|
||||
runtime.log("FAQ: https://docs.clawd.bot/faq");
|
||||
runtime.log("Troubleshooting: https://docs.clawd.bot/troubleshooting");
|
||||
runtime.log("");
|
||||
const updateHint = formatUpdateAvailableHint(update);
|
||||
if (updateHint) {
|
||||
runtime.log(theme.warn(updateHint));
|
||||
runtime.log("");
|
||||
}
|
||||
runtime.log("Next steps:");
|
||||
runtime.log(" Need to share? clawdbot status --all");
|
||||
runtime.log(" Need to debug live? clawdbot logs --follow");
|
||||
|
||||
@@ -24,6 +24,48 @@ export async function getUpdateCheckResult(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export type UpdateAvailability = {
|
||||
available: boolean;
|
||||
hasGitUpdate: boolean;
|
||||
hasRegistryUpdate: boolean;
|
||||
latestVersion: string | null;
|
||||
gitBehind: number | null;
|
||||
};
|
||||
|
||||
export function resolveUpdateAvailability(update: UpdateCheckResult): UpdateAvailability {
|
||||
const latestVersion = update.registry?.latestVersion ?? null;
|
||||
const registryCmp = latestVersion ? compareSemverStrings(VERSION, latestVersion) : null;
|
||||
const hasRegistryUpdate = registryCmp != null && registryCmp < 0;
|
||||
const gitBehind =
|
||||
update.installKind === "git" && typeof update.git?.behind === "number"
|
||||
? update.git.behind
|
||||
: null;
|
||||
const hasGitUpdate = gitBehind != null && gitBehind > 0;
|
||||
|
||||
return {
|
||||
available: hasGitUpdate || hasRegistryUpdate,
|
||||
hasGitUpdate,
|
||||
hasRegistryUpdate,
|
||||
latestVersion: hasRegistryUpdate ? latestVersion : null,
|
||||
gitBehind,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUpdateAvailableHint(update: UpdateCheckResult): string | null {
|
||||
const availability = resolveUpdateAvailability(update);
|
||||
if (!availability.available) return null;
|
||||
|
||||
const details: string[] = [];
|
||||
if (availability.hasGitUpdate && availability.gitBehind != null) {
|
||||
details.push(`git behind ${availability.gitBehind}`);
|
||||
}
|
||||
if (availability.hasRegistryUpdate && availability.latestVersion) {
|
||||
details.push(`npm ${availability.latestVersion}`);
|
||||
}
|
||||
const suffix = details.length > 0 ? ` (${details.join(" · ")})` : "";
|
||||
return `Update available${suffix}. Run: clawdbot update`;
|
||||
}
|
||||
|
||||
export function formatUpdateOneLiner(update: UpdateCheckResult): string {
|
||||
const parts: string[] = [];
|
||||
if (update.installKind === "git" && update.git) {
|
||||
|
||||
+22
-1
@@ -168,6 +168,9 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||
"agents.defaults.memorySearch": "Memory Search",
|
||||
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
|
||||
"agents.defaults.memorySearch.sources": "Memory Search Sources",
|
||||
"agents.defaults.memorySearch.experimental.sessionMemory":
|
||||
"Memory Search Session Index (Experimental)",
|
||||
"agents.defaults.memorySearch.provider": "Memory Search Provider",
|
||||
"agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL",
|
||||
"agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key",
|
||||
@@ -176,6 +179,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.defaults.memorySearch.fallback": "Memory Search Fallback",
|
||||
"agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path",
|
||||
"agents.defaults.memorySearch.store.path": "Memory Search Index Path",
|
||||
"agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index",
|
||||
"agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path",
|
||||
"agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens",
|
||||
"agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens",
|
||||
"agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start",
|
||||
@@ -351,18 +356,34 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).",
|
||||
"agents.defaults.memorySearch":
|
||||
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
|
||||
"agents.defaults.memorySearch.sources":
|
||||
'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).',
|
||||
"agents.defaults.memorySearch.experimental.sessionMemory":
|
||||
"Enable experimental session transcript indexing for memory search (default: false).",
|
||||
"agents.defaults.memorySearch.provider": 'Embedding provider ("openai" or "local").',
|
||||
"agents.defaults.memorySearch.remote.baseUrl":
|
||||
"Custom OpenAI-compatible base URL (e.g. for Gemini/OpenRouter proxies).",
|
||||
"agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.",
|
||||
"agents.defaults.memorySearch.remote.headers":
|
||||
"Extra headers for remote embeddings (merged; remote overrides OpenAI headers).",
|
||||
"agents.defaults.memorySearch.remote.batch.enabled":
|
||||
"Enable OpenAI Batch API for memory embeddings (default: true).",
|
||||
"agents.defaults.memorySearch.remote.batch.wait":
|
||||
"Wait for OpenAI batch completion when indexing (default: true).",
|
||||
"agents.defaults.memorySearch.remote.batch.pollIntervalMs":
|
||||
"Polling interval in ms for OpenAI batch status (default: 5000).",
|
||||
"agents.defaults.memorySearch.remote.batch.timeoutMinutes":
|
||||
"Timeout in minutes for OpenAI batch indexing (default: 60).",
|
||||
"agents.defaults.memorySearch.local.modelPath":
|
||||
"Local GGUF model path or hf: URI (node-llama-cpp).",
|
||||
"agents.defaults.memorySearch.fallback":
|
||||
'Fallback to OpenAI when local embeddings fail ("openai" or "none").',
|
||||
"agents.defaults.memorySearch.store.path":
|
||||
"SQLite index path (default: ~/.clawdbot/memory/{agentId}.sqlite).",
|
||||
"SQLite index path (default: ~/.clawdbot/state/memory/{agentId}.sqlite).",
|
||||
"agents.defaults.memorySearch.store.vector.enabled":
|
||||
"Enable sqlite-vec extension for vector search (default: true).",
|
||||
"agents.defaults.memorySearch.store.vector.extensionPath":
|
||||
"Optional override path to sqlite-vec extension library (.dylib/.so/.dll).",
|
||||
"agents.defaults.memorySearch.sync.onSearch":
|
||||
"Lazy sync: reindex on first search after a change.",
|
||||
"agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding
|
||||
import type { SessionEntry } from "./types.js";
|
||||
import { loadSessionStore, updateSessionStore } from "./store.js";
|
||||
import { resolveDefaultSessionStorePath, resolveSessionTranscriptPath } from "./paths.js";
|
||||
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
||||
|
||||
function stripQuery(value: string): string {
|
||||
const noHash = value.split("#")[0] ?? value;
|
||||
@@ -127,5 +128,6 @@ export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
});
|
||||
}
|
||||
|
||||
emitSessionTranscriptUpdate(sessionFile);
|
||||
return { ok: true, sessionFile };
|
||||
}
|
||||
@@ -145,12 +145,29 @@ export type AgentToolsConfig = {
|
||||
export type MemorySearchConfig = {
|
||||
/** Enable vector memory search (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Sources to index and search (default: ["memory"]). */
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
/** Experimental memory search settings. */
|
||||
experimental?: {
|
||||
/** Enable session transcript indexing (experimental, default: false). */
|
||||
sessionMemory?: boolean;
|
||||
};
|
||||
/** Embedding provider mode. */
|
||||
provider?: "openai" | "local";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
batch?: {
|
||||
/** Enable OpenAI Batch API for embedding indexing (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Wait for batch completion (default: true). */
|
||||
wait?: boolean;
|
||||
/** Poll interval in ms (default: 5000). */
|
||||
pollIntervalMs?: number;
|
||||
/** Timeout in minutes (default: 60). */
|
||||
timeoutMinutes?: number;
|
||||
};
|
||||
};
|
||||
/** Fallback behavior when local embeddings fail. */
|
||||
fallback?: "openai" | "none";
|
||||
@@ -167,6 +184,12 @@ export type MemorySearchConfig = {
|
||||
store?: {
|
||||
driver?: "sqlite";
|
||||
path?: string;
|
||||
vector?: {
|
||||
/** Enable sqlite-vec extension for vector search (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Optional override path to sqlite-vec extension (.dylib/.so/.dll). */
|
||||
extensionPath?: string;
|
||||
};
|
||||
};
|
||||
/** Chunking configuration. */
|
||||
chunking?: {
|
||||
|
||||
@@ -194,12 +194,26 @@ export const AgentToolsSchema = z
|
||||
export const MemorySearchSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
sources: z.array(z.union([z.literal("memory"), z.literal("sessions")])).optional(),
|
||||
experimental: z
|
||||
.object({
|
||||
sessionMemory: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
provider: z.union([z.literal("openai"), z.literal("local")]).optional(),
|
||||
remote: z
|
||||
.object({
|
||||
baseUrl: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
batch: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
wait: z.boolean().optional(),
|
||||
pollIntervalMs: z.number().int().nonnegative().optional(),
|
||||
timeoutMinutes: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
fallback: z.union([z.literal("openai"), z.literal("none")]).optional(),
|
||||
@@ -214,6 +228,12 @@ export const MemorySearchSchema = z
|
||||
.object({
|
||||
driver: z.literal("sqlite").optional(),
|
||||
path: z.string().optional(),
|
||||
vector: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
extensionPath: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
chunking: z
|
||||
|
||||
+23
-3
@@ -26,6 +26,15 @@ function quoteCmdArg(value: string): string {
|
||||
return `"${value.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function resolveTaskUser(env: Record<string, string | undefined>): string | null {
|
||||
const username = env.USERNAME || env.USER || env.LOGNAME;
|
||||
if (!username) return null;
|
||||
if (username.includes("\\")) return username;
|
||||
const domain = env.USERDOMAIN;
|
||||
if (domain) return `${domain}\\${username}`;
|
||||
return username;
|
||||
}
|
||||
|
||||
function parseCommandLine(value: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = "";
|
||||
@@ -216,7 +225,7 @@ export async function installScheduledTask({
|
||||
|
||||
const taskName = resolveGatewayWindowsTaskName(env.CLAWDBOT_PROFILE);
|
||||
const quotedScript = quoteCmdArg(scriptPath);
|
||||
const create = await execSchtasks([
|
||||
const baseArgs = [
|
||||
"/Create",
|
||||
"/F",
|
||||
"/SC",
|
||||
@@ -227,9 +236,20 @@ export async function installScheduledTask({
|
||||
taskName,
|
||||
"/TR",
|
||||
quotedScript,
|
||||
]);
|
||||
];
|
||||
const taskUser = resolveTaskUser(env);
|
||||
let create = await execSchtasks(
|
||||
taskUser ? [...baseArgs, "/RU", taskUser, "/NP", "/IT"] : baseArgs,
|
||||
);
|
||||
if (create.code !== 0 && taskUser) {
|
||||
create = await execSchtasks(baseArgs);
|
||||
}
|
||||
if (create.code !== 0) {
|
||||
throw new Error(`schtasks create failed: ${create.stderr || create.stdout}`.trim());
|
||||
const detail = create.stderr || create.stdout;
|
||||
const hint = /access is denied/i.test(detail)
|
||||
? " Run PowerShell as Administrator or rerun without installing the daemon."
|
||||
: "";
|
||||
throw new Error(`schtasks create failed: ${detail}${hint}`.trim());
|
||||
}
|
||||
|
||||
await execSchtasks(["/Run", "/TN", taskName]);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export function isSystemdUnavailableDetail(detail?: string): boolean {
|
||||
if (!detail) return false;
|
||||
const normalized = detail.toLowerCase();
|
||||
return (
|
||||
normalized.includes("systemctl --user unavailable") ||
|
||||
normalized.includes("systemctl not available") ||
|
||||
normalized.includes("not been booted with systemd") ||
|
||||
normalized.includes("failed to connect to bus") ||
|
||||
normalized.includes("systemd user services are required")
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSystemdUnavailableHints(options: { wsl?: boolean } = {}): string[] {
|
||||
if (options.wsl) {
|
||||
return [
|
||||
"WSL2 needs systemd enabled: edit /etc/wsl.conf with [boot]\\nsystemd=true",
|
||||
"Then run: wsl --shutdown (from PowerShell) and reopen your distro.",
|
||||
"Verify: systemctl --user status",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"systemd user services are unavailable; install/enable systemd or run the gateway under your supervisor.",
|
||||
"If you're in a container, run the gateway in the foreground instead of `clawdbot daemon`.",
|
||||
];
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeDiscordSlug,
|
||||
registerDiscordListener,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordReplyTarget,
|
||||
resolveDiscordShouldRequireMention,
|
||||
@@ -160,6 +161,46 @@ describe("discord guild/channel resolution", () => {
|
||||
});
|
||||
expect(channel?.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("inherits parent config for thread channels", () => {
|
||||
const guildInfo: DiscordGuildEntryResolved = {
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
random: { allow: false },
|
||||
},
|
||||
};
|
||||
const thread = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId: "thread-123",
|
||||
channelName: "topic",
|
||||
channelSlug: "topic",
|
||||
parentId: "999",
|
||||
parentName: "random",
|
||||
parentSlug: "random",
|
||||
scope: "thread",
|
||||
});
|
||||
expect(thread?.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match thread name/slug when resolving allowlists", () => {
|
||||
const guildInfo: DiscordGuildEntryResolved = {
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
random: { allow: false },
|
||||
},
|
||||
};
|
||||
const thread = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId: "thread-999",
|
||||
channelName: "general",
|
||||
channelSlug: "general",
|
||||
parentId: "999",
|
||||
parentName: "random",
|
||||
parentSlug: "random",
|
||||
scope: "thread",
|
||||
});
|
||||
expect(thread?.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discord mention gating", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordCommandAuthorized,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordShouldRequireMention,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Guild, User } from "@buape/carbon";
|
||||
|
||||
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../../channels/channel-config.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
export type DiscordAllowList = {
|
||||
@@ -36,6 +37,8 @@ export type DiscordChannelConfigResolved = {
|
||||
users?: Array<string | number>;
|
||||
systemPrompt?: string;
|
||||
autoThread?: boolean;
|
||||
matchKey?: string;
|
||||
matchSource?: "direct" | "parent";
|
||||
};
|
||||
|
||||
export function normalizeDiscordAllowList(
|
||||
@@ -137,6 +140,51 @@ export function resolveDiscordGuildEntry(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
type DiscordChannelEntry = NonNullable<DiscordGuildEntryResolved["channels"]>[string];
|
||||
type DiscordChannelLookup = {
|
||||
id: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
};
|
||||
type DiscordChannelScope = "channel" | "thread";
|
||||
type DiscordChannelMatch = {
|
||||
entry: DiscordChannelEntry;
|
||||
key: string;
|
||||
};
|
||||
|
||||
function resolveDiscordChannelEntry(
|
||||
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
|
||||
params: DiscordChannelLookup & { allowNameMatch?: boolean },
|
||||
): DiscordChannelMatch | null {
|
||||
const allowNameMatch = params.allowNameMatch !== false;
|
||||
const keys = buildChannelKeyCandidates(
|
||||
params.id,
|
||||
allowNameMatch ? params.slug : undefined,
|
||||
allowNameMatch ? params.name : undefined,
|
||||
);
|
||||
const { entry, key } = resolveChannelEntryMatch({ entries: channels, keys });
|
||||
if (!entry || !key) return null;
|
||||
return { entry, key };
|
||||
}
|
||||
|
||||
function resolveDiscordChannelConfigEntry(
|
||||
match: DiscordChannelMatch,
|
||||
matchSource: "direct" | "parent",
|
||||
): DiscordChannelConfigResolved {
|
||||
const resolved: DiscordChannelConfigResolved = {
|
||||
allowed: match.entry.allow !== false,
|
||||
requireMention: match.entry.requireMention,
|
||||
skills: match.entry.skills,
|
||||
enabled: match.entry.enabled,
|
||||
users: match.entry.users,
|
||||
systemPrompt: match.entry.systemPrompt,
|
||||
autoThread: match.entry.autoThread,
|
||||
};
|
||||
if (match.key) resolved.matchKey = match.key;
|
||||
resolved.matchSource = matchSource;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelConfig(params: {
|
||||
guildInfo?: DiscordGuildEntryResolved | null;
|
||||
channelId: string;
|
||||
@@ -146,40 +194,52 @@ export function resolveDiscordChannelConfig(params: {
|
||||
const { guildInfo, channelId, channelName, channelSlug } = params;
|
||||
const channels = guildInfo?.channels;
|
||||
if (!channels) return null;
|
||||
const byId = channels[channelId];
|
||||
if (byId)
|
||||
return {
|
||||
allowed: byId.allow !== false,
|
||||
requireMention: byId.requireMention,
|
||||
skills: byId.skills,
|
||||
enabled: byId.enabled,
|
||||
users: byId.users,
|
||||
systemPrompt: byId.systemPrompt,
|
||||
autoThread: byId.autoThread,
|
||||
};
|
||||
if (channelSlug && channels[channelSlug]) {
|
||||
const entry = channels[channelSlug];
|
||||
return {
|
||||
allowed: entry.allow !== false,
|
||||
requireMention: entry.requireMention,
|
||||
skills: entry.skills,
|
||||
enabled: entry.enabled,
|
||||
users: entry.users,
|
||||
systemPrompt: entry.systemPrompt,
|
||||
autoThread: entry.autoThread,
|
||||
};
|
||||
}
|
||||
if (channelName && channels[channelName]) {
|
||||
const entry = channels[channelName];
|
||||
return {
|
||||
allowed: entry.allow !== false,
|
||||
requireMention: entry.requireMention,
|
||||
skills: entry.skills,
|
||||
enabled: entry.enabled,
|
||||
users: entry.users,
|
||||
systemPrompt: entry.systemPrompt,
|
||||
autoThread: entry.autoThread,
|
||||
};
|
||||
const entry = resolveDiscordChannelEntry(channels, {
|
||||
id: channelId,
|
||||
name: channelName,
|
||||
slug: channelSlug,
|
||||
});
|
||||
if (!entry) return { allowed: false };
|
||||
return resolveDiscordChannelConfigEntry(entry, "direct");
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelConfigWithFallback(params: {
|
||||
guildInfo?: DiscordGuildEntryResolved | null;
|
||||
channelId: string;
|
||||
channelName?: string;
|
||||
channelSlug: string;
|
||||
parentId?: string;
|
||||
parentName?: string;
|
||||
parentSlug?: string;
|
||||
scope?: DiscordChannelScope;
|
||||
}): DiscordChannelConfigResolved | null {
|
||||
const {
|
||||
guildInfo,
|
||||
channelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
parentId,
|
||||
parentName,
|
||||
parentSlug,
|
||||
scope,
|
||||
} = params;
|
||||
const channels = guildInfo?.channels;
|
||||
if (!channels) return null;
|
||||
const entry = resolveDiscordChannelEntry(channels, {
|
||||
id: channelId,
|
||||
name: channelName,
|
||||
slug: channelSlug,
|
||||
allowNameMatch: scope !== "thread",
|
||||
});
|
||||
if (entry) return resolveDiscordChannelConfigEntry(entry, "direct");
|
||||
if (parentId || parentName || parentSlug) {
|
||||
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
|
||||
const parentEntry = resolveDiscordChannelEntry(channels, {
|
||||
id: parentId ?? "",
|
||||
name: parentName,
|
||||
slug: resolvedParentSlug,
|
||||
});
|
||||
if (parentEntry) return resolveDiscordChannelConfigEntry(parentEntry, "parent");
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ChannelType,
|
||||
type Client,
|
||||
MessageCreateListener,
|
||||
MessageReactionAddListener,
|
||||
@@ -8,18 +9,20 @@ import {
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatDurationSeconds } from "../../infra/format-duration.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import {
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordGuildEntry,
|
||||
shouldEmitDiscordReactionNotification,
|
||||
} from "./allow-list.js";
|
||||
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
||||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||
|
||||
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
|
||||
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
||||
type Logger = ReturnType<typeof import("../../logging.js").getChildLogger>;
|
||||
type Logger = ReturnType<typeof import("../../logging.js").createSubsystemLogger>;
|
||||
|
||||
export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0];
|
||||
|
||||
@@ -28,6 +31,7 @@ export type DiscordMessageHandler = (data: DiscordMessageEvent, client: Client)
|
||||
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
|
||||
|
||||
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
|
||||
const discordEventQueueLog = createSubsystemLogger("discord/event-queue");
|
||||
|
||||
function logSlowDiscordListener(params: {
|
||||
logger: Logger | undefined;
|
||||
@@ -40,12 +44,15 @@ function logSlowDiscordListener(params: {
|
||||
decimals: 1,
|
||||
unit: "seconds",
|
||||
});
|
||||
const message = `[EventQueue] Slow listener detected: ${params.listener} took ${duration} for event ${params.event}`;
|
||||
if (params.logger?.warn) {
|
||||
params.logger.warn(message);
|
||||
} else {
|
||||
console.warn(message);
|
||||
}
|
||||
const message = `Slow listener detected: ${params.listener} took ${duration} for event ${params.event}`;
|
||||
const logger = params.logger ?? discordEventQueueLog;
|
||||
logger.warn("Slow listener detected", {
|
||||
listener: params.listener,
|
||||
event: params.event,
|
||||
durationMs: params.durationMs,
|
||||
duration,
|
||||
consoleMessage: message,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerDiscordListener(listeners: Array<object>, listener: object) {
|
||||
@@ -184,11 +191,34 @@ async function handleDiscordReactionEvent(params: {
|
||||
if (!channel) return;
|
||||
const channelName = "name" in channel ? (channel.name ?? undefined) : undefined;
|
||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const channelConfig = resolveDiscordChannelConfig({
|
||||
const channelType = "type" in channel ? channel.type : undefined;
|
||||
const isThreadChannel =
|
||||
channelType === ChannelType.PublicThread ||
|
||||
channelType === ChannelType.PrivateThread ||
|
||||
channelType === ChannelType.AnnouncementThread;
|
||||
let parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
|
||||
let parentName: string | undefined;
|
||||
let parentSlug = "";
|
||||
if (isThreadChannel) {
|
||||
if (!parentId) {
|
||||
const channelInfo = await resolveDiscordChannelInfo(client, data.channel_id);
|
||||
parentId = channelInfo?.parentId;
|
||||
}
|
||||
if (parentId) {
|
||||
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
|
||||
parentName = parentInfo?.name;
|
||||
parentSlug = parentName ? normalizeDiscordSlug(parentName) : "";
|
||||
}
|
||||
}
|
||||
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId: data.channel_id,
|
||||
channelName,
|
||||
channelSlug,
|
||||
parentId,
|
||||
parentName,
|
||||
parentSlug,
|
||||
scope: isThreadChannel ? "thread" : "channel",
|
||||
});
|
||||
if (channelConfig?.allowed === false) return;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordShouldRequireMention,
|
||||
resolveDiscordUserAllowed,
|
||||
@@ -236,13 +236,20 @@ export async function preflightDiscordMessage(
|
||||
guildInfo?.slug ||
|
||||
(params.data.guild?.name ? normalizeDiscordSlug(params.data.guild.name) : "");
|
||||
|
||||
const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const channelConfig = isGuildMessage
|
||||
? resolveDiscordChannelConfig({
|
||||
? resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId: threadParentId ?? message.channelId,
|
||||
channelName: configChannelName,
|
||||
channelSlug: configChannelSlug,
|
||||
channelId: message.channelId,
|
||||
channelName,
|
||||
channelSlug: threadChannelSlug,
|
||||
parentId: threadParentId ?? undefined,
|
||||
parentName: threadParentName ?? undefined,
|
||||
parentSlug: threadParentSlug,
|
||||
scope: threadChannel ? "thread" : "channel",
|
||||
})
|
||||
: null;
|
||||
if (isGuildMessage && channelConfig?.enabled === false) {
|
||||
|
||||
@@ -47,11 +47,13 @@ import {
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordChannelConfig,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordUserAllowed,
|
||||
} from "./allow-list.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
||||
import { resolveDiscordThreadParentInfo } from "./threading.js";
|
||||
|
||||
type DiscordConfig = NonNullable<ClawdbotConfig["channels"]>["discord"];
|
||||
|
||||
@@ -499,8 +501,13 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
const channelType = channel?.type;
|
||||
const isDirectMessage = channelType === ChannelType.DM;
|
||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||
const isThreadChannel =
|
||||
channelType === ChannelType.PublicThread ||
|
||||
channelType === ChannelType.PrivateThread ||
|
||||
channelType === ChannelType.AnnouncementThread;
|
||||
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
|
||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const rawChannelId = channel?.id ?? "";
|
||||
const ownerAllowList = normalizeDiscordAllowList(discordConfig?.dm?.allowFrom ?? [], [
|
||||
"discord:",
|
||||
"user:",
|
||||
@@ -517,12 +524,36 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
guild: interaction.guild ?? undefined,
|
||||
guildEntries: discordConfig?.guilds,
|
||||
});
|
||||
let threadParentId: string | undefined;
|
||||
let threadParentName: string | undefined;
|
||||
let threadParentSlug = "";
|
||||
if (interaction.guild && channel && isThreadChannel && rawChannelId) {
|
||||
// Threads inherit parent channel config unless explicitly overridden.
|
||||
const channelInfo = await resolveDiscordChannelInfo(interaction.client, rawChannelId);
|
||||
const parentInfo = await resolveDiscordThreadParentInfo({
|
||||
client: interaction.client,
|
||||
threadChannel: {
|
||||
id: rawChannelId,
|
||||
name: channelName,
|
||||
parentId: "parentId" in channel ? channel.parentId ?? undefined : undefined,
|
||||
parent: undefined,
|
||||
},
|
||||
channelInfo,
|
||||
});
|
||||
threadParentId = parentInfo.id;
|
||||
threadParentName = parentInfo.name;
|
||||
threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||
}
|
||||
const channelConfig = interaction.guild
|
||||
? resolveDiscordChannelConfig({
|
||||
? resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId: channel?.id ?? "",
|
||||
channelId: rawChannelId,
|
||||
channelName,
|
||||
channelSlug,
|
||||
parentId: threadParentId,
|
||||
parentName: threadParentName,
|
||||
parentSlug: threadParentSlug,
|
||||
scope: isThreadChannel ? "thread" : "channel",
|
||||
})
|
||||
: null;
|
||||
if (channelConfig?.enabled === false) {
|
||||
@@ -664,7 +695,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
}
|
||||
|
||||
const isGuild = Boolean(interaction.guild);
|
||||
const channelId = channel?.id ?? "unknown";
|
||||
const channelId = rawChannelId || "unknown";
|
||||
const interactionId = interaction.rawData.id;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { ClawdbotConfig, ReplyToMode } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
|
||||
@@ -178,7 +178,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
],
|
||||
);
|
||||
|
||||
const logger = getChildLogger({ module: "discord-auto-reply" });
|
||||
const logger = createSubsystemLogger("discord/monitor");
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
let botUserId: string | undefined;
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ describe("gateway server sessions", () => {
|
||||
verboseLevel?: string;
|
||||
sendPolicy?: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
}>;
|
||||
}>(ws, "sessions.list", {});
|
||||
expect(list2.ok).toBe(true);
|
||||
@@ -211,6 +212,7 @@ describe("gateway server sessions", () => {
|
||||
expect(main2?.sendPolicy).toBe("deny");
|
||||
const subagent = list2.payload?.sessions.find((s) => s.key === "agent:main:subagent:one");
|
||||
expect(subagent?.label).toBe("Briefing");
|
||||
expect(subagent?.displayName).toBe("Briefing");
|
||||
|
||||
const clearedVerbose = await rpcReq<{ ok: true; key: string }>(ws, "sessions.patch", {
|
||||
key: "agent:main:main",
|
||||
|
||||
@@ -392,7 +392,8 @@ export function listSessionsFromStore(params: {
|
||||
id,
|
||||
key,
|
||||
})
|
||||
: undefined);
|
||||
: undefined) ??
|
||||
entry?.label;
|
||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||
return {
|
||||
key,
|
||||
|
||||
@@ -29,12 +29,15 @@ describe("gateway ws log helpers", () => {
|
||||
test("summarizeAgentEventForWsLog extracts useful fields", () => {
|
||||
const summary = summarizeAgentEventForWsLog({
|
||||
runId: "12345678-1234-1234-1234-123456789abc",
|
||||
sessionKey: "agent:main:main",
|
||||
stream: "assistant",
|
||||
seq: 2,
|
||||
data: { text: "hello world", mediaUrls: ["a", "b"] },
|
||||
});
|
||||
expect(summary).toMatchObject({
|
||||
agent: "main",
|
||||
run: "12345678…9abc",
|
||||
session: "main",
|
||||
stream: "assistant",
|
||||
aseq: 2,
|
||||
text: "hello world",
|
||||
|
||||
+21
-9
@@ -1,7 +1,8 @@
|
||||
import chalk from "chalk";
|
||||
import { isVerbose } from "../globals.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { createSubsystemLogger, shouldLogSubsystemToConsole } from "../logging.js";
|
||||
import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js";
|
||||
import { shouldLogSubsystemToConsole } from "../logging.js";
|
||||
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
|
||||
|
||||
const LOG_VALUE_LIMIT = 240;
|
||||
@@ -21,6 +22,7 @@ const wsInflightCompact = new Map<string, WsInflightEntry>();
|
||||
let wsLastCompactConnId: string | undefined;
|
||||
const wsInflightOptimized = new Map<string, number>();
|
||||
const wsInflightSince = new Map<string, number>();
|
||||
const wsLog = createSubsystemLogger("gateway/ws");
|
||||
|
||||
export function shortId(value: string): string {
|
||||
const s = value.trim();
|
||||
@@ -87,11 +89,21 @@ export function summarizeAgentEventForWsLog(payload: unknown): Record<string, un
|
||||
const runId = typeof rec.runId === "string" ? rec.runId : undefined;
|
||||
const stream = typeof rec.stream === "string" ? rec.stream : undefined;
|
||||
const seq = typeof rec.seq === "number" ? rec.seq : undefined;
|
||||
const sessionKey = typeof rec.sessionKey === "string" ? rec.sessionKey : undefined;
|
||||
const data =
|
||||
rec.data && typeof rec.data === "object" ? (rec.data as Record<string, unknown>) : undefined;
|
||||
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (runId) extra.run = shortId(runId);
|
||||
if (sessionKey) {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
if (parsed) {
|
||||
extra.agent = parsed.agentId;
|
||||
extra.session = parsed.rest;
|
||||
} else {
|
||||
extra.session = sessionKey;
|
||||
}
|
||||
}
|
||||
if (stream) extra.stream = stream;
|
||||
if (seq !== undefined) extra.aseq = seq;
|
||||
|
||||
@@ -167,7 +179,7 @@ export function logWs(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
|
||||
const dirArrow = direction === "in" ? "←" : "→";
|
||||
const dirColor = direction === "in" ? chalk.greenBright : chalk.cyanBright;
|
||||
const prefix = `${chalk.gray("[gws]")} ${dirColor(dirArrow)} ${chalk.bold(kind)}`;
|
||||
const prefix = `${dirColor(dirArrow)} ${chalk.bold(kind)}`;
|
||||
|
||||
const headline =
|
||||
(kind === "req" || kind === "res") && method
|
||||
@@ -206,7 +218,7 @@ export function logWs(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
(t): t is string => Boolean(t),
|
||||
);
|
||||
|
||||
console.log(tokens.join(" "));
|
||||
wsLog.info(tokens.join(" "));
|
||||
}
|
||||
|
||||
function logWsOptimized(direction: "in" | "out", kind: string, meta?: Record<string, unknown>) {
|
||||
@@ -225,9 +237,9 @@ function logWsOptimized(direction: "in" | "out", kind: string, meta?: Record<str
|
||||
|
||||
if (kind === "parse-error") {
|
||||
const errorMsg = typeof meta?.error === "string" ? formatForLog(meta.error) : undefined;
|
||||
console.log(
|
||||
wsLog.warn(
|
||||
[
|
||||
`${chalk.gray("[gws]")} ${chalk.redBright("✗")} ${chalk.bold("parse-error")}`,
|
||||
`${chalk.redBright("✗")} ${chalk.bold("parse-error")}`,
|
||||
errorMsg ? `${chalk.dim("error")}=${errorMsg}` : undefined,
|
||||
`${chalk.dim("conn")}=${chalk.gray(shortId(connId ?? "?"))}`,
|
||||
]
|
||||
@@ -262,7 +274,7 @@ function logWsOptimized(direction: "in" | "out", kind: string, meta?: Record<str
|
||||
}
|
||||
|
||||
const tokens = [
|
||||
`${chalk.gray("[gws]")} ${chalk.yellowBright("⇄")} ${chalk.bold("res")}`,
|
||||
`${chalk.yellowBright("⇄")} ${chalk.bold("res")}`,
|
||||
statusToken,
|
||||
method ? chalk.bold(method) : undefined,
|
||||
durationToken,
|
||||
@@ -271,7 +283,7 @@ function logWsOptimized(direction: "in" | "out", kind: string, meta?: Record<str
|
||||
id ? `${chalk.dim("id")}=${chalk.gray(shortId(id))}` : undefined,
|
||||
].filter((t): t is string => Boolean(t));
|
||||
|
||||
console.log(tokens.join(" "));
|
||||
wsLog.info(tokens.join(" "));
|
||||
}
|
||||
|
||||
function logWsCompact(direction: "in" | "out", kind: string, meta?: Record<string, unknown>) {
|
||||
@@ -298,7 +310,7 @@ function logWsCompact(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
? chalk.greenBright
|
||||
: chalk.cyanBright;
|
||||
|
||||
const prefix = `${chalk.gray("[gws]")} ${arrowColor(compactArrow)} ${chalk.bold(kind)}`;
|
||||
const prefix = `${arrowColor(compactArrow)} ${chalk.bold(kind)}`;
|
||||
|
||||
const statusToken =
|
||||
kind === "res" && ok !== undefined
|
||||
@@ -346,5 +358,5 @@ function logWsCompact(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
(t): t is string => Boolean(t),
|
||||
);
|
||||
|
||||
console.log(tokens.join(" "));
|
||||
wsLog.info(tokens.join(" "));
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js";
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
it("parses single-line key-value pairs", () => {
|
||||
const content = `---
|
||||
name: test-hook
|
||||
description: "A test hook"
|
||||
homepage: https://example.com
|
||||
---
|
||||
|
||||
# Test Hook
|
||||
`;
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.name).toBe("test-hook");
|
||||
expect(result.description).toBe("A test hook");
|
||||
expect(result.homepage).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("handles missing frontmatter", () => {
|
||||
const content = "# Just a markdown file";
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("handles unclosed frontmatter", () => {
|
||||
const content = `---
|
||||
name: broken
|
||||
`;
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("parses multi-line metadata block with indented JSON", () => {
|
||||
const content = `---
|
||||
name: session-memory
|
||||
description: "Save session context"
|
||||
metadata:
|
||||
{
|
||||
"clawdbot": {
|
||||
"emoji": "💾",
|
||||
"events": ["command:new"]
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
# Session Memory Hook
|
||||
`;
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.name).toBe("session-memory");
|
||||
expect(result.description).toBe("Save session context");
|
||||
expect(result.metadata).toBeDefined();
|
||||
expect(typeof result.metadata).toBe("string");
|
||||
|
||||
// Verify the metadata is valid JSON
|
||||
const parsed = JSON.parse(result.metadata as string);
|
||||
expect(parsed.clawdbot.emoji).toBe("💾");
|
||||
expect(parsed.clawdbot.events).toEqual(["command:new"]);
|
||||
});
|
||||
|
||||
it("parses multi-line metadata with complex nested structure", () => {
|
||||
const content = `---
|
||||
name: command-logger
|
||||
description: "Log all command events"
|
||||
metadata:
|
||||
{
|
||||
"clawdbot":
|
||||
{
|
||||
"emoji": "📝",
|
||||
"events": ["command"],
|
||||
"requires": { "config": ["workspace.dir"] },
|
||||
"install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled" }]
|
||||
}
|
||||
}
|
||||
---
|
||||
`;
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.name).toBe("command-logger");
|
||||
expect(result.metadata).toBeDefined();
|
||||
|
||||
const parsed = JSON.parse(result.metadata as string);
|
||||
expect(parsed.clawdbot.emoji).toBe("📝");
|
||||
expect(parsed.clawdbot.events).toEqual(["command"]);
|
||||
expect(parsed.clawdbot.requires.config).toEqual(["workspace.dir"]);
|
||||
expect(parsed.clawdbot.install[0].kind).toBe("bundled");
|
||||
});
|
||||
|
||||
it("handles single-line metadata (inline JSON)", () => {
|
||||
const content = `---
|
||||
name: simple-hook
|
||||
metadata: {"clawdbot": {"events": ["test"]}}
|
||||
---
|
||||
`;
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.name).toBe("simple-hook");
|
||||
expect(result.metadata).toBe('{"clawdbot": {"events": ["test"]}}');
|
||||
});
|
||||
|
||||
it("handles mixed single-line and multi-line values", () => {
|
||||
const content = `---
|
||||
name: mixed-hook
|
||||
description: "A hook with mixed values"
|
||||
homepage: https://example.com
|
||||
metadata:
|
||||
{
|
||||
"clawdbot": {
|
||||
"events": ["command:new"]
|
||||
}
|
||||
}
|
||||
enabled: true
|
||||
---
|
||||
`;
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.name).toBe("mixed-hook");
|
||||
expect(result.description).toBe("A hook with mixed values");
|
||||
expect(result.homepage).toBe("https://example.com");
|
||||
expect(result.metadata).toBeDefined();
|
||||
expect(result.enabled).toBe("true");
|
||||
});
|
||||
|
||||
it("strips surrounding quotes from values", () => {
|
||||
const content = `---
|
||||
name: "quoted-name"
|
||||
description: 'single-quoted'
|
||||
---
|
||||
`;
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.name).toBe("quoted-name");
|
||||
expect(result.description).toBe("single-quoted");
|
||||
});
|
||||
|
||||
it("handles CRLF line endings", () => {
|
||||
const content = "---\r\nname: test\r\ndescription: crlf\r\n---\r\n";
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.name).toBe("test");
|
||||
expect(result.description).toBe("crlf");
|
||||
});
|
||||
|
||||
it("handles CR line endings", () => {
|
||||
const content = "---\rname: test\rdescription: cr\r---\r";
|
||||
const result = parseFrontmatter(content);
|
||||
expect(result.name).toBe("test");
|
||||
expect(result.description).toBe("cr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClawdbotMetadata", () => {
|
||||
it("extracts clawdbot metadata from parsed frontmatter", () => {
|
||||
const frontmatter = {
|
||||
name: "test-hook",
|
||||
metadata: JSON.stringify({
|
||||
clawdbot: {
|
||||
emoji: "🔥",
|
||||
events: ["command:new", "command:reset"],
|
||||
requires: {
|
||||
config: ["workspace.dir"],
|
||||
bins: ["git"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = resolveClawdbotMetadata(frontmatter);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.emoji).toBe("🔥");
|
||||
expect(result?.events).toEqual(["command:new", "command:reset"]);
|
||||
expect(result?.requires?.config).toEqual(["workspace.dir"]);
|
||||
expect(result?.requires?.bins).toEqual(["git"]);
|
||||
});
|
||||
|
||||
it("returns undefined when metadata is missing", () => {
|
||||
const frontmatter = { name: "no-metadata" };
|
||||
const result = resolveClawdbotMetadata(frontmatter);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when clawdbot key is missing", () => {
|
||||
const frontmatter = {
|
||||
metadata: JSON.stringify({ other: "data" }),
|
||||
};
|
||||
const result = resolveClawdbotMetadata(frontmatter);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for invalid JSON", () => {
|
||||
const frontmatter = {
|
||||
metadata: "not valid json {",
|
||||
};
|
||||
const result = resolveClawdbotMetadata(frontmatter);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles install specs", () => {
|
||||
const frontmatter = {
|
||||
metadata: JSON.stringify({
|
||||
clawdbot: {
|
||||
events: ["command"],
|
||||
install: [
|
||||
{ id: "bundled", kind: "bundled", label: "Bundled with Clawdbot" },
|
||||
{ id: "npm", kind: "npm", package: "@clawdbot/hook" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = resolveClawdbotMetadata(frontmatter);
|
||||
expect(result?.install).toHaveLength(2);
|
||||
expect(result?.install?.[0].kind).toBe("bundled");
|
||||
expect(result?.install?.[1].kind).toBe("npm");
|
||||
expect(result?.install?.[1].package).toBe("@clawdbot/hook");
|
||||
});
|
||||
|
||||
it("handles os restrictions", () => {
|
||||
const frontmatter = {
|
||||
metadata: JSON.stringify({
|
||||
clawdbot: {
|
||||
events: ["command"],
|
||||
os: ["darwin", "linux"],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const result = resolveClawdbotMetadata(frontmatter);
|
||||
expect(result?.os).toEqual(["darwin", "linux"]);
|
||||
});
|
||||
|
||||
it("parses real session-memory HOOK.md format", () => {
|
||||
// This is the actual format used in the bundled hooks
|
||||
const content = `---
|
||||
name: session-memory
|
||||
description: "Save session context to memory when /new command is issued"
|
||||
homepage: https://docs.clawd.bot/hooks#session-memory
|
||||
metadata:
|
||||
{
|
||||
"clawdbot":
|
||||
{
|
||||
"emoji": "💾",
|
||||
"events": ["command:new"],
|
||||
"requires": { "config": ["workspace.dir"] },
|
||||
"install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }],
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
# Session Memory Hook
|
||||
`;
|
||||
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
expect(frontmatter.name).toBe("session-memory");
|
||||
expect(frontmatter.metadata).toBeDefined();
|
||||
|
||||
const clawdbot = resolveClawdbotMetadata(frontmatter);
|
||||
expect(clawdbot).toBeDefined();
|
||||
expect(clawdbot?.emoji).toBe("💾");
|
||||
expect(clawdbot?.events).toEqual(["command:new"]);
|
||||
expect(clawdbot?.requires?.config).toEqual(["workspace.dir"]);
|
||||
expect(clawdbot?.install?.[0].kind).toBe("bundled");
|
||||
});
|
||||
|
||||
it("parses YAML metadata map", () => {
|
||||
const content = `---
|
||||
name: yaml-metadata
|
||||
metadata:
|
||||
clawdbot:
|
||||
emoji: disk
|
||||
events:
|
||||
- command:new
|
||||
---
|
||||
`;
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
const clawdbot = resolveClawdbotMetadata(frontmatter);
|
||||
expect(clawdbot?.emoji).toBe("disk");
|
||||
expect(clawdbot?.events).toEqual(["command:new"]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import JSON5 from "json5";
|
||||
|
||||
import { parseFrontmatterBlock } from "../markdown/frontmatter.js";
|
||||
import type {
|
||||
ClawdbotHookMetadata,
|
||||
HookEntry,
|
||||
@@ -6,32 +9,8 @@ import type {
|
||||
ParsedHookFrontmatter,
|
||||
} from "./types.js";
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseFrontmatter(content: string): ParsedHookFrontmatter {
|
||||
const frontmatter: ParsedHookFrontmatter = {};
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return frontmatter;
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return frontmatter;
|
||||
const block = normalized.slice(4, endIndex);
|
||||
for (const line of block.split("\n")) {
|
||||
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!match) continue;
|
||||
const key = match[1];
|
||||
const value = stripQuotes(match[2].trim());
|
||||
if (!key || !value) continue;
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
return frontmatter;
|
||||
return parseFrontmatterBlock(content);
|
||||
}
|
||||
|
||||
function normalizeStringList(input: unknown): string[] {
|
||||
@@ -96,7 +75,7 @@ export function resolveClawdbotMetadata(
|
||||
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { clawdbot?: unknown };
|
||||
const parsed = JSON5.parse(raw) as { clawdbot?: unknown };
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
||||
if (!clawdbot || typeof clawdbot !== "object") return undefined;
|
||||
|
||||
@@ -51,8 +51,14 @@ export function resetAgentRunContextForTest() {
|
||||
export function emitAgentEvent(event: Omit<AgentEventPayload, "seq" | "ts">) {
|
||||
const nextSeq = (seqByRun.get(event.runId) ?? 0) + 1;
|
||||
seqByRun.set(event.runId, nextSeq);
|
||||
const context = runContextById.get(event.runId);
|
||||
const sessionKey =
|
||||
typeof event.sessionKey === "string" && event.sessionKey.trim()
|
||||
? event.sessionKey
|
||||
: context?.sessionKey;
|
||||
const enriched: AgentEventPayload = {
|
||||
...event,
|
||||
sessionKey,
|
||||
seq: nextSeq,
|
||||
ts: Date.now(),
|
||||
};
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user