Compare commits
89
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 | ||
|
|
ec9ba5b784 | ||
|
|
cee4149884 | ||
|
|
5599e4cf35 | ||
|
|
84cdd2df73 | ||
|
|
7929f57460 | ||
|
|
7876679c5d | ||
|
|
bc6928525d | ||
|
|
755c847d9a | ||
|
|
80a8639940 | ||
|
|
6cb5704291 | ||
|
|
4a987c836d | ||
|
|
a2fb55326c | ||
|
|
af29c6a980 | ||
|
|
f2a0e8e5bb | ||
|
|
f6456c2883 | ||
|
|
39f0d000d1 | ||
|
|
a8d9d630bc | ||
|
|
e93a1d8138 | ||
|
|
f6681be6f4 | ||
|
|
c79ac3fe81 | ||
|
|
b78b06353a | ||
|
|
c49b6cc241 | ||
|
|
41fbb4d8b0 |
No files matched your search
+44
-2
@@ -2,16 +2,52 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.17 (Unreleased)
|
## 2026.1.17-3
|
||||||
|
|
||||||
### Changes
|
### 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: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||||
- Docs: remove duplicate logging nav entry. (#1106) — thanks @gumadeiras.
|
- 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
|
## 2026.1.16-2
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
- 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
|
## 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.
|
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||||
- UI: allow deleting sessions from the Control UI.
|
- 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.
|
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
- 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.
|
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||||
@@ -67,6 +105,10 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
||||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
- 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: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||||
- Telegram: split long captions into follow-up messages.
|
- 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.
|
- 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/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/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/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/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/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/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/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/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/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>
|
</p>
|
||||||
@@ -35,7 +35,7 @@ enum CLIInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||||
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||||
let prefix = Self.installPrefix()
|
let prefix = Self.installPrefix()
|
||||||
await statusHandler("Installing clawdbot CLI…")
|
await statusHandler("Installing clawdbot CLI…")
|
||||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
|
|||||||
let path: ConfigPath
|
let path: ConfigPath
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
self.renderNode(schema, path: path)
|
self.renderNode(self.schema, path: self.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
||||||
let storedValue = store.configValue(at: path)
|
let storedValue = self.store.configValue(at: path)
|
||||||
let value = storedValue ?? schema.explicitDefault
|
let value = storedValue ?? schema.explicitDefault
|
||||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
||||||
@@ -21,7 +21,7 @@ struct ConfigSchemaForm: View {
|
|||||||
if nonNull.count == 1, let only = nonNull.first {
|
if nonNull.count == 1, let only = nonNull.first {
|
||||||
return self.renderNode(only, path: path)
|
return self.renderNode(only, path: path)
|
||||||
}
|
}
|
||||||
let literals = nonNull.compactMap { $0.literalValue }
|
let literals = nonNull.compactMap(\.literalValue)
|
||||||
if !literals.isEmpty, literals.count == nonNull.count {
|
if !literals.isEmpty, literals.count == nonNull.count {
|
||||||
return AnyView(
|
return AnyView(
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -31,15 +31,20 @@ struct ConfigSchemaForm: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
|
Picker(
|
||||||
|
"",
|
||||||
|
selection: self.enumBinding(
|
||||||
|
path,
|
||||||
|
options: literals,
|
||||||
|
defaultValue: schema.explicitDefault))
|
||||||
|
{
|
||||||
Text("Select…").tag(-1)
|
Text("Select…").tag(-1)
|
||||||
ForEach(literals.indices, id: \ .self) { index in
|
ForEach(literals.indices, id: \ .self) { index in
|
||||||
Text(String(describing: literals[index])).tag(index)
|
Text(String(describing: literals[index])).tag(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +76,7 @@ struct ConfigSchemaForm: View {
|
|||||||
if schema.allowsAdditionalProperties {
|
if schema.allowsAdditionalProperties {
|
||||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
case "array":
|
case "array":
|
||||||
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
||||||
case "boolean":
|
case "boolean":
|
||||||
@@ -80,8 +84,7 @@ struct ConfigSchemaForm: View {
|
|||||||
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
||||||
if let label { Text(label) } else { Text("Enabled") }
|
if let label { Text(label) } else { Text("Enabled") }
|
||||||
}
|
}
|
||||||
.help(help ?? "")
|
.help(help ?? ""))
|
||||||
)
|
|
||||||
case "number", "integer":
|
case "number", "integer":
|
||||||
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
||||||
case "string":
|
case "string":
|
||||||
@@ -93,8 +96,7 @@ struct ConfigSchemaForm: View {
|
|||||||
Text("Unsupported field type.")
|
Text("Unsupported field type.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +157,7 @@ struct ConfigSchemaForm: View {
|
|||||||
text: self.numberBinding(
|
text: self.numberBinding(
|
||||||
path,
|
path,
|
||||||
isInteger: schema.schemaType == "integer",
|
isInteger: schema.schemaType == "integer",
|
||||||
defaultValue: defaultValue
|
defaultValue: defaultValue))
|
||||||
)
|
|
||||||
)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
|
|||||||
Button("Remove") {
|
Button("Remove") {
|
||||||
var next = items
|
var next = items
|
||||||
next.remove(at: index)
|
next.remove(at: index)
|
||||||
store.updateConfigValue(path: path, value: next)
|
self.store.updateConfigValue(path: path, value: next)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
|
|||||||
} else {
|
} else {
|
||||||
next.append("")
|
next.append("")
|
||||||
}
|
}
|
||||||
store.updateConfigValue(path: path, value: next)
|
self.store.updateConfigValue(path: path, value: next)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
|
|||||||
Button("Remove") {
|
Button("Remove") {
|
||||||
var next = dict
|
var next = dict
|
||||||
next.removeValue(forKey: key)
|
next.removeValue(forKey: key)
|
||||||
store.updateConfigValue(path: path, value: next)
|
self.store.updateConfigValue(path: path, value: next)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
|
|||||||
key = "new-\(index)"
|
key = "new-\(index)"
|
||||||
}
|
}
|
||||||
next[key] = additionalSchema.defaultValue
|
next[key] = additionalSchema.defaultValue
|
||||||
store.updateConfigValue(path: path, value: next)
|
self.store.updateConfigValue(path: path, value: next)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -270,9 +270,8 @@ struct ConfigSchemaForm: View {
|
|||||||
},
|
},
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
|
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
|
||||||
@@ -282,16 +281,15 @@ struct ConfigSchemaForm: View {
|
|||||||
return defaultValue ?? false
|
return defaultValue ?? false
|
||||||
},
|
},
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
store.updateConfigValue(path: path, value: newValue)
|
self.store.updateConfigValue(path: path, value: newValue)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func numberBinding(
|
private func numberBinding(
|
||||||
_ path: ConfigPath,
|
_ path: ConfigPath,
|
||||||
isInteger: Bool,
|
isInteger: Bool,
|
||||||
defaultValue: Double?
|
defaultValue: Double?) -> Binding<String>
|
||||||
) -> Binding<String> {
|
{
|
||||||
Binding(
|
Binding(
|
||||||
get: {
|
get: {
|
||||||
if let value = store.configValue(at: path) { return String(describing: value) }
|
if let value = store.configValue(at: path) { return String(describing: value) }
|
||||||
@@ -301,22 +299,21 @@ struct ConfigSchemaForm: View {
|
|||||||
set: { newValue in
|
set: { newValue in
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
store.updateConfigValue(path: path, value: nil)
|
self.store.updateConfigValue(path: path, value: nil)
|
||||||
} else if let value = Double(trimmed) {
|
} else if let value = Double(trimmed) {
|
||||||
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enumBinding(
|
private func enumBinding(
|
||||||
_ path: ConfigPath,
|
_ path: ConfigPath,
|
||||||
options: [Any],
|
options: [Any],
|
||||||
defaultValue: Any?
|
defaultValue: Any?) -> Binding<Int>
|
||||||
) -> Binding<Int> {
|
{
|
||||||
Binding(
|
Binding(
|
||||||
get: {
|
get: {
|
||||||
let value = store.configValue(at: path) ?? defaultValue
|
let value = self.store.configValue(at: path) ?? defaultValue
|
||||||
guard let value else { return -1 }
|
guard let value else { return -1 }
|
||||||
return options.firstIndex { option in
|
return options.firstIndex { option in
|
||||||
String(describing: option) == String(describing: value)
|
String(describing: option) == String(describing: value)
|
||||||
@@ -324,12 +321,11 @@ struct ConfigSchemaForm: View {
|
|||||||
},
|
},
|
||||||
set: { index in
|
set: { index in
|
||||||
guard index >= 0, index < options.count else {
|
guard index >= 0, index < options.count else {
|
||||||
store.updateConfigValue(path: path, value: nil)
|
self.store.updateConfigValue(path: path, value: nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
store.updateConfigValue(path: path, value: options[index])
|
self.store.updateConfigValue(path: path, value: options[index])
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
||||||
@@ -339,14 +335,13 @@ struct ConfigSchemaForm: View {
|
|||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
guard trimmed != key else { return }
|
guard trimmed != key else { return }
|
||||||
let current = store.configValue(at: path) as? [String: Any] ?? [:]
|
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
|
||||||
guard current[trimmed] == nil else { return }
|
guard current[trimmed] == nil else { return }
|
||||||
var next = current
|
var next = current
|
||||||
next[trimmed] = current[key]
|
next[trimmed] = current[key]
|
||||||
next.removeValue(forKey: key)
|
next.removeValue(forKey: key)
|
||||||
store.updateConfigValue(path: path, value: next)
|
self.store.updateConfigValue(path: path, value: next)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +350,10 @@ struct ChannelConfigForm: View {
|
|||||||
let channelId: String
|
let channelId: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if store.configSchemaLoading {
|
if self.store.configSchemaLoading {
|
||||||
ProgressView().controlSize(.small)
|
ProgressView().controlSize(.small)
|
||||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||||
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
|
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
|
||||||
} else {
|
} else {
|
||||||
Text("Schema unavailable for this channel.")
|
Text("Schema unavailable for this channel.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|||||||
@@ -434,25 +434,25 @@ extension ChannelsSettings {
|
|||||||
|
|
||||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||||
switch id {
|
switch id {
|
||||||
case "whatsapp": return "WhatsApp Web"
|
case "whatsapp": "WhatsApp Web"
|
||||||
case "telegram": return "Telegram Bot"
|
case "telegram": "Telegram Bot"
|
||||||
case "discord": return "Discord Bot"
|
case "discord": "Discord Bot"
|
||||||
case "slack": return "Slack Bot"
|
case "slack": "Slack Bot"
|
||||||
case "signal": return "Signal REST"
|
case "signal": "Signal REST"
|
||||||
case "imessage": return "iMessage"
|
case "imessage": "iMessage"
|
||||||
default: return self.resolveChannelTitle(id)
|
default: self.resolveChannelTitle(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||||
switch id {
|
switch id {
|
||||||
case "whatsapp": return "message"
|
case "whatsapp": "message"
|
||||||
case "telegram": return "paperplane"
|
case "telegram": "paperplane"
|
||||||
case "discord": return "bubble.left.and.bubble.right"
|
case "discord": "bubble.left.and.bubble.right"
|
||||||
case "slack": return "number"
|
case "slack": "number"
|
||||||
case "signal": return "antenna.radiowaves.left.and.right"
|
case "signal": "antenna.radiowaves.left.and.right"
|
||||||
case "imessage": return "message.fill"
|
case "imessage": "message.fill"
|
||||||
default: return "message"
|
default: "message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ extension ChannelsStore {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
guard path.count >= 2 else { return nil }
|
guard path.count >= 2 else { return nil }
|
||||||
if case .key("channels") = path[0], case .key(_) = path[1] {
|
if case .key("channels") = path[0], case .key = path[1] {
|
||||||
let fallbackPath = Array(path.dropFirst())
|
let fallbackPath = Array(path.dropFirst())
|
||||||
return valueAtPath(self.configDraft, path: fallbackPath)
|
return valueAtPath(self.configDraft, path: fallbackPath)
|
||||||
}
|
}
|
||||||
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
|||||||
var current: Any? = root
|
var current: Any? = root
|
||||||
for segment in path {
|
for segment in path {
|
||||||
switch segment {
|
switch segment {
|
||||||
case .key(let key):
|
case let .key(key):
|
||||||
guard let dict = current as? [String: Any] else { return nil }
|
guard let dict = current as? [String: Any] else { return nil }
|
||||||
current = dict[key]
|
current = dict[key]
|
||||||
case .index(let index):
|
case let .index(index):
|
||||||
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
||||||
current = array[index]
|
current = array[index]
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
|||||||
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
||||||
guard let segment = path.first else { return }
|
guard let segment = path.first else { return }
|
||||||
switch segment {
|
switch segment {
|
||||||
case .key(let key):
|
case let .key(key):
|
||||||
var dict = root as? [String: Any] ?? [:]
|
var dict = root as? [String: Any] ?? [:]
|
||||||
if path.count == 1 {
|
if path.count == 1 {
|
||||||
if let value {
|
if let value {
|
||||||
@@ -122,7 +122,7 @@ private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
|||||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
setValue(&child, path: Array(path.dropFirst()), value: value)
|
||||||
dict[key] = child
|
dict[key] = child
|
||||||
root = dict
|
root = dict
|
||||||
case .index(let index):
|
case let .index(index):
|
||||||
var array = root as? [Any] ?? []
|
var array = root as? [Any] ?? []
|
||||||
if index >= array.count {
|
if index >= array.count {
|
||||||
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
|
|||||||
for segment in path {
|
for segment in path {
|
||||||
guard let node = current else { return nil }
|
guard let node = current else { return nil }
|
||||||
switch segment {
|
switch segment {
|
||||||
case .key(let key):
|
case let .key(key):
|
||||||
if node.schemaType == "object" {
|
if node.schemaType == "object" {
|
||||||
if let next = node.properties[key] {
|
if let next = node.properties[key] {
|
||||||
current = next
|
current = next
|
||||||
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
|
|||||||
var match = true
|
var match = true
|
||||||
for (index, seg) in segments.enumerated() {
|
for (index, seg) in segments.enumerated() {
|
||||||
let hintSegment = hintSegments[index]
|
let hintSegment = hintSegments[index]
|
||||||
if hintSegment != "*" && hintSegment != seg {
|
if hintSegment != "*", hintSegment != seg {
|
||||||
match = false
|
match = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
|
|||||||
func pathKey(_ path: ConfigPath) -> String {
|
func pathKey(_ path: ConfigPath) -> String {
|
||||||
path.compactMap { segment -> String? in
|
path.compactMap { segment -> String? in
|
||||||
switch segment {
|
switch segment {
|
||||||
case .key(let key): return key
|
case let .key(key): return key
|
||||||
case .index: return nil
|
case .index: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ extension ConfigSettings {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.store.configDirty && !self.isNixMode {
|
if self.store.configDirty, !self.isNixMode {
|
||||||
Text("Unsaved changes")
|
Text("Unsaved changes")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -80,8 +80,13 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func expectedGatewayVersion() -> Semver? {
|
static func expectedGatewayVersion() -> Semver? {
|
||||||
|
Semver.parse(self.expectedGatewayVersionString())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func expectedGatewayVersionString() -> String? {
|
||||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
return Semver.parse(bundleVersion)
|
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||||
@@ -100,6 +105,7 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let expected = self.expectedGatewayVersion()
|
let expected = self.expectedGatewayVersion()
|
||||||
|
let expectedString = self.expectedGatewayVersionString()
|
||||||
|
|
||||||
let projectRoot = CommandResolver.projectRoot()
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||||
@@ -110,8 +116,8 @@ enum GatewayEnvironment {
|
|||||||
kind: .missingNode,
|
kind: .missingNode,
|
||||||
nodeVersion: nil,
|
nodeVersion: nil,
|
||||||
gatewayVersion: nil,
|
gatewayVersion: nil,
|
||||||
requiredGateway: expected?.description,
|
requiredGateway: expectedString,
|
||||||
message: RuntimeLocator.describeFailure(err))
|
message: RuntimeLocator.describeFailure(err))
|
||||||
case let .success(runtime):
|
case let .success(runtime):
|
||||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||||
|
|
||||||
@@ -120,7 +126,7 @@ enum GatewayEnvironment {
|
|||||||
kind: .missingGateway,
|
kind: .missingGateway,
|
||||||
nodeVersion: runtime.version.description,
|
nodeVersion: runtime.version.description,
|
||||||
gatewayVersion: nil,
|
gatewayVersion: nil,
|
||||||
requiredGateway: expected?.description,
|
requiredGateway: expectedString,
|
||||||
message: "clawdbot CLI not found in PATH; install the CLI.")
|
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,13 +134,14 @@ enum GatewayEnvironment {
|
|||||||
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||||
|
|
||||||
if let expected, let installed, !installed.compatible(with: expected) {
|
if let expected, let installed, !installed.compatible(with: expected) {
|
||||||
|
let expectedText = expectedString ?? expected.description
|
||||||
return GatewayEnvironmentStatus(
|
return GatewayEnvironmentStatus(
|
||||||
kind: .incompatible(found: installed.description, required: expected.description),
|
kind: .incompatible(found: installed.description, required: expectedText),
|
||||||
nodeVersion: runtime.version.description,
|
nodeVersion: runtime.version.description,
|
||||||
gatewayVersion: installed.description,
|
gatewayVersion: installed.description,
|
||||||
requiredGateway: expected.description,
|
requiredGateway: expectedText,
|
||||||
message: """
|
message: """
|
||||||
Gateway version \(installed.description) is incompatible with app \(expected.description);
|
Gateway version \(installed.description) is incompatible with app \(expectedText);
|
||||||
install or update the global package.
|
install or update the global package.
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
@@ -152,7 +159,7 @@ enum GatewayEnvironment {
|
|||||||
kind: .ok,
|
kind: .ok,
|
||||||
nodeVersion: runtime.version.description,
|
nodeVersion: runtime.version.description,
|
||||||
gatewayVersion: gatewayVersionText,
|
gatewayVersion: gatewayVersionText,
|
||||||
requiredGateway: expected?.description,
|
requiredGateway: expectedString,
|
||||||
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,8 +227,18 @@ enum GatewayEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||||
|
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||||
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
let target = version?.description ?? "latest"
|
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let target: String
|
||||||
|
if let trimmed, !trimmed.isEmpty {
|
||||||
|
target = trimmed
|
||||||
|
} else {
|
||||||
|
target = "latest"
|
||||||
|
}
|
||||||
let npm = CommandResolver.findExecutable(named: "npm")
|
let npm = CommandResolver.findExecutable(named: "npm")
|
||||||
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||||
let bun = CommandResolver.findExecutable(named: "bun")
|
let bun = CommandResolver.findExecutable(named: "bun")
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
var canAdvance: Bool { !self.isWizardBlocking }
|
var canAdvance: Bool { !self.isWizardBlocking }
|
||||||
var devLinkCommand: String {
|
var devLinkCommand: String {
|
||||||
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||||
return "npm install -g clawdbot@\(version)"
|
return "npm install -g clawdbot@\(version)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,28 @@ import Testing
|
|||||||
@Suite struct GatewayEnvironmentTests {
|
@Suite struct GatewayEnvironmentTests {
|
||||||
@Test func semverParsesCommonForms() {
|
@Test func semverParsesCommonForms() {
|
||||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
#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("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("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("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("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))
|
||||||
|
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
|
||||||
#expect(Semver.parse(nil) == nil)
|
#expect(Semver.parse(nil) == nil)
|
||||||
#expect(Semver.parse("invalid") == nil)
|
#expect(Semver.parse("invalid") == nil)
|
||||||
|
#expect(Semver.parse("1.2") == nil)
|
||||||
|
#expect(Semver.parse("1.2.x") == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
|
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
|
||||||
let required = Semver(major: 2, minor: 1, patch: 0)
|
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: 1, patch: 0).compatible(with: required))
|
||||||
#expect(Semver(major: 2, minor: 2, 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: 3, minor: 0, patch: 0).compatible(with: required) == false)
|
||||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||||
}
|
}
|
||||||
@@ -38,6 +48,7 @@ import Testing
|
|||||||
|
|
||||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||||
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
|
||||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +335,7 @@ For fine-grained control, use these tags in agent responses:
|
|||||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||||
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
|
- 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`).
|
- 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.
|
- 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)
|
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||||
|
|
||||||
|
|||||||
@@ -360,6 +360,19 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
|
|||||||
|
|
||||||
The tag is stripped from the delivered text. Other channels ignore this tag.
|
The tag is stripped from the delivered text. Other channels ignore this tag.
|
||||||
|
|
||||||
|
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
|
||||||
|
(`message` is optional when media is present):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "send",
|
||||||
|
"channel": "telegram",
|
||||||
|
"to": "123456789",
|
||||||
|
"media": "https://example.com/voice.ogg",
|
||||||
|
"asVoice": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Streaming (drafts)
|
## Streaming (drafts)
|
||||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ Related docs:
|
|||||||
```bash
|
```bash
|
||||||
clawdbot channels list
|
clawdbot channels list
|
||||||
clawdbot channels status
|
clawdbot channels status
|
||||||
|
clawdbot channels capabilities
|
||||||
|
clawdbot channels capabilities --channel discord --target channel:123
|
||||||
clawdbot channels logs --channel all
|
clawdbot channels logs --channel all
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,3 +44,16 @@ clawdbot channels logout --channel whatsapp
|
|||||||
- Run `clawdbot status --deep` for a broad probe.
|
- Run `clawdbot status --deep` for a broad probe.
|
||||||
- Use `clawdbot doctor` for guided fixes.
|
- Use `clawdbot doctor` for guided fixes.
|
||||||
|
|
||||||
|
## 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
|
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
|
## 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.
|
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.
|
- 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).
|
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||||
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
|
- `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
|
### Options
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -292,7 +292,7 @@ Options:
|
|||||||
- `--non-interactive`
|
- `--non-interactive`
|
||||||
- `--mode <local|remote>`
|
- `--mode <local|remote>`
|
||||||
- `--flow <quickstart|advanced>`
|
- `--flow <quickstart|advanced>`
|
||||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||||
@@ -302,6 +302,7 @@ Options:
|
|||||||
- `--openrouter-api-key <key>`
|
- `--openrouter-api-key <key>`
|
||||||
- `--ai-gateway-api-key <key>`
|
- `--ai-gateway-api-key <key>`
|
||||||
- `--moonshot-api-key <key>`
|
- `--moonshot-api-key <key>`
|
||||||
|
- `--kimi-code-api-key <key>`
|
||||||
- `--gemini-api-key <key>`
|
- `--gemini-api-key <key>`
|
||||||
- `--zai-api-key <key>`
|
- `--zai-api-key <key>`
|
||||||
- `--minimax-api-key <key>`
|
- `--minimax-api-key <key>`
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@ Related:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot memory status
|
clawdbot memory status
|
||||||
|
clawdbot memory status --deep
|
||||||
|
clawdbot memory status --deep --index
|
||||||
clawdbot memory index
|
clawdbot memory index
|
||||||
clawdbot memory search "release checklist"
|
clawdbot memory search "release checklist"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -26,6 +26,11 @@ clawdbot models scan
|
|||||||
When provider usage snapshots are available, the OAuth/token status section includes
|
When provider usage snapshots are available, the OAuth/token status section includes
|
||||||
provider usage headers.
|
provider usage headers.
|
||||||
|
|
||||||
|
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
|
## Aliases + fallbacks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ clawdbot status --usage
|
|||||||
Notes:
|
Notes:
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- 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.
|
streams tool output via agent events when available.
|
||||||
More details: [Streaming + chunking](/concepts/streaming).
|
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)
|
## Configuration (minimal)
|
||||||
|
|
||||||
At minimum, set:
|
At minimum, set:
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ Defaults:
|
|||||||
- Watches memory files for changes (debounced).
|
- Watches memory files for changes (debounced).
|
||||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
- 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
|
Remote embeddings **require** an API key for the embedding provider. By default
|
||||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
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
|
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||||
`memorySearch.fallback = "none"`.
|
`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:
|
Config example:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -116,6 +122,9 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
fallback: "openai",
|
fallback: "openai",
|
||||||
|
remote: {
|
||||||
|
batch: { enabled: false }
|
||||||
|
},
|
||||||
sync: { watch: true }
|
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).
|
- 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.
|
- 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
|
### Local embedding auto-download
|
||||||
|
|
||||||
- Default local embedding model: `hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf` (~0.6 GB).
|
- Default local embedding model: `hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf` (~0.6 GB).
|
||||||
|
|||||||
@@ -155,6 +155,34 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Kimi Code
|
||||||
|
|
||||||
|
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
|
||||||
|
|
||||||
|
- Provider: `kimi-code`
|
||||||
|
- Auth: `KIMICODE_API_KEY`
|
||||||
|
- Example model: `kimi-code/kimi-for-coding`
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
env: { KIMICODE_API_KEY: "sk-..." },
|
||||||
|
agents: {
|
||||||
|
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
mode: "merge",
|
||||||
|
providers: {
|
||||||
|
"kimi-code": {
|
||||||
|
baseUrl: "https://api.kimi.com/coding/v1",
|
||||||
|
apiKey: "${KIMICODE_API_KEY}",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Synthetic
|
### Synthetic
|
||||||
|
|
||||||
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ Notes:
|
|||||||
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
||||||
- `/model <#>` selects from that picker.
|
- `/model <#>` selects from that picker.
|
||||||
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
|
- `/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).
|
Full command behavior/config: [Slash commands](/tools/slash-commands).
|
||||||
|
|
||||||
|
|||||||
@@ -2234,6 +2234,49 @@ Notes:
|
|||||||
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
||||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||||
|
|
||||||
|
### Kimi Code
|
||||||
|
|
||||||
|
Use Kimi Code's dedicated OpenAI-compatible endpoint (separate from Moonshot):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
env: { KIMICODE_API_KEY: "sk-..." },
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "kimi-code/kimi-for-coding" },
|
||||||
|
models: { "kimi-code/kimi-for-coding": { alias: "Kimi Code" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
mode: "merge",
|
||||||
|
providers: {
|
||||||
|
"kimi-code": {
|
||||||
|
baseUrl: "https://api.kimi.com/coding/v1",
|
||||||
|
apiKey: "${KIMICODE_API_KEY}",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "kimi-for-coding",
|
||||||
|
name: "Kimi For Coding",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 262144,
|
||||||
|
maxTokens: 32768,
|
||||||
|
headers: { "User-Agent": "KimiCLI/0.77" },
|
||||||
|
compat: { supportsDeveloperRole: false }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Set `KIMICODE_API_KEY` in the environment or use `clawdbot onboard --auth-choice kimi-code-api-key`.
|
||||||
|
- Model ref: `kimi-code/kimi-for-coding`.
|
||||||
|
|
||||||
### Synthetic (Anthropic-compatible)
|
### Synthetic (Anthropic-compatible)
|
||||||
|
|
||||||
Use Synthetic's Anthropic-compatible endpoint:
|
Use Synthetic's Anthropic-compatible endpoint:
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
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)
|
## 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:
|
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
|
|||||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||||
- [OpenRouter](/providers/openrouter)
|
- [OpenRouter](/providers/openrouter)
|
||||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||||
- [OpenCode Zen](/providers/opencode)
|
- [OpenCode Zen](/providers/opencode)
|
||||||
- [Z.AI](/providers/zai)
|
- [Z.AI](/providers/zai)
|
||||||
- [GLM models](/providers/glm)
|
- [GLM models](/providers/glm)
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Model refs are `minimax/<model>`.
|
- 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.
|
- 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
|
- 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.
|
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ model as `provider/model`.
|
|||||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||||
- [OpenRouter](/providers/openrouter)
|
- [OpenRouter](/providers/openrouter)
|
||||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||||
- [Synthetic](/providers/synthetic)
|
- [Synthetic](/providers/synthetic)
|
||||||
- [OpenCode Zen](/providers/opencode)
|
- [OpenCode Zen](/providers/opencode)
|
||||||
- [Z.AI](/providers/zai)
|
- [Z.AI](/providers/zai)
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
---
|
---
|
||||||
summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
|
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to use Moonshot/Kimi models in Clawdbot
|
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup
|
||||||
- You need the Moonshot auth + config example
|
- You need to understand separate endpoints, keys, and model refs
|
||||||
|
- You want copy/paste config for either provider
|
||||||
---
|
---
|
||||||
|
|
||||||
# Moonshot AI (Kimi)
|
# Moonshot AI (Kimi)
|
||||||
|
|
||||||
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
|
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
|
||||||
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
|
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
|
||||||
|
Kimi Code with `kimi-code/kimi-for-coding`.
|
||||||
|
|
||||||
Current Kimi K2 model IDs:
|
Current Kimi K2 model IDs:
|
||||||
{/* moonshot-kimi-k2-ids:start */}
|
{/* moonshot-kimi-k2-ids:start */}
|
||||||
@@ -21,7 +24,15 @@ Current Kimi K2 model IDs:
|
|||||||
clawdbot onboard --auth-choice moonshot-api-key
|
clawdbot onboard --auth-choice moonshot-api-key
|
||||||
```
|
```
|
||||||
|
|
||||||
## Config snippet
|
Kimi Code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot onboard --auth-choice kimi-code-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Code uses `kimi-code/...`).
|
||||||
|
|
||||||
|
## Config snippet (Moonshot API)
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -92,9 +103,48 @@ clawdbot onboard --auth-choice moonshot-api-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Kimi Code
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
env: { KIMICODE_API_KEY: "sk-..." },
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "kimi-code/kimi-for-coding" },
|
||||||
|
models: {
|
||||||
|
"kimi-code/kimi-for-coding": { alias: "Kimi Code" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
mode: "merge",
|
||||||
|
providers: {
|
||||||
|
"kimi-code": {
|
||||||
|
baseUrl: "https://api.kimi.com/coding/v1",
|
||||||
|
apiKey: "${KIMICODE_API_KEY}",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "kimi-for-coding",
|
||||||
|
name: "Kimi For Coding",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 262144,
|
||||||
|
maxTokens: 32768,
|
||||||
|
headers: { "User-Agent": "KimiCLI/0.77" },
|
||||||
|
compat: { supportsDeveloperRole: false }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Model refs use `moonshot/<modelId>`.
|
- Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<modelId>`.
|
||||||
- Override pricing and context metadata in `models.providers` if needed.
|
- Override pricing and context metadata in `models.providers` if needed.
|
||||||
- If Moonshot publishes different context limits for a model, adjust
|
- If Moonshot publishes different context limits for a model, adjust
|
||||||
`contextWindow` accordingly.
|
`contextWindow` accordingly.
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||||
- More detail: [Synthetic](/providers/synthetic)
|
- More detail: [Synthetic](/providers/synthetic)
|
||||||
- **Moonshot (Kimi K2)**: config is auto-written.
|
- **Moonshot (Kimi K2)**: config is auto-written.
|
||||||
- More detail: [Moonshot AI](/providers/moonshot)
|
- **Kimi Code**: config is auto-written.
|
||||||
|
- More detail: [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||||
- **Skip**: no auth configured yet.
|
- **Skip**: no auth configured yet.
|
||||||
- Pick a default model from detected options (or enter provider/model manually).
|
- Pick a default model from detected options (or enter provider/model manually).
|
||||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/copilot-proxy",
|
"name": "@clawdbot/copilot-proxy",
|
||||||
"version": "2026.1.16-1",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-antigravity-auth",
|
"name": "@clawdbot/google-antigravity-auth",
|
||||||
"version": "2026.1.16-1",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-gemini-cli-auth",
|
"name": "@clawdbot/google-gemini-cli-auth",
|
||||||
"version": "2026.1.16-1",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 2026.1.16
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/matrix",
|
"name": "@clawdbot/matrix",
|
||||||
"version": "2026.1.16",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Matrix channel plugin",
|
"description": "Clawdbot Matrix channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { MatrixClient } from "matrix-js-sdk";
|
|||||||
|
|
||||||
import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js";
|
import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js";
|
||||||
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
|
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
|
||||||
import { danger } from "../../../../../src/globals.js";
|
import { danger, logVerbose } from "../../../../../src/globals.js";
|
||||||
import type { RuntimeEnv } from "../../../../../src/runtime.js";
|
import type { RuntimeEnv } from "../../../../../src/runtime.js";
|
||||||
import { sendMessageMatrix } from "../send.js";
|
import { sendMessageMatrix } from "../send.js";
|
||||||
|
|
||||||
@@ -18,7 +18,12 @@ export async function deliverMatrixReplies(params: {
|
|||||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||||
let hasReplied = false;
|
let hasReplied = false;
|
||||||
for (const reply of params.replies) {
|
for (const reply of params.replies) {
|
||||||
if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
|
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||||
|
if (!reply?.text && !hasMedia) {
|
||||||
|
if (reply?.audioAsVoice) {
|
||||||
|
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
params.runtime.error?.(danger("matrix reply missing text/media"));
|
params.runtime.error?.(danger("matrix reply missing text/media"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -57,6 +62,7 @@ export async function deliverMatrixReplies(params: {
|
|||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||||
threadId: params.threadId,
|
threadId: params.threadId,
|
||||||
|
audioAsVoice: reply.audioAsVoice,
|
||||||
});
|
});
|
||||||
if (shouldIncludeReply(replyToId)) {
|
if (shouldIncludeReply(replyToId)) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parsePollStartContent } from "./poll-types.js";
|
||||||
|
|
||||||
|
describe("parsePollStartContent", () => {
|
||||||
|
it("parses legacy m.poll payloads", () => {
|
||||||
|
const summary = parsePollStartContent({
|
||||||
|
"m.poll": {
|
||||||
|
question: { "m.text": "Lunch?" },
|
||||||
|
kind: "m.poll.disclosed",
|
||||||
|
max_selections: 1,
|
||||||
|
answers: [
|
||||||
|
{ id: "answer1", "m.text": "Yes" },
|
||||||
|
{ id: "answer2", "m.text": "No" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(summary?.question).toBe("Lunch?");
|
||||||
|
expect(summary?.answers).toEqual(["Yes", "No"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,15 +7,17 @@
|
|||||||
* - m.poll.end - Closes a poll
|
* - m.poll.end - Closes a poll
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js";
|
||||||
|
import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js";
|
||||||
import type { PollInput } from "../../../../src/polls.js";
|
import type { PollInput } from "../../../../src/polls.js";
|
||||||
|
|
||||||
export const M_POLL_START = "m.poll.start";
|
export const M_POLL_START = "m.poll.start" as const;
|
||||||
export const M_POLL_RESPONSE = "m.poll.response";
|
export const M_POLL_RESPONSE = "m.poll.response" as const;
|
||||||
export const M_POLL_END = "m.poll.end";
|
export const M_POLL_END = "m.poll.end" as const;
|
||||||
|
|
||||||
export const ORG_POLL_START = "org.matrix.msc3381.poll.start";
|
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
|
||||||
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response";
|
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
|
||||||
export const ORG_POLL_END = "org.matrix.msc3381.poll.end";
|
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
|
||||||
|
|
||||||
export const POLL_EVENT_TYPES = [
|
export const POLL_EVENT_TYPES = [
|
||||||
M_POLL_START,
|
M_POLL_START,
|
||||||
@@ -32,9 +34,7 @@ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
|
|||||||
|
|
||||||
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
|
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
|
||||||
|
|
||||||
export type TextContent = {
|
export type TextContent = ExtensibleAnyMessageEventContent & {
|
||||||
"m.text"?: string;
|
|
||||||
"org.matrix.msc1767.text"?: string;
|
|
||||||
body?: string;
|
body?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,25 +42,19 @@ export type PollAnswer = {
|
|||||||
id: string;
|
id: string;
|
||||||
} & TextContent;
|
} & TextContent;
|
||||||
|
|
||||||
export type PollStartContent = {
|
export type PollStartSubtype = {
|
||||||
"m.poll"?: {
|
question: TextContent;
|
||||||
question: TextContent;
|
kind?: PollKind;
|
||||||
kind?: PollKind;
|
max_selections?: number;
|
||||||
max_selections?: number;
|
answers: PollAnswer[];
|
||||||
answers: PollAnswer[];
|
|
||||||
};
|
|
||||||
"org.matrix.msc3381.poll.start"?: {
|
|
||||||
question: TextContent;
|
|
||||||
kind?: PollKind;
|
|
||||||
max_selections?: number;
|
|
||||||
answers: PollAnswer[];
|
|
||||||
};
|
|
||||||
"m.relates_to"?: {
|
|
||||||
rel_type: "m.reference";
|
|
||||||
event_id: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LegacyPollStartContent = {
|
||||||
|
"m.poll"?: PollStartSubtype;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent;
|
||||||
|
|
||||||
export type PollSummary = {
|
export type PollSummary = {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -82,7 +76,9 @@ export function getTextContent(text?: TextContent): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
|
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
|
||||||
const poll = content["m.poll"] ?? content["org.matrix.msc3381.poll.start"];
|
const poll = (content as Record<string, PollStartSubtype | undefined>)[M_POLL_START]
|
||||||
|
?? (content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START]
|
||||||
|
?? (content as Record<string, PollStartSubtype | undefined>)["m.poll"];
|
||||||
if (!poll) return null;
|
if (!poll) return null;
|
||||||
|
|
||||||
const question = getTextContent(poll.question);
|
const question = getTextContent(poll.question);
|
||||||
@@ -121,6 +117,11 @@ function buildTextContent(body: string): TextContent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPollFallbackText(question: string, answers: string[]): string {
|
||||||
|
if (answers.length === 0) return question;
|
||||||
|
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPollStartContent(poll: PollInput): PollStartContent {
|
export function buildPollStartContent(poll: PollInput): PollStartContent {
|
||||||
const question = poll.question.trim();
|
const question = poll.question.trim();
|
||||||
const answers = poll.options
|
const answers = poll.options
|
||||||
@@ -132,13 +133,19 @@ export function buildPollStartContent(poll: PollInput): PollStartContent {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1;
|
const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1;
|
||||||
|
const fallbackText = buildPollFallbackText(
|
||||||
|
question,
|
||||||
|
answers.map((answer) => getTextContent(answer)),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"m.poll": {
|
[M_POLL_START]: {
|
||||||
question: buildTextContent(question),
|
question: buildTextContent(question),
|
||||||
kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed",
|
kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed",
|
||||||
max_selections: maxSelections,
|
max_selections: maxSelections,
|
||||||
answers,
|
answers,
|
||||||
},
|
},
|
||||||
|
"m.text": fallbackText,
|
||||||
|
"org.matrix.msc1767.text": fallbackText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,11 @@ vi.mock("../../../../src/web/media.js", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../../src/media/image-ops.js", () => ({
|
||||||
|
getImageMetadata: vi.fn().mockResolvedValue(null),
|
||||||
|
resizeToJpeg: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||||
|
|
||||||
const makeClient = () => {
|
const makeClient = () => {
|
||||||
@@ -65,13 +70,13 @@ describe("sendMessageMatrix media", () => {
|
|||||||
const uploadArg = uploadContent.mock.calls[0]?.[0];
|
const uploadArg = uploadContent.mock.calls[0]?.[0];
|
||||||
expect(Buffer.isBuffer(uploadArg)).toBe(true);
|
expect(Buffer.isBuffer(uploadArg)).toBe(true);
|
||||||
|
|
||||||
const content = sendMessage.mock.calls[0]?.[2] as {
|
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||||
url?: string;
|
url?: string;
|
||||||
msgtype?: string;
|
msgtype?: string;
|
||||||
format?: string;
|
format?: string;
|
||||||
formatted_body?: string;
|
formatted_body?: string;
|
||||||
};
|
};
|
||||||
expect(content.msgtype).toBe("m.file");
|
expect(content.msgtype).toBe("m.image");
|
||||||
expect(content.format).toBe("org.matrix.custom.html");
|
expect(content.format).toBe("org.matrix.custom.html");
|
||||||
expect(content.formatted_body).toContain("caption");
|
expect(content.formatted_body).toContain("caption");
|
||||||
expect(content.url).toBe("mxc://example/file");
|
expect(content.url).toBe("mxc://example/file");
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
|
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
|
||||||
import { EventType, MsgType, RelationType } from "matrix-js-sdk";
|
import { EventType, MsgType, RelationType } from "matrix-js-sdk";
|
||||||
import type {
|
import type {
|
||||||
ReactionEventContent,
|
|
||||||
RoomMessageEventContent,
|
RoomMessageEventContent,
|
||||||
|
ReactionEventContent,
|
||||||
} from "matrix-js-sdk/lib/@types/events.js";
|
} from "matrix-js-sdk/lib/@types/events.js";
|
||||||
|
|
||||||
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
|
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
|
||||||
import { loadConfig } from "../../../../src/config/config.js";
|
import { loadConfig } from "../../../../src/config/config.js";
|
||||||
|
import { isVoiceCompatibleAudio } from "../../../../src/media/audio.js";
|
||||||
|
import { mediaKindFromMime } from "../../../../src/media/constants.js";
|
||||||
|
import { getImageMetadata, resizeToJpeg } from "../../../../src/media/image-ops.js";
|
||||||
import type { PollInput } from "../../../../src/polls.js";
|
import type { PollInput } from "../../../../src/polls.js";
|
||||||
import { loadWebMedia } from "../../../../src/web/media.js";
|
import { loadWebMedia } from "../../../../src/web/media.js";
|
||||||
import { getActiveMatrixClient } from "./active-client.js";
|
import { getActiveMatrixClient } from "./active-client.js";
|
||||||
@@ -47,6 +50,8 @@ export type MatrixSendOpts = {
|
|||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
threadId?: string | number | null;
|
threadId?: string | number | null;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
||||||
|
audioAsVoice?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ensureNodeRuntime() {
|
function ensureNodeRuntime() {
|
||||||
@@ -71,6 +76,12 @@ function normalizeTarget(raw: string): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeThreadId(raw?: string | number | null): string | null {
|
||||||
|
if (raw === undefined || raw === null) return null;
|
||||||
|
const trimmed = String(raw).trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
||||||
const trimmed = userId.trim();
|
const trimmed = userId.trim();
|
||||||
if (!trimmed.startsWith("@")) {
|
if (!trimmed.startsWith("@")) {
|
||||||
@@ -119,6 +130,18 @@ export async function resolveMatrixRoomId(
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MatrixImageInfo = {
|
||||||
|
w?: number;
|
||||||
|
h?: number;
|
||||||
|
thumbnail_url?: string;
|
||||||
|
thumbnail_info?: {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
function buildMediaContent(params: {
|
function buildMediaContent(params: {
|
||||||
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
|
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -127,8 +150,24 @@ function buildMediaContent(params: {
|
|||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
size: number;
|
size: number;
|
||||||
relation?: MatrixReplyRelation;
|
relation?: MatrixReplyRelation;
|
||||||
|
isVoice?: boolean;
|
||||||
|
durationMs?: number;
|
||||||
|
imageInfo?: MatrixImageInfo;
|
||||||
}): RoomMessageEventContent {
|
}): RoomMessageEventContent {
|
||||||
const info = { mimetype: params.mimetype, size: params.size };
|
const info: Record<string, unknown> = { mimetype: params.mimetype, size: params.size };
|
||||||
|
if (params.durationMs !== undefined) {
|
||||||
|
info.duration = params.durationMs;
|
||||||
|
}
|
||||||
|
if (params.imageInfo) {
|
||||||
|
if (params.imageInfo.w) info.w = params.imageInfo.w;
|
||||||
|
if (params.imageInfo.h) info.h = params.imageInfo.h;
|
||||||
|
if (params.imageInfo.thumbnail_url) {
|
||||||
|
info.thumbnail_url = params.imageInfo.thumbnail_url;
|
||||||
|
if (params.imageInfo.thumbnail_info) {
|
||||||
|
info.thumbnail_info = params.imageInfo.thumbnail_info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const base: MatrixMessageContent = {
|
const base: MatrixMessageContent = {
|
||||||
msgtype: params.msgtype,
|
msgtype: params.msgtype,
|
||||||
body: params.body,
|
body: params.body,
|
||||||
@@ -136,6 +175,12 @@ function buildMediaContent(params: {
|
|||||||
info,
|
info,
|
||||||
url: params.url,
|
url: params.url,
|
||||||
};
|
};
|
||||||
|
if (params.isVoice) {
|
||||||
|
base["org.matrix.msc3245.voice"] = {};
|
||||||
|
base["org.matrix.msc1767.audio"] = {
|
||||||
|
duration: params.durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (params.relation) {
|
if (params.relation) {
|
||||||
base["m.relates_to"] = params.relation;
|
base["m.relates_to"] = params.relation;
|
||||||
}
|
}
|
||||||
@@ -171,6 +216,75 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
|
|||||||
return { "m.in_reply_to": { event_id: trimmed } };
|
return { "m.in_reply_to": { event_id: trimmed } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMatrixMsgType(
|
||||||
|
contentType?: string,
|
||||||
|
fileName?: string,
|
||||||
|
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
||||||
|
const kind = mediaKindFromMime(contentType ?? "");
|
||||||
|
switch (kind) {
|
||||||
|
case "image":
|
||||||
|
return MsgType.Image;
|
||||||
|
case "audio":
|
||||||
|
return MsgType.Audio;
|
||||||
|
case "video":
|
||||||
|
return MsgType.Video;
|
||||||
|
default:
|
||||||
|
return MsgType.File;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMatrixVoiceDecision(opts: {
|
||||||
|
wantsVoice: boolean;
|
||||||
|
contentType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
}): { useVoice: boolean } {
|
||||||
|
if (!opts.wantsVoice) return { useVoice: false };
|
||||||
|
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
||||||
|
return { useVoice: true };
|
||||||
|
}
|
||||||
|
return { useVoice: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const THUMBNAIL_MAX_SIDE = 800;
|
||||||
|
const THUMBNAIL_QUALITY = 80;
|
||||||
|
|
||||||
|
async function prepareImageInfo(params: {
|
||||||
|
buffer: Buffer;
|
||||||
|
client: MatrixClient;
|
||||||
|
}): Promise<MatrixImageInfo | undefined> {
|
||||||
|
const meta = await getImageMetadata(params.buffer).catch(() => null);
|
||||||
|
if (!meta) return undefined;
|
||||||
|
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
||||||
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
|
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||||
|
try {
|
||||||
|
const thumbBuffer = await resizeToJpeg({
|
||||||
|
buffer: params.buffer,
|
||||||
|
maxSide: THUMBNAIL_MAX_SIDE,
|
||||||
|
quality: THUMBNAIL_QUALITY,
|
||||||
|
withoutEnlargement: true,
|
||||||
|
});
|
||||||
|
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
|
||||||
|
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
|
||||||
|
type: "image/jpeg",
|
||||||
|
name: "thumbnail.jpg",
|
||||||
|
});
|
||||||
|
imageInfo.thumbnail_url = thumbUri.content_uri;
|
||||||
|
if (thumbMeta) {
|
||||||
|
imageInfo.thumbnail_info = {
|
||||||
|
w: thumbMeta.width,
|
||||||
|
h: thumbMeta.height,
|
||||||
|
mimetype: "image/jpeg",
|
||||||
|
size: thumbBuffer.byteLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Thumbnail generation failed, continue without it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadFile(
|
async function uploadFile(
|
||||||
client: MatrixClient,
|
client: MatrixClient,
|
||||||
file: MatrixUploadContent | Buffer,
|
file: MatrixUploadContent | Buffer,
|
||||||
@@ -238,14 +352,10 @@ export async function sendMessageMatrix(
|
|||||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
||||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||||
const rawThreadId = opts.threadId;
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const threadId =
|
|
||||||
rawThreadId !== undefined && rawThreadId !== null
|
|
||||||
? String(rawThreadId).trim()
|
|
||||||
: null;
|
|
||||||
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
||||||
const sendContent = (content: RoomMessageEventContent) =>
|
const sendContent = (content: RoomMessageEventContent) =>
|
||||||
client.sendMessage(roomId, threadId ?? undefined, content);
|
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content);
|
||||||
|
|
||||||
let lastMessageId = "";
|
let lastMessageId = "";
|
||||||
if (opts.mediaUrl) {
|
if (opts.mediaUrl) {
|
||||||
@@ -255,9 +365,17 @@ export async function sendMessageMatrix(
|
|||||||
contentType: media.contentType,
|
contentType: media.contentType,
|
||||||
filename: media.fileName,
|
filename: media.fileName,
|
||||||
});
|
});
|
||||||
const msgtype = MsgType.File;
|
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
|
||||||
|
const { useVoice } = resolveMatrixVoiceDecision({
|
||||||
|
wantsVoice: opts.audioAsVoice === true,
|
||||||
|
contentType: media.contentType,
|
||||||
|
fileName: media.fileName,
|
||||||
|
});
|
||||||
|
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
|
||||||
|
const isImage = msgtype === MsgType.Image;
|
||||||
|
const imageInfo = isImage ? await prepareImageInfo({ buffer: media.buffer, client }) : undefined;
|
||||||
const [firstChunk, ...rest] = chunks;
|
const [firstChunk, ...rest] = chunks;
|
||||||
const body = firstChunk ?? media.fileName ?? "(file)";
|
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
|
||||||
const content = buildMediaContent({
|
const content = buildMediaContent({
|
||||||
msgtype,
|
msgtype,
|
||||||
body,
|
body,
|
||||||
@@ -266,10 +384,13 @@ export async function sendMessageMatrix(
|
|||||||
mimetype: media.contentType,
|
mimetype: media.contentType,
|
||||||
size: media.buffer.byteLength,
|
size: media.buffer.byteLength,
|
||||||
relation,
|
relation,
|
||||||
|
isVoice: useVoice,
|
||||||
|
imageInfo,
|
||||||
});
|
});
|
||||||
const response = await sendContent(content);
|
const response = await sendContent(content);
|
||||||
lastMessageId = response.event_id ?? lastMessageId;
|
lastMessageId = response.event_id ?? lastMessageId;
|
||||||
for (const chunk of rest) {
|
const textChunks = useVoice ? chunks : rest;
|
||||||
|
for (const chunk of textChunks) {
|
||||||
const text = chunk.trim();
|
const text = chunk.trim();
|
||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
const followup = buildTextContent(text);
|
const followup = buildTextContent(text);
|
||||||
@@ -316,17 +437,19 @@ export async function sendPollMatrix(
|
|||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
const pollContent = buildPollStartContent(poll);
|
const pollContent = buildPollStartContent(poll);
|
||||||
const rawThreadId = opts.threadId;
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const threadId =
|
const response = threadId
|
||||||
rawThreadId !== undefined && rawThreadId !== null
|
? await client.sendEvent(
|
||||||
? String(rawThreadId).trim()
|
roomId,
|
||||||
: null;
|
threadId,
|
||||||
const response = await client.sendEvent(
|
M_POLL_START,
|
||||||
roomId,
|
pollContent,
|
||||||
threadId ?? undefined,
|
)
|
||||||
M_POLL_START as EventType.RoomMessage,
|
: await client.sendEvent(
|
||||||
pollContent as unknown as RoomMessageEventContent,
|
roomId,
|
||||||
);
|
M_POLL_START,
|
||||||
|
pollContent,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eventId: response.event_id ?? "unknown",
|
eventId: response.event_id ?? "unknown",
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 2026.1.16
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/msteams",
|
"name": "@clawdbot/msteams",
|
||||||
"version": "2026.1.16",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
|||||||
|
|
||||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||||
import { msteamsOutbound } from "./outbound.js";
|
import { msteamsOutbound } from "./outbound.js";
|
||||||
|
import { probeMSTeams } from "./probe.js";
|
||||||
import { sendMessageMSTeams } from "./send.js";
|
import { sendMessageMSTeams } from "./send.js";
|
||||||
import { resolveMSTeamsCredentials } from "./token.js";
|
import { resolveMSTeamsCredentials } from "./token.js";
|
||||||
|
|
||||||
@@ -218,7 +219,8 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
probe: snapshot.probe,
|
probe: snapshot.probe,
|
||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
}),
|
}),
|
||||||
buildAccountSnapshot: ({ account, runtime }) => ({
|
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
|
||||||
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
@@ -227,6 +229,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
lastError: runtime?.lastError ?? null,
|
lastError: runtime?.lastError ?? null,
|
||||||
port: runtime?.port ?? null,
|
port: runtime?.port ?? null,
|
||||||
|
probe,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
|
|||||||
@@ -7,8 +7,52 @@ export type ProbeMSTeamsResult = {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
appId?: 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> {
|
export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsResult> {
|
||||||
const creds = resolveMSTeamsCredentials(cfg);
|
const creds = resolveMSTeamsCredentials(cfg);
|
||||||
if (!creds) {
|
if (!creds) {
|
||||||
@@ -22,7 +66,29 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
|||||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||||
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
|
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) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 2026.1.16
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/voice-call",
|
"name": "@clawdbot/voice-call",
|
||||||
"version": "2026.1.16",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot voice-call plugin",
|
"description": "Clawdbot voice-call plugin",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 2026.1.16
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalo",
|
"name": "@clawdbot/zalo",
|
||||||
"version": "2026.1.16",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo channel plugin",
|
"description": "Clawdbot Zalo channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalouser",
|
"name": "@clawdbot/zalouser",
|
||||||
"version": "2026.1.16",
|
"version": "2026.1.17-1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.16-2",
|
"version": "2026.1.17-1",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -174,10 +174,12 @@
|
|||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
"sqlite-vec": "0.1.7-alpha.2",
|
||||||
"tar": "^7.5.3",
|
"tar": "^7.5.3",
|
||||||
"tslog": "^4.10.2",
|
"tslog": "^4.10.2",
|
||||||
"undici": "^7.18.2",
|
"undici": "^7.18.2",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|||||||
Generated
+57
@@ -133,6 +133,9 @@ importers:
|
|||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.34.5
|
specifier: ^0.34.5
|
||||||
version: 0.34.5
|
version: 0.34.5
|
||||||
|
sqlite-vec:
|
||||||
|
specifier: 0.1.7-alpha.2
|
||||||
|
version: 0.1.7-alpha.2
|
||||||
tar:
|
tar:
|
||||||
specifier: 7.5.3
|
specifier: 7.5.3
|
||||||
version: 7.5.3
|
version: 7.5.3
|
||||||
@@ -145,6 +148,9 @@ importers:
|
|||||||
ws:
|
ws:
|
||||||
specifier: ^8.19.0
|
specifier: ^8.19.0
|
||||||
version: 8.19.0
|
version: 8.19.0
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.8.2
|
||||||
|
version: 2.8.2
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
@@ -3910,6 +3916,34 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
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:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
@@ -8563,6 +8597,29 @@ snapshots:
|
|||||||
|
|
||||||
split2@4.2.0: {}
|
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: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"latitudeki5223",
|
"latitudeki5223",
|
||||||
"longmaba",
|
"longmaba",
|
||||||
"manmal",
|
"manmal",
|
||||||
"thesash"
|
"thesash",
|
||||||
|
"rhjoh"
|
||||||
],
|
],
|
||||||
"seedCommit": "d6863f87",
|
"seedCommit": "d6863f87",
|
||||||
"placeholderAvatar": "assets/avatar-placeholder.svg",
|
"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
|
# bird
|
||||||
|
|
||||||
Use `bird` to read/search X and post tweets/replies.
|
## Reading vs Writing - Different Tools
|
||||||
|
|
||||||
Quick start
|
**For READING tweets** (works great):
|
||||||
- `bird whoami`
|
- Use the `bird` CLI - it's fast and reliable
|
||||||
- `bird read <url-or-id>`
|
- `bird read <url-or-id>` - grab a specific tweet
|
||||||
- `bird thread <url-or-id>`
|
- `bird search "query" -n 5` - search tweets
|
||||||
- `bird search "query" -n 5`
|
- `bird mentions` - check notifications
|
||||||
|
|
||||||
Posting (confirm with user first)
|
**For WRITING tweets** (here's where it gets spicy):
|
||||||
- `bird tweet "text"`
|
- **Don't use the CLI for posting** - Twitter flags it as automated and you'll get rate limited or soft-banned fast
|
||||||
- `bird reply <id-or-url> "text"`
|
- **Use the browser tool instead** - mimics real human behavior
|
||||||
|
|
||||||
Auth sources
|
## Quick Reference (Reading Only)
|
||||||
- Browser cookies (default: Firefox/Chrome)
|
|
||||||
- Sweetistics API: set `SWEETISTICS_API_KEY` or use `--engine sweetistics`
|
```bash
|
||||||
- Check sources: `bird check`
|
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;
|
allowBackground?: boolean;
|
||||||
scopeKey?: string;
|
scopeKey?: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
messageProvider?: string;
|
||||||
notifyOnExit?: boolean;
|
notifyOnExit?: boolean;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
};
|
};
|
||||||
@@ -220,6 +221,11 @@ export function createExecTool(
|
|||||||
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
||||||
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
|
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
|
||||||
const gates: string[] = [];
|
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) {
|
if (!elevatedDefaults?.enabled) {
|
||||||
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
|
gates.push("enabled (tools.elevated.enabled / agents.list[].tools.elevated.enabled)");
|
||||||
} else {
|
} else {
|
||||||
@@ -231,12 +237,15 @@ export function createExecTool(
|
|||||||
[
|
[
|
||||||
`elevated is not available right now (runtime=${runtime}).`,
|
`elevated is not available right now (runtime=${runtime}).`,
|
||||||
`Failing gates: ${gates.join(", ")}`,
|
`Failing gates: ${gates.join(", ")}`,
|
||||||
|
contextParts.length > 0 ? `Context: ${contextParts.join(" ")}` : undefined,
|
||||||
"Fix-it keys:",
|
"Fix-it keys:",
|
||||||
"- tools.elevated.enabled",
|
"- tools.elevated.enabled",
|
||||||
"- tools.elevated.allowFrom.<provider>",
|
"- tools.elevated.allowFrom.<provider>",
|
||||||
"- agents.list[].tools.elevated.enabled",
|
"- agents.list[].tools.elevated.enabled",
|
||||||
"- agents.list[].tools.elevated.allowFrom.<provider>",
|
"- agents.list[].tools.elevated.allowFrom.<provider>",
|
||||||
].join("\n"),
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
logInfo(
|
logInfo(
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ describe("exec tool backgrounding", () => {
|
|||||||
it("rejects elevated requests when not allowed", async () => {
|
it("rejects elevated requests when not allowed", async () => {
|
||||||
const customBash = createExecTool({
|
const customBash = createExecTool({
|
||||||
elevated: { enabled: true, allowed: false, defaultLevel: "off" },
|
elevated: { enabled: true, allowed: false, defaultLevel: "off" },
|
||||||
|
messageProvider: "telegram",
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -157,7 +159,7 @@ describe("exec tool backgrounding", () => {
|
|||||||
command: "echo hi",
|
command: "echo hi",
|
||||||
elevated: true,
|
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 () => {
|
it("does not default to elevated when not allowed", async () => {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ describe("memory search config", () => {
|
|||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
|
store: {
|
||||||
|
vector: {
|
||||||
|
enabled: false,
|
||||||
|
extensionPath: "/opt/sqlite-vec.dylib",
|
||||||
|
},
|
||||||
|
},
|
||||||
chunking: { tokens: 500, overlap: 100 },
|
chunking: { tokens: 500, overlap: 100 },
|
||||||
query: { maxResults: 4, minScore: 0.2 },
|
query: { maxResults: 4, minScore: 0.2 },
|
||||||
},
|
},
|
||||||
@@ -40,6 +46,11 @@ describe("memory search config", () => {
|
|||||||
memorySearch: {
|
memorySearch: {
|
||||||
chunking: { tokens: 320 },
|
chunking: { tokens: 320 },
|
||||||
query: { maxResults: 8 },
|
query: { maxResults: 8 },
|
||||||
|
store: {
|
||||||
|
vector: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -52,6 +63,8 @@ describe("memory search config", () => {
|
|||||||
expect(resolved?.chunking.overlap).toBe(100);
|
expect(resolved?.chunking.overlap).toBe(100);
|
||||||
expect(resolved?.query.maxResults).toBe(8);
|
expect(resolved?.query.maxResults).toBe(8);
|
||||||
expect(resolved?.query.minScore).toBe(0.2);
|
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", () => {
|
it("merges remote defaults with agent overrides", () => {
|
||||||
@@ -84,6 +97,50 @@ describe("memory search config", () => {
|
|||||||
baseUrl: "https://agent.example/v1",
|
baseUrl: "https://agent.example/v1",
|
||||||
apiKey: "default-key",
|
apiKey: "default-key",
|
||||||
headers: { "X-Default": "on" },
|
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 = {
|
export type ResolvedMemorySearchConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
sources: Array<"memory" | "sessions">;
|
||||||
provider: "openai" | "local";
|
provider: "openai" | "local";
|
||||||
remote?: {
|
remote?: {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
batch?: {
|
||||||
|
enabled: boolean;
|
||||||
|
wait: boolean;
|
||||||
|
pollIntervalMs: number;
|
||||||
|
timeoutMinutes: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
experimental: {
|
||||||
|
sessionMemory: boolean;
|
||||||
};
|
};
|
||||||
fallback: "openai" | "none";
|
fallback: "openai" | "none";
|
||||||
model: string;
|
model: string;
|
||||||
@@ -23,6 +33,10 @@ export type ResolvedMemorySearchConfig = {
|
|||||||
store: {
|
store: {
|
||||||
driver: "sqlite";
|
driver: "sqlite";
|
||||||
path: string;
|
path: string;
|
||||||
|
vector: {
|
||||||
|
enabled: boolean;
|
||||||
|
extensionPath?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
chunking: {
|
chunking: {
|
||||||
tokens: number;
|
tokens: number;
|
||||||
@@ -47,6 +61,21 @@ const DEFAULT_CHUNK_OVERLAP = 80;
|
|||||||
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
|
const DEFAULT_WATCH_DEBOUNCE_MS = 1500;
|
||||||
const DEFAULT_MAX_RESULTS = 6;
|
const DEFAULT_MAX_RESULTS = 6;
|
||||||
const DEFAULT_MIN_SCORE = 0.35;
|
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 {
|
function resolveStorePath(agentId: string, raw?: string): string {
|
||||||
const stateDir = resolveStateDir(process.env, os.homedir);
|
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||||
@@ -62,13 +91,28 @@ function mergeConfig(
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
): ResolvedMemorySearchConfig {
|
): ResolvedMemorySearchConfig {
|
||||||
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
||||||
|
const sessionMemory =
|
||||||
|
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
|
||||||
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
|
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
|
||||||
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
|
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
|
const remote = hasRemote
|
||||||
? {
|
? {
|
||||||
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
|
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
|
||||||
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
|
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
|
||||||
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
|
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
|
||||||
|
batch,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
|
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
|
||||||
@@ -77,9 +121,16 @@ function mergeConfig(
|
|||||||
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
|
||||||
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
|
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 = {
|
const store = {
|
||||||
driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite",
|
driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite",
|
||||||
path: resolveStorePath(agentId, overrides?.store?.path ?? defaults?.store?.path),
|
path: resolveStorePath(agentId, overrides?.store?.path ?? defaults?.store?.path),
|
||||||
|
vector,
|
||||||
};
|
};
|
||||||
const chunking = {
|
const chunking = {
|
||||||
tokens: overrides?.chunking?.tokens ?? defaults?.chunking?.tokens ?? DEFAULT_CHUNK_TOKENS,
|
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));
|
const minScore = Math.max(0, Math.min(1, query.minScore));
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
|
sources,
|
||||||
provider,
|
provider,
|
||||||
remote,
|
remote,
|
||||||
|
experimental: {
|
||||||
|
sessionMemory,
|
||||||
|
},
|
||||||
fallback,
|
fallback,
|
||||||
model,
|
model,
|
||||||
local,
|
local,
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
|||||||
openrouter: "OPENROUTER_API_KEY",
|
openrouter: "OPENROUTER_API_KEY",
|
||||||
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
||||||
moonshot: "MOONSHOT_API_KEY",
|
moonshot: "MOONSHOT_API_KEY",
|
||||||
|
"kimi-code": "KIMICODE_API_KEY",
|
||||||
minimax: "MINIMAX_API_KEY",
|
minimax: "MINIMAX_API_KEY",
|
||||||
synthetic: "SYNTHETIC_API_KEY",
|
synthetic: "SYNTHETIC_API_KEY",
|
||||||
mistral: "MISTRAL_API_KEY",
|
mistral: "MISTRAL_API_KEY",
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ const MOONSHOT_DEFAULT_COST = {
|
|||||||
cacheRead: 0,
|
cacheRead: 0,
|
||||||
cacheWrite: 0,
|
cacheWrite: 0,
|
||||||
};
|
};
|
||||||
|
const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
|
||||||
|
const KIMI_CODE_MODEL_ID = "kimi-for-coding";
|
||||||
|
const KIMI_CODE_CONTEXT_WINDOW = 262144;
|
||||||
|
const KIMI_CODE_MAX_TOKENS = 32768;
|
||||||
|
const KIMI_CODE_HEADERS = { "User-Agent": "KimiCLI/0.77" } as const;
|
||||||
|
const KIMI_CODE_COMPAT = { supportsDeveloperRole: false } as const;
|
||||||
|
const KIMI_CODE_DEFAULT_COST = {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeApiKeyConfig(value: string): string {
|
function normalizeApiKeyConfig(value: string): string {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -184,6 +196,26 @@ function buildMoonshotProvider(): ProviderConfig {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildKimiCodeProvider(): ProviderConfig {
|
||||||
|
return {
|
||||||
|
baseUrl: KIMI_CODE_BASE_URL,
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: KIMI_CODE_MODEL_ID,
|
||||||
|
name: "Kimi For Coding",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: KIMI_CODE_DEFAULT_COST,
|
||||||
|
contextWindow: KIMI_CODE_CONTEXT_WINDOW,
|
||||||
|
maxTokens: KIMI_CODE_MAX_TOKENS,
|
||||||
|
headers: KIMI_CODE_HEADERS,
|
||||||
|
compat: KIMI_CODE_COMPAT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildSyntheticProvider(): ProviderConfig {
|
function buildSyntheticProvider(): ProviderConfig {
|
||||||
return {
|
return {
|
||||||
baseUrl: SYNTHETIC_BASE_URL,
|
baseUrl: SYNTHETIC_BASE_URL,
|
||||||
@@ -212,6 +244,13 @@ export function resolveImplicitProviders(params: { agentDir: string }): ModelsCo
|
|||||||
providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey };
|
providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kimiCodeKey =
|
||||||
|
resolveEnvApiKeyVarName("kimi-code") ??
|
||||||
|
resolveApiKeyFromProfiles({ provider: "kimi-code", store: authStore });
|
||||||
|
if (kimiCodeKey) {
|
||||||
|
providers["kimi-code"] = { ...buildKimiCodeProvider(), apiKey: kimiCodeKey };
|
||||||
|
}
|
||||||
|
|
||||||
const syntheticKey =
|
const syntheticKey =
|
||||||
resolveEnvApiKeyVarName("synthetic") ??
|
resolveEnvApiKeyVarName("synthetic") ??
|
||||||
resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore });
|
resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sanitizeToolResult,
|
sanitizeToolResult,
|
||||||
} from "./pi-embedded-subscribe.tools.js";
|
} from "./pi-embedded-subscribe.tools.js";
|
||||||
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
||||||
|
import { normalizeToolName } from "./tool-policy.js";
|
||||||
|
|
||||||
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
|
function extendExecMeta(toolName: string, args: unknown, meta?: string): string | undefined {
|
||||||
const normalized = toolName.trim().toLowerCase();
|
const normalized = toolName.trim().toLowerCase();
|
||||||
@@ -35,7 +36,8 @@ export async function handleToolExecutionStart(
|
|||||||
void ctx.params.onBlockReplyFlush();
|
void ctx.params.onBlockReplyFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolName = String(evt.toolName);
|
const rawToolName = String(evt.toolName);
|
||||||
|
const toolName = normalizeToolName(rawToolName);
|
||||||
const toolCallId = String(evt.toolCallId);
|
const toolCallId = String(evt.toolCallId);
|
||||||
const args = evt.args;
|
const args = evt.args;
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ export function handleToolExecutionUpdate(
|
|||||||
partialResult?: unknown;
|
partialResult?: unknown;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const toolName = String(evt.toolName);
|
const toolName = normalizeToolName(String(evt.toolName));
|
||||||
const toolCallId = String(evt.toolCallId);
|
const toolCallId = String(evt.toolCallId);
|
||||||
const partial = evt.partialResult;
|
const partial = evt.partialResult;
|
||||||
const sanitized = sanitizeToolResult(partial);
|
const sanitized = sanitizeToolResult(partial);
|
||||||
@@ -142,7 +144,7 @@ export function handleToolExecutionEnd(
|
|||||||
result?: unknown;
|
result?: unknown;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const toolName = String(evt.toolName);
|
const toolName = normalizeToolName(String(evt.toolName));
|
||||||
const toolCallId = String(evt.toolCallId);
|
const toolCallId = String(evt.toolCallId);
|
||||||
const isError = Boolean(evt.isError);
|
const isError = Boolean(evt.isError);
|
||||||
const result = evt.result;
|
const result = evt.result;
|
||||||
|
|||||||
@@ -25,4 +25,25 @@ describe("pi tool definition adapter", () => {
|
|||||||
expect(result.details).toMatchObject({ error: "nope" });
|
expect(result.details).toMatchObject({ error: "nope" });
|
||||||
expect(JSON.stringify(result.details)).not.toContain("\n at ");
|
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";
|
} from "@mariozechner/pi-agent-core";
|
||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import { logDebug, logError } from "../logger.js";
|
import { logDebug, logError } from "../logger.js";
|
||||||
|
import { normalizeToolName } from "./tool-policy.js";
|
||||||
import { jsonResult } from "./tools/common.js";
|
import { jsonResult } from "./tools/common.js";
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
|
// 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[] {
|
export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
|
||||||
return tools.map((tool) => {
|
return tools.map((tool) => {
|
||||||
const name = tool.name || "tool";
|
const name = tool.name || "tool";
|
||||||
|
const normalizedName = normalizeToolName(name);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
label: tool.label ?? name,
|
label: tool.label ?? name,
|
||||||
@@ -50,12 +52,12 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
|
|||||||
if (name === "AbortError") throw err;
|
if (name === "AbortError") throw err;
|
||||||
const described = describeToolExecutionError(err);
|
const described = describeToolExecutionError(err);
|
||||||
if (described.stack && described.stack !== described.message) {
|
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({
|
return jsonResult({
|
||||||
status: "error",
|
status: "error",
|
||||||
tool: tool.name,
|
tool: normalizedName,
|
||||||
error: described.message,
|
error: described.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
allowBackground,
|
allowBackground,
|
||||||
scopeKey,
|
scopeKey,
|
||||||
sessionKey: options?.sessionKey,
|
sessionKey: options?.sessionKey,
|
||||||
|
messageProvider: options?.messageProvider,
|
||||||
sandbox: sandbox
|
sandbox: sandbox
|
||||||
? {
|
? {
|
||||||
containerName: sandbox.containerName,
|
containerName: sandbox.containerName,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|||||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import { makeMissingToolResult } from "./session-transcript-repair.js";
|
import { makeMissingToolResult } from "./session-transcript-repair.js";
|
||||||
|
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||||
|
|
||||||
type ToolCall = { id: string; name?: string };
|
type ToolCall = { id: string; name?: string };
|
||||||
|
|
||||||
@@ -111,6 +112,12 @@ export function installSessionToolResultGuard(sessionManager: SessionManager): {
|
|||||||
|
|
||||||
const result = originalAppend(sanitized as never);
|
const result = originalAppend(sanitized as never);
|
||||||
|
|
||||||
|
const sessionFile = (sessionManager as { getSessionFile?: () => string | null })
|
||||||
|
.getSessionFile?.();
|
||||||
|
if (sessionFile) {
|
||||||
|
emitSessionTranscriptUpdate(sessionFile);
|
||||||
|
}
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
if (toolCalls.length > 0) {
|
||||||
for (const call of toolCalls) {
|
for (const call of toolCalls) {
|
||||||
pending.set(call.id, call.name);
|
pending.set(call.id, call.name);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import JSON5 from "json5";
|
||||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
|
||||||
import type {
|
import type {
|
||||||
ClawdbotSkillMetadata,
|
ClawdbotSkillMetadata,
|
||||||
ParsedSkillFrontmatter,
|
ParsedSkillFrontmatter,
|
||||||
@@ -8,32 +10,8 @@ import type {
|
|||||||
SkillInvocationPolicy,
|
SkillInvocationPolicy,
|
||||||
} from "./types.js";
|
} 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 {
|
export function parseFrontmatter(content: string): ParsedSkillFrontmatter {
|
||||||
const frontmatter: ParsedSkillFrontmatter = {};
|
return parseFrontmatterBlock(content);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStringList(input: unknown): string[] {
|
function normalizeStringList(input: unknown): string[] {
|
||||||
@@ -99,7 +77,7 @@ export function resolveClawdbotMetadata(
|
|||||||
const raw = getFrontmatterValue(frontmatter, "metadata");
|
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||||
if (!raw) return undefined;
|
if (!raw) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as { clawdbot?: unknown };
|
const parsed = JSON5.parse(raw) as { clawdbot?: unknown };
|
||||||
if (!parsed || typeof parsed !== "object") return undefined;
|
if (!parsed || typeof parsed !== "object") return undefined;
|
||||||
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot;
|
||||||
if (!clawdbot || typeof clawdbot !== "object") return undefined;
|
if (!clawdbot || typeof clawdbot !== "object") return undefined;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function createMemorySearchTool(options: {
|
|||||||
label: "Memory Search",
|
label: "Memory Search",
|
||||||
name: "memory_search",
|
name: "memory_search",
|
||||||
description:
|
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,
|
parameters: MemorySearchSchema,
|
||||||
execute: async (_toolCallId, params) => {
|
execute: async (_toolCallId, params) => {
|
||||||
const query = readStringParam(params, "query", { required: true });
|
const query = readStringParam(params, "query", { required: true });
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function buildSendSchema(options: { includeButtons: boolean }) {
|
|||||||
media: Type.Optional(Type.String()),
|
media: Type.Optional(Type.String()),
|
||||||
replyTo: Type.Optional(Type.String()),
|
replyTo: Type.Optional(Type.String()),
|
||||||
threadId: Type.Optional(Type.String()),
|
threadId: Type.Optional(Type.String()),
|
||||||
|
asVoice: Type.Optional(Type.Boolean()),
|
||||||
bestEffort: Type.Optional(Type.Boolean()),
|
bestEffort: Type.Optional(Type.Boolean()),
|
||||||
gifPlayback: Type.Optional(Type.Boolean()),
|
gifPlayback: Type.Optional(Type.Boolean()),
|
||||||
buttons: Type.Optional(
|
buttons: Type.Optional(
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export async function handleTelegramAction(
|
|||||||
buttons,
|
buttons,
|
||||||
replyToMessageId: replyToMessageId ?? undefined,
|
replyToMessageId: replyToMessageId ?? undefined,
|
||||||
messageThreadId: messageThreadId ?? undefined,
|
messageThreadId: messageThreadId ?? undefined,
|
||||||
|
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
|
||||||
});
|
});
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
+6
-1
@@ -104,7 +104,12 @@ describe("trigger handling", () => {
|
|||||||
{
|
{
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
displayName: "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 type { SessionEntry, SessionScope } from "../../config/sessions.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import {
|
import {
|
||||||
formatUsageSummaryLine,
|
formatUsageWindowSummary,
|
||||||
loadProviderUsageSummary,
|
loadProviderUsageSummary,
|
||||||
resolveUsageProviderId,
|
resolveUsageProviderId,
|
||||||
} from "../../infra/provider-usage.js";
|
} from "../../infra/provider-usage.js";
|
||||||
@@ -148,11 +148,15 @@ export async function buildStatusReply(params: {
|
|||||||
providers: [currentUsageProvider],
|
providers: [currentUsageProvider],
|
||||||
agentDir: statusAgentDir,
|
agentDir: statusAgentDir,
|
||||||
});
|
});
|
||||||
const summaryLine = formatUsageSummaryLine(usageSummary, {
|
const usageEntry = usageSummary.providers[0];
|
||||||
now: Date.now(),
|
if (usageEntry && !usageEntry.error && usageEntry.windows.length > 0) {
|
||||||
maxProviders: 1,
|
const summaryLine = formatUsageWindowSummary(usageEntry, {
|
||||||
});
|
now: Date.now(),
|
||||||
if (summaryLine) usageLine = summaryLine;
|
maxWindows: 2,
|
||||||
|
includeResets: true,
|
||||||
|
});
|
||||||
|
if (summaryLine) usageLine = `📊 Usage: ${summaryLine}`;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
usageLine = null;
|
usageLine = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { prependSystemEvents } from "./session-updates.js";
|
|||||||
describe("prependSystemEvents", () => {
|
describe("prependSystemEvents", () => {
|
||||||
it("adds a UTC timestamp to queued system events", async () => {
|
it("adds a UTC timestamp to queued system events", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const timestamp = new Date("2026-01-12T20:19:17");
|
const timestamp = new Date("2026-01-12T20:19:17Z");
|
||||||
vi.setSystemTime(timestamp);
|
vi.setSystemTime(timestamp);
|
||||||
|
|
||||||
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
|
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export type MsgContext = {
|
|||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
ReplyToBody?: string;
|
ReplyToBody?: string;
|
||||||
ReplyToSender?: string;
|
ReplyToSender?: string;
|
||||||
|
ForwardedFrom?: string;
|
||||||
|
ForwardedFromType?: string;
|
||||||
|
ForwardedFromId?: string;
|
||||||
|
ForwardedFromUsername?: string;
|
||||||
|
ForwardedFromTitle?: string;
|
||||||
|
ForwardedFromSignature?: string;
|
||||||
|
ForwardedDate?: number;
|
||||||
ThreadStarterBody?: string;
|
ThreadStarterBody?: string;
|
||||||
ThreadLabel?: string;
|
ThreadLabel?: string;
|
||||||
MediaPath?: string;
|
MediaPath?: string;
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ describe("tool meta formatting", () => {
|
|||||||
expect(out).toContain("`~/dir/a.txt`");
|
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", () => {
|
it("formats prefixes with default labels", () => {
|
||||||
vi.stubEnv("HOME", "/Users/test");
|
vi.stubEnv("HOME", "/Users/test");
|
||||||
expect(formatToolPrefix(undefined, undefined)).toBe("🧩 tool");
|
expect(formatToolPrefix(undefined, undefined)).toBe("🧩 tool");
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function formatToolAggregate(
|
|||||||
|
|
||||||
const allSegments = [...rawSegments, ...segments];
|
const allSegments = [...rawSegments, ...segments];
|
||||||
const meta = allSegments.join("; ");
|
const meta = allSegments.join("; ");
|
||||||
return `${prefix}: ${maybeWrapMarkdown(meta, options?.markdown)}`;
|
return `${prefix}: ${formatMetaForDisplay(toolName, meta, options?.markdown)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatToolPrefix(toolName?: string, meta?: string) {
|
export function formatToolPrefix(toolName?: string, meta?: string) {
|
||||||
@@ -69,6 +69,40 @@ export function formatToolPrefix(toolName?: string, meta?: string) {
|
|||||||
return formatToolSummary(display);
|
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 {
|
function isPathLike(value: string): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
if (value.includes(" ")) 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||||
|
import { telegramMessageActions } from "./telegram.js";
|
||||||
|
|
||||||
|
const handleTelegramAction = vi.fn(async () => ({ ok: true }));
|
||||||
|
|
||||||
|
vi.mock("../../../agents/tools/telegram-actions.js", () => ({
|
||||||
|
handleTelegramAction: (...args: unknown[]) => handleTelegramAction(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("telegramMessageActions", () => {
|
||||||
|
it("allows media-only sends and passes asVoice", async () => {
|
||||||
|
handleTelegramAction.mockClear();
|
||||||
|
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
|
|
||||||
|
await telegramMessageActions.handleAction({
|
||||||
|
action: "send",
|
||||||
|
params: {
|
||||||
|
to: "123",
|
||||||
|
media: "https://example.com/voice.ogg",
|
||||||
|
asVoice: true,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleTelegramAction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "sendMessage",
|
||||||
|
to: "123",
|
||||||
|
content: "",
|
||||||
|
mediaUrl: "https://example.com/voice.ogg",
|
||||||
|
asVoice: true,
|
||||||
|
}),
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,29 @@ import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../t
|
|||||||
|
|
||||||
const providerId = "telegram";
|
const providerId = "telegram";
|
||||||
|
|
||||||
|
function readTelegramSendParams(params: Record<string, unknown>) {
|
||||||
|
const to = readStringParam(params, "to", { required: true });
|
||||||
|
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||||
|
const content =
|
||||||
|
readStringParam(params, "message", {
|
||||||
|
required: !mediaUrl,
|
||||||
|
allowEmpty: true,
|
||||||
|
}) ?? "";
|
||||||
|
const replyTo = readStringParam(params, "replyTo");
|
||||||
|
const threadId = readStringParam(params, "threadId");
|
||||||
|
const buttons = params.buttons;
|
||||||
|
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
|
||||||
|
return {
|
||||||
|
to,
|
||||||
|
content,
|
||||||
|
mediaUrl: mediaUrl ?? undefined,
|
||||||
|
replyToMessageId: replyTo ?? undefined,
|
||||||
|
messageThreadId: threadId ?? undefined,
|
||||||
|
buttons,
|
||||||
|
asVoice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const telegramMessageActions: ChannelMessageActionAdapter = {
|
export const telegramMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const accounts = listEnabledTelegramAccounts(cfg).filter(
|
const accounts = listEnabledTelegramAccounts(cfg).filter(
|
||||||
@@ -41,25 +64,12 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||||
if (action === "send") {
|
if (action === "send") {
|
||||||
const to = readStringParam(params, "to", { required: true });
|
const sendParams = readTelegramSendParams(params);
|
||||||
const content = readStringParam(params, "message", {
|
|
||||||
required: true,
|
|
||||||
allowEmpty: true,
|
|
||||||
});
|
|
||||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
|
||||||
const replyTo = readStringParam(params, "replyTo");
|
|
||||||
const threadId = readStringParam(params, "threadId");
|
|
||||||
const buttons = params.buttons;
|
|
||||||
return await handleTelegramAction(
|
return await handleTelegramAction(
|
||||||
{
|
{
|
||||||
action: "sendMessage",
|
action: "sendMessage",
|
||||||
to,
|
...sendParams,
|
||||||
content,
|
|
||||||
mediaUrl: mediaUrl ?? undefined,
|
|
||||||
replyToMessageId: replyTo ?? undefined,
|
|
||||||
messageThreadId: threadId ?? undefined,
|
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
buttons,
|
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Command } from "commander";
|
|||||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import {
|
import {
|
||||||
channelsAddCommand,
|
channelsAddCommand,
|
||||||
|
channelsCapabilitiesCommand,
|
||||||
channelsListCommand,
|
channelsListCommand,
|
||||||
channelsLogsCommand,
|
channelsLogsCommand,
|
||||||
channelsRemoveCommand,
|
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
|
channels
|
||||||
.command("logs")
|
.command("logs")
|
||||||
.description("Show recent channel logs from the gateway log file")
|
.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 {
|
import {
|
||||||
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
isGatewayDaemonRuntime,
|
isGatewayDaemonRuntime,
|
||||||
} from "../../commands/daemon-runtime.js";
|
} from "../../commands/daemon-runtime.js";
|
||||||
|
import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js";
|
||||||
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||||
import { resolveIsNixMode } from "../../config/paths.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 { resolveGatewayService } from "../../daemon/service.js";
|
||||||
import { buildServiceEnvironment } from "../../daemon/service-env.js";
|
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
||||||
import { parsePort } from "./shared.js";
|
import { parsePort } from "./shared.js";
|
||||||
@@ -96,34 +88,15 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const devMode =
|
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||||
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({
|
|
||||||
env: process.env,
|
env: process.env,
|
||||||
port,
|
port,
|
||||||
token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN,
|
token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
launchdLabel:
|
runtime: runtimeRaw,
|
||||||
process.platform === "darwin"
|
warn: (message) => {
|
||||||
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
|
if (json) warnings.push(message);
|
||||||
: undefined,
|
else defaultRuntime.log(message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { resolveIsNixMode } from "../../config/paths.js";
|
import { resolveIsNixMode } from "../../config/paths.js";
|
||||||
import { resolveGatewayService } from "../../daemon/service.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 { defaultRuntime } from "../../runtime.js";
|
||||||
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
import { buildDaemonServiceSnapshot, createNullWriter, emitDaemonActionJson } from "./response.js";
|
||||||
import { renderGatewayServiceStartHints } from "./shared.js";
|
import { renderGatewayServiceStartHints } from "./shared.js";
|
||||||
@@ -89,7 +92,13 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!loaded) {
|
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({
|
emit({
|
||||||
ok: true,
|
ok: true,
|
||||||
result: "not-loaded",
|
result: "not-loaded",
|
||||||
@@ -229,7 +238,13 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!loaded) {
|
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({
|
emit({
|
||||||
ok: true,
|
ok: true,
|
||||||
result: "not-loaded",
|
result: "not-loaded",
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export async function gatherDaemonStatus(
|
|||||||
const [loaded, command, runtime] = await Promise.all([
|
const [loaded, command, runtime] = await Promise.all([
|
||||||
service.isLoaded({ env: process.env }).catch(() => false),
|
service.isLoaded({ env: process.env }).catch(() => false),
|
||||||
service.readCommand(process.env).catch(() => null),
|
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({
|
const configAudit = await auditGatewayServiceConfig({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
} from "../../daemon/constants.js";
|
} from "../../daemon/constants.js";
|
||||||
import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js";
|
import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js";
|
||||||
import { resolveGatewayLogPaths } from "../../daemon/launchd.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 { getResolvedLoggerSettings } from "../../logging.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
@@ -164,6 +169,16 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
|||||||
spacer();
|
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) {
|
if (service.runtime?.missingUnit) {
|
||||||
defaultRuntime.error(errorText("Service unit not found."));
|
defaultRuntime.error(errorText("Service unit not found."));
|
||||||
for (const hint of renderRuntimeHints(service.runtime)) {
|
for (const hint of renderRuntimeHints(service.runtime)) {
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
|||||||
import { setVerbose } from "../../globals.js";
|
import { setVerbose } from "../../globals.js";
|
||||||
import { GatewayLockError } from "../../infra/gateway-lock.js";
|
import { GatewayLockError } from "../../infra/gateway-lock.js";
|
||||||
import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.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 { defaultRuntime } from "../../runtime.js";
|
||||||
import { forceFreePortAndWait } from "../ports.js";
|
import { forceFreePortAndWait } from "../ports.js";
|
||||||
import { ensureDevGatewayConfig } from "./dev.js";
|
import { ensureDevGatewayConfig } from "./dev.js";
|
||||||
@@ -59,6 +63,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setConsoleTimestampPrefix(true);
|
||||||
setVerbose(Boolean(opts.verbose));
|
setVerbose(Boolean(opts.verbose));
|
||||||
if (opts.claudeCliLogs) {
|
if (opts.claudeCliLogs) {
|
||||||
setConsoleSubsystemFilter(["agent/claude-cli"]);
|
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 type { Command } from "commander";
|
||||||
|
|
||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { loadConfig } from "../config/config.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 { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
type MemoryCommandOptions = {
|
type MemoryCommandOptions = {
|
||||||
agent?: string;
|
agent?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
|
deep?: boolean;
|
||||||
|
index?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>;
|
||||||
|
|
||||||
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
|
||||||
const trimmed = agent?.trim();
|
const trimmed = agent?.trim();
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) return trimmed;
|
||||||
@@ -34,32 +39,150 @@ export function registerMemoryCli(program: Command) {
|
|||||||
.description("Show memory search index status")
|
.description("Show memory search index status")
|
||||||
.option("--agent <id>", "Agent id (default: default agent)")
|
.option("--agent <id>", "Agent id (default: default agent)")
|
||||||
.option("--json", "Print JSON")
|
.option("--json", "Print JSON")
|
||||||
|
.option("--deep", "Probe embedding provider availability")
|
||||||
|
.option("--index", "Reindex if dirty (implies --deep)")
|
||||||
.action(async (opts: MemoryCommandOptions) => {
|
.action(async (opts: MemoryCommandOptions) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const agentId = resolveAgent(cfg, opts.agent);
|
const agentId = resolveAgent(cfg, opts.agent);
|
||||||
const { manager, error } = await getMemorySearchManager({ cfg, agentId });
|
await withManager<MemoryManager>({
|
||||||
if (!manager) {
|
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||||
defaultRuntime.log(error ?? "Memory search disabled.");
|
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||||
return;
|
onCloseError: (err) =>
|
||||||
}
|
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||||
const status = manager.status();
|
close: (manager) => manager.close(),
|
||||||
if (opts.json) {
|
run: async (manager) => {
|
||||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
const deep = Boolean(opts.deep || opts.index);
|
||||||
return;
|
let embeddingProbe:
|
||||||
}
|
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
|
||||||
const lines = [
|
| undefined;
|
||||||
`${chalk.bold.cyan("Memory Search")} (${agentId})`,
|
let indexError: string | undefined;
|
||||||
`Provider: ${status.provider} (requested: ${status.requestedProvider})`,
|
if (deep) {
|
||||||
status.fallback ? chalk.yellow(`Fallback: ${status.fallback.from}`) : null,
|
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
|
||||||
`Files: ${status.files}`,
|
progress.setLabel("Probing vector…");
|
||||||
`Chunks: ${status.chunks}`,
|
await manager.probeVectorAvailability();
|
||||||
`Dirty: ${status.dirty ? "yes" : "no"}`,
|
progress.tick();
|
||||||
`Index: ${status.dbPath}`,
|
progress.setLabel("Probing embeddings…");
|
||||||
].filter(Boolean) as string[];
|
embeddingProbe = await manager.probeEmbeddingAvailability();
|
||||||
if (status.fallback?.reason) {
|
progress.tick();
|
||||||
lines.push(chalk.gray(status.fallback.reason));
|
});
|
||||||
}
|
if (opts.index) {
|
||||||
defaultRuntime.log(lines.join("\n"));
|
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
|
memory
|
||||||
@@ -70,19 +193,23 @@ export function registerMemoryCli(program: Command) {
|
|||||||
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
|
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const agentId = resolveAgent(cfg, opts.agent);
|
const agentId = resolveAgent(cfg, opts.agent);
|
||||||
const { manager, error } = await getMemorySearchManager({ cfg, agentId });
|
await withManager<MemoryManager>({
|
||||||
if (!manager) {
|
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||||
defaultRuntime.log(error ?? "Memory search disabled.");
|
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
|
||||||
return;
|
onCloseError: (err) =>
|
||||||
}
|
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
|
||||||
try {
|
close: (manager) => manager.close(),
|
||||||
await manager.sync({ reason: "cli", force: opts.force });
|
run: async (manager) => {
|
||||||
defaultRuntime.log("Memory index updated.");
|
try {
|
||||||
} catch (err) {
|
await manager.sync({ reason: "cli", force: opts.force });
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
defaultRuntime.log("Memory index updated.");
|
||||||
defaultRuntime.error(`Memory index failed: ${message}`);
|
} catch (err) {
|
||||||
process.exitCode = 1;
|
const message = formatErrorMessage(err);
|
||||||
}
|
defaultRuntime.error(`Memory index failed: ${message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
memory
|
memory
|
||||||
@@ -103,43 +230,49 @@ export function registerMemoryCli(program: Command) {
|
|||||||
) => {
|
) => {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const agentId = resolveAgent(cfg, opts.agent);
|
const agentId = resolveAgent(cfg, opts.agent);
|
||||||
const { manager, error } = await getMemorySearchManager({
|
await withManager<MemoryManager>({
|
||||||
cfg,
|
getManager: () => getMemorySearchManager({ cfg, agentId }),
|
||||||
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());
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,6 +180,29 @@ describe("cli program (smoke)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes kimi code api key to onboard", async () => {
|
||||||
|
const program = buildProgram();
|
||||||
|
await program.parseAsync(
|
||||||
|
[
|
||||||
|
"onboard",
|
||||||
|
"--non-interactive",
|
||||||
|
"--auth-choice",
|
||||||
|
"kimi-code-api-key",
|
||||||
|
"--kimi-code-api-key",
|
||||||
|
"sk-kimi-code-test",
|
||||||
|
],
|
||||||
|
{ from: "user" },
|
||||||
|
);
|
||||||
|
expect(onboardCommand).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
nonInteractive: true,
|
||||||
|
authChoice: "kimi-code-api-key",
|
||||||
|
kimiCodeApiKey: "sk-kimi-code-test",
|
||||||
|
}),
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("passes synthetic api key to onboard", async () => {
|
it("passes synthetic api key to onboard", async () => {
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(
|
await program.parseAsync(
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||||
.option(
|
.option(
|
||||||
"--auth-choice <choice>",
|
"--auth-choice <choice>",
|
||||||
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|synthetic-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
|
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
|
||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--token-provider <id>",
|
"--token-provider <id>",
|
||||||
@@ -68,6 +68,7 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
.option("--openrouter-api-key <key>", "OpenRouter API key")
|
.option("--openrouter-api-key <key>", "OpenRouter API key")
|
||||||
.option("--ai-gateway-api-key <key>", "Vercel AI Gateway API key")
|
.option("--ai-gateway-api-key <key>", "Vercel AI Gateway API key")
|
||||||
.option("--moonshot-api-key <key>", "Moonshot API key")
|
.option("--moonshot-api-key <key>", "Moonshot API key")
|
||||||
|
.option("--kimi-code-api-key <key>", "Kimi Code API key")
|
||||||
.option("--gemini-api-key <key>", "Gemini API key")
|
.option("--gemini-api-key <key>", "Gemini API key")
|
||||||
.option("--zai-api-key <key>", "Z.AI API key")
|
.option("--zai-api-key <key>", "Z.AI API key")
|
||||||
.option("--minimax-api-key <key>", "MiniMax API key")
|
.option("--minimax-api-key <key>", "MiniMax API key")
|
||||||
@@ -116,6 +117,7 @@ export function registerOnboardCommand(program: Command) {
|
|||||||
openrouterApiKey: opts.openrouterApiKey as string | undefined,
|
openrouterApiKey: opts.openrouterApiKey as string | undefined,
|
||||||
aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined,
|
aiGatewayApiKey: opts.aiGatewayApiKey as string | undefined,
|
||||||
moonshotApiKey: opts.moonshotApiKey as string | undefined,
|
moonshotApiKey: opts.moonshotApiKey as string | undefined,
|
||||||
|
kimiCodeApiKey: opts.kimiCodeApiKey as string | undefined,
|
||||||
geminiApiKey: opts.geminiApiKey as string | undefined,
|
geminiApiKey: opts.geminiApiKey as string | undefined,
|
||||||
zaiApiKey: opts.zaiApiKey as string | undefined,
|
zaiApiKey: opts.zaiApiKey as string | undefined,
|
||||||
minimaxApiKey: opts.minimaxApiKey as string | undefined,
|
minimaxApiKey: opts.minimaxApiKey as string | undefined,
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ const STEP_LABELS: Record<string, string> = {
|
|||||||
type UpdateChannel = "stable" | "beta";
|
type UpdateChannel = "stable" | "beta";
|
||||||
|
|
||||||
const DEFAULT_UPDATE_CHANNEL: UpdateChannel = "stable";
|
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 {
|
function normalizeChannel(value?: string | null): UpdateChannel | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
@@ -61,6 +83,10 @@ function channelToTag(channel: UpdateChannel): string {
|
|||||||
return channel === "beta" ? "beta" : "latest";
|
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 {
|
function normalizeVersionTag(tag: string): string | null {
|
||||||
const trimmed = tag.trim();
|
const trimmed = tag.trim();
|
||||||
if (!trimmed) return null;
|
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) {
|
export function registerUpdateCli(program: Command) {
|
||||||
|
|||||||
@@ -219,4 +219,43 @@ describe("deliverAgentCommandResult", () => {
|
|||||||
expect.objectContaining({ channel: "telegram", to: "123" }),
|
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 { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||||
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
|
import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
@@ -22,6 +23,28 @@ type RunResult = Awaited<
|
|||||||
ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]>
|
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: {
|
export async function deliverAgentCommandResult(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
deps: CliDeps;
|
deps: CliDeps;
|
||||||
@@ -112,7 +135,12 @@ export async function deliverAgentCommandResult(params: {
|
|||||||
const logPayload = (payload: NormalizedOutboundPayload) => {
|
const logPayload = (payload: NormalizedOutboundPayload) => {
|
||||||
if (opts.json) return;
|
if (opts.json) return;
|
||||||
const output = formatOutboundPayloadLog(payload);
|
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) {
|
if (!deliver) {
|
||||||
for (const payload of deliveryPayloads) logPayload(payload);
|
for (const payload of deliveryPayloads) logPayload(payload);
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ describe("buildAuthChoiceOptions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(options.some((opt) => opt.value === "moonshot-api-key")).toBe(true);
|
expect(options.some((opt) => opt.value === "moonshot-api-key")).toBe(true);
|
||||||
|
expect(options.some((opt) => opt.value === "kimi-code-api-key")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes Vercel AI Gateway auth choice", () => {
|
it("includes Vercel AI Gateway auth choice", () => {
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||||||
{
|
{
|
||||||
value: "moonshot",
|
value: "moonshot",
|
||||||
label: "Moonshot AI",
|
label: "Moonshot AI",
|
||||||
hint: "Kimi K2 preview",
|
hint: "Kimi K2 + Kimi Code",
|
||||||
choices: ["moonshot-api-key"],
|
choices: ["moonshot-api-key", "kimi-code-api-key"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "zai",
|
value: "zai",
|
||||||
@@ -180,6 +180,7 @@ export function buildAuthChoiceOptions(params: {
|
|||||||
label: "Vercel AI Gateway API key",
|
label: "Vercel AI Gateway API key",
|
||||||
});
|
});
|
||||||
options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" });
|
options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" });
|
||||||
|
options.push({ value: "kimi-code-api-key", label: "Kimi Code API key" });
|
||||||
options.push({ value: "synthetic-api-key", label: "Synthetic API key" });
|
options.push({ value: "synthetic-api-key", label: "Synthetic API key" });
|
||||||
options.push({
|
options.push({
|
||||||
value: "github-copilot",
|
value: "github-copilot",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from "./google-gemini-model-default.js";
|
} from "./google-gemini-model-default.js";
|
||||||
import {
|
import {
|
||||||
applyAuthProfileConfig,
|
applyAuthProfileConfig,
|
||||||
|
applyKimiCodeConfig,
|
||||||
|
applyKimiCodeProviderConfig,
|
||||||
applyMoonshotConfig,
|
applyMoonshotConfig,
|
||||||
applyMoonshotProviderConfig,
|
applyMoonshotProviderConfig,
|
||||||
applyOpencodeZenConfig,
|
applyOpencodeZenConfig,
|
||||||
@@ -24,11 +26,13 @@ import {
|
|||||||
applyVercelAiGatewayConfig,
|
applyVercelAiGatewayConfig,
|
||||||
applyVercelAiGatewayProviderConfig,
|
applyVercelAiGatewayProviderConfig,
|
||||||
applyZaiConfig,
|
applyZaiConfig,
|
||||||
|
KIMI_CODE_MODEL_REF,
|
||||||
MOONSHOT_DEFAULT_MODEL_REF,
|
MOONSHOT_DEFAULT_MODEL_REF,
|
||||||
OPENROUTER_DEFAULT_MODEL_REF,
|
OPENROUTER_DEFAULT_MODEL_REF,
|
||||||
SYNTHETIC_DEFAULT_MODEL_REF,
|
SYNTHETIC_DEFAULT_MODEL_REF,
|
||||||
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
|
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
|
||||||
setGeminiApiKey,
|
setGeminiApiKey,
|
||||||
|
setKimiCodeApiKey,
|
||||||
setMoonshotApiKey,
|
setMoonshotApiKey,
|
||||||
setOpencodeZenApiKey,
|
setOpencodeZenApiKey,
|
||||||
setOpenrouterApiKey,
|
setOpenrouterApiKey,
|
||||||
@@ -208,6 +212,55 @@ export async function applyAuthChoiceApiProviders(
|
|||||||
return { config: nextConfig, agentModelOverride };
|
return { config: nextConfig, agentModelOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.authChoice === "kimi-code-api-key") {
|
||||||
|
await params.prompter.note(
|
||||||
|
[
|
||||||
|
"Kimi Code uses a dedicated endpoint and API key.",
|
||||||
|
"Get your API key at: https://www.kimi.com/code/en",
|
||||||
|
].join("\n"),
|
||||||
|
"Kimi Code",
|
||||||
|
);
|
||||||
|
let hasCredential = false;
|
||||||
|
const envKey = resolveEnvApiKey("kimi-code");
|
||||||
|
if (envKey) {
|
||||||
|
const useExisting = await params.prompter.confirm({
|
||||||
|
message: `Use existing KIMICODE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (useExisting) {
|
||||||
|
await setKimiCodeApiKey(envKey.apiKey, params.agentDir);
|
||||||
|
hasCredential = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasCredential) {
|
||||||
|
const key = await params.prompter.text({
|
||||||
|
message: "Enter Kimi Code API key",
|
||||||
|
validate: validateApiKeyInput,
|
||||||
|
});
|
||||||
|
await setKimiCodeApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
|
||||||
|
}
|
||||||
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
|
profileId: "kimi-code:default",
|
||||||
|
provider: "kimi-code",
|
||||||
|
mode: "api_key",
|
||||||
|
});
|
||||||
|
{
|
||||||
|
const applied = await applyDefaultModelChoice({
|
||||||
|
config: nextConfig,
|
||||||
|
setDefaultModel: params.setDefaultModel,
|
||||||
|
defaultModel: KIMI_CODE_MODEL_REF,
|
||||||
|
applyDefaultConfig: applyKimiCodeConfig,
|
||||||
|
applyProviderConfig: applyKimiCodeProviderConfig,
|
||||||
|
noteDefault: KIMI_CODE_MODEL_REF,
|
||||||
|
noteAgentModel,
|
||||||
|
prompter: params.prompter,
|
||||||
|
});
|
||||||
|
nextConfig = applied.config;
|
||||||
|
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
|
||||||
|
}
|
||||||
|
return { config: nextConfig, agentModelOverride };
|
||||||
|
}
|
||||||
|
|
||||||
if (params.authChoice === "gemini-api-key") {
|
if (params.authChoice === "gemini-api-key") {
|
||||||
let hasCredential = false;
|
let hasCredential = false;
|
||||||
const envKey = resolveEnvApiKey("google");
|
const envKey = resolveEnvApiKey("google");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
|
|||||||
"openrouter-api-key": "openrouter",
|
"openrouter-api-key": "openrouter",
|
||||||
"ai-gateway-api-key": "vercel-ai-gateway",
|
"ai-gateway-api-key": "vercel-ai-gateway",
|
||||||
"moonshot-api-key": "moonshot",
|
"moonshot-api-key": "moonshot",
|
||||||
|
"kimi-code-api-key": "kimi-code",
|
||||||
"gemini-api-key": "google",
|
"gemini-api-key": "google",
|
||||||
"zai-api-key": "zai",
|
"zai-api-key": "zai",
|
||||||
"synthetic-api-key": "synthetic",
|
"synthetic-api-key": "synthetic",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export type { ChannelsAddOptions } from "./channels/add.js";
|
export type { ChannelsAddOptions } from "./channels/add.js";
|
||||||
export { channelsAddCommand } 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 type { ChannelsListOptions } from "./channels/list.js";
|
||||||
export { channelsListCommand } from "./channels/list.js";
|
export { channelsListCommand } from "./channels/list.js";
|
||||||
export type { ChannelsLogsOptions } from "./channels/logs.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))");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user