Compare commits

..
Author SHA1 Message Date
Peter SteinbergerandNicholas Spisak a535be7832 refactor: centralize cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-18 00:11:45 +00:00
Peter SteinbergerandNicholas Spisak 794bab45ff fix: harden memory cli manager cleanup
Co-authored-by: Nicholas Spisak <jsnsdirect@gmail.com>
2026-01-17 23:45:42 +00:00
Peter Steinberger 16e5fa1db9 test: cover daemon install helpers 2026-01-17 23:41:45 +00:00
Peter Steinberger 125be3e111 fix: restore wizard/doctor imports 2026-01-17 23:41:45 +00:00
Peter Steinberger b60a53e10d feat: enable batch indexing by default 2026-01-17 23:29:40 +00:00
Peter Steinberger 9de762faa2 refactor: unify gateway daemon install plan 2026-01-17 23:29:34 +00:00
Peter SteinbergerandCodex 5aed38eebc fix(discord): honor thread allowlists in reactions
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Peter SteinbergerandCodex e63e483c38 refactor(channels): share channel config matching
Co-authored-by: Codex <codex@openai.com>
2026-01-17 23:03:51 +00:00
Shadow 277e43e32c Discord: inherit thread allowlists 2026-01-17 23:03:51 +00:00
Peter Steinberger 852aa16ca0 fix: stabilize memory sync progress 2026-01-17 23:02:03 +00:00
Peter Steinberger 82b7153ac1 fix: handle daemon install failure in wizard 2026-01-17 23:00:34 +00:00
Peter Steinberger 7d2e510087 fix: retry embedding 5xx errors 2026-01-17 22:48:50 +00:00
Peter Steinberger 9ca4c10e59 test: cover channels capabilities probes 2026-01-17 22:33:18 +00:00
Peter Steinberger a31a79396b feat: add OpenAI batch memory indexing 2026-01-17 22:32:04 +00:00
Peter Steinberger acc3eb11d0 Update bird skill with Twitter posting wisdom from Ruby
- CLI for reading only (Twitter flags CLI posts as automated)
- Browser tool with paste hack for writing
- React input workaround with ClipboardEvent
- Selectors and rate limiting tips
- Credit: Shadow's Ruby documented the forbidden arts
2026-01-17 22:28:23 +00:00
Peter Steinbergerandabdaraxus 9d9fff2991 fix: sessions list label fallback
Co-authored-by: abdaraxus <abdaraxus@users.noreply.github.com>
2026-01-17 22:22:01 +00:00
Peter Steinberger 030ed5d592 fix: skip empty memory chunks 2026-01-17 21:58:59 +00:00
Peter Steinberger f6d359932a fix: parallelize memory embedding indexing 2026-01-17 21:57:12 +00:00
Peter Steinberger 3200b51160 fix: format exec elevated flag first in tool summaries 2026-01-17 21:54:24 +00:00
Peter Steinberger 4b11ebb30e fix: split long memory lines 2026-01-17 21:11:56 +00:00
Peter Steinberger 40345642fa fix: show memory index counts in progress 2026-01-17 21:09:22 +00:00
Peter Steinberger e932772230 fix: report memory index progress 2026-01-17 20:42:04 +00:00
Peter SteinbergerandClaude Opus 4.5 63d466fe5e fix(telegram): expand text_link entities in inbound text
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 20:41:34 +00:00
Peter Steinberger c2fada7062 fix: suppress duplicate discord slow-listener logs 2026-01-17 20:37:36 +00:00
Peter Steinberger d9c29f5ce5 fix: add agent context to ws logs 2026-01-17 20:37:36 +00:00
Peter Steinberger f5d5ef6857 feat: confirm memory index completion 2026-01-17 20:35:15 +00:00
Peter Steinberger 361a17415f chore: release 2026.1.17-1 2026-01-17 20:26:24 +00:00
Peter Steinberger fb393c3c51 feat: add progress to memory status deep 2026-01-17 20:25:19 +00:00
Peter Steinberger e0158c5d5d feat: add deep memory status checks 2026-01-17 20:18:36 +00:00
Peter Steinberger be12b0771c fix: soften windows daemon install 2026-01-17 20:12:26 +00:00
Peter Steinberger 1309fc1f48 test: expand frontmatter coverage 2026-01-17 20:12:04 +00:00
Peter Steinberger 4fdecfb845 fix: split memory embedding batches 2026-01-17 20:10:11 +00:00
Peter Steinberger 31c6f178f3 fix: preserve inline frontmatter values 2026-01-17 19:56:10 +00:00
Peter Steinberger 1e2ab8bf1e fix: improve frontmatter parsing 2026-01-17 19:56:10 +00:00
Sebastian Slight 35a1d81518 fix: handle multi-line metadata blocks in HOOK.md frontmatter
The frontmatter parser was using a simple line-by-line regex that only
captured single-line key-value pairs. This meant multi-line metadata
blocks (as used by bundled hooks) were not parsed correctly.

