Compare commits
184
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afefcdf8fb | ||
|
|
9fc3926607 | ||
|
|
22c7f659f6 | ||
|
|
79a44d0da4 | ||
|
|
d593a809f0 | ||
|
|
22add31e91 | ||
|
|
b44d740720 | ||
|
|
4d590f9254 | ||
|
|
a5aa48beea | ||
|
|
1bf3861ca4 | ||
|
|
f8052be369 | ||
|
|
a08438ae97 | ||
|
|
fe00d6aacf | ||
|
|
984692cda2 | ||
|
|
4c12c4fc04 | ||
|
|
794bab45ff | ||
|
|
16e5fa1db9 | ||
|
|
125be3e111 | ||
|
|
b60a53e10d | ||
|
|
9de762faa2 | ||
|
|
5aed38eebc | ||
|
|
e63e483c38 | ||
|
|
277e43e32c | ||
|
|
852aa16ca0 | ||
|
|
82b7153ac1 | ||
|
|
7d2e510087 | ||
|
|
9ca4c10e59 | ||
|
|
a31a79396b | ||
|
|
acc3eb11d0 | ||
|
|
9d9fff2991 | ||
|
|
030ed5d592 | ||
|
|
f6d359932a | ||
|
|
3200b51160 | ||
|
|
4b11ebb30e | ||
|
|
40345642fa | ||
|
|
e932772230 | ||
|
|
63d466fe5e | ||
|
|
c2fada7062 | ||
|
|
d9c29f5ce5 | ||
|
|
f5d5ef6857 | ||
|
|
361a17415f | ||
|
|
fb393c3c51 | ||
|
|
e0158c5d5d | ||
|
|
be12b0771c | ||
|
|
1309fc1f48 | ||
|
|
4fdecfb845 | ||
|
|
31c6f178f3 | ||
|
|
1e2ab8bf1e | ||
|
|
35a1d81518 | ||
|
|
1c4297d8b5 | ||
|
|
e3638a9a9e | ||
|
|
1f8558771a | ||
|
|
2e231d09ec | ||
|
|
727c07bd88 | ||
|
|
c32ad19377 | ||
|
|
ef40ab2933 | ||
|
|
e71fa4a145 | ||
|
|
a7c0887f94 | ||
|
|
53218b91c6 | ||
|
|
2d4de656d2 | ||
|
|
b0f44acf9e | ||
|
|
a828e60067 | ||
|
|
96df70fccf | ||
|
|
0e49dca53c | ||
|
|
8ec4af4641 | ||
|
|
2f6d9417bd | ||
|
|
534a012a4e | ||
|
|
7a3fa9ce03 | ||
|
|
8a67d29748 | ||
|
|
408f4f2dac | ||
|
|
3df2dc0b15 | ||
|
|
5304a8c2d1 | ||
|
|
1569d29b2d | ||
|
|
50c8e74230 | ||
|
|
1045b032a2 | ||
|
|
a813343aa7 | ||
|
|
5a08471dcd | ||
|
|
252dfbcd40 | ||
|
|
75588fe732 | ||
|
|
9bbdeb3d52 | ||
|
|
ec9ba5b784 | ||
|
|
cee4149884 | ||
|
|
5599e4cf35 | ||
|
|
84cdd2df73 | ||
|
|
7929f57460 | ||
|
|
7876679c5d | ||
|
|
bc6928525d | ||
|
|
755c847d9a | ||
|
|
80a8639940 | ||
|
|
6cb5704291 | ||
|
|
4a987c836d | ||
|
|
a2fb55326c | ||
|
|
af29c6a980 | ||
|
|
f2a0e8e5bb | ||
|
|
f6456c2883 | ||
|
|
39f0d000d1 | ||
|
|
a8d9d630bc | ||
|
|
e93a1d8138 | ||
|
|
f6681be6f4 | ||
|
|
c79ac3fe81 | ||
|
|
b78b06353a | ||
|
|
c49b6cc241 | ||
|
|
30c945fe92 | ||
|
|
dfd511c310 | ||
|
|
1657525201 | ||
|
|
3e4b0d0505 | ||
|
|
003c6c9ae1 | ||
|
|
41fbb4d8b0 | ||
|
|
eecb340f64 | ||
|
|
d029eaa0bb | ||
|
|
9c7dcc1ed7 | ||
|
|
be37b39782 | ||
|
|
49c35c752c | ||
|
|
25d8043b9d | ||
|
|
f9f4a953fc | ||
|
|
34c3fbc66c | ||
|
|
a9f21b3d3a | ||
|
|
ed5c5629f6 | ||
|
|
868952f958 | ||
|
|
9b9836be71 | ||
|
|
22cd839cb2 | ||
|
|
dc3ac9fa28 | ||
|
|
c874fa9712 | ||
|
|
6b784a9771 | ||
|
|
f8e673cdbc | ||
|
|
ad360b4d18 | ||
|
|
69ba2765de | ||
|
|
31e8ecca10 | ||
|
|
4ca38286d8 | ||
|
|
fbf1c3ca3c | ||
|
|
1a4313c2aa | ||
|
|
a6deb0d9d5 | ||
|
|
b6ea5895b6 | ||
|
|
d66bc65ca6 | ||
|
|
89f85ddeab | ||
|
|
bbb71c9198 | ||
|
|
ae6792522d | ||
|
|
e637bbdfb5 | ||
|
|
869ef0c5ba | ||
|
|
1002c74d9c | ||
|
|
61e60f3b84 | ||
|
|
13b931c006 | ||
|
|
ab49fe0e92 | ||
|
|
d0bc08a934 | ||
|
|
64d21f5ea8 | ||
|
|
0a95d8a840 | ||
|
|
56f3a2de25 | ||
|
|
d8b463d0b3 | ||
|
|
eef3df9fa5 | ||
|
|
837eea4ebd | ||
|
|
55622bac06 | ||
|
|
f172ccfcf6 | ||
|
|
a2a6893566 | ||
|
|
616ee3075c | ||
|
|
c5239f6a8e | ||
|
|
cccd7c7b8e | ||
|
|
8f1132e8ec | ||
|
|
e6477363e9 | ||
|
|
1a4fc8dea6 | ||
|
|
a3daf3d115 | ||
|
|
f3f80509e3 | ||
|
|
7cebe7a506 | ||
|
|
5986175268 | ||
|
|
7630c6dccb | ||
|
|
d63cc1e8a7 | ||
|
|
80bb6b712c | ||
|
|
410b8f223e | ||
|
|
693f152895 | ||
|
|
78a4441ac2 | ||
|
|
c92265a51b | ||
|
|
d5fdda8e28 | ||
|
|
cddf198321 | ||
|
|
6d969fe58e | ||
|
|
68c7d577a4 | ||
|
|
1ea8917e2b | ||
|
|
07c93dfd30 | ||
|
|
cf0ea6c756 | ||
|
|
8c9e32c4a3 | ||
|
|
34d59d7913 | ||
|
|
0c0d9e1d22 | ||
|
|
2ee45d50a4 | ||
|
|
0c0e1e4226 | ||
|
|
e7953d8164 | ||
|
|
f42b12646d |
No files matched your search
@@ -73,6 +73,7 @@
|
||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## Troubleshooting
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
||||
@@ -117,6 +118,7 @@
|
||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
|
||||
+103
-13
@@ -1,40 +1,109 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.16 (unreleased)
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.18-1
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Tools: add Perplexity Sonar provider for `web_search` with direct Perplexity/OpenRouter support. (#1131) — thanks @CMLKevin.
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
|
||||
## 2026.1.17-3
|
||||
|
||||
### Changes
|
||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||
|
||||
### Fixes
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
|
||||
## 2026.1.17-2
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
|
||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||
- CLI: surface update availability in `clawdbot status`.
|
||||
- CLI: add `clawdbot memory status --deep/--index` probes.
|
||||
- CLI: add playful update completion quips.
|
||||
|
||||
### Fixes
|
||||
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
|
||||
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
||||
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
|
||||
|
||||
## 2026.1.16-1
|
||||
|
||||
### Highlights
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
|
||||
- Hooks: add internal hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
|
||||
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks
|
||||
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding
|
||||
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser
|
||||
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; internal hooks live under `clawdbot hooks`.
|
||||
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
|
||||
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
||||
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
|
||||
- Tools: improve `web_fetch` extraction using Readability (with fallback).
|
||||
- Tools: add Firecrawl fallback for `web_fetch` when configured.
|
||||
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
|
||||
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
|
||||
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
|
||||
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
|
||||
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
|
||||
- Tools: add `process submit` helper to send CR for PTY sessions.
|
||||
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
|
||||
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
|
||||
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
|
||||
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
|
||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||
- UI: allow deleting sessions from the Control UI.
|
||||
- Memory: add sqlite-vec vector acceleration with CLI status details.
|
||||
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
|
||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
- iMessage: add remote attachment support for VM/SSH deployments.
|
||||
- Messages: refresh live directory cache results when resolving targets.
|
||||
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
|
||||
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
|
||||
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
|
||||
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
|
||||
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
|
||||
- Config: support env var substitution in config values. (#1044) — thanks @sebslight.
|
||||
@@ -43,29 +112,50 @@
|
||||
- Plugins: add zip installs and `--link` to avoid copying local paths.
|
||||
|
||||
### Fixes
|
||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
||||
- Tools: include provider/session context in elevated exec denial errors.
|
||||
- Tools: normalize exec tool alias naming in tool error logs.
|
||||
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
|
||||
- Logging: prefix nested agent output with session/run/channel context.
|
||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Telegram: split long captions into follow-up messages.
|
||||
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
|
||||
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
|
||||
- Config: handle object-format Telegram capabilities in channel capability resolution. (#1071) — thanks @danielz1z.
|
||||
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
|
||||
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
|
||||
- Sessions: preserve overrides on `/new` reset.
|
||||
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
|
||||
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
|
||||
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
|
||||
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
|
||||
- Build: allow `@lydell/node-pty` builds on supported platforms.
|
||||
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
|
||||
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
|
||||
- Messages: honor message tool channel when deduping sends.
|
||||
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
|
||||
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
|
||||
- Sessions: repair orphaned user turns before embedded prompts.
|
||||
- Sessions: hard-stop `sessions.delete` cleanup.
|
||||
- Channels: treat replies to the bot as implicit mentions across supported channels.
|
||||
- Security: lock down slash/control commands to sender allowlists across Discord/Slack/Telegram/Signal/iMessage/WhatsApp (+ plugin channels like Matrix/Teams) and add stable `clawdbot security audit` checkIds for Slack/Discord command allowlists.
|
||||
- Channels: normalize object-format capabilities in channel capability parsing.
|
||||
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
|
||||
- Security: redact sensitive text in gateway WS logs.
|
||||
- Tools: cap pending `exec` process output to avoid unbounded buffers.
|
||||
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
|
||||
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
|
||||
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
|
||||
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
|
||||
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
|
||||
- WhatsApp: include `linked` field in `describeAccount`.
|
||||
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
|
||||
- Agents: hide the image tool when the primary model already supports images.
|
||||
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
|
||||
- Auth: inherit/merge sub-agent auth profiles from the main agent.
|
||||
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
|
||||
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
|
||||
- OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
|
||||
- iMessage: avoid RPC restart loops.
|
||||
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
|
||||
- CLI: auto-update global installs when installed via a package manager.
|
||||
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
|
||||
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
|
||||
@@ -73,6 +163,7 @@
|
||||
- Models: align ZAI thinking toggles.
|
||||
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
|
||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||
- Skills: fix skills watcher ignored list typing (tsc).
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
@@ -113,7 +204,6 @@
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||
- Media: add optional inbound media understanding for image/audio/video with provider + CLI fallbacks. (#1005) — thanks @tristanmanchester.
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
|
||||
@@ -478,20 +478,21 @@ Thanks to all clawtributors:
|
||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a>
|
||||
<a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
|
||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
|
||||
<a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a>
|
||||
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a>
|
||||
<a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a>
|
||||
<a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
||||
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
|
||||
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
|
||||
<a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a>
|
||||
<a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a>
|
||||
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a>
|
||||
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
|
||||
<a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a>
|
||||
<a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
|
||||
<a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
||||
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
||||
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
|
||||
<a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
|
||||
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
|
||||
<a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
|
||||
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
||||
<a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
+16
-16
@@ -2,6 +2,22 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.16-2</title>
|
||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>6273</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.15</title>
|
||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||
@@ -255,21 +271,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.12-2</title>
|
||||
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5534</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -35,7 +35,7 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
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()
|
||||
await statusHandler("Installing clawdbot CLI…")
|
||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||
|
||||
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
|
||||
let path: ConfigPath
|
||||
|
||||
var body: some View {
|
||||
self.renderNode(schema, path: path)
|
||||
self.renderNode(self.schema, path: self.path)
|
||||
}
|
||||
|
||||
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 label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||
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 {
|
||||
return self.renderNode(only, path: path)
|
||||
}
|
||||
let literals = nonNull.compactMap { $0.literalValue }
|
||||
let literals = nonNull.compactMap(\.literalValue)
|
||||
if !literals.isEmpty, literals.count == nonNull.count {
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -31,15 +31,20 @@ struct ConfigSchemaForm: View {
|
||||
.font(.caption)
|
||||
.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)
|
||||
ForEach(literals.indices, id: \ .self) { index in
|
||||
Text(String(describing: literals[index])).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +76,7 @@ struct ConfigSchemaForm: View {
|
||||
if schema.allowsAdditionalProperties {
|
||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
case "array":
|
||||
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
||||
case "boolean":
|
||||
@@ -80,8 +84,7 @@ struct ConfigSchemaForm: View {
|
||||
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
||||
if let label { Text(label) } else { Text("Enabled") }
|
||||
}
|
||||
.help(help ?? "")
|
||||
)
|
||||
.help(help ?? ""))
|
||||
case "number", "integer":
|
||||
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
||||
case "string":
|
||||
@@ -93,8 +96,7 @@ struct ConfigSchemaForm: View {
|
||||
Text("Unsupported field type.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +157,7 @@ struct ConfigSchemaForm: View {
|
||||
text: self.numberBinding(
|
||||
path,
|
||||
isInteger: schema.schemaType == "integer",
|
||||
defaultValue: defaultValue
|
||||
)
|
||||
)
|
||||
defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
|
||||
Button("Remove") {
|
||||
var next = items
|
||||
next.remove(at: index)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
|
||||
} else {
|
||||
next.append("")
|
||||
}
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
|
||||
Button("Remove") {
|
||||
var next = dict
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
|
||||
key = "new-\(index)"
|
||||
}
|
||||
next[key] = additionalSchema.defaultValue
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -270,9 +270,8 @@ struct ConfigSchemaForm: View {
|
||||
},
|
||||
set: { newValue in
|
||||
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> {
|
||||
@@ -282,16 +281,15 @@ struct ConfigSchemaForm: View {
|
||||
return defaultValue ?? false
|
||||
},
|
||||
set: { newValue in
|
||||
store.updateConfigValue(path: path, value: newValue)
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: newValue)
|
||||
})
|
||||
}
|
||||
|
||||
private func numberBinding(
|
||||
_ path: ConfigPath,
|
||||
isInteger: Bool,
|
||||
defaultValue: Double?
|
||||
) -> Binding<String> {
|
||||
defaultValue: Double?) -> Binding<String>
|
||||
{
|
||||
Binding(
|
||||
get: {
|
||||
if let value = store.configValue(at: path) { return String(describing: value) }
|
||||
@@ -301,22 +299,21 @@ struct ConfigSchemaForm: View {
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
self.store.updateConfigValue(path: path, value: nil)
|
||||
} 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(
|
||||
_ path: ConfigPath,
|
||||
options: [Any],
|
||||
defaultValue: Any?
|
||||
) -> Binding<Int> {
|
||||
defaultValue: Any?) -> Binding<Int>
|
||||
{
|
||||
Binding(
|
||||
get: {
|
||||
let value = store.configValue(at: path) ?? defaultValue
|
||||
let value = self.store.configValue(at: path) ?? defaultValue
|
||||
guard let value else { return -1 }
|
||||
return options.firstIndex { option in
|
||||
String(describing: option) == String(describing: value)
|
||||
@@ -324,12 +321,11 @@ struct ConfigSchemaForm: View {
|
||||
},
|
||||
set: { index in
|
||||
guard index >= 0, index < options.count else {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
self.store.updateConfigValue(path: path, value: nil)
|
||||
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> {
|
||||
@@ -339,14 +335,13 @@ struct ConfigSchemaForm: View {
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty 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 }
|
||||
var next = current
|
||||
next[trimmed] = current[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
|
||||
|
||||
var body: some View {
|
||||
if store.configSchemaLoading {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} 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 {
|
||||
Text("Schema unavailable for this channel.")
|
||||
.font(.caption)
|
||||
|
||||
@@ -434,25 +434,25 @@ extension ChannelsSettings {
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "WhatsApp Web"
|
||||
case "telegram": return "Telegram Bot"
|
||||
case "discord": return "Discord Bot"
|
||||
case "slack": return "Slack Bot"
|
||||
case "signal": return "Signal REST"
|
||||
case "imessage": return "iMessage"
|
||||
default: return self.resolveChannelTitle(id)
|
||||
case "whatsapp": "WhatsApp Web"
|
||||
case "telegram": "Telegram Bot"
|
||||
case "discord": "Discord Bot"
|
||||
case "slack": "Slack Bot"
|
||||
case "signal": "Signal REST"
|
||||
case "imessage": "iMessage"
|
||||
default: self.resolveChannelTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "message"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "signal": return "antenna.radiowaves.left.and.right"
|
||||
case "imessage": return "message.fill"
|
||||
default: return "message"
|
||||
case "whatsapp": "message"
|
||||
case "telegram": "paperplane"
|
||||
case "discord": "bubble.left.and.bubble.right"
|
||||
case "slack": "number"
|
||||
case "signal": "antenna.radiowaves.left.and.right"
|
||||
case "imessage": "message.fill"
|
||||
default: "message"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ extension ChannelsStore {
|
||||
return value
|
||||
}
|
||||
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())
|
||||
return valueAtPath(self.configDraft, path: fallbackPath)
|
||||
}
|
||||
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
||||
var current: Any? = root
|
||||
for segment in path {
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
guard let dict = current as? [String: Any] else { return nil }
|
||||
current = dict[key]
|
||||
case .index(let index):
|
||||
case let .index(index):
|
||||
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
||||
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?) {
|
||||
guard let segment = path.first else { return }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
var dict = root as? [String: Any] ?? [:]
|
||||
if path.count == 1 {
|
||||
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)
|
||||
dict[key] = child
|
||||
root = dict
|
||||
case .index(let index):
|
||||
case let .index(index):
|
||||
var array = root as? [Any] ?? []
|
||||
if index >= array.count {
|
||||
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
||||
|
||||
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
|
||||
for segment in path {
|
||||
guard let node = current else { return nil }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
if node.schemaType == "object" {
|
||||
if let next = node.properties[key] {
|
||||
current = next
|
||||
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
|
||||
var match = true
|
||||
for (index, seg) in segments.enumerated() {
|
||||
let hintSegment = hintSegments[index]
|
||||
if hintSegment != "*" && hintSegment != seg {
|
||||
if hintSegment != "*", hintSegment != seg {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
|
||||
func pathKey(_ path: ConfigPath) -> String {
|
||||
path.compactMap { segment -> String? in
|
||||
switch segment {
|
||||
case .key(let key): return key
|
||||
case let .key(key): return key
|
||||
case .index: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ extension ConfigSettings {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if self.store.configDirty && !self.isNixMode {
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -25,8 +25,10 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||
let major = Int(parts[0]),
|
||||
let minor = Int(parts[1])
|
||||
else { return nil }
|
||||
let patch = Int(parts[2]) ?? 0
|
||||
return Semver(major: major, minor: minor, patch: patch)
|
||||
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
||||
let patchRaw = String(parts[2])
|
||||
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
|
||||
return Semver(major: major, minor: minor, patch: patchNumeric)
|
||||
}
|
||||
|
||||
func compatible(with required: Semver) -> Bool {
|
||||
@@ -78,8 +80,13 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func expectedGatewayVersion() -> Semver? {
|
||||
Semver.parse(self.expectedGatewayVersionString())
|
||||
}
|
||||
|
||||
static func expectedGatewayVersionString() -> 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.
|
||||
@@ -98,6 +105,7 @@ enum GatewayEnvironment {
|
||||
}
|
||||
}
|
||||
let expected = self.expectedGatewayVersion()
|
||||
let expectedString = self.expectedGatewayVersionString()
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
@@ -108,8 +116,8 @@ enum GatewayEnvironment {
|
||||
kind: .missingNode,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
requiredGateway: expectedString,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
case let .success(runtime):
|
||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||
|
||||
@@ -118,7 +126,7 @@ enum GatewayEnvironment {
|
||||
kind: .missingGateway,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
requiredGateway: expectedString,
|
||||
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||
}
|
||||
|
||||
@@ -126,13 +134,14 @@ enum GatewayEnvironment {
|
||||
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
let expectedText = expectedString ?? expected.description
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
kind: .incompatible(found: installed.description, required: expectedText),
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
requiredGateway: expectedText,
|
||||
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.
|
||||
""")
|
||||
}
|
||||
@@ -150,7 +159,7 @@ enum GatewayEnvironment {
|
||||
kind: .ok,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
requiredGateway: expectedString,
|
||||
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||
}
|
||||
}
|
||||
@@ -218,8 +227,18 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
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 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 pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||
let bun = CommandResolver.findExecutable(named: "bun")
|
||||
@@ -278,8 +297,7 @@ enum GatewayEnvironment {
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
@@ -294,7 +312,6 @@ enum GatewayEnvironment {
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let raw = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Semver.parse(raw)
|
||||
|
||||
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
|
||||
let process = Process()
|
||||
process.launchPath = "/bin/launchctl"
|
||||
process.arguments = args
|
||||
process.standardOutput = Pipe()
|
||||
process.standardError = Pipe()
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
_ = try process.runAndReadToEnd(from: pipe)
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
return -1
|
||||
|
||||
@@ -16,9 +16,7 @@ enum Launchctl {
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return Result(status: process.terminationStatus, output: output)
|
||||
} catch {
|
||||
|
||||
@@ -580,11 +580,10 @@ final class NodePairingApprovalPrompter {
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
_ = try process.runAndReadToEnd(from: pipe)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus == 0
|
||||
}.value
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ struct OnboardingView: View {
|
||||
|
||||
var canAdvance: Bool { !self.isWizardBlocking }
|
||||
var devLinkCommand: String {
|
||||
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
return "npm install -g clawdbot@\(version)"
|
||||
}
|
||||
|
||||
|
||||
@@ -203,15 +203,13 @@ actor PortGuardian {
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
let data = try proc.runAndReadToEnd(from: pipe)
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func parseListeners(from text: String) -> [Listener] {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension Process {
|
||||
/// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers.
|
||||
func runAndReadToEnd(from pipe: Pipe) throws -> Data {
|
||||
try self.run()
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
self.waitUntilExit()
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -133,8 +133,7 @@ enum RuntimeLocator {
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = try process.runAndReadToEnd(from: pipe)
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
if elapsedMs > 500 {
|
||||
self.logger.warning(
|
||||
@@ -149,7 +148,6 @@ enum RuntimeLocator {
|
||||
bin=\(binary, privacy: .public)
|
||||
""")
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} catch {
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
|
||||
@@ -5,16 +5,28 @@ import Testing
|
||||
@Suite struct GatewayEnvironmentTests {
|
||||
@Test func semverParsesCommonForms() {
|
||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
|
||||
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
|
||||
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
|
||||
#expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
|
||||
#expect(Semver.parse(nil) == nil)
|
||||
#expect(Semver.parse("invalid") == nil)
|
||||
#expect(Semver.parse("1.2") == nil)
|
||||
#expect(Semver.parse("1.2.x") == nil)
|
||||
}
|
||||
|
||||
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
|
||||
let required = Semver(major: 2, minor: 1, patch: 0)
|
||||
#expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false)
|
||||
#expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false)
|
||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||
}
|
||||
@@ -36,6 +48,7 @@ import Testing
|
||||
|
||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||
#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)
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,7 @@ Notes:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||
- If `channels` is present, any channel not listed is denied by default.
|
||||
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread id explicitly.
|
||||
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
|
||||
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||
|
||||
@@ -365,6 +366,7 @@ Allowlist matching notes:
|
||||
Native command notes:
|
||||
- The registered commands mirror Clawdbot’s chat commands.
|
||||
- Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules).
|
||||
- Slash commands may still be visible in Discord UI to users who aren’t allowlisted; Clawdbot enforces allowlists on execution and replies “not authorized”.
|
||||
|
||||
## Tool actions
|
||||
The agent can call `discord` with actions like:
|
||||
|
||||
@@ -335,6 +335,7 @@ For fine-grained control, use these tags in agent responses:
|
||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
|
||||
- Slash commands use `agent:<agentId>:slack:slash:<userId>` sessions (prefix configurable via `channels.slack.slashCommand.sessionPrefix`).
|
||||
- If Slack doesn’t provide `channel_type`, Clawdbot infers it from the channel ID prefix (`D`, `C`, `G`) and defaults to `channel` to keep session keys stable.
|
||||
- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `channels.slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
|
||||
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
||||
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
||||
- Topic configs inherit group settings (requireMention, allowlists, skills, prompts, enabled) unless overridden per topic.
|
||||
|
||||
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
|
||||
|
||||
@@ -360,6 +361,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.
|
||||
|
||||
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)
|
||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||
|
||||
@@ -18,6 +18,8 @@ Related docs:
|
||||
```bash
|
||||
clawdbot channels list
|
||||
clawdbot channels status
|
||||
clawdbot channels capabilities
|
||||
clawdbot channels capabilities --channel discord --target channel:123
|
||||
clawdbot channels logs --channel all
|
||||
```
|
||||
|
||||
@@ -42,3 +44,16 @@ clawdbot channels logout --channel whatsapp
|
||||
- Run `clawdbot status --deep` for a broad probe.
|
||||
- Use `clawdbot doctor` for guided fixes.
|
||||
|
||||
## Capabilities probe
|
||||
|
||||
Fetch provider capability hints (intents/scopes where available) plus static feature support:
|
||||
|
||||
```bash
|
||||
clawdbot channels capabilities
|
||||
clawdbot channels capabilities --channel discord --target channel:123
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--channel` is optional; omit it to list every channel (including extensions).
|
||||
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
|
||||
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
|
||||
@@ -21,6 +21,9 @@ clawdbot doctor --repair
|
||||
clawdbot doctor --deep
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
|
||||
@@ -29,6 +29,7 @@ Notes:
|
||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
|
||||
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
|
||||
|
||||
### Options
|
||||
|
||||
|
||||
+11
-11
@@ -1,16 +1,16 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot hooks` (internal hooks)"
|
||||
summary: "CLI reference for `clawdbot hooks` (agent hooks)"
|
||||
read_when:
|
||||
- You want to manage internal agent hooks
|
||||
- You want to install or update internal hooks
|
||||
- You want to manage agent hooks
|
||||
- You want to install or update hooks
|
||||
---
|
||||
|
||||
# `clawdbot hooks`
|
||||
|
||||
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
|
||||
Related:
|
||||
- Internal Hooks: [Internal Agent Hooks](/internal-hooks)
|
||||
- Hooks: [Hooks](/hooks)
|
||||
|
||||
## List All Hooks
|
||||
|
||||
@@ -18,7 +18,7 @@ Related:
|
||||
clawdbot hooks list
|
||||
```
|
||||
|
||||
List all discovered internal hooks from workspace, managed, and bundled directories.
|
||||
List all discovered hooks from workspace, managed, and bundled directories.
|
||||
|
||||
**Options:**
|
||||
- `--eligible`: Show only eligible hooks (requirements met)
|
||||
@@ -28,7 +28,7 @@ List all discovered internal hooks from workspace, managed, and bundled director
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Internal Hooks (2/2 ready)
|
||||
Hooks (2/2 ready)
|
||||
|
||||
Ready:
|
||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||
@@ -82,7 +82,7 @@ Details:
|
||||
Source: clawdbot-bundled
|
||||
Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md
|
||||
Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts
|
||||
Homepage: https://docs.clawd.bot/internal-hooks#session-memory
|
||||
Homepage: https://docs.clawd.bot/hooks#session-memory
|
||||
Events: command:new
|
||||
|
||||
Requirements:
|
||||
@@ -103,7 +103,7 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Internal Hooks Status
|
||||
Hooks Status
|
||||
|
||||
Total hooks: 2
|
||||
Ready: 2
|
||||
@@ -228,7 +228,7 @@ clawdbot hooks enable session-memory
|
||||
|
||||
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
|
||||
|
||||
**See:** [session-memory documentation](/internal-hooks#session-memory)
|
||||
**See:** [session-memory documentation](/hooks#session-memory)
|
||||
|
||||
### command-logger
|
||||
|
||||
@@ -255,4 +255,4 @@ cat ~/.clawdbot/logs/commands.log | jq .
|
||||
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
||||
```
|
||||
|
||||
**See:** [command-logger documentation](/internal-hooks#command-logger)
|
||||
**See:** [command-logger documentation](/hooks#command-logger)
|
||||
+3
-2
@@ -292,7 +292,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--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|antigravity|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 <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -302,6 +302,7 @@ Options:
|
||||
- `--openrouter-api-key <key>`
|
||||
- `--ai-gateway-api-key <key>`
|
||||
- `--moonshot-api-key <key>`
|
||||
- `--kimi-code-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
- `--zai-api-key <key>`
|
||||
- `--minimax-api-key <key>`
|
||||
@@ -527,7 +528,7 @@ Surfaces:
|
||||
|
||||
Notes:
|
||||
- Data comes directly from provider usage endpoints (no estimates).
|
||||
- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
|
||||
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled.
|
||||
- If no matching credentials exist, usage is hidden.
|
||||
- Details: see [Usage tracking](/concepts/usage-tracking).
|
||||
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ Related:
|
||||
|
||||
```bash
|
||||
clawdbot memory status
|
||||
clawdbot memory status --deep
|
||||
clawdbot memory status --deep --index
|
||||
clawdbot memory index
|
||||
clawdbot memory search "release checklist"
|
||||
```
|
||||
|
||||
@@ -26,6 +26,11 @@ clawdbot models scan
|
||||
When provider usage snapshots are available, the OAuth/token status section includes
|
||||
provider usage headers.
|
||||
|
||||
Notes:
|
||||
- `models set <model-or-alias>` accepts `provider/model` or an alias.
|
||||
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
## Aliases + fallbacks
|
||||
|
||||
```bash
|
||||
|
||||
@@ -25,6 +25,9 @@ clawdbot plugins update <id>
|
||||
clawdbot plugins update --all
|
||||
```
|
||||
|
||||
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
|
||||
activate them.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
|
||||
@@ -19,3 +19,4 @@ clawdbot status --usage
|
||||
Notes:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||
@@ -15,6 +15,8 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
|
||||
|
||||
```bash
|
||||
clawdbot update
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --tag beta
|
||||
clawdbot update --restart
|
||||
clawdbot update --json
|
||||
clawdbot --update
|
||||
@@ -23,9 +25,13 @@ clawdbot --update
|
||||
## Options
|
||||
|
||||
- `--restart`: restart the Gateway daemon after a successful update.
|
||||
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
Note: downgrades require confirmation because older versions can break configuration.
|
||||
|
||||
## What it does (git checkout)
|
||||
|
||||
High-level:
|
||||
|
||||
@@ -98,6 +98,14 @@ Verbose tool summaries are emitted at tool start (no debounce); Control UI
|
||||
streams tool output via agent events when available.
|
||||
More details: [Streaming + chunking](/concepts/streaming).
|
||||
|
||||
## Model refs
|
||||
|
||||
Model refs in config (for example `agents.defaults.model` and `agents.defaults.models`) are parsed by splitting on the **first** `/`.
|
||||
|
||||
- Use `provider/model` when configuring models.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
## Configuration (minimal)
|
||||
|
||||
At minimum, set:
|
||||
|
||||
@@ -78,6 +78,7 @@ Defaults:
|
||||
- Watches memory files for changes (debounced).
|
||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||
|
||||
Remote embeddings **require** an API key for the embedding provider. By default
|
||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||
@@ -107,6 +108,11 @@ agents: {
|
||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||
`memorySearch.fallback = "none"`.
|
||||
|
||||
Batch indexing (OpenAI only):
|
||||
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
|
||||
|
||||
Config example:
|
||||
|
||||
```json5
|
||||
@@ -116,6 +122,9 @@ agents: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
fallback: "openai",
|
||||
remote: {
|
||||
batch: { enabled: false }
|
||||
},
|
||||
sync: { watch: true }
|
||||
}
|
||||
}
|
||||
@@ -143,6 +152,60 @@ Local mode:
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
|
||||
|
||||
### Session memory search (experimental)
|
||||
|
||||
You can optionally index **session transcripts** and surface them via `memory_search`.
|
||||
This is gated behind an experimental flag.
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
experimental: { sessionMemory: true },
|
||||
sources: ["memory", "sessions"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Session indexing is **opt-in** (off by default).
|
||||
- Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`).
|
||||
- Results still include snippets only; `memory_get` remains limited to memory files.
|
||||
- Session indexing is isolated per agent (only that agent’s session logs are indexed).
|
||||
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
|
||||
|
||||
### SQLite vector acceleration (sqlite-vec)
|
||||
|
||||
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
|
||||
SQLite virtual table (`vec0`) and performs vector distance queries in the
|
||||
database. This keeps search fast without loading every embedding into JS.
|
||||
|
||||
Configuration (optional):
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
store: {
|
||||
vector: {
|
||||
enabled: true,
|
||||
extensionPath: "/path/to/sqlite-vec"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `enabled` defaults to true; when disabled, search falls back to in-process
|
||||
cosine similarity over stored embeddings.
|
||||
- If the sqlite-vec extension is missing or fails to load, Clawdbot logs the
|
||||
error and continues with the JS fallback (no vector table).
|
||||
- `extensionPath` overrides the bundled sqlite-vec path (useful for custom builds
|
||||
or non-standard install locations).
|
||||
|
||||
### Local embedding auto-download
|
||||
|
||||
- Default local embedding model: `hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf` (~0.6 GB).
|
||||
|
||||
@@ -83,7 +83,12 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
|
||||
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
|
||||
- CLI: `clawdbot onboard --auth-choice antigravity` (others via interactive wizard)
|
||||
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-antigravity-auth`
|
||||
- Login: `clawdbot models auth login --provider google-antigravity --set-default`
|
||||
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
|
||||
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
### Z.AI (GLM)
|
||||
|
||||
@@ -150,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 provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
|
||||
@@ -102,6 +102,9 @@ Notes:
|
||||
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
||||
- `/model <#>` selects from that picker.
|
||||
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
|
||||
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), you must include the provider prefix (example: `/model openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
Full command behavior/config: [Slash commands](/tools/slash-commands).
|
||||
|
||||
|
||||
@@ -894,7 +894,6 @@
|
||||
"gateway/heartbeat",
|
||||
"gateway/doctor",
|
||||
"gateway/logging",
|
||||
"logging",
|
||||
"gateway/security",
|
||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||
"gateway/sandboxing",
|
||||
|
||||
@@ -33,6 +33,7 @@ When spawning long-running child processes outside the exec/process tools (for e
|
||||
Environment overrides:
|
||||
- `PI_BASH_YIELD_MS`: default yield (ms)
|
||||
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
|
||||
- `CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
|
||||
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
|
||||
|
||||
Config (preferred):
|
||||
|
||||
@@ -2234,6 +2234,49 @@ Notes:
|
||||
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
||||
- 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)
|
||||
|
||||
Use Synthetic's Anthropic-compatible endpoint:
|
||||
|
||||
@@ -52,6 +52,14 @@ When the audit prints findings, treat this as a priority order:
|
||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
||||
|
||||
## Local session logs live on disk
|
||||
|
||||
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
||||
This is required for session continuity and (optionally) session memory indexing, but it also means
|
||||
**any process/user with filesystem access can read those logs**. Treat disk access as the trust
|
||||
boundary and lock down permissions on `~/.clawdbot` (see the audit section below). If you need
|
||||
stronger isolation between agents, run them under separate OS users or separate hosts.
|
||||
|
||||
## Node execution (system.run)
|
||||
|
||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
summary: "Internal agent hooks: event-driven automation for commands and lifecycle events"
|
||||
summary: "Hooks: event-driven automation for commands and lifecycle events"
|
||||
read_when:
|
||||
- You want event-driven automation for /new, /reset, /stop, and agent lifecycle events
|
||||
- You want to build, install, or debug internal hooks
|
||||
- You want to build, install, or debug hooks
|
||||
---
|
||||
# Internal Agent Hooks
|
||||
# Hooks
|
||||
|
||||
Internal hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
|
||||
Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
|
||||
|
||||
## Getting Oriented
|
||||
|
||||
Hooks are small scripts that run when something happens. There are two kinds:
|
||||
|
||||
- **Internal hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
|
||||
|
||||
Common uses:
|
||||
- Save a memory snapshot when you reset a session
|
||||
@@ -21,11 +21,11 @@ Common uses:
|
||||
- Trigger follow-up automation when a session starts or ends
|
||||
- Write files into the agent workspace or call external APIs when events fire
|
||||
|
||||
If you can write a small TypeScript function, you can write an internal hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
|
||||
If you can write a small TypeScript function, you can write a hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
The internal hooks system allows you to:
|
||||
The hooks system allows you to:
|
||||
- Save session context to memory when `/new` is issued
|
||||
- Log all commands for auditing
|
||||
- Trigger custom automations on agent lifecycle events
|
||||
@@ -120,7 +120,7 @@ The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documenta
|
||||
---
|
||||
name: my-hook
|
||||
description: "Short description of what this hook does"
|
||||
homepage: https://docs.clawd.bot/internal-hooks#my-hook
|
||||
homepage: https://docs.clawd.bot/hooks#my-hook
|
||||
metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}}
|
||||
---
|
||||
|
||||
@@ -162,12 +162,12 @@ The `metadata.clawdbot` object supports:
|
||||
|
||||
### Handler Implementation
|
||||
|
||||
The `handler.ts` file exports an `InternalHookHandler` function:
|
||||
The `handler.ts` file exports a `HookHandler` function:
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
|
||||
import type { HookHandler } from '../../src/hooks/hooks.js';
|
||||
|
||||
const myHandler: InternalHookHandler = async (event) => {
|
||||
const myHandler: HookHandler = async (event) => {
|
||||
// Only trigger on 'new' command
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
@@ -260,9 +260,9 @@ This hook does something useful when you issue `/new`.
|
||||
### 4. Create handler.ts
|
||||
|
||||
```typescript
|
||||
import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
|
||||
import type { HookHandler } from '../../src/hooks/hooks.js';
|
||||
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
}
|
||||
@@ -505,12 +505,12 @@ Hooks run during command processing. Keep them lightweight:
|
||||
|
||||
```typescript
|
||||
// ✓ Good - async work, returns immediately
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
void processInBackground(event); // Fire and forget
|
||||
};
|
||||
|
||||
// ✗ Bad - blocks command processing
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
await slowDatabaseQuery(event);
|
||||
await evenSlowerAPICall(event);
|
||||
};
|
||||
@@ -521,7 +521,7 @@ const handler: InternalHookHandler = async (event) => {
|
||||
Always wrap risky operations:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
try {
|
||||
await riskyOperation(event);
|
||||
} catch (err) {
|
||||
@@ -536,7 +536,7 @@ const handler: InternalHookHandler = async (event) => {
|
||||
Return early if the event isn't relevant:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
// Only handle 'new' commands
|
||||
if (event.type !== 'command' || event.action !== 'new') {
|
||||
return;
|
||||
@@ -584,7 +584,7 @@ clawdbot hooks list --verbose
|
||||
In your handler, log when it's called:
|
||||
|
||||
```typescript
|
||||
const handler: InternalHookHandler = async (event) => {
|
||||
const handler: HookHandler = async (event) => {
|
||||
console.log('[my-handler] Triggered:', event.type, event.action);
|
||||
// Your logic
|
||||
};
|
||||
@@ -620,11 +620,11 @@ Test your handlers in isolation:
|
||||
|
||||
```typescript
|
||||
import { test } from 'vitest';
|
||||
import { createInternalHookEvent } from './src/hooks/internal-hooks.js';
|
||||
import { createHookEvent } from './src/hooks/hooks.js';
|
||||
import myHandler from './hooks/my-hook/handler.js';
|
||||
|
||||
test('my handler works', async () => {
|
||||
const event = createInternalHookEvent('command', 'new', 'test-session', {
|
||||
const event = createHookEvent('command', 'new', 'test-session', {
|
||||
foo: 'bar'
|
||||
});
|
||||
|
||||
@@ -50,6 +50,22 @@ pnpm add -g clawdbot@latest
|
||||
```
|
||||
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
||||
|
||||
To stay on the beta channel for CLI updates:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel beta
|
||||
```
|
||||
|
||||
Switch back to stable later:
|
||||
|
||||
```bash
|
||||
clawdbot update --channel stable
|
||||
```
|
||||
|
||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||
|
||||
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
@@ -75,7 +91,7 @@ It runs a safe-ish update flow:
|
||||
- Fetches + rebases against the configured upstream.
|
||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
|
||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||
|
||||
## Update (Control UI / RPC)
|
||||
|
||||
|
||||
@@ -62,8 +62,25 @@ read_when:
|
||||
}
|
||||
```
|
||||
|
||||
### Provider-only (Deepgram)
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes & limits
|
||||
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
|
||||
- Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used.
|
||||
- Deepgram setup details: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||
|
||||
@@ -32,6 +32,8 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
|
||||
- `tools.media.models`: shared model list (use `capabilities` to gate).
|
||||
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
|
||||
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
|
||||
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
|
||||
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
|
||||
- optional **per‑capability `models` list** (preferred before shared models)
|
||||
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
|
||||
- `scope` (optional gating by channel/chatType/session key)
|
||||
@@ -108,6 +110,7 @@ lists, Clawdbot can infer defaults:
|
||||
- `openai`, `anthropic`, `minimax`: **image**
|
||||
- `google` (Gemini API): **image + audio + video**
|
||||
- `groq`: **audio**
|
||||
- `deepgram`: **audio**
|
||||
|
||||
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
|
||||
If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
@@ -116,7 +119,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
| Capability | Provider integration | Notes |
|
||||
|------------|----------------------|-------|
|
||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||
| Audio | OpenAI, Groq | Provider transcription (Whisper). |
|
||||
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
@@ -125,8 +128,9 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
- `openai/whisper-1` or `groq/whisper-large-v3-turbo`.
|
||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper` binary.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
|
||||
@@ -256,6 +260,15 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
|
||||
}
|
||||
```
|
||||
|
||||
## Status output
|
||||
When media understanding runs, `/status` includes a short summary line:
|
||||
|
||||
```
|
||||
📎 Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)
|
||||
```
|
||||
|
||||
This shows per‑capability outcomes and the chosen provider/model when applicable.
|
||||
|
||||
## Notes
|
||||
- Understanding is **best‑effort**. Errors do not block replies.
|
||||
- Attachments are still passed to models even when understanding is disabled.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
summary: "Perplexity Sonar setup for web_search"
|
||||
read_when:
|
||||
- You want to use Perplexity Sonar for web search
|
||||
- You need PERPLEXITY_API_KEY or OpenRouter setup
|
||||
---
|
||||
|
||||
# Perplexity Sonar
|
||||
|
||||
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect
|
||||
through Perplexity’s direct API or via OpenRouter.
|
||||
|
||||
## API options
|
||||
|
||||
### Perplexity (direct)
|
||||
|
||||
- Base URL: https://api.perplexity.ai
|
||||
- Environment variable: `PERPLEXITY_API_KEY`
|
||||
|
||||
### OpenRouter (alternative)
|
||||
|
||||
- Base URL: https://openrouter.ai/api/v1
|
||||
- Environment variable: `OPENROUTER_API_KEY`
|
||||
- Supports prepaid/crypto credits.
|
||||
|
||||
## Config example
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
model: "perplexity/sonar-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
|
||||
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
|
||||
to disambiguate.
|
||||
|
||||
## Models
|
||||
|
||||
- `perplexity/sonar` — fast Q&A with web search
|
||||
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
|
||||
- `perplexity/sonar-reasoning-pro` — deep research
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
+18
-5
@@ -41,6 +41,9 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
||||
|
||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||
register:
|
||||
@@ -58,16 +61,26 @@ Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
|
||||
Clawdbot scans, in order:
|
||||
|
||||
1) Global extensions
|
||||
- `~/.clawdbot/extensions/*.ts`
|
||||
- `~/.clawdbot/extensions/*/index.ts`
|
||||
1) Config paths
|
||||
- `plugins.load.paths` (file or directory)
|
||||
|
||||
2) Workspace extensions
|
||||
- `<workspace>/.clawdbot/extensions/*.ts`
|
||||
- `<workspace>/.clawdbot/extensions/*/index.ts`
|
||||
|
||||
3) Config paths
|
||||
- `plugins.load.paths` (file or directory)
|
||||
3) Global extensions
|
||||
- `~/.clawdbot/extensions/*.ts`
|
||||
- `~/.clawdbot/extensions/*/index.ts`
|
||||
|
||||
4) Bundled extensions (shipped with Clawdbot, **disabled by default**)
|
||||
- `<clawdbot>/extensions/*`
|
||||
|
||||
Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
|
||||
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
|
||||
but can be disabled the same way.
|
||||
|
||||
If multiple plugins resolve to the same id, the first match in the order above
|
||||
wins and lower-precedence copies are ignored.
|
||||
|
||||
### Package packs
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
summary: "Deepgram transcription for inbound voice notes"
|
||||
read_when:
|
||||
- You want Deepgram speech-to-text for audio attachments
|
||||
- You need a quick Deepgram config example
|
||||
---
|
||||
# Deepgram (Audio Transcription)
|
||||
|
||||
Deepgram is a speech-to-text API. In Clawdbot it is used for **inbound audio/voice note
|
||||
transcription** via `tools.media.audio`.
|
||||
|
||||
When enabled, Clawdbot uploads the audio file to Deepgram and injects the transcript
|
||||
into the reply pipeline (`{{Transcript}}` + `[Audio]` block). This is **not streaming**;
|
||||
it uses the pre-recorded transcription endpoint.
|
||||
|
||||
Website: https://deepgram.com
|
||||
Docs: https://developers.deepgram.com
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Set your API key:
|
||||
```
|
||||
DEEPGRAM_API_KEY=dg_...
|
||||
```
|
||||
|
||||
2) Enable the provider:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `model`: Deepgram model id (default: `nova-3`)
|
||||
- `language`: language hint (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.detect_language`: enable language detection (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.punctuate`: enable punctuation (optional)
|
||||
- `tools.media.audio.providerOptions.deepgram.smart_format`: enable smart formatting (optional)
|
||||
|
||||
Example with language:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "deepgram", model: "nova-3", language: "en" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example with Deepgram options:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: true,
|
||||
providerOptions: {
|
||||
deepgram: {
|
||||
detect_language: true,
|
||||
punctuate: true,
|
||||
smart_format: true
|
||||
}
|
||||
},
|
||||
models: [{ provider: "deepgram", model: "nova-3" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Authentication follows the standard provider auth order; `DEEPGRAM_API_KEY` is the simplest path.
|
||||
- Override endpoints or headers with `tools.media.audio.baseUrl` and `tools.media.audio.headers` when using a proxy.
|
||||
- Output follows the same audio rules as other providers (size caps, timeouts, transcript injection).
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
summary: "Sign in to GitHub Copilot from Clawdbot using the device flow"
|
||||
read_when:
|
||||
- You want to use GitHub Copilot as a model provider
|
||||
- You need the `clawdbot models auth login-github-copilot` flow
|
||||
---
|
||||
# Github Copilot
|
||||
|
||||
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
||||
the GitHub device flow, saves an auth profile, and updates your config to use that
|
||||
profile.
|
||||
|
||||
## CLI setup
|
||||
|
||||
```bash
|
||||
clawdbot models auth login-github-copilot
|
||||
```
|
||||
|
||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||
open until it completes.
|
||||
|
||||
### Optional flags
|
||||
|
||||
```bash
|
||||
clawdbot models auth login-github-copilot --profile-id github-copilot:work
|
||||
clawdbot models auth login-github-copilot --yes
|
||||
```
|
||||
|
||||
## Set a default model
|
||||
|
||||
```bash
|
||||
clawdbot models set github-copilot/gpt-4o
|
||||
```
|
||||
|
||||
### Config snippet
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } }
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires an interactive TTY; run it directly in a terminal.
|
||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||
another ID (for example `github-copilot/gpt-4.1`).
|
||||
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||
Copilot API token when Clawdbot runs.
|
||||
@@ -26,13 +26,18 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
|
||||
|
||||
- [OpenAI (API + Codex)](/providers/openai)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [Github Copilot](/providers/github-copilot)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [Z.AI](/providers/zai)
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
|
||||
## Transcription providers
|
||||
|
||||
- [Deepgram (audio transcription)](/providers/deepgram)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
@@ -155,6 +155,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
## Notes
|
||||
|
||||
- Model refs are `minimax/<model>`.
|
||||
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
|
||||
- Update pricing values in `models.json` if you need exact cost tracking.
|
||||
- Referral link for MiniMax Coding Plan (10% off): https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link
|
||||
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
|
||||
|
||||
@@ -26,7 +26,7 @@ model as `provider/model`.
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||
- [Synthetic](/providers/synthetic)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [Z.AI](/providers/zai)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
---
|
||||
summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
|
||||
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)"
|
||||
read_when:
|
||||
- You want to use Moonshot/Kimi models in Clawdbot
|
||||
- You need the Moonshot auth + config example
|
||||
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup
|
||||
- You need to understand separate endpoints, keys, and model refs
|
||||
- You want copy/paste config for either provider
|
||||
---
|
||||
|
||||
# Moonshot AI (Kimi)
|
||||
|
||||
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:
|
||||
{/* moonshot-kimi-k2-ids:start */}
|
||||
@@ -21,7 +24,15 @@ Current Kimi K2 model IDs:
|
||||
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
|
||||
{
|
||||
@@ -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
|
||||
|
||||
- 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.
|
||||
- If Moonshot publishes different context limits for a model, adjust
|
||||
`contextWindow` accordingly.
|
||||
|
||||
@@ -10,6 +10,12 @@ read_when:
|
||||
|
||||
Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
|
||||
|
||||
## Operator trigger
|
||||
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
||||
- Read this doc and `docs/platforms/mac/release.md`.
|
||||
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
|
||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||
|
||||
1) **Version & metadata**
|
||||
- [ ] Bump `package.json` version (e.g., `1.1.0`).
|
||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
||||
@@ -20,6 +26,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
2) **Build & artifacts**
|
||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
||||
- [ ] `pnpm run build` (regenerates `dist/`).
|
||||
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
||||
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
||||
|
||||
3) **Changelog & docs**
|
||||
@@ -51,7 +58,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
- [ ] Confirm git status is clean; commit and push as needed.
|
||||
- [ ] `npm login` (verify 2FA) if needed.
|
||||
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
|
||||
- [ ] Verify the registry: `npm view clawdbot version` and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
|
||||
- [ ] Verify the registry: `npm view clawdbot version`, `npm view clawdbot dist-tags`, and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
|
||||
|
||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
||||
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/Clawdbot.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/Clawdbot.app` is not listed.
|
||||
|
||||
@@ -95,7 +95,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||
- More detail: [Synthetic](/providers/synthetic)
|
||||
- **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.
|
||||
- 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.
|
||||
|
||||
@@ -290,6 +290,11 @@ Live tests discover credentials the same way the CLI does. Practical implication
|
||||
|
||||
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
|
||||
|
||||
## Deepgram live (audio transcription)
|
||||
|
||||
- Test: `src/media-understanding/providers/deepgram/audio.live.test.ts`
|
||||
- Enable: `DEEPGRAM_API_KEY=... DEEPGRAM_LIVE_TEST=1 pnpm test:live src/media-understanding/providers/deepgram/audio.live.test.ts`
|
||||
|
||||
## Docker runners (optional “works in Linux” checks)
|
||||
|
||||
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
|
||||
|
||||
@@ -27,6 +27,7 @@ Tool params:
|
||||
- `label?` (optional)
|
||||
- `agentId?` (optional; spawn under another agent id if allowed)
|
||||
- `model?` (optional; overrides the sub-agent model; invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result)
|
||||
- `thinking?` (optional; overrides thinking level for the sub-agent run)
|
||||
- `runTimeoutSeconds?` (default `0`; when set, the sub-agent run is aborted after N seconds)
|
||||
- `cleanup?` (`delete|keep`, default `keep`)
|
||||
|
||||
|
||||
+91
-6
@@ -1,15 +1,16 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Brave Search API)"
|
||||
summary: "Web search + fetch tools (Brave Search API, Perplexity Sonar)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Brave Search API key setup
|
||||
- You want to use Perplexity Sonar for web search
|
||||
---
|
||||
|
||||
# Web tools
|
||||
|
||||
Clawdbot ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Brave Search API queries (fast, structured results).
|
||||
- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter).
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
@@ -17,13 +18,37 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
|
||||
## How it works
|
||||
|
||||
- `web_search` calls Brave’s Search API and returns structured results
|
||||
(title, URL, snippet). No browser is involved.
|
||||
- `web_search` calls your configured provider and returns results.
|
||||
- **Brave** (default): returns structured results (title, URL, snippet).
|
||||
- **Perplexity**: returns AI-synthesized answers with citations from real-time web search.
|
||||
- Results are cached by query for 15 minutes (configurable).
|
||||
- `web_fetch` does a plain HTTP GET and extracts readable content
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- `web_fetch` is enabled by default (unless explicitly disabled).
|
||||
|
||||
## Choosing a search provider
|
||||
|
||||
Provider docs: [Brave Search API](https://brave.com/search/api/) and [Perplexity Sonar](/perplexity).
|
||||
|
||||
| Provider | Pros | Cons | API Key |
|
||||
|----------|------|------|---------|
|
||||
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
|
||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter credits | `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` |
|
||||
|
||||
Set the provider in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave" // or "perplexity"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Getting a Brave API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
@@ -42,14 +67,74 @@ current limits and pricing.
|
||||
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
|
||||
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
||||
|
||||
## Using Perplexity Sonar
|
||||
|
||||
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized
|
||||
answers with citations. You can use them either directly via Perplexity’s API or via OpenRouter
|
||||
(supports crypto/prepaid).
|
||||
|
||||
See [Perplexity Sonar](/perplexity) for a dedicated setup guide.
|
||||
|
||||
### Getting an API key
|
||||
|
||||
**Perplexity (direct):**
|
||||
|
||||
1) Create a Perplexity account and generate an API key.
|
||||
2) Set `PERPLEXITY_API_KEY` in the Gateway environment or store the key in config.
|
||||
|
||||
**OpenRouter (alternative):**
|
||||
|
||||
1) Create an account at https://openrouter.ai/
|
||||
2) Add credits (supports crypto, prepaid, or credit card)
|
||||
3) Generate an API key in your account settings
|
||||
|
||||
### Setting up Perplexity search
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set)
|
||||
apiKey: "pplx-... or sk-or-v1-...",
|
||||
// Base URL:
|
||||
// - https://api.perplexity.ai (default when PERPLEXITY_API_KEY is set)
|
||||
// - https://openrouter.ai/api/v1 (default when only OPENROUTER_API_KEY is set)
|
||||
baseUrl: "https://api.perplexity.ai",
|
||||
// Model (defaults to perplexity/sonar-pro)
|
||||
model: "perplexity/sonar-pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Environment alternative:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` in the Gateway
|
||||
environment. For a daemon install, put it in `~/.clawdbot/.env`. If you set both keys, set
|
||||
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`) to disambiguate.
|
||||
|
||||
### Available Perplexity models
|
||||
|
||||
| Model | Description | Best for |
|
||||
|-------|-------------|----------|
|
||||
| `perplexity/sonar` | Fast Q&A with web search | Quick lookups |
|
||||
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
|
||||
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
|
||||
|
||||
## web_search
|
||||
|
||||
Search the web with Brave’s API.
|
||||
Search the web using your configured provider.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `tools.web.search.enabled` must not be `false` (default: enabled)
|
||||
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
|
||||
- API key for your chosen provider:
|
||||
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity**: `PERPLEXITY_API_KEY` (direct), `OPENROUTER_API_KEY` (OpenRouter), or `tools.web.search.perplexity.apiKey`
|
||||
|
||||
### Config
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copilot Proxy (Clawdbot plugin)
|
||||
|
||||
Provider plugin for the **Copilot Proxy** VS Code extension.
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable copilot-proxy
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider copilot-proxy --set-default
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Copilot Proxy must be running in VS Code.
|
||||
- Base URL must include `/v1`.
|
||||
@@ -0,0 +1,139 @@
|
||||
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
|
||||
const DEFAULT_API_KEY = "n/a";
|
||||
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
||||
const DEFAULT_MAX_TOKENS = 8192;
|
||||
const DEFAULT_MODEL_IDS = [
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.1",
|
||||
"gpt-5.1-codex",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5-mini",
|
||||
"claude-opus-4.5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-haiku-4.5",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
"grok-code-fast-1",
|
||||
] as const;
|
||||
|
||||
function normalizeBaseUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return DEFAULT_BASE_URL;
|
||||
let normalized = trimmed;
|
||||
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
||||
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateBaseUrl(value: string): string | undefined {
|
||||
const normalized = normalizeBaseUrl(value);
|
||||
try {
|
||||
new URL(normalized);
|
||||
} catch {
|
||||
return "Enter a valid URL";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseModelIds(input: string): string[] {
|
||||
const parsed = input
|
||||
.split(/[\n,]/)
|
||||
.map((model) => model.trim())
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(parsed));
|
||||
}
|
||||
|
||||
function buildModelDefinition(modelId: string) {
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-completions",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
const copilotProxyPlugin = {
|
||||
id: "copilot-proxy",
|
||||
name: "Copilot Proxy",
|
||||
description: "Local Copilot Proxy (VS Code LM) provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "copilot-proxy",
|
||||
label: "Copilot Proxy",
|
||||
docsPath: "/providers/models",
|
||||
auth: [
|
||||
{
|
||||
id: "local",
|
||||
label: "Local proxy",
|
||||
hint: "Configure base URL + models for the Copilot Proxy server",
|
||||
kind: "custom",
|
||||
run: async (ctx) => {
|
||||
const baseUrlInput = await ctx.prompter.text({
|
||||
message: "Copilot Proxy base URL",
|
||||
initialValue: DEFAULT_BASE_URL,
|
||||
validate: validateBaseUrl,
|
||||
});
|
||||
|
||||
const modelInput = await ctx.prompter.text({
|
||||
message: "Model IDs (comma-separated)",
|
||||
initialValue: DEFAULT_MODEL_IDS.join(", "),
|
||||
validate: (value) =>
|
||||
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
|
||||
});
|
||||
|
||||
const baseUrl = normalizeBaseUrl(baseUrlInput);
|
||||
const modelIds = parseModelIds(modelInput);
|
||||
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
|
||||
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "copilot-proxy:local",
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "copilot-proxy",
|
||||
token: DEFAULT_API_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
models: {
|
||||
providers: {
|
||||
"copilot-proxy": {
|
||||
baseUrl,
|
||||
apiKey: DEFAULT_API_KEY,
|
||||
api: "openai-completions",
|
||||
authHeader: false,
|
||||
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: Object.fromEntries(
|
||||
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: defaultModelRef,
|
||||
notes: [
|
||||
"Start the Copilot Proxy VS Code extension before using these models.",
|
||||
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
|
||||
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default copilotProxyPlugin;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/copilot-proxy",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Google Antigravity Auth (Clawdbot plugin)
|
||||
|
||||
OAuth provider plugin for **Google Antigravity** (Cloud Code Assist).
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable google-antigravity-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider google-antigravity --set-default
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Antigravity uses Google Cloud project quotas.
|
||||
- If requests fail, ensure Gemini for Google Cloud is enabled.
|
||||
@@ -0,0 +1,428 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const CLIENT_ID = decode(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
);
|
||||
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
||||
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
||||
const DEFAULT_MODEL = "google-antigravity/claude-opus-4-5-thinking";
|
||||
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
];
|
||||
|
||||
const CODE_ASSIST_ENDPOINTS = [
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
];
|
||||
|
||||
const RESPONSE_PAGE = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Clawdbot Antigravity OAuth</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Authentication complete</h1>
|
||||
<p>You can return to the terminal.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWSL2(): boolean {
|
||||
if (!isWSL()) return false;
|
||||
try {
|
||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2();
|
||||
}
|
||||
|
||||
function buildAuthUrl(params: { challenge: string; state: string }): string {
|
||||
const url = new URL(AUTH_URL);
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPES.join(" "));
|
||||
url.searchParams.set("code_challenge", params.challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", params.state);
|
||||
url.searchParams.set("access_type", "offline");
|
||||
url.searchParams.set("prompt", "consent");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { error: "No input provided" };
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
||||
if (!state) return { error: "Missing 'state' parameter in URL" };
|
||||
return { code, state };
|
||||
} catch {
|
||||
return { error: "Paste the full redirect URL (not just the code)." };
|
||||
}
|
||||
}
|
||||
|
||||
async function startCallbackServer(params: { timeoutMs: number }) {
|
||||
const redirect = new URL(REDIRECT_URI);
|
||||
const port = redirect.port ? Number(redirect.port) : 51121;
|
||||
|
||||
let settled = false;
|
||||
let resolveCallback: (url: URL) => void;
|
||||
let rejectCallback: (err: Error) => void;
|
||||
|
||||
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
||||
resolveCallback = (url) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(url);
|
||||
};
|
||||
rejectCallback = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
rejectCallback(new Error("Timed out waiting for OAuth callback"));
|
||||
}, params.timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
if (!request.url) {
|
||||
response.writeHead(400, { "Content-Type": "text/plain" });
|
||||
response.end("Missing URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
|
||||
if (url.pathname !== redirect.pathname) {
|
||||
response.writeHead(404, { "Content-Type": "text/plain" });
|
||||
response.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(RESPONSE_PAGE);
|
||||
resolveCallback(url);
|
||||
|
||||
setImmediate(() => {
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
server.off("error", onError);
|
||||
reject(err);
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeCode(params: {
|
||||
code: string;
|
||||
verifier: string;
|
||||
}): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: params.code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: params.verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Token exchange failed: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
const access = data.access_token?.trim();
|
||||
const refresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access) throw new Error("Token exchange returned no access_token");
|
||||
if (!refresh) throw new Error("Token exchange returned no refresh_token");
|
||||
|
||||
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
||||
return { access, refresh, expires };
|
||||
}
|
||||
|
||||
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjectId(accessToken: string): Promise<string> {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
};
|
||||
|
||||
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) continue;
|
||||
const data = (await response.json()) as {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
};
|
||||
|
||||
if (typeof data.cloudaicompanionProject === "string") {
|
||||
return data.cloudaicompanionProject;
|
||||
}
|
||||
if (
|
||||
data.cloudaicompanionProject &&
|
||||
typeof data.cloudaicompanionProject === "object" &&
|
||||
data.cloudaicompanionProject.id
|
||||
) {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_PROJECT_ID;
|
||||
}
|
||||
|
||||
async function loginAntigravity(params: {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
}): Promise<{
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
projectId: string;
|
||||
}> {
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const state = randomBytes(16).toString("hex");
|
||||
const authUrl = buildAuthUrl({ challenge, state });
|
||||
|
||||
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
|
||||
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
|
||||
if (!needsManual) {
|
||||
try {
|
||||
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
|
||||
} catch {
|
||||
callbackServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!callbackServer) {
|
||||
await params.note(
|
||||
[
|
||||
"Open the URL in your local browser.",
|
||||
"After signing in, copy the full redirect URL and paste it back here.",
|
||||
"",
|
||||
`Auth URL: ${authUrl}`,
|
||||
`Redirect URI: ${REDIRECT_URI}`,
|
||||
].join("\n"),
|
||||
"Google Antigravity OAuth",
|
||||
);
|
||||
}
|
||||
|
||||
if (!needsManual) {
|
||||
params.progress.update("Opening Google sign-in…");
|
||||
try {
|
||||
await params.openUrl(authUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let code = "";
|
||||
let returnedState = "";
|
||||
|
||||
if (callbackServer) {
|
||||
params.progress.update("Waiting for OAuth callback…");
|
||||
const callback = await callbackServer.waitForCallback();
|
||||
code = callback.searchParams.get("code") ?? "";
|
||||
returnedState = callback.searchParams.get("state") ?? "";
|
||||
await callbackServer.close();
|
||||
} else {
|
||||
params.progress.update("Waiting for redirect URL…");
|
||||
const input = await params.prompt("Paste the redirect URL: ");
|
||||
const parsed = parseCallbackInput(input);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
code = parsed.code;
|
||||
returnedState = parsed.state;
|
||||
}
|
||||
|
||||
if (!code) throw new Error("Missing OAuth code");
|
||||
if (returnedState !== state) {
|
||||
throw new Error("OAuth state mismatch. Please try again.");
|
||||
}
|
||||
|
||||
params.progress.update("Exchanging code for tokens…");
|
||||
const tokens = await exchangeCode({ code, verifier });
|
||||
const email = await fetchUserEmail(tokens.access);
|
||||
const projectId = await fetchProjectId(tokens.access);
|
||||
|
||||
params.progress.stop("Antigravity OAuth complete");
|
||||
return { ...tokens, email, projectId };
|
||||
}
|
||||
|
||||
const antigravityPlugin = {
|
||||
id: "google-antigravity-auth",
|
||||
name: "Google Antigravity Auth",
|
||||
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "google-antigravity",
|
||||
label: "Google Antigravity",
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["antigravity"],
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
const spin = ctx.prompter.progress("Starting Antigravity OAuth…");
|
||||
try {
|
||||
const result = await loginAntigravity({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
||||
note: ctx.prompter.note,
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
const profileId = `google-antigravity:${result.email ?? "default"}`;
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "google-antigravity",
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
projectId: result.projectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
notes: [
|
||||
"Antigravity uses Google Cloud project quotas.",
|
||||
"Enable Gemini for Google Cloud on your project if requests fail.",
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Antigravity OAuth failed");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default antigravityPlugin;
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/google-antigravity-auth",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Google Gemini CLI Auth (Clawdbot plugin)
|
||||
|
||||
OAuth provider plugin for **Gemini CLI** (Google Code Assist).
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable google-gemini-cli-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
||||
```
|
||||
|
||||
## Env vars
|
||||
|
||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
||||
@@ -0,0 +1,88 @@
|
||||
import { loginGeminiCliOAuth } from "./oauth.js";
|
||||
|
||||
const PROVIDER_ID = "google-gemini-cli";
|
||||
const PROVIDER_LABEL = "Gemini CLI OAuth";
|
||||
const DEFAULT_MODEL = "google-gemini-cli/gemini-3-pro-preview";
|
||||
const ENV_VARS = [
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_ID",
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_ID",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
];
|
||||
|
||||
const geminiCliPlugin = {
|
||||
id: "google-gemini-cli-auth",
|
||||
name: "Google Gemini CLI Auth",
|
||||
description: "OAuth flow for Gemini CLI (Google Code Assist)",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
docsPath: "/providers/models",
|
||||
aliases: ["gemini-cli"],
|
||||
envVars: ENV_VARS,
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Google OAuth",
|
||||
hint: "PKCE + localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
|
||||
try {
|
||||
const result = await loginGeminiCliOAuth({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
log: (msg) => ctx.runtime.log(msg),
|
||||
note: ctx.prompter.note,
|
||||
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
||||
progress: spin,
|
||||
});
|
||||
|
||||
spin.stop("Gemini CLI OAuth complete");
|
||||
const profileId = `google-gemini-cli:${result.email ?? "default"}`;
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: PROVIDER_ID,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
email: result.email,
|
||||
projectId: result.projectId,
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[DEFAULT_MODEL]: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
notes: [
|
||||
"If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Gemini CLI OAuth failed");
|
||||
await ctx.prompter.note(
|
||||
"Trouble with OAuth? Ensure your Google account has Gemini CLI access.",
|
||||
"OAuth help",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default geminiCliPlugin;
|
||||
@@ -0,0 +1,496 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||
const CLIENT_SECRET_KEYS = [
|
||||
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
];
|
||||
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
|
||||
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
];
|
||||
|
||||
const TIER_FREE = "free-tier";
|
||||
const TIER_LEGACY = "legacy-tier";
|
||||
const TIER_STANDARD = "standard-tier";
|
||||
|
||||
export type GeminiCliOAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type GeminiCliOAuthContext = {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
log: (msg: string) => void;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
};
|
||||
|
||||
function resolveEnv(keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
||||
const clientId = resolveEnv(CLIENT_ID_KEYS);
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
|
||||
);
|
||||
}
|
||||
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
function isWSL(): boolean {
|
||||
if (process.platform !== "linux") return false;
|
||||
try {
|
||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return release.includes("microsoft") || release.includes("wsl");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWSL2(): boolean {
|
||||
if (!isWSL()) return false;
|
||||
try {
|
||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2();
|
||||
}
|
||||
|
||||
function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function buildAuthUrl(challenge: string, verifier: string): string {
|
||||
const { clientId } = resolveOAuthClientConfig();
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES.join(" "),
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
expectedState: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { error: "No input provided" };
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state") ?? expectedState;
|
||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
||||
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||
return { code, state };
|
||||
} catch {
|
||||
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." };
|
||||
return { code: trimmed, state: expectedState };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLocalCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ code: string; state: string }> {
|
||||
const port = 8085;
|
||||
const hostname = "localhost";
|
||||
const expectedPath = "/oauth2callback";
|
||||
|
||||
return new Promise<{ code: string; state: string }>((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
|
||||
if (requestUrl.pathname !== expectedPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = requestUrl.searchParams.get("error");
|
||||
const code = requestUrl.searchParams.get("code")?.trim();
|
||||
const state = requestUrl.searchParams.get("state")?.trim();
|
||||
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`Authentication failed: ${error}`);
|
||||
finish(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing code or state");
|
||||
finish(new Error("Missing OAuth code or state"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== params.expectedState) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid state");
|
||||
finish(new Error("OAuth state mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
|
||||
"<body><h2>Gemini CLI OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to Clawdbot.</p></body></html>",
|
||||
);
|
||||
|
||||
finish(undefined, { code, state });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(new Error("OAuth callback timeout"));
|
||||
}, params.timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, verifier: string): Promise<GeminiCliOAuthCredentials> {
|
||||
const { clientId, clientSecret } = resolveOAuthClientConfig();
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
if (clientSecret) {
|
||||
body.set("client_secret", clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
if (!data.refresh_token) {
|
||||
throw new Error("No refresh token received. Please try again.");
|
||||
}
|
||||
|
||||
const email = await getUserEmail(data.access_token);
|
||||
const projectId = await discoverProject(data.access_token);
|
||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
return {
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: expiresAt,
|
||||
projectId,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(USERINFO_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { email?: string };
|
||||
return data.email;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function discoverProject(accessToken: string): Promise<string> {
|
||||
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "gl-node/clawdbot",
|
||||
};
|
||||
|
||||
const loadBody = {
|
||||
cloudaicompanionProject: envProject,
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
duetProject: envProject,
|
||||
},
|
||||
};
|
||||
|
||||
let data: {
|
||||
currentTier?: { id?: string };
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
||||
} = {};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(loadBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => null);
|
||||
if (isVpcScAffected(errorPayload)) {
|
||||
data = { currentTier: { id: TIER_STANDARD } };
|
||||
} else {
|
||||
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
} else {
|
||||
data = (await response.json()) as typeof data;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error("loadCodeAssist failed");
|
||||
}
|
||||
|
||||
if (data.currentTier) {
|
||||
const project = data.cloudaicompanionProject;
|
||||
if (typeof project === "string" && project) return project;
|
||||
if (typeof project === "object" && project?.id) return project.id;
|
||||
if (envProject) return envProject;
|
||||
throw new Error(
|
||||
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
||||
);
|
||||
}
|
||||
|
||||
const tier = getDefaultTier(data.allowedTiers);
|
||||
const tierId = tier?.id || TIER_FREE;
|
||||
if (tierId !== TIER_FREE && !envProject) {
|
||||
throw new Error(
|
||||
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
||||
);
|
||||
}
|
||||
|
||||
const onboardBody: Record<string, unknown> = {
|
||||
tierId,
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
};
|
||||
if (tierId !== TIER_FREE && envProject) {
|
||||
onboardBody.cloudaicompanionProject = envProject;
|
||||
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
|
||||
}
|
||||
|
||||
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(onboardBody),
|
||||
});
|
||||
|
||||
if (!onboardResponse.ok) {
|
||||
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`);
|
||||
}
|
||||
|
||||
let lro = (await onboardResponse.json()) as {
|
||||
done?: boolean;
|
||||
name?: string;
|
||||
response?: { cloudaicompanionProject?: { id?: string } };
|
||||
};
|
||||
|
||||
if (!lro.done && lro.name) {
|
||||
lro = await pollOperation(lro.name, headers);
|
||||
}
|
||||
|
||||
const projectId = lro.response?.cloudaicompanionProject?.id;
|
||||
if (projectId) return projectId;
|
||||
if (envProject) return envProject;
|
||||
|
||||
throw new Error(
|
||||
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
||||
);
|
||||
}
|
||||
|
||||
function isVpcScAffected(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== "object") return false;
|
||||
const error = (payload as { error?: unknown }).error;
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const details = (error as { details?: unknown[] }).details;
|
||||
if (!Array.isArray(details)) return false;
|
||||
return details.some(
|
||||
(item) =>
|
||||
typeof item === "object" && item && (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED",
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultTier(
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
|
||||
): { id?: string } | undefined {
|
||||
if (!allowedTiers?.length) return { id: TIER_LEGACY };
|
||||
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
|
||||
}
|
||||
|
||||
async function pollOperation(
|
||||
operationName: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
|
||||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) continue;
|
||||
const data = (await response.json()) as {
|
||||
done?: boolean;
|
||||
response?: { cloudaicompanionProject?: { id?: string } };
|
||||
};
|
||||
if (data.done) return data;
|
||||
}
|
||||
throw new Error("Operation polling timeout");
|
||||
}
|
||||
|
||||
export async function loginGeminiCliOAuth(ctx: GeminiCliOAuthContext): Promise<GeminiCliOAuthCredentials> {
|
||||
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
|
||||
await ctx.note(
|
||||
needsManual
|
||||
? [
|
||||
"You are running in a remote/VPS environment.",
|
||||
"A URL will be shown for you to open in your LOCAL browser.",
|
||||
"After signing in, copy the redirect URL and paste it back here.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Google authentication.",
|
||||
"Sign in with your Google account for Gemini CLI access.",
|
||||
"The callback will be captured automatically on localhost:8085.",
|
||||
].join("\n"),
|
||||
"Gemini CLI OAuth",
|
||||
);
|
||||
|
||||
const { verifier, challenge } = generatePkce();
|
||||
const authUrl = buildAuthUrl(challenge, verifier);
|
||||
|
||||
if (needsManual) {
|
||||
ctx.progress.update("OAuth URL ready");
|
||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||
ctx.progress.update("Waiting for you to paste the callback URL...");
|
||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return exchangeCodeForTokens(parsed.code, verifier);
|
||||
}
|
||||
|
||||
ctx.progress.update("Complete sign-in in browser...");
|
||||
try {
|
||||
await ctx.openUrl(authUrl);
|
||||
} catch {
|
||||
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code } = await waitForLocalCallback({
|
||||
expectedState: verifier,
|
||||
timeoutMs: 5 * 60 * 1000,
|
||||
onProgress: (msg) => ctx.progress.update(msg),
|
||||
});
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return await exchangeCodeForTokens(code, verifier);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("EADDRINUSE") ||
|
||||
err.message.includes("port") ||
|
||||
err.message.includes("listen"))
|
||||
) {
|
||||
ctx.progress.update("Local callback server failed. Switching to manual mode...");
|
||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== verifier) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return exchangeCodeForTokens(parsed.code, verifier);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/google-gemini-cli-auth",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/matrix",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Matrix channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it": "14.1.0",
|
||||
|
||||
@@ -164,13 +164,15 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMatrixMessagingTarget,
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
|
||||
return trimmed.includes(":");
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
|
||||
return trimmed.includes(":");
|
||||
},
|
||||
hint: "<room|alias|user>",
|
||||
},
|
||||
targetHint: "<room|alias|user>",
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
|
||||
@@ -16,14 +16,14 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupRoom = params.groupRoom?.trim() ?? "";
|
||||
const aliases = groupRoom ? [groupRoom] : [];
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupRoom || undefined,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
if (resolved) {
|
||||
if (resolved.autoReply === true) return false;
|
||||
|
||||
@@ -10,23 +10,49 @@ function normalizeMatrixUser(raw?: string | null): string {
|
||||
return (raw ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export type MatrixAllowListMatch = {
|
||||
allowed: boolean;
|
||||
matchKey?: string;
|
||||
matchSource?: "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart";
|
||||
};
|
||||
|
||||
export function resolveMatrixAllowListMatch(params: {
|
||||
allowList: string[];
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}): MatrixAllowListMatch {
|
||||
const allowList = params.allowList;
|
||||
if (allowList.length === 0) return { allowed: false };
|
||||
if (allowList.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
const userId = normalizeMatrixUser(params.userId);
|
||||
const userName = normalizeMatrixUser(params.userName);
|
||||
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
|
||||
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
|
||||
{ value: userId, source: "id" },
|
||||
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
|
||||
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
|
||||
{ value: userName, source: "name" },
|
||||
{ value: localPart, source: "localpart" },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) continue;
|
||||
if (allowList.includes(candidate.value)) {
|
||||
return {
|
||||
allowed: true,
|
||||
matchKey: candidate.value,
|
||||
matchSource: candidate.source,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export function resolveMatrixAllowListMatches(params: {
|
||||
allowList: string[];
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}) {
|
||||
const allowList = params.allowList;
|
||||
if (allowList.length === 0) return false;
|
||||
if (allowList.includes("*")) return true;
|
||||
const userId = normalizeMatrixUser(params.userId);
|
||||
const userName = normalizeMatrixUser(params.userName);
|
||||
const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : "";
|
||||
const candidates = [
|
||||
userId,
|
||||
userId ? `matrix:${userId}` : "",
|
||||
userId ? `user:${userId}` : "",
|
||||
userName,
|
||||
localPart,
|
||||
].filter(Boolean);
|
||||
return candidates.some((value) => allowList.includes(value));
|
||||
return resolveMatrixAllowListMatch(params).allowed;
|
||||
}
|
||||
@@ -41,7 +41,11 @@ import {
|
||||
parsePollStartContent,
|
||||
} from "../poll-types.js";
|
||||
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
|
||||
import { resolveMatrixAllowListMatches, normalizeAllowListLower } from "./allowlist.js";
|
||||
import {
|
||||
resolveMatrixAllowListMatch,
|
||||
resolveMatrixAllowListMatches,
|
||||
normalizeAllowListLower,
|
||||
} from "./allowlist.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
import { createDirectRoomTracker } from "./direct.js";
|
||||
import { downloadMatrixMedia } from "./media.js";
|
||||
@@ -184,18 +188,21 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
aliases: roomAliases,
|
||||
name: roomName,
|
||||
});
|
||||
const roomMatchMeta = `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
|
||||
roomConfigInfo.matchSource ?? "none"
|
||||
}`;
|
||||
|
||||
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
||||
logVerbose(`matrix: room disabled room=${roomId}`);
|
||||
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (!roomConfigInfo.allowlistConfigured) {
|
||||
logVerbose("matrix: drop room message (no allowlist)");
|
||||
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (!roomConfigInfo.config) {
|
||||
logVerbose("matrix: drop room message (not in allowlist)");
|
||||
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -207,14 +214,15 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
if (isDirectMessage) {
|
||||
if (!dmEnabled || dmPolicy === "disabled") return;
|
||||
if (dmPolicy !== "open") {
|
||||
const permitted =
|
||||
effectiveAllowFrom.length > 0 &&
|
||||
resolveMatrixAllowListMatches({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
if (!permitted) {
|
||||
const allowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
const allowMatchMeta = `matchKey=${allowMatch.matchKey ?? "none"} matchSource=${
|
||||
allowMatch.matchSource ?? "none"
|
||||
}`;
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "matrix",
|
||||
@@ -222,6 +230,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
meta: { name: senderName },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||
);
|
||||
try {
|
||||
await sendMessageMatrix(
|
||||
`room:${roomId}`,
|
||||
@@ -240,6 +251,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dmPolicy !== "pairing") {
|
||||
logVerbose(
|
||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -252,10 +268,15 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
userName: senderName,
|
||||
});
|
||||
if (!userAllowed) {
|
||||
logVerbose(`matrix: blocked sender ${senderId} (room users allowlist)`);
|
||||
logVerbose(
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isRoom) {
|
||||
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
}
|
||||
|
||||
const rawBody = content.body.trim();
|
||||
let media: {
|
||||
@@ -379,7 +400,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
SenderId: senderId,
|
||||
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
||||
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
||||
GroupRoom: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
||||
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
|
||||
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
||||
Provider: "matrix" as const,
|
||||
Surface: "matrix" as const,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.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 { sendMessageMatrix } from "../send.js";
|
||||
|
||||
@@ -18,7 +18,12 @@ export async function deliverMatrixReplies(params: {
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
let hasReplied = false;
|
||||
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"));
|
||||
continue;
|
||||
}
|
||||
@@ -57,6 +62,7 @@ export async function deliverMatrixReplies(params: {
|
||||
mediaUrl,
|
||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||
threadId: params.threadId,
|
||||
audioAsVoice: reply.audioAsVoice,
|
||||
});
|
||||
if (shouldIncludeReply(replyToId)) {
|
||||
hasReplied = true;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { MatrixConfig, MatrixRoomConfig } from "../../types.js";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
} from "../../../../../src/channels/plugins/channel-config.js";
|
||||
|
||||
export type MatrixRoomConfigResolved = {
|
||||
allowed: boolean;
|
||||
allowlistConfigured: boolean;
|
||||
config?: MatrixRoomConfig;
|
||||
matchKey?: string;
|
||||
matchSource?: "direct" | "wildcard";
|
||||
};
|
||||
|
||||
export function resolveMatrixRoomConfig(params: {
|
||||
@@ -15,22 +21,26 @@ export function resolveMatrixRoomConfig(params: {
|
||||
const rooms = params.rooms ?? {};
|
||||
const keys = Object.keys(rooms);
|
||||
const allowlistConfigured = keys.length > 0;
|
||||
const candidates = [
|
||||
const candidates = buildChannelKeyCandidates(
|
||||
params.roomId,
|
||||
`room:${params.roomId}`,
|
||||
...params.aliases,
|
||||
params.name ?? "",
|
||||
].filter(Boolean);
|
||||
let matched: MatrixRoomConfigResolved["config"] | undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (rooms[candidate]) {
|
||||
matched = rooms[candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched && rooms["*"]) {
|
||||
matched = rooms["*"];
|
||||
}
|
||||
const allowed = matched ? matched.enabled !== false && matched.allow !== false : false;
|
||||
return { allowed, allowlistConfigured, config: matched };
|
||||
);
|
||||
const { entry: matched, key: matchedKey, wildcardEntry, wildcardKey } = resolveChannelEntryMatch({
|
||||
entries: rooms,
|
||||
keys: candidates,
|
||||
wildcardKey: "*",
|
||||
});
|
||||
const resolved = matched ?? wildcardEntry;
|
||||
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
|
||||
const matchKey = matchedKey ?? wildcardKey;
|
||||
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
|
||||
return {
|
||||
allowed,
|
||||
allowlistConfigured,
|
||||
config: resolved,
|
||||
matchKey,
|
||||
matchSource,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parsePollStartContent } from "./poll-types.js";
|
||||
|
||||
describe("parsePollStartContent", () => {
|
||||
it("parses legacy m.poll payloads", () => {
|
||||
const summary = parsePollStartContent({
|
||||
"m.poll": {
|
||||
question: { "m.text": "Lunch?" },
|
||||
kind: "m.poll.disclosed",
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "answer1", "m.text": "Yes" },
|
||||
{ id: "answer2", "m.text": "No" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary?.question).toBe("Lunch?");
|
||||
expect(summary?.answers).toEqual(["Yes", "No"]);
|
||||
});
|
||||
});
|
||||
@@ -7,15 +7,17 @@
|
||||
* - m.poll.end - Closes a poll
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export const M_POLL_START = "m.poll.start";
|
||||
export const M_POLL_RESPONSE = "m.poll.response";
|
||||
export const M_POLL_END = "m.poll.end";
|
||||
export const M_POLL_START = "m.poll.start" as const;
|
||||
export const M_POLL_RESPONSE = "m.poll.response" as const;
|
||||
export const M_POLL_END = "m.poll.end" as const;
|
||||
|
||||
export const ORG_POLL_START = "org.matrix.msc3381.poll.start";
|
||||
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response";
|
||||
export const ORG_POLL_END = "org.matrix.msc3381.poll.end";
|
||||
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
|
||||
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
|
||||
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
|
||||
|
||||
export const POLL_EVENT_TYPES = [
|
||||
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 TextContent = {
|
||||
"m.text"?: string;
|
||||
"org.matrix.msc1767.text"?: string;
|
||||
export type TextContent = ExtensibleAnyMessageEventContent & {
|
||||
body?: string;
|
||||
};
|
||||
|
||||
@@ -42,25 +42,19 @@ export type PollAnswer = {
|
||||
id: string;
|
||||
} & TextContent;
|
||||
|
||||
export type PollStartContent = {
|
||||
"m.poll"?: {
|
||||
question: TextContent;
|
||||
kind?: PollKind;
|
||||
max_selections?: number;
|
||||
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 PollStartSubtype = {
|
||||
question: TextContent;
|
||||
kind?: PollKind;
|
||||
max_selections?: number;
|
||||
answers: PollAnswer[];
|
||||
};
|
||||
|
||||
export type LegacyPollStartContent = {
|
||||
"m.poll"?: PollStartSubtype;
|
||||
};
|
||||
|
||||
export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent;
|
||||
|
||||
export type PollSummary = {
|
||||
eventId: string;
|
||||
roomId: string;
|
||||
@@ -82,7 +76,9 @@ export function getTextContent(text?: TextContent): string {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 {
|
||||
const question = poll.question.trim();
|
||||
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 fallbackText = buildPollFallbackText(
|
||||
question,
|
||||
answers.map((answer) => getTextContent(answer)),
|
||||
);
|
||||
|
||||
return {
|
||||
"m.poll": {
|
||||
[M_POLL_START]: {
|
||||
question: buildTextContent(question),
|
||||
kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed",
|
||||
max_selections: maxSelections,
|
||||
answers,
|
||||
},
|
||||
"m.text": fallbackText,
|
||||
"org.matrix.msc1767.text": fallbackText,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,11 @@ vi.mock("../../../../src/web/media.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/media/image-ops.js", () => ({
|
||||
getImageMetadata: vi.fn().mockResolvedValue(null),
|
||||
resizeToJpeg: vi.fn(),
|
||||
}));
|
||||
|
||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||
|
||||
const makeClient = () => {
|
||||
@@ -65,13 +70,13 @@ describe("sendMessageMatrix media", () => {
|
||||
const uploadArg = uploadContent.mock.calls[0]?.[0];
|
||||
expect(Buffer.isBuffer(uploadArg)).toBe(true);
|
||||
|
||||
const content = sendMessage.mock.calls[0]?.[2] as {
|
||||
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||
url?: string;
|
||||
msgtype?: string;
|
||||
format?: 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.formatted_body).toContain("caption");
|
||||
expect(content.url).toBe("mxc://example/file");
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
|
||||
import { EventType, MsgType, RelationType } from "matrix-js-sdk";
|
||||
import type {
|
||||
ReactionEventContent,
|
||||
RoomMessageEventContent,
|
||||
ReactionEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.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 { loadWebMedia } from "../../../../src/web/media.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
@@ -47,6 +50,8 @@ export type MatrixSendOpts = {
|
||||
replyToId?: string;
|
||||
threadId?: string | number | null;
|
||||
timeoutMs?: number;
|
||||
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
|
||||
audioAsVoice?: boolean;
|
||||
};
|
||||
|
||||
function ensureNodeRuntime() {
|
||||
@@ -71,6 +76,12 @@ function normalizeTarget(raw: string): string {
|
||||
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> {
|
||||
const trimmed = userId.trim();
|
||||
if (!trimmed.startsWith("@")) {
|
||||
@@ -119,6 +130,18 @@ export async function resolveMatrixRoomId(
|
||||
return target;
|
||||
}
|
||||
|
||||
type MatrixImageInfo = {
|
||||
w?: number;
|
||||
h?: number;
|
||||
thumbnail_url?: string;
|
||||
thumbnail_info?: {
|
||||
w: number;
|
||||
h: number;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
};
|
||||
};
|
||||
|
||||
function buildMediaContent(params: {
|
||||
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
|
||||
body: string;
|
||||
@@ -127,8 +150,24 @@ function buildMediaContent(params: {
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
relation?: MatrixReplyRelation;
|
||||
isVoice?: boolean;
|
||||
durationMs?: number;
|
||||
imageInfo?: MatrixImageInfo;
|
||||
}): 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 = {
|
||||
msgtype: params.msgtype,
|
||||
body: params.body,
|
||||
@@ -136,6 +175,12 @@ function buildMediaContent(params: {
|
||||
info,
|
||||
url: params.url,
|
||||
};
|
||||
if (params.isVoice) {
|
||||
base["org.matrix.msc3245.voice"] = {};
|
||||
base["org.matrix.msc1767.audio"] = {
|
||||
duration: params.durationMs,
|
||||
};
|
||||
}
|
||||
if (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 } };
|
||||
}
|
||||
|
||||
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(
|
||||
client: MatrixClient,
|
||||
file: MatrixUploadContent | Buffer,
|
||||
@@ -238,14 +352,10 @@ export async function sendMessageMatrix(
|
||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||
const rawThreadId = opts.threadId;
|
||||
const threadId =
|
||||
rawThreadId !== undefined && rawThreadId !== null
|
||||
? String(rawThreadId).trim()
|
||||
: null;
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
||||
const sendContent = (content: RoomMessageEventContent) =>
|
||||
client.sendMessage(roomId, threadId ?? undefined, content);
|
||||
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content);
|
||||
|
||||
let lastMessageId = "";
|
||||
if (opts.mediaUrl) {
|
||||
@@ -255,9 +365,17 @@ export async function sendMessageMatrix(
|
||||
contentType: media.contentType,
|
||||
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 body = firstChunk ?? media.fileName ?? "(file)";
|
||||
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
|
||||
const content = buildMediaContent({
|
||||
msgtype,
|
||||
body,
|
||||
@@ -266,10 +384,13 @@ export async function sendMessageMatrix(
|
||||
mimetype: media.contentType,
|
||||
size: media.buffer.byteLength,
|
||||
relation,
|
||||
isVoice: useVoice,
|
||||
imageInfo,
|
||||
});
|
||||
const response = await sendContent(content);
|
||||
lastMessageId = response.event_id ?? lastMessageId;
|
||||
for (const chunk of rest) {
|
||||
const textChunks = useVoice ? chunks : rest;
|
||||
for (const chunk of textChunks) {
|
||||
const text = chunk.trim();
|
||||
if (!text) continue;
|
||||
const followup = buildTextContent(text);
|
||||
@@ -316,17 +437,19 @@ export async function sendPollMatrix(
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const pollContent = buildPollStartContent(poll);
|
||||
const rawThreadId = opts.threadId;
|
||||
const threadId =
|
||||
rawThreadId !== undefined && rawThreadId !== null
|
||||
? String(rawThreadId).trim()
|
||||
: null;
|
||||
const response = await client.sendEvent(
|
||||
roomId,
|
||||
threadId ?? undefined,
|
||||
M_POLL_START as EventType.RoomMessage,
|
||||
pollContent as unknown as RoomMessageEventContent,
|
||||
);
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const response = threadId
|
||||
? await client.sendEvent(
|
||||
roomId,
|
||||
threadId,
|
||||
M_POLL_START,
|
||||
pollContent,
|
||||
)
|
||||
: await client.sendEvent(
|
||||
roomId,
|
||||
M_POLL_START,
|
||||
pollContent,
|
||||
);
|
||||
|
||||
return {
|
||||
eventId: response.event_id ?? "unknown",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/msteams",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/agents-hosting": "^1.1.1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
import { sendMessageMSTeams } from "./send.js";
|
||||
import { resolveMSTeamsCredentials } from "./token.js";
|
||||
|
||||
@@ -133,13 +134,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeMSTeamsMessagingTarget,
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(conversation:|user:)/i.test(trimmed)) return true;
|
||||
return trimmed.includes("@thread");
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(conversation:|user:)/i.test(trimmed)) return true;
|
||||
return trimmed.includes("@thread");
|
||||
},
|
||||
hint: "<conversationId|user:ID|conversation:ID>",
|
||||
},
|
||||
targetHint: "<conversationId|user:ID|conversation:ID>",
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
@@ -216,7 +219,8 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
@@ -225,6 +229,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
port: runtime?.port ?? null,
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
|
||||
@@ -89,6 +89,18 @@ describe("msteams policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits team mention settings when channel config is missing", () => {
|
||||
const policy = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
globalConfig: { requireMention: true },
|
||||
teamConfig: { requireMention: false },
|
||||
});
|
||||
expect(policy).toEqual({
|
||||
requireMention: false,
|
||||
replyStyle: "top-level",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses explicit replyStyle even when requireMention defaults would differ", () => {
|
||||
const policy = resolveMSTeamsReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
|
||||
@@ -7,8 +7,52 @@ export type ProbeMSTeamsResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
appId?: string;
|
||||
graph?: {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
function readAccessToken(value: unknown): string | null {
|
||||
if (typeof value === "string") return value;
|
||||
if (value && typeof value === "object") {
|
||||
const token =
|
||||
(value as { accessToken?: unknown }).accessToken ??
|
||||
(value as { token?: unknown }).token;
|
||||
return typeof token === "string" ? token : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const payload = parts[1] ?? "";
|
||||
const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
|
||||
const normalized = padded.replace(/-/g, "+").replace(/_/g, "/");
|
||||
try {
|
||||
const decoded = Buffer.from(normalized, "base64").toString("utf8");
|
||||
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const out = value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function readScopes(value: unknown): string[] | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const out = value.split(/\s+/).map((entry) => entry.trim()).filter(Boolean);
|
||||
return out.length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsResult> {
|
||||
const creds = resolveMSTeamsCredentials(cfg);
|
||||
if (!creds) {
|
||||
@@ -22,7 +66,29 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
|
||||
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
|
||||
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
|
||||
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
|
||||
return { ok: true, appId: creds.appId };
|
||||
let graph:
|
||||
| {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
roles?: string[];
|
||||
scopes?: string[];
|
||||
}
|
||||
| undefined;
|
||||
try {
|
||||
const graphToken = await tokenProvider.getAccessToken(
|
||||
"https://graph.microsoft.com/.default",
|
||||
);
|
||||
const accessToken = readAccessToken(graphToken);
|
||||
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
|
||||
graph = {
|
||||
ok: true,
|
||||
roles: readStringArray(payload?.roles),
|
||||
scopes: readScopes(payload?.scp),
|
||||
};
|
||||
} catch (err) {
|
||||
graph = { ok: false, error: formatUnknownError(err) };
|
||||
}
|
||||
return { ok: true, appId: creds.appId, ...(graph ? { graph } : {}) };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clawdbot/voice-call",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot voice-call plugin",
|
||||
"dependencies": {
|
||||
@@ -9,6 +9,8 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Clawdbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/zalo",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"undici": "7.18.2"
|
||||
|
||||
@@ -150,12 +150,14 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
actions: zaloMessageActions,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeZaloMessagingTarget,
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<chatId>",
|
||||
},
|
||||
targetHint: "<chatId>",
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
isControlCommandMessage,
|
||||
shouldComputeCommandAuthorized,
|
||||
} from "../../../src/auto-reply/command-detection.js";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
|
||||
import {
|
||||
ZaloApiError,
|
||||
deleteWebhook,
|
||||
@@ -437,6 +442,21 @@ async function processMessageWithPipeline(params: {
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await deps.readChannelAllowFromStore("zalo").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
@@ -445,9 +465,7 @@ async function processMessageWithPipeline(params: {
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await deps.readChannelAllowFromStore("zalo").catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const allowed = senderAllowedForCommands;
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
@@ -496,7 +514,11 @@ async function processMessageWithPipeline(params: {
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
||||
logVerbose(deps, `zalo: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
@@ -511,7 +533,7 @@ async function processMessageWithPipeline(params: {
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`,
|
||||
From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
|
||||
To: `zalo:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
@@ -519,6 +541,7 @@ async function processMessageWithPipeline(params: {
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "zalo",
|
||||
Surface: "zalo",
|
||||
MessageSid: message_id,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"name": "@clawdbot/zalouser",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -218,12 +218,14 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
||||
},
|
||||
looksLikeTargetId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<threadId>",
|
||||
},
|
||||
targetHint: "<threadId>",
|
||||
},
|
||||
directory: {
|
||||
self: async ({ cfg, accountId, runtime }) => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import {
|
||||
isControlCommandMessage,
|
||||
shouldComputeCommandAuthorized,
|
||||
} from "../../../src/auto-reply/command-detection.js";
|
||||
import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-context.js";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
|
||||
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
|
||||
@@ -105,6 +110,21 @@ async function processMessage(
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
const rawBody = content.trim();
|
||||
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await deps.readChannelAllowFromStore("zalouser").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
@@ -113,9 +133,7 @@ async function processMessage(
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await deps.readChannelAllowFromStore("zalouser").catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const allowed = senderAllowedForCommands;
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
@@ -158,6 +176,11 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
|
||||
logVerbose(deps, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
|
||||
|
||||
const route = deps.resolveAgentRoute({
|
||||
@@ -172,9 +195,7 @@ async function processMessage(
|
||||
});
|
||||
|
||||
const rawBody = content.trim();
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
@@ -186,7 +207,7 @@ async function processMessage(
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`,
|
||||
From: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
|
||||
To: `zalouser:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
@@ -194,6 +215,7 @@ async function processMessage(
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
MessageSid: message.msgId ?? `${timestamp}`,
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.15",
|
||||
"version": "2026.1.17-1",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -26,6 +26,7 @@
|
||||
"dist/infra/**",
|
||||
"dist/macos/**",
|
||||
"dist/media/**",
|
||||
"dist/media-understanding/**",
|
||||
"dist/process/**",
|
||||
"dist/plugins/**",
|
||||
"dist/security/**",
|
||||
@@ -65,7 +66,7 @@
|
||||
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
|
||||
"docs:dev": "cd docs && mint dev",
|
||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts",
|
||||
"build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts && tsx scripts/write-build-info.ts",
|
||||
"plugins:sync": "tsx scripts/sync-plugin-versions.ts",
|
||||
"release:check": "tsx scripts/release-check.ts",
|
||||
"ui:install": "node scripts/ui.js install",
|
||||
@@ -173,10 +174,12 @@
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sqlite-vec": "0.1.7-alpha.2",
|
||||
"tar": "^7.5.3",
|
||||
"tslog": "^4.10.2",
|
||||
"undici": "^7.18.2",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
Whitespace-only changes.
Generated
+63
@@ -133,6 +133,9 @@ importers:
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
sqlite-vec:
|
||||
specifier: 0.1.7-alpha.2
|
||||
version: 0.1.7-alpha.2
|
||||
tar:
|
||||
specifier: 7.5.3
|
||||
version: 7.5.3
|
||||
@@ -145,6 +148,9 @@ importers:
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
zod:
|
||||
specifier: ^4.3.5
|
||||
version: 4.3.5
|
||||
@@ -232,6 +238,12 @@ importers:
|
||||
specifier: 3.14.5
|
||||
version: 3.14.5(typescript@5.9.3)
|
||||
|
||||
extensions/copilot-proxy: {}
|
||||
|
||||
extensions/google-antigravity-auth: {}
|
||||
|
||||
extensions/google-gemini-cli-auth: {}
|
||||
|
||||
extensions/matrix:
|
||||
dependencies:
|
||||
markdown-it:
|
||||
@@ -3904,6 +3916,34 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sqlite-vec-darwin-arm64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
sqlite-vec-darwin-x64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-jeZEELsQjjRsVojsvU5iKxOvkaVuE+JYC8Y4Ma8U45aAERrDYmqZoHvgSG7cg1PXL3bMlumFTAmHynf1y4pOzA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
sqlite-vec-linux-arm64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-6Spj4Nfi7tG13jsUG+W7jnT0bCTWbyPImu2M8nWp20fNrd1SZ4g3CSlDAK8GBdavX7wRlbBHCZ+BDa++rbDewA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
sqlite-vec-linux-x64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-IcgrbHaDccTVhXDf8Orwdc2+hgDLAFORl6OBUhcvlmwswwBP1hqBTSEhovClG4NItwTOBNgpwOoQ7Qp3VDPWLg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
sqlite-vec-windows-x64@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
sqlite-vec@0.1.7-alpha.2:
|
||||
resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
@@ -8557,6 +8597,29 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sqlite-vec-darwin-arm64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec-darwin-x64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec-linux-arm64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec-linux-x64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec-windows-x64@0.1.7-alpha.2:
|
||||
optional: true
|
||||
|
||||
sqlite-vec@0.1.7-alpha.2:
|
||||
optionalDependencies:
|
||||
sqlite-vec-darwin-arm64: 0.1.7-alpha.2
|
||||
sqlite-vec-darwin-x64: 0.1.7-alpha.2
|
||||
sqlite-vec-linux-arm64: 0.1.7-alpha.2
|
||||
sqlite-vec-linux-x64: 0.1.7-alpha.2
|
||||
sqlite-vec-windows-x64: 0.1.7-alpha.2
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"latitudeki5223",
|
||||
"longmaba",
|
||||
"manmal",
|
||||
"thesash"
|
||||
"thesash",
|
||||
"rhjoh"
|
||||
],
|
||||
"seedCommit": "d6863f87",
|
||||
"placeholderAvatar": "assets/avatar-placeholder.svg",
|
||||
|
||||
@@ -168,24 +168,33 @@ TRASH
|
||||
fi
|
||||
}
|
||||
|
||||
select_skip_hooks() {
|
||||
# Hooks multiselect: pick "Skip for now".
|
||||
wait_for_log "Enable hooks?" 60 || true
|
||||
send $'"'"' \r'"'"' 0.6
|
||||
}
|
||||
|
||||
send_local_basic() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
send $'"'"'y\r'"'"' 0.6
|
||||
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
|
||||
send $'"'"'\r'"'"' 0.5
|
||||
select_skip_hooks
|
||||
}
|
||||
|
||||
send_reset_config_only() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
send $'"'"'y\r'"'"' 0.8
|
||||
# Reset config + reuse the local defaults flow.
|
||||
send $'"'"'\e[B'"'"' 0.3
|
||||
send $'"'"'\e[B'"'"' 0.3
|
||||
send $'"'"'\r'"'"' 0.4
|
||||
send $'"'"'\r'"'"' 0.4
|
||||
send "" 1.2
|
||||
send_local_basic
|
||||
}
|
||||
send_reset_config_only() {
|
||||
# Risk acknowledgement (default is "No").
|
||||
send $'"'"'y\r'"'"' 0.8
|
||||
# Select reset flow for existing config.
|
||||
wait_for_log "Config handling" 40 || true
|
||||
send $'"'"'\e[B'"'"' 0.3
|
||||
send $'"'"'\e[B'"'"' 0.3
|
||||
send $'"'"'\r'"'"' 0.4
|
||||
# Reset scope -> Config only (default).
|
||||
wait_for_log "Reset scope" 40 || true
|
||||
send $'"'"'\r'"'"' 0.4
|
||||
select_skip_hooks
|
||||
}
|
||||
|
||||
send_channels_flow() {
|
||||
# Configure channels via configure wizard.
|
||||
@@ -202,11 +211,13 @@ TRASH
|
||||
|
||||
send_skills_flow() {
|
||||
# Select skills section and skip optional installs.
|
||||
send $'"'"'\r'"'"' 1.2
|
||||
send "" 1.0
|
||||
wait_for_log "Where will the Gateway run?" 40 || true
|
||||
send $'"'"'\r'"'"' 0.8
|
||||
# Configure skills now? -> No
|
||||
send $'"'"'n\r'"'"' 1.2
|
||||
send "" 2.0
|
||||
wait_for_log "Configure skills now?" 40 || true
|
||||
send $'"'"'n\r'"'"' 0.8
|
||||
wait_for_log "Configure complete." 40 || true
|
||||
send "" 0.8
|
||||
}
|
||||
|
||||
run_case_local_basic() {
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const distDir = path.join(rootDir, "dist");
|
||||
const pkgPath = path.join(rootDir, "package.json");
|
||||
|
||||
const readPackageVersion = () => {
|
||||
try {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return parsed.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveCommit = () => {
|
||||
const envCommit = process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim();
|
||||
if (envCommit) return envCommit;
|
||||
try {
|
||||
return execSync("git rev-parse HEAD", {
|
||||
cwd: rootDir,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const version = readPackageVersion();
|
||||
const commit = resolveCommit();
|
||||
|
||||
const buildInfo = {
|
||||
version,
|
||||
commit,
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(distDir, "build-info.json"),
|
||||
`${JSON.stringify(buildInfo, null, 2)}\n`,
|
||||
);
|
||||
Loaded 100 of 491 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user