Changes:
- Add extractMultiLineValue() to handle indented continuation lines
- Use JSON5 instead of JSON.parse() to support trailing commas
- Add comprehensive test coverage for frontmatter parsing

Fixes #1113
2026-01-17 19:56:10 +00:00
Peter Steinberger 1c4297d8b5 test: update memory cli mocks for vector probe 2026-01-17 19:49:41 +00:00
Peter Steinberger e3638a9a9e fix: probe memory vector availability 2026-01-17 19:46:34 +00:00
Peter Steinberger 1f8558771a Docs: note MiniMax usage endpoint 2026-01-17 19:45:54 +00:00
Peter Steinberger 2e231d09ec Infra: update MiniMax usage endpoint 2026-01-17 19:45:48 +00:00
Peter Steinberger 727c07bd88 feat: add slack user scopes and teams graph hints 2026-01-17 19:33:03 +00:00
Peter Steinberger c32ad19377 docs: restore changelog entries 2026-01-17 19:32:30 +00:00
Peter Steinberger ef40ab2933 test: expand memory cli coverage 2026-01-17 19:30:46 +00:00
Peter Steinberger e71fa4a145 docs: note session log disk access 2026-01-17 19:30:46 +00:00
Peter Steinberger a7c0887f94 feat: add per-provider scope probes to channels capabilities 2026-01-17 19:28:52 +00:00
Peter Steinberger 53218b91c6 fix: close memory cli managers 2026-01-17 19:20:55 +00:00
Peter Steinberger 2d4de656d2 test: avoid global SIGTERM emit in child-process-bridge 2026-01-17 19:20:48 +00:00
Peter Steinberger b0f44acf9e chore: bump versions to 2026.1.17 2026-01-17 19:16:35 +00:00
Peter Steinberger a828e60067 feat: add channels capabilities command 2026-01-17 19:06:07 +00:00
Peter Steinberger 96df70fccf fix: add nested agent log context 2026-01-17 18:59:59 +00:00
Peter Steinberger 0e49dca53c feat: add experimental session memory source 2026-01-17 18:53:52 +00:00
Peter SteinbergerandRhys Johnston 8ec4af4641 fix(status): show 2 usage windows in /status (#1101)
Thanks @rhjoh.

Co-authored-by: Rhys Johnston <rhys.johnston00@gmail.com>
2026-01-17 18:46:41 +00:00
Peter Steinberger 2f6d9417bd test(memory): await watch sync completion 2026-01-17 18:45:42 +00:00
Peter Steinberger 534a012a4e style: apply oxfmt 2026-01-17 18:32:23 +00:00
Peter Steinberger 7a3fa9ce03 feat: show update availability in status 2026-01-17 18:23:27 +00:00
Peter Steinberger 8a67d29748 fix: improve WSL2 systemd daemon hints 2026-01-17 18:19:55 +00:00
Peter Steinberger 408f4f2dac fix: reuse shared ansi stripper 2026-01-17 18:18:14 +00:00
Peter Steinberger 3df2dc0b15 fix: normalize exec tool alias naming 2026-01-17 18:15:45 +00:00
Peter Steinberger 5304a8c2d1 fix: add timestamped tool context to logs 2026-01-17 18:14:21 +00:00
Peter Steinberger 1569d29b2d fix: normalize telegram forwarded context (#1090) (thanks @sleontenko) 2026-01-17 18:08:23 +00:00
Peter SteinbergerandYuri Chukhlib 50c8e74230 fix(doctor): avoid ack reaction migration without config (#1087)
Thanks @YuriNachos.

Co-authored-by: Yuri Chukhlib <YuriNachos@users.noreply.github.com>
2026-01-17 18:07:06 +00:00
Peter Steinberger 1045b032a2 refactor(logging): use subsystem loggers for discord/ws 2026-01-17 18:03:40 +00:00
Peter SteinbergerandYuri Chukhlib a813343aa7 docs: clarify model refs and runtime notes
Co-authored-by: Yuri Chukhlib <YuriNachos@users.noreply.github.com>
2026-01-17 18:03:40 +00:00
Peter Steinberger 5a08471dcd feat: add sqlite-vec memory search acceleration 2026-01-17 18:02:34 +00:00
Peter Steinberger 252dfbcd40 fix: include context in elevated exec denial 2026-01-17 17:55:11 +00:00
Peter Steinberger 75588fe732 test: expand semver parsing coverage 2026-01-17 17:54:41 +00:00
Peter Steinberger 9bbdeb3d52 Merge pull request #1111 from artuskg/fix/cli-install-version-suffix
macos: keep CLI install build suffix
2026-01-17 17:46:13 +00:00
Peter Steinberger ec9ba5b784 fix: show full gateway version string in status (#1111) (thanks @artuskg) 2026-01-17 17:45:14 +00:00
Artus KG cee4149884 macos: handle empty install version safely 2026-01-17 17:45:14 +00:00
Artus KG 5599e4cf35 test: make session update timestamp UTC 2026-01-17 17:45:14 +00:00
Artus KG 84cdd2df73 changelog: note CLI install build suffix fix 2026-01-17 17:45:14 +00:00
Artus KG 7929f57460 macos: keep CLI install build suffix 2026-01-17 17:45:04 +00:00
Peter Steinberger 7876679c5d style: apply oxfmt 2026-01-17 17:44:54 +00:00
Peter Steinberger bc6928525d docs: note discord interaction logging fix 2026-01-17 17:42:56 +00:00
Peter Steinberger 755c847d9a fix: soften discord interaction logging 2026-01-17 17:42:46 +00:00
Peter Steinberger 80a8639940 refactor: centralize telegram send param parsing 2026-01-17 17:36:37 +00:00
Peter Steinberger 6cb5704291 Merge pull request #1085 from dan-dr/chore/kimi-code-provider
Add Kimi Code provider onboarding
2026-01-17 17:36:30 +00:00
Peter Steinberger 4a987c836d fix: add Kimi Code docs + defaults (#1085) (thanks @dan-dr) 2026-01-17 17:35:40 +00:00
Peter Steinberger a2fb55326c Merge pull request #1099 from mukhtharcm/feat/message-tool-voice-support
feat(telegram): support sending audio as native voice notes via asVoice param in message tool
2026-01-17 17:33:20 +00:00
Peter Steinberger af29c6a980 fix: allow media-only telegram voice sends (#1099) (thanks @mukhtharcm) 2026-01-17 17:33:08 +00:00
Muhammed Mukhthar CM f2a0e8e5bb feat(telegram): support sending audio as native voice notes via asVoice param in message tool 2026-01-17 17:32:50 +00:00
Peter Steinberger f6456c2883 Merge pull request #1106 from gumadeiras/patch-2
Remove extra 'logging' page in the docs
2026-01-17 17:32:15 +00:00
Peter Steinberger 39f0d000d1 Merge pull request #1088 from sibbl/fix-matrix
feat(matrix): fix sending bug, add specific support for voice messages and images
2026-01-17 17:27:44 +00:00
Peter Steinberger a8d9d630bc fix: handle legacy matrix polls (#1088) (thanks @sibbl) 2026-01-17 17:27:12 +00:00
ddyo e93a1d8138 feat: add kimi code provider onboarding 2026-01-17 17:25:07 +00:00
Peter Steinberger f6681be6f4 style: tidy macOS config UI formatting 2026-01-17 17:22:42 +00:00
Peter Steinberger c79ac3fe81 test: cover semver suffix variants 2026-01-17 17:15:08 +00:00
Sebastian Schubotz b78b06353a feat(matrix): add specific voice message + image sending extending generic attachment sending 2026-01-17 17:12:38 +00:00
Sebastian Schubotz c49b6cc241 fix(matrix): fix sending being broken by normalizing thread ID normalization in message sending functions; improve matrix types 2026-01-17 17:12:38 +00:00
Gustavo Madeira Santana 41fbb4d8b0 Remove extra 'logging' page in the docs
Removed 'logging' from the list of gateways.
2026-01-17 09:49:39 -05:00
169 changed files with 6658 additions and 923 deletions

No files matched your search

+44 -2
View File
@@ -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.
+17 -16
View File
@@ -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")
+1 -1
View File
@@ -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)
} }
} }
+1
View File
@@ -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)
+13
View File
@@ -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
+15
View File
@@ -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`.
+3
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
``` ```
+5
View File
@@ -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
+1
View File
@@ -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)).
+8
View File
@@ -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:
+63
View File
@@ -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).
+28
View File
@@ -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:
+3
View File
@@ -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).
+43
View File
@@ -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:
+8
View File
@@ -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:
+1 -1
View File
@@ -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)
+1
View File
@@ -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.
+1 -1
View File
@@ -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)
+56 -6
View File
@@ -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.
+2 -1
View File
@@ -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 -1
View File
@@ -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": {
+10
View File
@@ -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 -1
View File
@@ -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"]);
});
});
+35 -28
View File
@@ -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,
}; };
} }
+7 -2
View File
@@ -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");
+145 -22
View 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",
+10
View File
@@ -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 -1
View File
@@ -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": {
+4 -1
View File
@@ -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: {
+67 -1
View File
@@ -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,
+10
View File
@@ -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 -1
View File
@@ -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": {
+10
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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": {
+57
View File
@@ -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: {}
+2 -1
View File
@@ -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",
+40
View File
@@ -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
View File
@@ -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.** 🐦
+10 -1
View File
@@ -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(
+3 -1
View File
@@ -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 () => {
+57
View File
@@ -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");
});
}); });
+55
View File
@@ -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,
+1
View File
@@ -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",
+39
View File
@@ -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 -3
View File
@@ -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,
}); });
} }
+1
View File
@@ -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,
+7
View File
@@ -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);
+4 -26
View File
@@ -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;
+1 -1
View File
@@ -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 });
+1
View File
@@ -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(
+1
View File
@@ -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,
@@ -104,7 +104,12 @@ describe("trigger handling", () => {
{ {
provider: "anthropic", provider: "anthropic",
displayName: "Anthropic", displayName: "Anthropic",
windows: [], windows: [
{
label: "5h",
usedPercent: 20,
},
],
}, },
], ],
}); });
+10 -6
View File
@@ -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;
} }
+1 -1
View File
@@ -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" });
+7
View File
@@ -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;
+10
View File
@@ -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");
+35 -1
View File
@@ -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;
+24
View File
@@ -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("*");
});
});
+41
View File
@@ -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,
);
});
});
+25 -15
View File
@@ -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,
); );
+18
View File
@@ -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")
+31
View 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);
}
}
}
+7 -34
View File
@@ -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 {
+17 -2
View File
@@ -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",
+1 -1
View File
@@ -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,
+15
View File
@@ -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)) {
+6 -1
View File
@@ -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"]);
+325
View File
@@ -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
View File
@@ -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());
}, },
); );
} }
+23
View File
@@ -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(
+3 -1
View File
@@ -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,
+30
View File
@@ -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) {
+39
View File
@@ -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");
});
}); });
+29 -1
View File
@@ -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);
+1
View File
@@ -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", () => {
+3 -2
View File
@@ -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",
+2
View File
@@ -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";
+138
View File
@@ -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