Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a79c19f9 | ||
|
|
cb0f6cefa4 | ||
|
|
25ef01b74a | ||
|
|
6db0201fcd | ||
|
|
40e508823f | ||
|
|
3368284b2a | ||
|
|
ece01d89fe | ||
|
|
9c0c4f50ec | ||
|
|
6729637f61 | ||
|
|
18d22aa426 | ||
|
|
a3641526ab | ||
|
|
f50e06a1b6 | ||
|
|
2ae3b45ac1 | ||
|
|
780a43711f | ||
|
|
d682b604de | ||
|
|
fa75d84b75 | ||
|
|
bb2df13be0 | ||
|
|
5918def440 | ||
|
|
1fdd3592d3 | ||
|
|
a96d299971 | ||
|
|
42ff634a9d | ||
|
|
b45d7c3256 | ||
|
|
77ca508274 | ||
|
|
61b7398cb7 | ||
|
|
0321d5ed74 | ||
|
|
78627ce7c2 | ||
|
|
c851bdd47a | ||
|
|
9ec0016258 | ||
|
|
01776e0569 | ||
|
|
d8f14078f0 | ||
|
|
38244b8e94 | ||
|
|
9308762d0b | ||
|
|
8eb1c76337 | ||
|
|
f94ad21f1e | ||
|
|
8d640ccc68 | ||
|
|
f566e6451f | ||
|
|
75a7855223 | ||
|
|
8f105288d2 | ||
|
|
696a6ec4d4 | ||
|
|
3271ff1d6e | ||
|
|
0efcfc0864 | ||
|
|
0aba911912 | ||
|
|
4efb5cc18e | ||
|
|
57db3f22a1 | ||
|
|
9b44c80b30 | ||
|
|
7c7f4d0eb7 | ||
|
|
ccc24e2c26 | ||
|
|
62bdbe1821 | ||
|
|
58d1d11762 | ||
|
|
8a9096cd52 | ||
|
|
b70298fbca | ||
|
|
7616b02bb1 | ||
|
|
d4c205f8e1 | ||
|
|
0ba60ff69c | ||
|
|
755a7e1b20 | ||
|
|
3061d8e057 | ||
|
|
ea5597b483 | ||
|
|
b41e75a15d | ||
|
|
bfdbaa5ab6 | ||
|
|
93ae3b8405 | ||
|
|
f249a82383 | ||
|
|
ea9486ae2d | ||
|
|
da95b58a2a | ||
|
|
e15d5d0533 | ||
|
|
1cf45f8439 | ||
|
|
2a9ef806a0 | ||
|
|
32115a8b98 | ||
|
|
7ce902b096 | ||
|
|
c4e8b60d2c | ||
|
|
c9fdd68232 | ||
|
|
50260fd385 | ||
|
|
0822f5610a | ||
|
|
2c2ca7f03b | ||
|
|
c4014c0092 | ||
|
|
c08441c42c | ||
|
|
980f274fc9 | ||
|
|
9f1f65f0e3 | ||
|
|
cddd836909 | ||
|
|
bab7eeaf91 | ||
|
|
d45915d39f | ||
|
|
fcc814accd | ||
|
|
78a3d965e0 | ||
|
|
66ad8a9289 | ||
|
|
df6634727e | ||
|
|
8b5cd97ceb | ||
|
|
25297ce3f5 | ||
|
|
3800fea962 | ||
|
|
ecb91bbb1a | ||
|
|
ab993904d7 | ||
|
|
2467a103b2 | ||
|
|
68569afb4b | ||
|
|
67d0ab3030 | ||
|
|
4024bca55d | ||
|
|
b928b96af5 | ||
|
|
cb871a3fc1 | ||
|
|
da0a062fa7 | ||
|
|
ba316a10cc | ||
|
|
4086408b10 | ||
|
|
c1f82d9ec1 | ||
|
|
eb6cace60f | ||
|
|
00f4331343 | ||
|
|
cb469ecf45 | ||
|
|
46a694bbc7 | ||
|
|
56c406b19e | ||
|
|
79a6506593 | ||
|
|
99a6fcf3f7 | ||
|
|
abe9440096 | ||
|
|
542c8020ec | ||
|
|
8edf2146ae | ||
|
|
3c8d0083cb | ||
|
|
28248f9602 | ||
|
|
d225c4a7d1 | ||
|
|
e05a8477b9 | ||
|
|
357a6e340a | ||
|
|
a87d37f26d | ||
|
|
958a4fd414 | ||
|
|
a27efd57bd | ||
|
|
d57db17300 | ||
|
|
4f1c6e76fd | ||
|
|
2111d0c653 | ||
|
|
642e6acf49 | ||
|
|
88716d8d2a | ||
|
|
c2e37c78ff | ||
|
|
5a2688c7b5 | ||
|
|
ba1d80bd00 | ||
|
|
4dfcd56893 | ||
|
|
95ed49ce9a |
No files matched your search
@@ -1,4 +1,6 @@
|
||||
# Repository Guidelines
|
||||
- Repo: https://github.com/clawdbot/clawdbot
|
||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
@@ -98,6 +100,7 @@
|
||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
|
||||
+52
-1
@@ -1,17 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.13-2
|
||||
|
||||
### Changes
|
||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
||||
- Testing: update Vitest + browser-playwright to 4.0.17.
|
||||
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
|
||||
|
||||
## 2026.1.13-1
|
||||
|
||||
### Changes
|
||||
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
|
||||
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
|
||||
- Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow)
|
||||
- Agents: support per-agent model fallbacks via `agents.list[].model`. (#583 — thanks @mitschabaude-bot)
|
||||
- Tools: add tool profiles plus group shorthands for tool policy allow/deny (global, per-agent, sandbox).
|
||||
- Thinking: allow xhigh for GPT-5.2 + Codex models and downgrade on unsupported switches. (#444 — thanks @grp06)
|
||||
|
||||
### Fixes
|
||||
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
|
||||
- Tools: harden tool input schemas for strict providers (no unions; flattened actions; enum helpers + tests).
|
||||
- Discord: allow clearing channel parents via message tool without nullable schema inputs.
|
||||
|
||||
## 2026.1.12-4
|
||||
|
||||
### Changes
|
||||
- Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan)
|
||||
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
|
||||
- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei)
|
||||
- Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated.
|
||||
|
||||
### Fixes
|
||||
- Typing: keep typing indicators alive during tool execution. (#450, #447 — thanks @thewilloftheshadow)
|
||||
- Cron: coerce enabled patches so disabling jobs persists correctly. (#205 — thanks @thewilloftheshadow)
|
||||
- Control UI: keep chat scroll position unless user is near the bottom. (#217 — thanks @thewilloftheshadow)
|
||||
- Fallback: treat credential validation failures ("no credentials found", "no API key found") as auth errors that trigger model fallback. (#822 — thanks @sebslight)
|
||||
- Telegram: preserve forum topic thread ids, including General topic replies. (#727 — thanks @thewilloftheshadow)
|
||||
- Telegram: persist polling update offsets across restarts to avoid duplicate updates. (#739 — thanks @thewilloftheshadow)
|
||||
- Discord: avoid duplicate message/reaction listeners on monitor reloads. (#744 — thanks @thewilloftheshadow)
|
||||
- System events: include local timestamps when events are injected into prompts. (#245 — thanks @thewilloftheshadow)
|
||||
- Cron: accept `jobId` aliases for cron update/run/remove params in gateway validation. (#252 — thanks @thewilloftheshadow)
|
||||
- Models/Google: normalize Gemini 3 model ids to preview variants before runtime selection. (#795 — thanks @thewilloftheshadow)
|
||||
- Models/Google: strip Gemini CLI tool call/response ids in patched provider handling. (#783 — thanks @ananth-vardhan-cn)
|
||||
- TUI: keep the last streamed response instead of replacing it with "(no output)". (#747 — thanks @thewilloftheshadow)
|
||||
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798 — thanks @thewilloftheshadow)
|
||||
- Onboarding/Configure: refuse to proceed with invalid configs; run `clawdbot doctor` first to avoid wiping custom fields. (#764 — thanks @mukhtharcm)
|
||||
- Onboarding: quote Windows browser URLs when launching via `cmd start` to preserve OAuth query params. (#794 — thanks @roshanasingh4)
|
||||
- Gateway/Auth: allow Tailscale Serve identity headers to satisfy token auth when `allowTailscale` is enabled. (#823 — thanks @roshanasingh4)
|
||||
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid “Incorrect role information” errors. (#804 — thanks @ThomsenDrake)
|
||||
- Connections UI: show account counts for multi-account providers and keep Telegram per-account cards. (#813 — thanks @dbhurley)
|
||||
- Discord/Slack: centralize reply-thread planning so auto-thread replies stay in the created thread without parent reply refs.
|
||||
- Telegram: respect account-scoped bindings when webhook mode is enabled. (#821 — thanks @gumadeiras)
|
||||
- Update: run `clawdbot doctor --non-interactive` during updates to avoid TTY hangs. (#781 — thanks @ronyrus)
|
||||
- Browser tools: treat explicit `maxChars: 0` as unlimited while keeping the default limit only when omitted. (#796 — thanks @gabriel-trigo)
|
||||
- Tools: allow Claude/Gemini tool param aliases (`file_path`, `old_string`, `new_string`) while enforcing required params at runtime. (#793 — thanks @hsrvc)
|
||||
- Gemini: downgrade tool-call history missing `thought_signature` to avoid INVALID_ARGUMENT errors. (#793 — thanks @hsrvc)
|
||||
- Messaging: enforce context isolation for message tool sends across providers (normalized targets + tests). (#793 — thanks @hsrvc)
|
||||
- Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj)
|
||||
- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1)
|
||||
- Connections UI: polish multi-account account cards in the Connections view. (#816 — thanks @steipete)
|
||||
- Gemini: strip Claude `msg_*` thought_signature fields from session history to avoid base64 decode errors. (#805 — thanks @marcmarg)
|
||||
|
||||
## 2026.1.12-3
|
||||
|
||||
@@ -52,10 +99,13 @@
|
||||
- Memory: embedding providers support OpenAI or local `node-llama-cpp`; config adds defaults + per-agent overrides, provider/fallback metadata surfaced in tools/CLI.
|
||||
- CLI/Tools: new `clawdbot memory` commands plus `memory_search`/`memory_get` tools returning snippets + line ranges and provider info.
|
||||
- Runtime: memory index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default; inline status replies now stay auth-gated while inline prompts continue to the agent.
|
||||
- Doctor: rebuild Control UI assets when protocol schema is newer to avoid stale UI connect errors. (#786) — thanks @meaningfool.
|
||||
- Cron: allow jobs to target a specific agent and expose agent selection in the macOS app + Control UI.
|
||||
- Discord: add `discord.allowBots` to permit bot-authored messages (still ignores its own messages) with docs warning about bot loops. (#802) — thanks @zknicker.
|
||||
- CLI/Onboarding: `clawdbot dashboard` prints/copies the tokenized Control UI link and opens it; onboarding now auto-opens the dashboard with your token and keeps the link in the summary.
|
||||
- Commands: native slash commands now default to `"auto"` (on for Discord/Telegram, off for Slack) with per-provider overrides (`discord/telegram/slack.commands.native`) and docs updated.
|
||||
- Sandbox: allow Docker bind mounts via `docker.binds`; merges global + per-agent binds (per-agent ignored under shared scope) for custom host paths. (#790 — thanks @akonyer)
|
||||
- Models: add Synthetic provider (Anthropic-compatible) and trim legacy MiniMax M2 from default catalogs. (#811 — thanks @siraht)
|
||||
|
||||
### Fixes
|
||||
- Auto-reply: inline `/status` now honors allowlists (authorized stripped + replied inline; unauthorized leaves text for the agent) to match command gating tests.
|
||||
@@ -144,6 +194,7 @@
|
||||
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
|
||||
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
|
||||
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
|
||||
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
|
||||
- Heartbeat: refresh prompt text for updated defaults.
|
||||
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
|
||||
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
@@ -64,7 +64,7 @@ clawdbot gateway --port 18789 --verbose
|
||||
# Send a message
|
||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||
clawdbot agent --message "Ship checklist" --thinking high
|
||||
```
|
||||
|
||||
@@ -96,7 +96,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
|
||||
|
||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `clawdbot pairing approve <provider> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
||||
@@ -106,7 +106,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## Highlights
|
||||
|
||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-provider inbox](https://docs.clawd.bot/providers)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||
@@ -114,6 +114,10 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
||||
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
|
||||
|
||||
## Everything we built so far
|
||||
|
||||
### Core platform
|
||||
@@ -124,7 +128,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||
|
||||
### Providers
|
||||
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Providers](https://docs.clawd.bot/providers): [WhatsApp](https://docs.clawd.bot/providers/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/providers/telegram) (grammY), [Slack](https://docs.clawd.bot/providers/slack) (Bolt), [Discord](https://docs.clawd.bot/providers/discord) (discord.js), [Signal](https://docs.clawd.bot/providers/signal) (signal-cli), [iMessage](https://docs.clawd.bot/providers/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/providers/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/providers).
|
||||
|
||||
### Apps + nodes
|
||||
@@ -155,7 +159,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
@@ -238,12 +242,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/cost on|off` — append per-response token/cost usage lines
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
@@ -356,6 +360,11 @@ Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker +
|
||||
- macOS only; Messages must be signed in.
|
||||
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.clawd.bot/providers/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [WebChat](https://docs.clawd.bot/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
@@ -459,20 +468,20 @@ Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/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/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/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/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/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/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/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
|
||||
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
|
||||
<a href="https://github.com/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/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/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/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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></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/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/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/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/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/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/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/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/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/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/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/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/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/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/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/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/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/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="Sebastian Barrios" title="Sebastian Barrios"/></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/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/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/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/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/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/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/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/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/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/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/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=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></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/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=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=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
|
||||
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/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/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/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/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/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a>
|
||||
<a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></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/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/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/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/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/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/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/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/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/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/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/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/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=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></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/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/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=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/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/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/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/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/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/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/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/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=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -13,7 +13,9 @@ extension CronJobEditor {
|
||||
guard let job else { return }
|
||||
self.name = job.name
|
||||
self.description = job.description ?? ""
|
||||
self.agentId = job.agentId ?? ""
|
||||
self.enabled = job.enabled
|
||||
self.deleteAfterRun = job.deleteAfterRun ?? false
|
||||
self.sessionTarget = job.sessionTarget
|
||||
self.wakeMode = job.wakeMode
|
||||
|
||||
@@ -59,18 +61,60 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
func buildPayload() throws -> [String: AnyCodable] {
|
||||
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = try self.requireName()
|
||||
let description = self.trimmed(self.description)
|
||||
let agentId = self.trimmed(self.agentId)
|
||||
let schedule = try self.buildSchedule()
|
||||
let payload = try self.buildSelectedPayload()
|
||||
|
||||
try self.validateSessionTarget(payload)
|
||||
try self.validatePayloadRequiredFields(payload)
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
self.applyDeleteAfterRun(to: &root)
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
if !agentId.isEmpty {
|
||||
root["agentId"] = agentId
|
||||
} else if self.job?.agentId != nil {
|
||||
root["agentId"] = NSNull()
|
||||
}
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func requireName() throws -> String {
|
||||
let name = self.trimmed(self.name)
|
||||
if name.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
|
||||
}
|
||||
let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let schedule: [String: Any]
|
||||
return name
|
||||
}
|
||||
|
||||
func buildSchedule() throws -> [String: Any] {
|
||||
switch self.scheduleKind {
|
||||
case .at:
|
||||
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
|
||||
case .every:
|
||||
guard let ms = Self.parseDurationMs(self.everyText) else {
|
||||
throw NSError(
|
||||
@@ -78,34 +122,35 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
|
||||
}
|
||||
schedule = ["kind": "every", "everyMs": ms]
|
||||
return ["kind": "every", "everyMs": ms]
|
||||
case .cron:
|
||||
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expr = self.trimmed(self.cronExpr)
|
||||
if expr.isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
||||
}
|
||||
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let tz = self.trimmed(self.cronTz)
|
||||
if tz.isEmpty {
|
||||
schedule = ["kind": "cron", "expr": expr]
|
||||
} else {
|
||||
schedule = ["kind": "cron", "expr": expr, "tz": tz]
|
||||
return ["kind": "cron", "expr": expr]
|
||||
}
|
||||
return ["kind": "cron", "expr": expr, "tz": tz]
|
||||
}
|
||||
}
|
||||
|
||||
let payload: [String: Any] = {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}()
|
||||
func buildSelectedPayload() throws -> [String: Any] {
|
||||
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
|
||||
switch self.payloadKind {
|
||||
case .systemEvent:
|
||||
let text = self.trimmed(self.systemEventText)
|
||||
return ["kind": "systemEvent", "text": text]
|
||||
case .agentTurn:
|
||||
return self.buildAgentTurnPayload()
|
||||
}
|
||||
}
|
||||
|
||||
func validateSessionTarget(_ payload: [String: Any]) throws {
|
||||
if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
@@ -122,7 +167,9 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
|
||||
}
|
||||
}
|
||||
|
||||
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
|
||||
if payload["kind"] as? String == "systemEvent" {
|
||||
if (payload["text"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
@@ -130,7 +177,8 @@ extension CronJobEditor {
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
|
||||
}
|
||||
} else if payload["kind"] as? String == "agentTurn" {
|
||||
}
|
||||
if payload["kind"] as? String == "agentTurn" {
|
||||
if (payload["message"] as? String ?? "").isEmpty {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
@@ -138,25 +186,14 @@ extension CronJobEditor {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var root: [String: Any] = [
|
||||
"name": name,
|
||||
"enabled": self.enabled,
|
||||
"schedule": schedule,
|
||||
"sessionTarget": self.sessionTarget.rawValue,
|
||||
"wakeMode": self.wakeMode.rawValue,
|
||||
"payload": payload,
|
||||
]
|
||||
if !description.isEmpty { root["description"] = description }
|
||||
|
||||
if self.sessionTarget == .isolated {
|
||||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
root["isolation"] = [
|
||||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||||
]
|
||||
func applyDeleteAfterRun(to root: inout [String: Any]) {
|
||||
if self.scheduleKind == .at {
|
||||
root["deleteAfterRun"] = self.deleteAfterRun
|
||||
} else if self.job?.deleteAfterRun != nil {
|
||||
root["deleteAfterRun"] = false
|
||||
}
|
||||
|
||||
return root.mapValues { AnyCodable($0) }
|
||||
}
|
||||
|
||||
func buildAgentTurnPayload() -> [String: Any] {
|
||||
|
||||
@@ -3,6 +3,7 @@ extension CronJobEditor {
|
||||
mutating func exerciseForTesting() {
|
||||
self.name = "Test job"
|
||||
self.description = "Test description"
|
||||
self.agentId = "ops"
|
||||
self.enabled = true
|
||||
self.sessionTarget = .isolated
|
||||
self.wakeMode = .now
|
||||
|
||||
@@ -27,9 +27,11 @@ struct CronJobEditor: View {
|
||||
|
||||
@State var name: String = ""
|
||||
@State var description: String = ""
|
||||
@State var agentId: String = ""
|
||||
@State var enabled: Bool = true
|
||||
@State var sessionTarget: CronSessionTarget = .main
|
||||
@State var wakeMode: CronWakeMode = .nextHeartbeat
|
||||
@State var deleteAfterRun: Bool = false
|
||||
|
||||
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
|
||||
@State var scheduleKind: ScheduleKind = .every
|
||||
@@ -77,6 +79,12 @@ struct CronJobEditor: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Agent ID")
|
||||
TextField("Optional (default agent)", text: self.$agentId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$enabled)
|
||||
@@ -149,6 +157,11 @@ struct CronJobEditor: View {
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Auto-delete")
|
||||
Toggle("Delete after successful run", isOn: self.$deleteAfterRun)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
case .every:
|
||||
GridRow {
|
||||
self.gridLabel("Every")
|
||||
|
||||
@@ -145,9 +145,11 @@ struct CronJobState: Codable, Equatable {
|
||||
|
||||
struct CronJob: Identifiable, Codable, Equatable {
|
||||
let id: String
|
||||
let agentId: String?
|
||||
var name: String
|
||||
var description: String?
|
||||
var enabled: Bool
|
||||
var deleteAfterRun: Bool?
|
||||
let createdAtMs: Int
|
||||
let updatedAtMs: Int
|
||||
let schedule: CronSchedule
|
||||
|
||||
@@ -20,6 +20,9 @@ extension CronSettings {
|
||||
HStack(spacing: 6) {
|
||||
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
|
||||
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
StatusPill(text: "agent \(agentId)", tint: .secondary)
|
||||
}
|
||||
if let status = job.state.lastStatus {
|
||||
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
|
||||
}
|
||||
@@ -91,9 +94,15 @@ extension CronSettings {
|
||||
func detailCard(_ job: CronJob) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
|
||||
if case .at = job.schedule, job.deleteAfterRun == true {
|
||||
LabeledContent("Auto-delete") { Text("after success") }
|
||||
}
|
||||
if let desc = job.description, !desc.isEmpty {
|
||||
LabeledContent("Description") { Text(desc).font(.callout) }
|
||||
}
|
||||
if let agentId = job.agentId, !agentId.isEmpty {
|
||||
LabeledContent("Agent") { Text(agentId) }
|
||||
}
|
||||
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
|
||||
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
|
||||
LabeledContent("Next run") {
|
||||
|
||||
@@ -7,9 +7,11 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
store.jobs = [
|
||||
CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||
@@ -59,9 +61,11 @@ extension CronSettings {
|
||||
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: "Summary job",
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
|
||||
@@ -149,8 +149,7 @@ final class OnboardingWizardModel {
|
||||
onboardingWizardLogger.error("wizard step decode failed")
|
||||
}
|
||||
if res.done { self.currentStep = nil }
|
||||
if res.done || status == "done" || status == "cancelled" || status == "error"
|
||||
{
|
||||
if res.done || status == "done" || status == "cancelled" || status == "error" {
|
||||
self.sessionId = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1356,6 +1356,7 @@ public struct CronJob: Codable, Sendable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
public let deleteafterrun: Bool?
|
||||
public let createdatms: Int
|
||||
public let updatedatms: Int
|
||||
public let schedule: AnyCodable
|
||||
@@ -1371,6 +1372,7 @@ public struct CronJob: Codable, Sendable {
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
deleteafterrun: Bool?,
|
||||
createdatms: Int,
|
||||
updatedatms: Int,
|
||||
schedule: AnyCodable,
|
||||
@@ -1385,6 +1387,7 @@ public struct CronJob: Codable, Sendable {
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.createdatms = createdatms
|
||||
self.updatedatms = updatedatms
|
||||
self.schedule = schedule
|
||||
@@ -1400,6 +1403,7 @@ public struct CronJob: Codable, Sendable {
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case createdatms = "createdAtMs"
|
||||
case updatedatms = "updatedAtMs"
|
||||
case schedule
|
||||
@@ -1432,6 +1436,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public let agentid: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
public let schedule: AnyCodable
|
||||
public let sessiontarget: AnyCodable
|
||||
public let wakemode: AnyCodable
|
||||
@@ -1443,6 +1448,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
schedule: AnyCodable,
|
||||
sessiontarget: AnyCodable,
|
||||
wakemode: AnyCodable,
|
||||
@@ -1453,6 +1459,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
self.agentid = agentid
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteafterrun = deleteafterrun
|
||||
self.schedule = schedule
|
||||
self.sessiontarget = sessiontarget
|
||||
self.wakemode = wakemode
|
||||
@@ -1464,6 +1471,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case description
|
||||
case enabled
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
case schedule
|
||||
case sessiontarget = "sessionTarget"
|
||||
case wakemode = "wakeMode"
|
||||
@@ -1472,70 +1480,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronUpdateParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let patch: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
patch: [String: AnyCodable]
|
||||
) {
|
||||
self.id = id
|
||||
self.patch = patch
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case patch
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRemoveParams: Codable, Sendable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
id: String
|
||||
) {
|
||||
self.id = id
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let mode: AnyCodable?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
mode: AnyCodable?
|
||||
) {
|
||||
self.id = id
|
||||
self.mode = mode
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case mode
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunsParams: Codable, Sendable {
|
||||
public let id: String
|
||||
public let limit: Int?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
limit: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.limit = limit
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable, Sendable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
|
||||
@@ -22,17 +22,17 @@ struct WizardCliOptions {
|
||||
case "--json":
|
||||
opts.json = true
|
||||
case "--url":
|
||||
opts.url = nextValue(args, index: &i)
|
||||
opts.url = self.nextValue(args, index: &i)
|
||||
case "--token":
|
||||
opts.token = nextValue(args, index: &i)
|
||||
opts.token = self.nextValue(args, index: &i)
|
||||
case "--password":
|
||||
opts.password = nextValue(args, index: &i)
|
||||
opts.password = self.nextValue(args, index: &i)
|
||||
case "--mode":
|
||||
if let value = nextValue(args, index: &i) {
|
||||
opts.mode = value
|
||||
}
|
||||
case "--workspace":
|
||||
opts.workspace = nextValue(args, index: &i)
|
||||
opts.workspace = self.nextValue(args, index: &i)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -118,7 +118,8 @@ private func printUsage() {
|
||||
clawdbot-mac-wizard
|
||||
|
||||
Usage:
|
||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>] [--mode <local|remote>] [--workspace <path>] [--json]
|
||||
clawdbot-mac-wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
|
||||
[--mode <local|remote>] [--workspace <path>] [--json]
|
||||
|
||||
Options:
|
||||
--url <url> Gateway WebSocket URL (overrides config)
|
||||
|
||||
@@ -23,8 +23,11 @@ struct CronJobEditorSmokeTests {
|
||||
@Test func cronJobEditorBuildsBodyForExistingJob() {
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: "Daily summary",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_000_000,
|
||||
schedule: .every(everyMs: 3_600_000, anchorMs: 1_700_000_000_000),
|
||||
@@ -65,4 +68,24 @@ struct CronJobEditorSmokeTests {
|
||||
onSave: { _ in })
|
||||
view.exerciseForTesting()
|
||||
}
|
||||
|
||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||
var view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
view.name = "One-shot"
|
||||
view.sessionTarget = .main
|
||||
view.payloadKind = .systemEvent
|
||||
view.systemEventText = "hello"
|
||||
view.scheduleKind = .at
|
||||
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
view.deleteAfterRun = true
|
||||
|
||||
let payload = try view.buildPayload()
|
||||
let raw = payload["deleteAfterRun"]?.value as? Bool
|
||||
#expect(raw == true)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,27 @@ struct CronModelsTests {
|
||||
#expect(decoded == payload)
|
||||
}
|
||||
|
||||
@Test func jobEncodesAndDecodesDeleteAfterRun() throws {
|
||||
let job = CronJob(
|
||||
id: "job-1",
|
||||
agentId: nil,
|
||||
name: "One-shot",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: true,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 1_700_000_000_000),
|
||||
sessionTarget: .main,
|
||||
wakeMode: .now,
|
||||
payload: .systemEvent(text: "ping"),
|
||||
isolation: nil,
|
||||
state: CronJobState())
|
||||
let data = try JSONEncoder().encode(job)
|
||||
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
|
||||
#expect(decoded.deleteAfterRun == true)
|
||||
}
|
||||
|
||||
@Test func scheduleDecodeRejectsUnknownKind() {
|
||||
let json = """
|
||||
{"kind":"wat","atMs":1}
|
||||
@@ -60,9 +81,11 @@ struct CronModelsTests {
|
||||
@Test func displayNameTrimsWhitespaceAndFallsBack() {
|
||||
let base = CronJob(
|
||||
id: "x",
|
||||
agentId: nil,
|
||||
name: " hello ",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
@@ -81,9 +104,11 @@ struct CronModelsTests {
|
||||
@Test func nextRunDateAndLastRunDateDeriveFromState() {
|
||||
let job = CronJob(
|
||||
id: "x",
|
||||
agentId: nil,
|
||||
name: "t",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .at(atMs: 0),
|
||||
|
||||
@@ -19,22 +19,19 @@ import Testing
|
||||
error: "gateway connect failed",
|
||||
elapsedMs: 12,
|
||||
bot: nil,
|
||||
webhook: nil
|
||||
),
|
||||
lastProbeAt: 0
|
||||
),
|
||||
webhook: nil),
|
||||
lastProbeAt: 0),
|
||||
],
|
||||
providerOrder: ["whatsapp"],
|
||||
providerLabels: ["whatsapp": "WhatsApp"],
|
||||
heartbeatSeconds: 60,
|
||||
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: [])
|
||||
)
|
||||
sessions: .init(path: "/tmp/sessions.json", count: 0, recent: []))
|
||||
|
||||
let store = HealthStore.shared
|
||||
store.__setSnapshotForTest(snap, lastError: nil)
|
||||
|
||||
switch store.state {
|
||||
case .degraded(let message):
|
||||
case let .degraded(message):
|
||||
#expect(!message.isEmpty)
|
||||
default:
|
||||
Issue.record("Expected degraded state when probe fails for linked provider")
|
||||
@@ -43,4 +40,3 @@ import Testing
|
||||
#expect(store.summaryLine.contains("probe degraded"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ struct SettingsViewSmokeTests {
|
||||
|
||||
let job1 = CronJob(
|
||||
id: "job-1",
|
||||
agentId: "ops",
|
||||
name: " Morning Check-in ",
|
||||
description: nil,
|
||||
enabled: true,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
@@ -32,9 +34,11 @@ struct SettingsViewSmokeTests {
|
||||
|
||||
let job2 = CronJob(
|
||||
id: "job-2",
|
||||
agentId: nil,
|
||||
name: "",
|
||||
description: nil,
|
||||
enabled: false,
|
||||
deleteAfterRun: nil,
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .every(everyMs: 30000, anchorMs: nil),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -20,6 +20,24 @@ cron is the mechanism.
|
||||
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
|
||||
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
|
||||
|
||||
## Beginner-friendly overview
|
||||
Think of a cron job as: **when** to run + **what** to do.
|
||||
|
||||
1) **Choose a schedule**
|
||||
- One-shot reminder → `schedule.kind = "at"` (CLI: `--at`)
|
||||
- Repeating job → `schedule.kind = "every"` or `schedule.kind = "cron"`
|
||||
- If your ISO timestamp omits a timezone, it is treated as **UTC**.
|
||||
|
||||
2) **Choose where it runs**
|
||||
- `sessionTarget: "main"` → run during the next heartbeat with main context.
|
||||
- `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:<jobId>`.
|
||||
|
||||
3) **Choose the payload**
|
||||
- Main session → `payload.kind = "systemEvent"`
|
||||
- Isolated session → `payload.kind = "agentTurn"`
|
||||
|
||||
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
|
||||
|
||||
## Concepts
|
||||
|
||||
### Jobs
|
||||
@@ -32,10 +50,11 @@ A cron job is a stored record with:
|
||||
|
||||
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
|
||||
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
|
||||
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
|
||||
|
||||
### Schedules
|
||||
Cron supports three schedule kinds:
|
||||
- `at`: one-shot timestamp (ms since epoch).
|
||||
- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.
|
||||
- `every`: fixed interval (ms).
|
||||
- `cron`: 5-field cron expression with optional IANA timezone.
|
||||
|
||||
@@ -86,7 +105,7 @@ Isolation options (only for `session=isolated`):
|
||||
### Model and thinking overrides
|
||||
Isolated jobs (`agentTurn`) can override the model and thinking level:
|
||||
- `model`: Provider/model string (e.g., `anthropic/claude-sonnet-4-20250514`) or alias (e.g., `opus`)
|
||||
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`)
|
||||
- `thinking`: Thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only)
|
||||
|
||||
Note: You can set `model` on main-session jobs too, but it changes the shared main
|
||||
session model. We recommend model overrides only for isolated jobs to avoid
|
||||
@@ -143,6 +162,17 @@ Disable cron entirely:
|
||||
|
||||
## CLI quickstart
|
||||
|
||||
One-shot reminder (UTC ISO, auto-delete after success):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Send reminder" \
|
||||
--at "2026-01-12T18:00:00Z" \
|
||||
--session main \
|
||||
--system-event "Reminder: submit expense report." \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
```
|
||||
|
||||
One-shot reminder (main session, wake immediately):
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
summary: "Use Amazon Bedrock (Converse API) models with Clawdbot"
|
||||
read_when:
|
||||
- You want to use Amazon Bedrock models with Clawdbot
|
||||
- You need AWS credential/region setup for model calls
|
||||
---
|
||||
# Amazon Bedrock
|
||||
|
||||
Clawdbot can use **Amazon Bedrock** models via pi‑ai’s **Bedrock Converse**
|
||||
streaming provider. Bedrock auth uses the **AWS SDK default credential chain**,
|
||||
not an API key.
|
||||
|
||||
## What pi‑ai supports
|
||||
|
||||
- Provider: `amazon-bedrock`
|
||||
- API: `bedrock-converse-stream`
|
||||
- Auth: AWS credentials (env vars, shared config, or instance role)
|
||||
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
|
||||
|
||||
## Setup (manual)
|
||||
|
||||
1) Ensure AWS credentials are available on the **gateway host**:
|
||||
|
||||
```bash
|
||||
export AWS_ACCESS_KEY_ID="AKIA..."
|
||||
export AWS_SECRET_ACCESS_KEY="..."
|
||||
export AWS_REGION="us-east-1"
|
||||
# Optional:
|
||||
export AWS_SESSION_TOKEN="..."
|
||||
export AWS_PROFILE="your-profile"
|
||||
```
|
||||
|
||||
2) Add a Bedrock provider and model to your config:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
"amazon-bedrock": {
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
api: "bedrock-converse-stream",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
name: "Claude 3.7 Sonnet (Bedrock)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||
- If you use profiles, set `AWS_PROFILE` on the gateway host.
|
||||
- Reasoning support depends on the model; check the Bedrock model card for
|
||||
current capabilities.
|
||||
- If you prefer a managed key flow, you can also place an OpenAI‑compatible
|
||||
proxy in front of Bedrock and configure it as an OpenAI provider instead.
|
||||
+1
-1
@@ -398,7 +398,7 @@ Required:
|
||||
Options:
|
||||
- `--to <dest>` (for session key and optional delivery)
|
||||
- `--session-id <id>`
|
||||
- `--thinking <off|minimal|low|medium|high>`
|
||||
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
|
||||
- `--verbose <on|off>`
|
||||
- `--provider <whatsapp|telegram|discord|slack|signal|imessage>`
|
||||
- `--local`
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
summary: "Compaction modes and configuration"
|
||||
read_when:
|
||||
- You want to configure compaction summarization behavior
|
||||
- You are tuning compaction settings in clawdbot.json
|
||||
---
|
||||
# Compaction
|
||||
|
||||
Compaction summarizes older session history so the conversation stays within the model context window. The summary is stored in the session JSONL history and combined with the most recent messages.
|
||||
|
||||
## Modes
|
||||
|
||||
`agents.defaults.compaction.mode` controls how summaries are generated.
|
||||
|
||||
- `default` (default): use the built-in compaction summarizer.
|
||||
- `safeguard`: uses a chunked summarization pass to avoid context overflow for very long histories. If chunked summarization fails, Clawdbot falls back to a minimal summary plus file-operation metadata.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 20000,
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 4000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Context window + compaction behavior](/concepts/compaction)
|
||||
- [Gateway configuration reference](/gateway/configuration)
|
||||
@@ -109,8 +109,10 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
|
||||
- Canvas UI files for node displays (for example `canvas/index.html`).
|
||||
|
||||
If any bootstrap file is missing, Clawdbot injects a "missing file" marker into
|
||||
the session and continues. `clawdbot setup` can recreate missing defaults
|
||||
without overwriting existing files.
|
||||
the session and continues. Large bootstrap files are truncated when injected;
|
||||
adjust the limit with `agents.defaults.bootstrapMaxChars` (default: 20000).
|
||||
`clawdbot setup` can recreate missing defaults without overwriting existing
|
||||
files.
|
||||
|
||||
## What is NOT in the workspace
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ Compaction **summarizes older conversation** into a compact summary entry and ke
|
||||
|
||||
Compaction **persists** in the session’s JSONL history.
|
||||
|
||||
## Configuration
|
||||
See [Compaction config & modes](/compaction) for the `agents.defaults.compaction` settings.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ Group inbound payloads set:
|
||||
- `WasMentioned` (mention gating result)
|
||||
- Telegram forum topics also include `MessageThreadId` and `IsForum`.
|
||||
|
||||
The agent system prompt includes a group intro on the first turn of a new group session.
|
||||
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, and avoid typing literal `\n` sequences.
|
||||
|
||||
## iMessage specifics
|
||||
- Prefer `chat_id:<id>` when routing or allowlisting.
|
||||
|
||||
+51
-4
@@ -79,10 +79,32 @@ Defaults:
|
||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
|
||||
Remote embeddings **require** an OpenAI API key (`OPENAI_API_KEY` or
|
||||
`models.providers.openai.apiKey`). Codex OAuth only covers chat/completions and
|
||||
does **not** satisfy embeddings for memory search. If you don't want to set an
|
||||
API key, use `memorySearch.provider = "local"` or set
|
||||
Remote embeddings **require** an API key for the embedding provider. By default
|
||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||
OAuth only covers chat/completions and does **not** satisfy embeddings for
|
||||
memory search. When using a custom OpenAI-compatible endpoint, set
|
||||
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||
|
||||
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
||||
you can use the `remote` configuration:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "YOUR_GEMINI_API_KEY",
|
||||
headers: { "X-Custom-Header": "value" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||
`memorySearch.fallback = "none"`.
|
||||
|
||||
Config example:
|
||||
@@ -127,3 +149,28 @@ Local mode:
|
||||
- When `memorySearch.provider = "local"`, `node-llama-cpp` resolves `modelPath`; if the GGUF is missing it **auto-downloads** to the cache (or `local.modelCacheDir` if set), then loads it. Downloads resume on retry.
|
||||
- Native build requirement: run `pnpm approve-builds`, pick `node-llama-cpp`, then `pnpm rebuild node-llama-cpp`.
|
||||
- Fallback: if local setup fails and `memorySearch.fallback = "openai"`, we automatically switch to remote embeddings (`openai/text-embedding-3-small` unless overridden) and record the reason.
|
||||
|
||||
### Custom OpenAI-compatible endpoint example
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://api.example.com/v1/",
|
||||
apiKey: "YOUR_REMOTE_API_KEY",
|
||||
headers: {
|
||||
"X-Organization": "org-id",
|
||||
"X-Project": "project-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `remote.*` takes precedence over `models.providers.openai.*`.
|
||||
- `remote.headers` merge with OpenAI headers; remote wins on key conflicts. Omit `remote.headers` to use the OpenAI defaults.
|
||||
@@ -117,8 +117,13 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
- Provider: `moonshot`
|
||||
- Auth: `MOONSHOT_API_KEY`
|
||||
- Example model: `moonshot/kimi-k2-0905-preview`
|
||||
- CLI: `clawdbot onboard --auth-choice moonshot-api-key`
|
||||
|
||||
- Kimi K2 model IDs:
|
||||
{/* moonshot-kimi-k2-model-refs:start */}
|
||||
- `moonshot/kimi-k2-0905-preview`
|
||||
- `moonshot/kimi-k2-turbo-preview`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
- `moonshot/kimi-k2-thinking-turbo`
|
||||
{/* moonshot-kimi-k2-model-refs:end */}
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
@@ -138,6 +143,34 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
}
|
||||
```
|
||||
|
||||
### Synthetic
|
||||
|
||||
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
|
||||
- Provider: `synthetic`
|
||||
- Auth: `SYNTHETIC_API_KEY`
|
||||
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`
|
||||
- CLI: `clawdbot onboard --auth-choice synthetic-api-key`
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } }
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MiniMax
|
||||
|
||||
MiniMax is configured via `models.providers` because it uses custom endpoints:
|
||||
|
||||
@@ -38,7 +38,9 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
|
||||
- `HEARTBEAT.md`
|
||||
- `BOOTSTRAP.md` (only on brand-new workspaces)
|
||||
|
||||
Large files are truncated with a marker. Missing files inject a short missing-file marker.
|
||||
Large files are truncated with a marker. The max per-file size is controlled by
|
||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
||||
short missing-file marker.
|
||||
|
||||
## Time handling
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Add `hooks.gmail.model` config option to specify an optional cheaper model for G
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `hooks.gmail.model` | `string` | (none) | Model to use for Gmail hook processing. Accepts `provider/model` refs or aliases from `agents.defaults.models`. |
|
||||
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
|
||||
| `hooks.gmail.thinking` | `string` | (inherited) | Thinking level override (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`; GPT-5.2 + Codex models only). If unset, inherits from `agents.defaults.thinkingDefault` or model's default. |
|
||||
|
||||
### Alias Support
|
||||
|
||||
|
||||
@@ -241,6 +241,14 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
prompt: "HEARTBEAT",
|
||||
ackMaxChars: 300
|
||||
},
|
||||
memorySearch: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-004",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "${GEMINI_API_KEY}"
|
||||
}
|
||||
},
|
||||
sandbox: {
|
||||
mode: "non-main",
|
||||
perSession: true,
|
||||
|
||||
@@ -569,7 +569,9 @@ Inbound messages are routed to an agent via bindings.
|
||||
- `name`: display name for the agent.
|
||||
- `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to `agents.defaults.workspace`).
|
||||
- `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
|
||||
- `model`: per-agent default model (provider/model), overrides `agents.defaults.model` for that agent.
|
||||
- `model`: per-agent default model, overrides `agents.defaults.model` for that agent.
|
||||
- string form: `"provider/model"`, overrides only `agents.defaults.model.primary`
|
||||
- object form: `{ primary, fallbacks }` (fallbacks override `agents.defaults.model.fallbacks`; `[]` disables global fallbacks for that agent)
|
||||
- `identity`: per-agent name/theme/emoji (used for mention patterns + ack reactions).
|
||||
- `groupChat`: per-agent mention-gating (`mentionPatterns`).
|
||||
- `sandbox`: per-agent sandbox config (overrides `agents.defaults.sandbox`).
|
||||
@@ -583,6 +585,7 @@ Inbound messages are routed to an agent via bindings.
|
||||
- `subagents`: per-agent sub-agent defaults.
|
||||
- `allowAgents`: allowlist of agent ids for `sessions_spawn` from this agent (`["*"]` = allow any; default: only same agent)
|
||||
- `tools`: per-agent tool restrictions (applied before sandbox tool policy).
|
||||
- `profile`: base tool profile (applied before allow/deny)
|
||||
- `allow`: array of allowed tool names
|
||||
- `deny`: array of denied tool names (deny wins)
|
||||
- `agents.defaults`: shared agent defaults (model, workspace, sandbox, etc.).
|
||||
@@ -1075,6 +1078,20 @@ Use this for pre-seeded deployments where your workspace files come from a repo.
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.bootstrapMaxChars`
|
||||
|
||||
Max characters of each workspace bootstrap file injected into the system prompt
|
||||
before truncation. Default: `20000`.
|
||||
|
||||
When a file exceeds this limit, Clawdbot logs a warning and injects a truncated
|
||||
head/tail with a marker.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { bootstrapMaxChars: 20000 } }
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.userTimezone`
|
||||
|
||||
Sets the user’s timezone for **system prompt context** (not for timestamps in
|
||||
@@ -1378,6 +1395,8 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
|
||||
|
||||
#### `agents.defaults.compaction` (reserve headroom + memory flush)
|
||||
|
||||
`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/compaction](/compaction).
|
||||
|
||||
`agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens`
|
||||
value for Pi compaction (default: `20000`). Set it to `0` to disable the floor.
|
||||
|
||||
@@ -1399,6 +1418,7 @@ Example (tuned):
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 24000,
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
@@ -1486,6 +1506,34 @@ Legacy: `tools.bash` is still accepted as an alias.
|
||||
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
||||
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
|
||||
|
||||
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`:
|
||||
- `minimal`: `session_status` only
|
||||
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
|
||||
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
|
||||
- `full`: no restriction (same as unset)
|
||||
|
||||
Per-agent override: `agents.list[].tools.profile`.
|
||||
|
||||
Example (messaging-only by default, allow Slack + Discord tools too):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
profile: "messaging",
|
||||
allow: ["slack", "discord"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (coding profile, but deny exec/process everywhere):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
profile: "coding",
|
||||
deny: ["group:runtime"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
|
||||
This is applied even when the Docker sandbox is **off**.
|
||||
|
||||
@@ -1496,6 +1544,17 @@ Example (disable browser/canvas everywhere):
|
||||
}
|
||||
```
|
||||
|
||||
Tool groups (shorthands) work in **global** and **per-agent** tool policies:
|
||||
- `group:runtime`: `exec`, `bash`, `process`
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- `group:memory`: `memory_search`, `memory_get`
|
||||
- `group:ui`: `browser`, `canvas`
|
||||
- `group:automation`: `cron`, `gateway`
|
||||
- `group:messaging`: `message`
|
||||
- `group:nodes`: `nodes`
|
||||
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
|
||||
|
||||
`tools.elevated` controls elevated (host) exec access:
|
||||
- `enabled`: allow elevated mode (default true)
|
||||
- `allowFrom`: per-provider allowlists (empty = disabled)
|
||||
@@ -1819,6 +1878,48 @@ Notes:
|
||||
- Model ref: `moonshot/kimi-k2-0905-preview`.
|
||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||
|
||||
### Synthetic (Anthropic-compatible)
|
||||
|
||||
Use Synthetic's Anthropic-compatible endpoint:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 192000,
|
||||
maxTokens: 65536
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Set `SYNTHETIC_API_KEY` or use `clawdbot onboard --auth-choice synthetic-api-key`.
|
||||
- Model ref: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`.
|
||||
- Base URL should omit `/v1` because the Anthropic client appends it.
|
||||
|
||||
### Local models (LM Studio) — recommended setup
|
||||
|
||||
See [/gateway/local-models](/gateway/local-models) for the current local guidance. TL;DR: run MiniMax M2.1 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
|
||||
@@ -2135,7 +2236,12 @@ Auth and Tailscale:
|
||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
|
||||
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
||||
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback
|
||||
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When
|
||||
`true`, Serve requests do not need a token/password; set `false` to require
|
||||
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
||||
auth mode is not `password`.
|
||||
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||
|
||||
@@ -56,6 +56,7 @@ cat ~/.clawdbot/clawdbot.json
|
||||
|
||||
## What it does (summary)
|
||||
- Optional pre-flight update for git installs (interactive only).
|
||||
- UI protocol freshness check (rebuilds Control UI when the protocol schema is newer).
|
||||
- Health check + restart prompt.
|
||||
- Skills status summary (eligible/missing/blocked).
|
||||
- Legacy config migration and normalization.
|
||||
|
||||
@@ -49,6 +49,8 @@ pnpm gateway:watch
|
||||
|
||||
## Multiple gateways (same host)
|
||||
|
||||
Usually unnecessary: one Gateway can serve multiple messaging channels and agents. Use multiple Gateways only for redundancy or strict isolation (ex: rescue bot).
|
||||
|
||||
Supported if you isolate state + config and use unique ports.
|
||||
|
||||
Service names are profile-aware:
|
||||
@@ -96,6 +98,12 @@ Checklist per instance:
|
||||
- unique `agents.defaults.workspace`
|
||||
- separate WhatsApp numbers (if using WA)
|
||||
|
||||
Daemon install per profile:
|
||||
```bash
|
||||
clawdbot --profile main daemon install
|
||||
clawdbot --profile rescue daemon install
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json CLAWDBOT_STATE_DIR=~/.clawdbot-a clawdbot gateway --port 19001
|
||||
|
||||
@@ -55,17 +55,6 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
|
||||
- Adjust `contextWindow`/`maxTokens` if your LM Studio build differs.
|
||||
- For WhatsApp, stick to Responses API so only final text is sent.
|
||||
|
||||
## Model catalog (remote + local)
|
||||
|
||||
| Tier | Model ID | Context | Who downloads | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Hosted | `anthropic/claude-opus-4-5` | 200k | Provider | Latest Claude; keep as high-quality fallback. |
|
||||
| Hosted | `anthropic/claude-sonnet-4-5` | 200k | Provider | Cheaper Claude; good default. |
|
||||
| Hosted | `openai/gpt-4.1` | 128k | Provider | Latest GPT-4; strong tools + reasoning. |
|
||||
| Hosted | `openai/gpt-4.1-mini` | 128k | Provider | Fast/cheap GPT-4 family; good fallback. |
|
||||
| Local | `lmstudio/minimax-m2.1-gs32` | ~196k (build-dependent) | You (LM Studio UI) | Recommended local heavy model; keep loaded. |
|
||||
| Local | Custom `vllm` / `litellm` model | server-defined | You (server) | Any OpenAI-compatible endpoint; align context settings. |
|
||||
|
||||
Keep hosted models configured even when running local; use `models.mode: "merge"` so fallbacks stay available.
|
||||
|
||||
### Hybrid config: hosted primary, local fallback
|
||||
@@ -76,12 +65,12 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4-5",
|
||||
fallbacks: ["lmstudio/minimax-m2.1-gs32", "openai/gpt-4.1-mini"]
|
||||
fallbacks: ["lmstudio/minimax-m2.1-gs32", "anthropic/claude-opus-4-5"]
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "MiniMax Local" },
|
||||
"openai/gpt-4.1-mini": { alias: "GPT-4.1 mini" }
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -111,7 +100,7 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
|
||||
|
||||
### Local-first with hosted safety net
|
||||
|
||||
Swap the primary and fallback order; keep the same providers block and `models.mode: "merge"` so you can fall back to Sonnet or GPT-4.1 when the local box is down.
|
||||
Swap the primary and fallback order; keep the same providers block and `models.mode: "merge"` so you can fall back to Sonnet or Opus when the local box is down.
|
||||
|
||||
### Regional hosting / data routing
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
|
||||
## Tool policy: which tools exist/are callable
|
||||
|
||||
Two layers matter:
|
||||
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
|
||||
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
|
||||
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
|
||||
|
||||
@@ -59,7 +60,7 @@ Rules of thumb:
|
||||
|
||||
### Tool groups (shorthands)
|
||||
|
||||
For sandbox tool policy, you can use `group:*` entries that expand to multiple tools:
|
||||
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -78,6 +79,11 @@ Available groups:
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- `group:memory`: `memory_search`, `memory_get`
|
||||
- `group:ui`: `browser`, `canvas`
|
||||
- `group:automation`: `cron`, `gateway`
|
||||
- `group:messaging`: `message`
|
||||
- `group:nodes`: `nodes`
|
||||
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
|
||||
|
||||
## Elevated: exec-only “run on host”
|
||||
|
||||
|
||||
@@ -145,6 +145,20 @@ Doctor can generate one for you: `clawdbot doctor --generate-gateway-token`.
|
||||
Note: `gateway.remote.token` is **only** for remote CLI calls; it does not
|
||||
protect local WS access.
|
||||
|
||||
### 0.6) Tailscale Serve identity headers
|
||||
|
||||
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
|
||||
accepts Tailscale Serve identity headers (`tailscale-user-login`) as
|
||||
authentication. This only triggers for requests that hit loopback and include
|
||||
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by
|
||||
Tailscale.
|
||||
|
||||
**Security rule:** do not forward these headers from your own reverse proxy. If
|
||||
you terminate TLS or proxy in front of the gateway, disable
|
||||
`gateway.auth.allowTailscale` and use token/password auth instead.
|
||||
|
||||
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
|
||||
|
||||
### 1) DMs: pairing by default
|
||||
|
||||
```json5
|
||||
|
||||
@@ -23,9 +23,13 @@ Set `gateway.auth.mode` to control the handshake:
|
||||
- `token` (default when `CLAWDBOT_GATEWAY_TOKEN` is set)
|
||||
- `password` (shared secret via `CLAWDBOT_GATEWAY_PASSWORD` or config)
|
||||
|
||||
When `tailscale.mode = "serve"`, the gateway trusts Tailscale identity headers by
|
||||
default unless you force `gateway.auth.mode` to `password` or set
|
||||
`gateway.auth.allowTailscale: false`.
|
||||
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
|
||||
valid Serve proxy requests can authenticate via Tailscale identity headers
|
||||
(`tailscale-user-login`) without supplying a token/password. Clawdbot only
|
||||
treats a request as Serve when it arrives from loopback with Tailscale’s
|
||||
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers.
|
||||
To require explicit credentials, set `gateway.auth.allowTailscale: false` or
|
||||
force `gateway.auth.mode: "password"`.
|
||||
|
||||
## Config examples
|
||||
|
||||
|
||||
@@ -106,6 +106,28 @@ For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevate
|
||||
|
||||
---
|
||||
|
||||
### Example 2b: Global coding profile + messaging-only agent
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": { "profile": "coding" },
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "support",
|
||||
"tools": { "profile": "messaging", "allow": ["slack"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- default agents get coding tools
|
||||
- `support` agent is messaging-only (+ Slack tool)
|
||||
|
||||
---
|
||||
|
||||
### Example 3: Different Sandbox Modes per Agent
|
||||
|
||||
```json
|
||||
@@ -165,22 +187,29 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
|
||||
|
||||
### Tool Restrictions
|
||||
The filtering order is:
|
||||
1. **Global tool policy** (`tools.allow` / `tools.deny`)
|
||||
2. **Agent-specific tool policy** (`agents.list[].tools`)
|
||||
3. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
|
||||
4. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
|
||||
1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
|
||||
2. **Global tool policy** (`tools.allow` / `tools.deny`)
|
||||
3. **Agent-specific tool policy** (`agents.list[].tools`)
|
||||
4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
|
||||
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
|
||||
|
||||
Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
|
||||
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
|
||||
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
|
||||
|
||||
### Tool groups (shorthands)
|
||||
|
||||
Sandbox tool policy supports `group:*` entries that expand to multiple concrete tools:
|
||||
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple concrete tools:
|
||||
|
||||
- `group:runtime`: `exec`, `bash`, `process`
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- `group:memory`: `memory_search`, `memory_get`
|
||||
- `group:ui`: `browser`, `canvas`
|
||||
- `group:automation`: `cron`, `gateway`
|
||||
- `group:messaging`: `message`
|
||||
- `group:nodes`: `nodes`
|
||||
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
|
||||
|
||||
### Elevated Mode
|
||||
`tools.elevated` is the global baseline (sender-based allowlist). `agents.list[].tools.elevated` can further restrict elevated for specific agents (both must allow).
|
||||
|
||||
@@ -29,6 +29,9 @@ MiniMax highlights these improvements in M2.1:
|
||||
|
||||
- **Speed:** Lightning is the “fast” variant in MiniMax’s pricing docs.
|
||||
- **Cost:** Pricing shows the same input cost, but Lightning has higher output cost.
|
||||
- **Coding plan routing:** The Lightning back-end isn’t directly available on the MiniMax
|
||||
coding plan. MiniMax auto-routes most requests to Lightning, but falls back to the
|
||||
regular M2.1 back-end during traffic spikes.
|
||||
|
||||
## Choose a setup
|
||||
|
||||
|
||||
@@ -26,10 +26,12 @@ model as `provider/model`.
|
||||
- [Anthropic (API + Claude CLI)](/providers/anthropic)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Moonshot AI (Kimi)](/providers/moonshot)
|
||||
- [Synthetic](/providers/synthetic)
|
||||
- [OpenCode Zen](/providers/opencode)
|
||||
- [Z.AI](/providers/zai)
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Amazon Bedrock](/bedrock)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
@@ -9,7 +9,13 @@ read_when:
|
||||
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
|
||||
provider and set the default model to `moonshot/kimi-k2-0905-preview`.
|
||||
|
||||
## CLI setup
|
||||
Current Kimi K2 model IDs:
|
||||
{/* moonshot-kimi-k2-ids:start */}
|
||||
- `kimi-k2-0905-preview`
|
||||
- `kimi-k2-turbo-preview`
|
||||
- `kimi-k2-thinking`
|
||||
- `kimi-k2-thinking-turbo`
|
||||
{/* moonshot-kimi-k2-ids:end */}
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice moonshot-api-key
|
||||
@@ -24,7 +30,12 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
defaults: {
|
||||
model: { primary: "moonshot/kimi-k2-0905-preview" },
|
||||
models: {
|
||||
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" }
|
||||
// moonshot-kimi-k2-aliases:start
|
||||
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" },
|
||||
"moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" },
|
||||
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" },
|
||||
"moonshot/kimi-k2-thinking-turbo": { alias: "Kimi K2 Thinking Turbo" }
|
||||
// moonshot-kimi-k2-aliases:end
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -36,6 +47,7 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
apiKey: "${MOONSHOT_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
// moonshot-kimi-k2-models:start
|
||||
{
|
||||
id: "kimi-k2-0905-preview",
|
||||
name: "Kimi K2 0905 Preview",
|
||||
@@ -44,7 +56,35 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-turbo-preview",
|
||||
name: "Kimi K2 Turbo",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-thinking",
|
||||
name: "Kimi K2 Thinking",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
},
|
||||
{
|
||||
id: "kimi-k2-thinking-turbo",
|
||||
name: "Kimi K2 Thinking Turbo",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192
|
||||
}
|
||||
// moonshot-kimi-k2-models:end
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -56,4 +96,6 @@ clawdbot onboard --auth-choice moonshot-api-key
|
||||
|
||||
- Model refs use `moonshot/<modelId>`.
|
||||
- Override pricing and context metadata in `models.providers` if needed.
|
||||
- If Moonshot publishes different context limits for a model, adjust
|
||||
`contextWindow` accordingly.
|
||||
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
summary: "Use Synthetic's Anthropic-compatible API in Clawdbot"
|
||||
read_when:
|
||||
- You want to use Synthetic as a model provider
|
||||
- You need a Synthetic API key or base URL setup
|
||||
---
|
||||
# Synthetic
|
||||
|
||||
Synthetic exposes Anthropic-compatible endpoints. Clawdbot registers it as the
|
||||
`synthetic` provider and uses the Anthropic Messages API.
|
||||
|
||||
## Quick setup
|
||||
|
||||
1) Set `SYNTHETIC_API_KEY` (or run the wizard below).
|
||||
2) Run onboarding:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice synthetic-api-key
|
||||
```
|
||||
|
||||
The default model is set to:
|
||||
|
||||
```
|
||||
synthetic/hf:MiniMaxAI/MiniMax-M2.1
|
||||
```
|
||||
|
||||
## Config example
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } }
|
||||
}
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 192000,
|
||||
maxTokens: 65536
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: Clawdbot's Anthropic client appends `/v1` to the base URL, so use
|
||||
`https://api.synthetic.new/anthropic` (not `/anthropic/v1`). If Synthetic changes
|
||||
its base URL, override `models.providers.synthetic.baseUrl`.
|
||||
|
||||
## Model catalog
|
||||
|
||||
All models below use cost `0` (input/output/cache).
|
||||
|
||||
| Model ID | Context window | Max tokens | Reasoning | Input |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `hf:MiniMaxAI/MiniMax-M2.1` | 192000 | 65536 | false | text |
|
||||
| `hf:moonshotai/Kimi-K2-Thinking` | 256000 | 8192 | true | text |
|
||||
| `hf:zai-org/GLM-4.7` | 198000 | 128000 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-R1-0528` | 128000 | 8192 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3-0324` | 128000 | 8192 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3.1` | 128000 | 8192 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3.1-Terminus` | 128000 | 8192 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3.2` | 159000 | 8192 | false | text |
|
||||
| `hf:meta-llama/Llama-3.3-70B-Instruct` | 128000 | 8192 | false | text |
|
||||
| `hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8` | 524000 | 8192 | false | text |
|
||||
| `hf:moonshotai/Kimi-K2-Instruct-0905` | 256000 | 8192 | false | text |
|
||||
| `hf:openai/gpt-oss-120b` | 128000 | 8192 | false | text |
|
||||
| `hf:Qwen/Qwen3-235B-A22B-Instruct-2507` | 256000 | 8192 | false | text |
|
||||
| `hf:Qwen/Qwen3-Coder-480B-A35B-Instruct` | 256000 | 8192 | false | text |
|
||||
| `hf:Qwen/Qwen3-VL-235B-A22B-Instruct` | 250000 | 8192 | false | text + image |
|
||||
| `hf:zai-org/GLM-4.5` | 128000 | 128000 | false | text |
|
||||
| `hf:zai-org/GLM-4.6` | 198000 | 128000 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-V3` | 128000 | 8192 | false | text |
|
||||
| `hf:Qwen/Qwen3-235B-A22B-Thinking-2507` | 256000 | 8192 | true | text |
|
||||
|
||||
## Notes
|
||||
|
||||
- Model refs use `synthetic/<modelId>`.
|
||||
- If you enable a model allowlist (`agents.defaults.models`), add every model you
|
||||
plan to use.
|
||||
- See [Model providers](/concepts/model-providers) for provider rules.
|
||||
+130
-11
@@ -5,6 +5,90 @@ summary: "Frequently asked questions about Clawdbot setup, configuration, and us
|
||||
|
||||
Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, multi-agent, OAuth/API keys, model failover). For runtime diagnostics, see [Troubleshooting](/gateway/troubleshooting). For the full config reference, see [Configuration](/gateway/configuration).
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
|
||||
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
|
||||
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
|
||||
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
|
||||
- [What runtime do I need?](#what-runtime-do-i-need)
|
||||
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
|
||||
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
|
||||
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
|
||||
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
|
||||
- [How does Codex auth work?](#how-does-codex-auth-work)
|
||||
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
|
||||
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
|
||||
- [Can I use Bun?](#can-i-use-bun)
|
||||
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
|
||||
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
|
||||
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
|
||||
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
|
||||
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
|
||||
- [How do I customize skills without keeping the repo dirty?](#how-do-i-customize-skills-without-keeping-the-repo-dirty)
|
||||
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
|
||||
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
|
||||
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
|
||||
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
|
||||
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
|
||||
- [How does memory work?](#how-does-memory-work)
|
||||
- [Does semantic memory search require an OpenAI API key?](#does-semantic-memory-search-require-an-openai-api-key)
|
||||
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
|
||||
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
|
||||
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
|
||||
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
|
||||
- [What format is the config? Where is it?](#what-format-is-the-config-where-is-it)
|
||||
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
|
||||
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
|
||||
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
|
||||
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
|
||||
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
|
||||
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
|
||||
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
|
||||
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
|
||||
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
|
||||
- [How does Clawdbot load environment variables?](#how-does-clawdbot-load-environment-variables)
|
||||
- [“I started the Gateway via a daemon and my env vars disappeared.” What now?](#i-started-the-gateway-via-a-daemon-and-my-env-vars-disappeared-what-now)
|
||||
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
||||
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
||||
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
||||
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
||||
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
|
||||
- [What is the “default model”?](#what-is-the-default-model)
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
- [Are opus / sonnet / gpt built‑in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
|
||||
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
|
||||
- [How do I add models from other providers like OpenRouter or Z.AI?](#how-do-i-add-models-from-other-providers-like-openrouter-or-zai)
|
||||
- [How does failover work?](#how-does-failover-work)
|
||||
- [What does this error mean?](#what-does-this-error-mean)
|
||||
- [Fix checklist for `No credentials found for profile "anthropic:default"`](#fix-checklist-for-no-credentials-found-for-profile-anthropicdefault)
|
||||
- [Why did it also try Google Gemini and fail?](#why-did-it-also-try-google-gemini-and-fail)
|
||||
- [What is an auth profile?](#what-is-an-auth-profile)
|
||||
- [What are typical profile IDs?](#what-are-typical-profile-ids)
|
||||
- [Can I control which auth profile is tried first?](#can-i-control-which-auth-profile-is-tried-first)
|
||||
- [OAuth vs API key: what’s the difference?](#oauth-vs-api-key-whats-the-difference)
|
||||
- [What port does the Gateway use?](#what-port-does-the-gateway-use)
|
||||
- [Why does `clawdbot daemon status` say `Runtime: running` but `RPC probe: failed`?](#why-does-clawdbot-daemon-status-say-runtime-running-but-rpc-probe-failed)
|
||||
- [Why does `clawdbot daemon status` show `Config (cli)` and `Config (daemon)` different?](#why-does-clawdbot-daemon-status-show-config-cli-and-config-daemon-different)
|
||||
- [What does “another gateway instance is already listening” mean?](#what-does-another-gateway-instance-is-already-listening-mean)
|
||||
- [How do I run Clawdbot in remote mode (client connects to a Gateway elsewhere)?](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere)
|
||||
- [The Control UI says “unauthorized” (or keeps reconnecting). What now?](#the-control-ui-says-unauthorized-or-keeps-reconnecting-what-now)
|
||||
- [I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens](#i-set-gatewaybind-tailnet-but-it-cant-bind-nothing-listens)
|
||||
- [Can I run multiple Gateways on the same host?](#can-i-run-multiple-gateways-on-the-same-host)
|
||||
- [Where are logs?](#where-are-logs)
|
||||
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
|
||||
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
||||
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
||||
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
||||
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
||||
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
|
||||
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
|
||||
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
|
||||
- [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
|
||||
- [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
|
||||
- [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
|
||||
|
||||
## First 60 seconds if something's broken
|
||||
|
||||
1) **Quick status (first check)**
|
||||
@@ -87,6 +171,20 @@ The wizard can also build UI assets automatically. After onboarding, you typical
|
||||
|
||||
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser.
|
||||
|
||||
### How do I authenticate the dashboard (token) on localhost vs remote?
|
||||
|
||||
**Localhost (same machine):**
|
||||
- Open `http://127.0.0.1:18789/`.
|
||||
- If it asks for auth, run `clawdbot dashboard` and use the tokenized link (`?token=...`).
|
||||
- The token is the same value as `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) and is stored by the UI after first load.
|
||||
|
||||
**Not on localhost:**
|
||||
- **Tailscale Serve** (recommended): keep bind loopback, run `clawdbot gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy auth (no token).
|
||||
- **Tailnet bind**: run `clawdbot gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings.
|
||||
- **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...` from `clawdbot dashboard`.
|
||||
|
||||
See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth details.
|
||||
|
||||
### What runtime do I need?
|
||||
|
||||
Node **>= 22** is required. `pnpm` is recommended; `bun` is optional.
|
||||
@@ -114,7 +212,7 @@ Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also support
|
||||
|
||||
### Is AWS Bedrock supported?
|
||||
|
||||
Not currently. Clawdbot doesn’t ship a Bedrock provider today. If you must use Bedrock, the common workaround is an OpenAI‑compatible proxy in front of Bedrock, then point Clawdbot at that endpoint. See [Model providers](/providers/models) and [Model providers (full list)](/concepts/model-providers).
|
||||
Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
|
||||
|
||||
### How does Codex auth work?
|
||||
|
||||
@@ -186,6 +284,19 @@ Doctor detects a gateway service entrypoint mismatch and offers to rewrite the s
|
||||
|
||||
Use managed overrides instead of editing the repo copy. Put your changes in `~/.clawdbot/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`). Precedence is `<workspace>/skills` > `~/.clawdbot/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs.
|
||||
|
||||
### Can I load skills from a custom folder?
|
||||
|
||||
Yes. Add extra directories via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json` (lowest precedence). Default precedence remains: `<workspace>/skills` → `~/.clawdbot/skills` → bundled → `skills.load.extraDirs`. `clawdhub` installs into `./skills` by default, which Clawdbot treats as `<workspace>/skills`.
|
||||
|
||||
### How can I use different models for different tasks?
|
||||
|
||||
Today the supported patterns are:
|
||||
- **Cron jobs**: isolated jobs can set a `model` override per job.
|
||||
- **Sub-agents**: route tasks to separate agents with different default models.
|
||||
- **On-demand switch**: use `/model` to switch the current session model at any time.
|
||||
|
||||
See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-agent), and [Slash commands](/tools/slash-commands).
|
||||
|
||||
### How do I install skills on Linux?
|
||||
|
||||
Use **ClawdHub** (CLI) or drop skills into your workspace. The macOS Skills UI isn’t available on Linux.
|
||||
@@ -211,7 +322,7 @@ clawdhub install <skill-slug>
|
||||
clawdhub update --all
|
||||
```
|
||||
|
||||
ClawdHub installs into `./skills` under your current directory; Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub).
|
||||
ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub).
|
||||
|
||||
### Is there a dedicated sandboxing doc?
|
||||
|
||||
@@ -231,13 +342,16 @@ Clawdbot also runs a **silent pre-compaction memory flush** to remind the model
|
||||
to write durable notes before auto-compaction. This only runs when the workspace
|
||||
is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
|
||||
|
||||
### Why does memory search need an OpenAI API key if I already signed in with Codex?
|
||||
### Does semantic memory search require an OpenAI API key?
|
||||
|
||||
Vector memory search uses **embeddings**. Codex OAuth only covers
|
||||
chat/completions and does **not** grant embeddings access, so the upstream
|
||||
memory indexer needs a real OpenAI API key (`OPENAI_API_KEY` or
|
||||
`models.providers.openai.apiKey`). If you don’t want to set a key, switch to
|
||||
`memorySearch.provider = "local"` or set `memorySearch.fallback = "none"`.
|
||||
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
|
||||
chat/completions and does **not** grant embeddings access, so **signing in with
|
||||
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
|
||||
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
|
||||
`models.providers.openai.apiKey`). If you’d rather stay local, set
|
||||
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
|
||||
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
|
||||
for the setup details.
|
||||
|
||||
## Where things live on disk
|
||||
|
||||
@@ -799,6 +913,8 @@ Fix:
|
||||
|
||||
### Can I run multiple Gateways on the same host?
|
||||
|
||||
Usually no — one Gateway can run multiple messaging channels and agents. Use multiple Gateways only when you need redundancy (ex: rescue bot) or hard isolation.
|
||||
|
||||
Yes, but you must isolate:
|
||||
|
||||
- `CLAWDBOT_CONFIG_PATH` (per‑instance config)
|
||||
@@ -806,9 +922,12 @@ Yes, but you must isolate:
|
||||
- `agents.defaults.workspace` (workspace isolation)
|
||||
- `gateway.port` (unique ports)
|
||||
|
||||
There are convenience CLI flags like `--dev` and `--profile <name>` that shift state dirs and ports.
|
||||
When using profiles, service names are suffixed (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`,
|
||||
`Clawdbot Gateway (<profile>)`).
|
||||
Quick setup (recommended):
|
||||
- Use `clawdbot --profile <name> …` per instance (auto-creates `~/.clawdbot-<name>`).
|
||||
- Set a unique `gateway.port` in each profile config (or pass `--port` for manual runs).
|
||||
- Install a per-profile daemon: `clawdbot --profile <name> daemon install`.
|
||||
|
||||
Profiles also suffix service names (`com.clawdbot.<profile>`, `clawdbot-gateway-<profile>.service`, `Clawdbot Gateway (<profile>)`).
|
||||
|
||||
## Logging and debugging
|
||||
|
||||
|
||||
@@ -42,6 +42,22 @@ Full setup walkthrough (28m) by VelvetShark.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
|
||||
<Card title="PR Review → Telegram Feedback" icon="code-pull-request" href="https://x.com/i/status/2010878524543131691">
|
||||
**@bangnokia** • `review` `github` `telegram`
|
||||
|
||||
OpenCode finishes the change → opens a PR → Clawdbot reviews the diff and replies in Telegram with “minor suggestions” plus a clear merge verdict (including critical fixes to apply first).
|
||||
|
||||
<img src="/assets/showcase/pr-review-telegram.jpg" alt="Clawdbot PR review feedback delivered in Telegram" />
|
||||
</Card>
|
||||
|
||||
<Card title="Wine Cellar Skill in Minutes" icon="wine-glass" href="https://x.com/i/status/2010916352454791216">
|
||||
**@prades_maxime** • `skills` `local` `csv`
|
||||
|
||||
Asked “Robby” (@clawdbot) for a local wine cellar skill. It requests a sample CSV export + where to store it, then builds/tests the skill fast (962 bottles in the example).
|
||||
|
||||
<img src="/assets/showcase/wine-cellar-skill.jpg" alt="Clawdbot building a local wine cellar skill from CSV" />
|
||||
</Card>
|
||||
|
||||
<Card title="Tesco Shop Autopilot" icon="cart-shopping" href="https://x.com/i/status/2009724862470689131">
|
||||
**@marchattonhere** • `automation` `browser` `shopping`
|
||||
|
||||
@@ -196,6 +212,18 @@ Full setup walkthrough (28m) by VelvetShark.
|
||||
Searches job listings, matches against CV keywords, and returns relevant opportunities with links. Built in 30 minutes using JSearch API.
|
||||
</Card>
|
||||
|
||||
<Card title="Jira Skill Builder" icon="diagram-project" href="https://x.com/jdrhyne/status/2008336434827002232">
|
||||
**@jdrhyne** • `automation` `jira` `skill` `devtools`
|
||||
|
||||
Clawdbot connected to Jira, then generated a new skill on the fly (before it existed on ClawdHub).
|
||||
</Card>
|
||||
|
||||
<Card title="Todoist Skill via Telegram" icon="list-check" href="https://x.com/iamsubhrajyoti/status/2009949389884920153">
|
||||
**@iamsubhrajyoti** • `automation` `todoist` `skill` `telegram`
|
||||
|
||||
Automated Todoist tasks and had Clawdbot generate the skill directly in Telegram chat.
|
||||
</Card>
|
||||
|
||||
<Card title="TradingView Analysis" icon="chart-line">
|
||||
**@bheem1798** • `finance` `browser` `automation`
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- **API key**: stores the key for you.
|
||||
- **MiniMax M2.1**: config is auto-written.
|
||||
- More detail: [MiniMax](/providers/minimax)
|
||||
- **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)
|
||||
- **Skip**: no auth configured yet.
|
||||
@@ -214,6 +216,17 @@ clawdbot onboard --non-interactive \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
Synthetic example:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice synthetic-api-key \
|
||||
--synthetic-api-key "$SYNTHETIC_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
OpenCode Zen example:
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Clawdbot assembles its own system prompt on every run. It includes:
|
||||
- Tool list + short descriptions
|
||||
- Skills list (only metadata; instructions are loaded on demand with `read`)
|
||||
- Self-update instructions
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new)
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000).
|
||||
- Time (UTC + user timezone)
|
||||
- Reply tags + heartbeat behavior
|
||||
- Runtime metadata (host/OS/model/thinking)
|
||||
|
||||
@@ -38,7 +38,7 @@ clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
||||
- `--local`: run locally (requires provider keys in your shell)
|
||||
- `--deliver`: send the reply to the chosen provider (requires `--to`)
|
||||
- `--provider`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
|
||||
- `--thinking <off|minimal|low|medium|high>`: persist thinking level
|
||||
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
|
||||
- `--verbose <on|off>`: persist verbose level
|
||||
- `--timeout <seconds>`: override agent timeout
|
||||
- `--json`: output structured JSON
|
||||
@@ -48,7 +48,7 @@ bun add -g clawdhub
|
||||
|
||||
## How it fits into Clawdbot
|
||||
|
||||
By default, the CLI installs skills into `./skills` under your current working directory. Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
|
||||
By default, the CLI installs skills into `./skills` under your current working directory. If a Clawdbot workspace is configured, `clawdhub` falls back to that workspace unless you override `--workdir` (or `CLAWDHUB_WORKDIR`). Clawdbot loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence.
|
||||
|
||||
For more detail on how skills are loaded, shared, and gated, see
|
||||
[Skills](/tools/skills).
|
||||
@@ -67,7 +67,7 @@ For more detail on how skills are loaded, shared, and gated, see
|
||||
|
||||
Global options (apply to all commands):
|
||||
|
||||
- `--workdir <dir>`: Working directory (default: current dir).
|
||||
- `--workdir <dir>`: Working directory (default: current dir; falls back to Clawdbot workspace).
|
||||
- `--dir <dir>`: Skills directory, relative to workdir (default: `skills`).
|
||||
- `--site <url>`: Site base URL (browser login).
|
||||
- `--registry <url>`: Registry API base URL.
|
||||
@@ -201,4 +201,5 @@ export CLAWDHUB_DISABLE_TELEMETRY=1
|
||||
- `CLAWDHUB_SITE`: Override the site URL.
|
||||
- `CLAWDHUB_REGISTRY`: Override the registry API URL.
|
||||
- `CLAWDHUB_CONFIG_PATH`: Override where the CLI stores the token/config.
|
||||
- `CLAWDHUB_WORKDIR`: Override the default workdir.
|
||||
- `CLAWDHUB_DISABLE_TELEMETRY=1`: Disable telemetry on `sync`.
|
||||
@@ -22,6 +22,77 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
|
||||
}
|
||||
```
|
||||
|
||||
## Tool profiles (base allowlist)
|
||||
|
||||
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
|
||||
Per-agent override: `agents.list[].tools.profile`.
|
||||
|
||||
Profiles:
|
||||
- `minimal`: `session_status` only
|
||||
- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
|
||||
- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
|
||||
- `full`: no restriction (same as unset)
|
||||
|
||||
Example (messaging-only by default, allow Slack + Discord tools too):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
profile: "messaging",
|
||||
allow: ["slack", "discord"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (coding profile, but deny exec/process everywhere):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
profile: "coding",
|
||||
deny: ["group:runtime"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example (global coding profile, messaging-only support agent):
|
||||
```json5
|
||||
{
|
||||
tools: { profile: "coding" },
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "support",
|
||||
tools: { profile: "messaging", allow: ["slack"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool groups (shorthands)
|
||||
|
||||
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
|
||||
Use these in `tools.allow` / `tools.deny`.
|
||||
|
||||
Available groups:
|
||||
- `group:runtime`: `exec`, `bash`, `process`
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
|
||||
- `group:memory`: `memory_search`, `memory_get`
|
||||
- `group:ui`: `browser`, `canvas`
|
||||
- `group:automation`: `cron`, `gateway`
|
||||
- `group:messaging`: `message`
|
||||
- `group:nodes`: `nodes`
|
||||
- `group:clawdbot`: all built-in Clawdbot tools (excludes provider plugins)
|
||||
|
||||
Example (allow only file tools + browser):
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
allow: ["group:fs", "browser"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Plugins + tools
|
||||
|
||||
Plugins can register **additional tools** (and CLI commands) beyond the core set.
|
||||
|
||||
@@ -58,7 +58,8 @@ Common flows:
|
||||
- `clawdhub sync --all`
|
||||
|
||||
By default, `clawdhub` installs into `./skills` under your current working
|
||||
directory; Clawdbot picks that up as `<workspace>/skills` on the next session.
|
||||
directory (or falls back to the configured Clawdbot workspace). Clawdbot picks
|
||||
that up as `<workspace>/skills` on the next session.
|
||||
|
||||
## Format (AgentSkills + Pi-compatible)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Text + native (when enabled):
|
||||
- `/activation mention|always` (groups only)
|
||||
- `/send on|off|inherit` (owner-only)
|
||||
- `/reset` or `/new`
|
||||
- `/think <level>` (aliases: `/thinking`, `/t`)
|
||||
- `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only; aliases: `/thinking`, `/t`)
|
||||
- `/verbose on|off` (alias: `/v`)
|
||||
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
|
||||
- `/elevated on|off` (alias: `/elev`)
|
||||
|
||||
@@ -7,11 +7,12 @@ read_when:
|
||||
|
||||
## What it does
|
||||
- Inline directive in any inbound body: `/t <level>`, `/think:<level>`, or `/thinking <level>`.
|
||||
- Levels (aliases): `off | minimal | low | medium | high`
|
||||
- Levels (aliases): `off | minimal | low | medium | high | xhigh` (GPT-5.2 + Codex models only)
|
||||
- minimal → “think”
|
||||
- low → “think hard”
|
||||
- medium → “think harder”
|
||||
- high → “ultrathink” (max budget)
|
||||
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
|
||||
- `highest`, `max` map to `high`.
|
||||
|
||||
## Resolution order
|
||||
|
||||
@@ -77,8 +77,12 @@ clawdbot gateway --tailscale serve
|
||||
Open:
|
||||
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
|
||||
|
||||
By default, the gateway trusts Tailscale identity headers in serve mode. You can still set
|
||||
`gateway.auth` (or `CLAWDBOT_GATEWAY_TOKEN`) if you want a shared secret instead.
|
||||
By default, Serve requests can authenticate via Tailscale identity headers
|
||||
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
|
||||
only accepts these when the request hits loopback with Tailscale’s
|
||||
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force
|
||||
`gateway.auth.mode: "password"`) if you want to require a token/password even
|
||||
for Serve traffic.
|
||||
|
||||
### Bind to tailnet + token
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth`
|
||||
- Re-open anytime: `clawdbot dashboard` (copies link, opens browser if possible, shows SSH hint if headless).
|
||||
- The token stays local (query param only); the UI strips it after first load and saves it in localStorage.
|
||||
|
||||
## Token basics (local vs remote)
|
||||
|
||||
- **Localhost**: open `http://127.0.0.1:18789/`. If you see “unauthorized,” run `clawdbot dashboard` and use the tokenized link (`?token=...`).
|
||||
- **Token source**: `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`); the UI stores it after first load.
|
||||
- **Not localhost**: use Tailscale Serve (tokenless if `gateway.auth.allowTailscale: true`), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
|
||||
|
||||
## If you see “unauthorized” / 1008
|
||||
|
||||
- Run `clawdbot dashboard` to get a fresh tokenized link.
|
||||
|
||||
+4
-1
@@ -94,7 +94,10 @@ Open:
|
||||
- Binding the Gateway to a non-loopback address **requires** auth (`gateway.auth` or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||
- The wizard generates a gateway token by default (even on loopback).
|
||||
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
|
||||
- Use `gateway.auth.allowTailscale: false` to require explicit credentials even in Serve mode.
|
||||
- With Serve, Tailscale identity headers can satisfy auth when
|
||||
`gateway.auth.allowTailscale` is `true` (no token/password required). Set
|
||||
`gateway.auth.allowTailscale: false` to require explicit credentials. See
|
||||
[Tailscale](/gateway/tailscale) and [Security](/gateway/security).
|
||||
- `gateway.tailscale.mode: "funnel"` requires `gateway.auth.mode: "password"` (shared password).
|
||||
|
||||
## Building the UI
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
- `/agent <id>` (or `/agents`)
|
||||
- `/session <key>` (or `/sessions`)
|
||||
- `/model <provider/model>` (or `/model list`, `/models`)
|
||||
- `/think <off|minimal|low|medium|high>`
|
||||
- `/think <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
|
||||
- `/verbose <on|off>`
|
||||
- `/reasoning <on|off|stream>` (stream = Telegram draft only)
|
||||
- `/cost <on|off>`
|
||||
|
||||
+5
-5
@@ -132,10 +132,10 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.4",
|
||||
"@mariozechner/pi-agent-core": "^0.43.0",
|
||||
"@mariozechner/pi-ai": "^0.43.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.43.0",
|
||||
"@mariozechner/pi-tui": "^0.43.0",
|
||||
"@mariozechner/pi-agent-core": "^0.45.3",
|
||||
"@mariozechner/pi-ai": "^0.45.3",
|
||||
"@mariozechner/pi-coding-agent": "^0.45.3",
|
||||
"@mariozechner/pi-tui": "^0.45.3",
|
||||
"@microsoft/agents-hosting": "^1.1.1",
|
||||
"@microsoft/agents-hosting-express": "^1.1.1",
|
||||
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
||||
@@ -207,7 +207,7 @@
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-ai@0.43.0": "patches/@mariozechner__pi-ai@0.43.0.patch"
|
||||
"@mariozechner/pi-ai@0.45.3": "patches/@mariozechner__pi-ai@0.45.3.patch"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
|
||||
+6
-39
@@ -1,8 +1,8 @@
|
||||
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
|
||||
index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4053c9d2a 100644
|
||||
index cc9e0cb..2b18ec4 100644
|
||||
--- a/dist/providers/google-gemini-cli.js
|
||||
+++ b/dist/providers/google-gemini-cli.js
|
||||
@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
|
||||
@@ -329,6 +329,11 @@ export const streamGoogleGeminiCli = (model, context, options) => {
|
||||
break; // Success, exit retry loop
|
||||
}
|
||||
const errorText = await response.text();
|
||||
@@ -14,41 +14,8 @@ index 12540bb1069087a0d0a2967f792008627b9f79d9..f30b525620e6d8e45146b439ec3733e4
|
||||
// Check if retryable
|
||||
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
|
||||
// Use server-provided delay or exponential backoff
|
||||
diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
|
||||
index ae4710b0f134ac4a48f5b7053f454d1068bee71f..b1b5bd94586f68461ccc44e4a9cdf3acb4e0d084 100644
|
||||
--- a/dist/providers/google-shared.js
|
||||
+++ b/dist/providers/google-shared.js
|
||||
@@ -42,6 +42,8 @@ export function retainThoughtSignature(existing, incoming) {
|
||||
export function convertMessages(model, context) {
|
||||
const contents = [];
|
||||
const transformedMessages = transformMessages(context.messages, model);
|
||||
+ const shouldStripFunctionId = typeof model.provider === "string" &&
|
||||
+ model.provider.startsWith("google");
|
||||
for (const msg of transformedMessages) {
|
||||
if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
@@ -113,6 +115,9 @@ export function convertMessages(model, context) {
|
||||
args: block.arguments,
|
||||
},
|
||||
};
|
||||
+ if (shouldStripFunctionId && part?.functionCall?.id) {
|
||||
+ delete part.functionCall.id; // Google Gemini/Vertex do not support 'id' in functionCall
|
||||
+ }
|
||||
if (block.thoughtSignature) {
|
||||
part.thoughtSignature = block.thoughtSignature;
|
||||
}
|
||||
@@ -155,6 +160,9 @@ export function convertMessages(model, context) {
|
||||
...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
|
||||
},
|
||||
};
|
||||
+ if (shouldStripFunctionId && functionResponsePart.functionResponse?.id) {
|
||||
+ delete functionResponsePart.functionResponse.id; // Google Gemini/Vertex do not support 'id' in functionResponse
|
||||
+ }
|
||||
// Cloud Code Assist API requires all function responses to be in a single user turn.
|
||||
// Check if the last content is already a user turn with function responses and merge.
|
||||
const lastContent = contents[contents.length - 1];
|
||||
diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js
|
||||
index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8cd2ffb02 100644
|
||||
index 7488c79..4c34587 100644
|
||||
--- a/dist/providers/openai-codex-responses.js
|
||||
+++ b/dist/providers/openai-codex-responses.js
|
||||
@@ -517,7 +517,7 @@ function convertTools(tools) {
|
||||
@@ -61,10 +28,10 @@ index ad0a2aabbe10382cee4e463b68a02864dd235e57..8c001acfd0b4e0743181c246f1bedcf8
|
||||
}
|
||||
function mapStopReason(status) {
|
||||
diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js
|
||||
index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c904b76a99 100644
|
||||
index c4714f4..4d1e6b0 100644
|
||||
--- a/dist/providers/openai-responses.js
|
||||
+++ b/dist/providers/openai-responses.js
|
||||
@@ -396,10 +396,16 @@ function convertMessages(model, context) {
|
||||
@@ -400,10 +400,16 @@ function convertMessages(model, context) {
|
||||
}
|
||||
else if (msg.role === "assistant") {
|
||||
const output = [];
|
||||
@@ -81,7 +48,7 @@ index f07085c64390b211340d6a826b28ea9c2e77302f..7f758532246cc7b062df48e9cec4e6c9
|
||||
const reasoningItem = JSON.parse(block.thinkingSignature);
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
@@ -434,6 +440,16 @@ function convertMessages(model, context) {
|
||||
@@ -438,6 +444,16 @@ function convertMessages(model, context) {
|
||||
});
|
||||
}
|
||||
}
|
||||
Generated
+1258
-315
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -10,4 +10,4 @@ onlyBuiltDependencies:
|
||||
- sharp
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai@0.43.0': patches/@mariozechner__pi-ai@0.43.0.patch
|
||||
'@mariozechner/pi-ai@0.45.3': patches/@mariozechner__pi-ai@0.45.3.patch
|
||||
@@ -0,0 +1,128 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MOONSHOT_KIMI_K2_CONTEXT_WINDOW,
|
||||
MOONSHOT_KIMI_K2_COST,
|
||||
MOONSHOT_KIMI_K2_INPUT,
|
||||
MOONSHOT_KIMI_K2_MAX_TOKENS,
|
||||
MOONSHOT_KIMI_K2_MODELS,
|
||||
} from "../ui/src/ui/data/moonshot-kimi-k2";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "..");
|
||||
|
||||
function replaceBlockLines(
|
||||
text: string,
|
||||
startMarker: string,
|
||||
endMarker: string,
|
||||
lines: string[],
|
||||
): string {
|
||||
const startIndex = text.indexOf(startMarker);
|
||||
if (startIndex === -1) {
|
||||
throw new Error(`Missing start marker: ${startMarker}`);
|
||||
}
|
||||
const endIndex = text.indexOf(endMarker, startIndex);
|
||||
if (endIndex === -1) {
|
||||
throw new Error(`Missing end marker: ${endMarker}`);
|
||||
}
|
||||
|
||||
const startLineStart = text.lastIndexOf("\n", startIndex);
|
||||
const startLineStartIndex = startLineStart === -1 ? 0 : startLineStart + 1;
|
||||
const indent = text.slice(startLineStartIndex, startIndex);
|
||||
|
||||
const endLineEnd = text.indexOf("\n", endIndex);
|
||||
const endLineEndIndex = endLineEnd === -1 ? text.length : endLineEnd + 1;
|
||||
|
||||
const before = text.slice(0, startLineStartIndex);
|
||||
const after = text.slice(endLineEndIndex);
|
||||
|
||||
const replacementLines = [
|
||||
`${indent}${startMarker}`,
|
||||
...lines.map((line) => `${indent}${line}`),
|
||||
`${indent}${endMarker}`,
|
||||
];
|
||||
|
||||
const replacement = replacementLines.join("\n");
|
||||
if (!after) return `${before}${replacement}`;
|
||||
return `${before}${replacement}\n${after}`;
|
||||
}
|
||||
|
||||
function renderKimiK2Ids(prefix: string) {
|
||||
return MOONSHOT_KIMI_K2_MODELS.map(
|
||||
(model) => `- \`${prefix}${model.id}\``,
|
||||
);
|
||||
}
|
||||
|
||||
function renderMoonshotAliases() {
|
||||
return MOONSHOT_KIMI_K2_MODELS.map((model, index) => {
|
||||
const isLast = index === MOONSHOT_KIMI_K2_MODELS.length - 1;
|
||||
const suffix = isLast ? "" : ",";
|
||||
return `"moonshot/${model.id}": { alias: "${model.alias}" }${suffix}`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderMoonshotModels() {
|
||||
const input = JSON.stringify([...MOONSHOT_KIMI_K2_INPUT]);
|
||||
const cost = `input: ${MOONSHOT_KIMI_K2_COST.input}, output: ${MOONSHOT_KIMI_K2_COST.output}, cacheRead: ${MOONSHOT_KIMI_K2_COST.cacheRead}, cacheWrite: ${MOONSHOT_KIMI_K2_COST.cacheWrite}`;
|
||||
|
||||
return MOONSHOT_KIMI_K2_MODELS.flatMap((model, index) => {
|
||||
const isLast = index === MOONSHOT_KIMI_K2_MODELS.length - 1;
|
||||
const closing = isLast ? "}" : "},";
|
||||
return [
|
||||
"{",
|
||||
` id: "${model.id}",`,
|
||||
` name: "${model.name}",`,
|
||||
` reasoning: ${model.reasoning},`,
|
||||
` input: ${input},`,
|
||||
` cost: { ${cost} },`,
|
||||
` contextWindow: ${MOONSHOT_KIMI_K2_CONTEXT_WINDOW},`,
|
||||
` maxTokens: ${MOONSHOT_KIMI_K2_MAX_TOKENS}`,
|
||||
closing,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
async function syncMoonshotDocs() {
|
||||
const moonshotDoc = path.join(repoRoot, "docs/providers/moonshot.md");
|
||||
const conceptsDoc = path.join(
|
||||
repoRoot,
|
||||
"docs/concepts/model-providers.md",
|
||||
);
|
||||
|
||||
let moonshotText = await readFile(moonshotDoc, "utf8");
|
||||
moonshotText = replaceBlockLines(
|
||||
moonshotText,
|
||||
"<!-- moonshot-kimi-k2-ids:start -->",
|
||||
"<!-- moonshot-kimi-k2-ids:end -->",
|
||||
renderKimiK2Ids(""),
|
||||
);
|
||||
moonshotText = replaceBlockLines(
|
||||
moonshotText,
|
||||
"// moonshot-kimi-k2-aliases:start",
|
||||
"// moonshot-kimi-k2-aliases:end",
|
||||
renderMoonshotAliases(),
|
||||
);
|
||||
moonshotText = replaceBlockLines(
|
||||
moonshotText,
|
||||
"// moonshot-kimi-k2-models:start",
|
||||
"// moonshot-kimi-k2-models:end",
|
||||
renderMoonshotModels(),
|
||||
);
|
||||
|
||||
let conceptsText = await readFile(conceptsDoc, "utf8");
|
||||
conceptsText = replaceBlockLines(
|
||||
conceptsText,
|
||||
"<!-- moonshot-kimi-k2-model-refs:start -->",
|
||||
"<!-- moonshot-kimi-k2-model-refs:end -->",
|
||||
renderKimiK2Ids("moonshot/"),
|
||||
);
|
||||
|
||||
await writeFile(moonshotDoc, moonshotText);
|
||||
await writeFile(conceptsDoc, conceptsText);
|
||||
}
|
||||
|
||||
syncMoonshotDocs().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -20,6 +20,10 @@ docker run --rm -t \
|
||||
--entrypoint bash \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e HOME=/home/node \
|
||||
-e CLAWDBOT_LIVE_TEST=1 \
|
||||
-e CLAWDBOT_LIVE_GATEWAY_MODELS="${CLAWDBOT_LIVE_GATEWAY_MODELS:-all}" \
|
||||
-e CLAWDBOT_LIVE_GATEWAY_PROVIDERS="${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}" \
|
||||
-e CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${CLAWDBOT_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-}" \
|
||||
-v "$CONFIG_DIR":/home/node/.clawdbot \
|
||||
-v "$WORKSPACE_DIR":/home/node/clawd \
|
||||
"${PROFILE_MOUNT[@]}" \
|
||||
|
||||
@@ -21,8 +21,10 @@ docker run --rm -t \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e HOME=/home/node \
|
||||
-e CLAWDBOT_LIVE_TEST=1 \
|
||||
-e CLAWDBOT_LIVE_ALL_MODELS=1 \
|
||||
-e CLAWDBOT_LIVE_MODELS="${CLAWDBOT_LIVE_MODELS:-all}" \
|
||||
-e CLAWDBOT_LIVE_PROVIDERS="${CLAWDBOT_LIVE_PROVIDERS:-}" \
|
||||
-e CLAWDBOT_LIVE_MODEL_TIMEOUT_MS="${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}" \
|
||||
-e CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS="${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}" \
|
||||
-v "$CONFIG_DIR":/home/node/.clawdbot \
|
||||
-v "$WORKSPACE_DIR":/home/node/clawd \
|
||||
"${PROFILE_MOUNT[@]}" \
|
||||
|
||||
@@ -49,5 +49,5 @@ clawdhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.2.0 --
|
||||
|
||||
Notes
|
||||
- Default registry: https://clawdhub.com (override with CLAWDHUB_REGISTRY or --registry)
|
||||
- Default workdir: cwd; install dir: ./skills (override with --workdir / --dir)
|
||||
- Default workdir: cwd (falls back to Clawdbot workspace); install dir: ./skills (override with --workdir / --dir / CLAWDHUB_WORKDIR)
|
||||
- Update command hashes local files, resolves matching version, and upgrades to latest unless --version is set
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveAgentConfig } from "./agent-scope.js";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentModelFallbacksOverride,
|
||||
resolveAgentModelPrimary,
|
||||
} from "./agent-scope.js";
|
||||
|
||||
describe("resolveAgentConfig", () => {
|
||||
it("should return undefined when no agents config exists", () => {
|
||||
@@ -47,6 +51,68 @@ describe("resolveAgentConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("supports per-agent model primary+fallbacks", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4",
|
||||
fallbacks: ["openai/gpt-5-nano"],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "linus",
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4",
|
||||
fallbacks: ["openai/gpt-5.2"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAgentModelPrimary(cfg, "linus")).toBe(
|
||||
"anthropic/claude-opus-4",
|
||||
);
|
||||
expect(resolveAgentModelFallbacksOverride(cfg, "linus")).toEqual([
|
||||
"openai/gpt-5.2",
|
||||
]);
|
||||
|
||||
// If fallbacks isn't present, we don't override the global fallbacks.
|
||||
const cfgNoOverride: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "linus",
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(resolveAgentModelFallbacksOverride(cfgNoOverride, "linus")).toBe(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Explicit empty list disables global fallbacks for that agent.
|
||||
const cfgDisable: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "linus",
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(resolveAgentModelFallbacksOverride(cfgDisable, "linus")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return agent-specific sandbox config", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
|
||||
@@ -21,7 +21,7 @@ type ResolvedAgentConfig = {
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
agentDir?: string;
|
||||
model?: string;
|
||||
model?: AgentEntry["model"];
|
||||
memorySearch?: AgentEntry["memorySearch"];
|
||||
humanDelay?: AgentEntry["humanDelay"];
|
||||
identity?: AgentEntry["identity"];
|
||||
@@ -95,7 +95,11 @@ export function resolveAgentConfig(
|
||||
workspace:
|
||||
typeof entry.workspace === "string" ? entry.workspace : undefined,
|
||||
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
|
||||
model: typeof entry.model === "string" ? entry.model : undefined,
|
||||
model:
|
||||
typeof entry.model === "string" ||
|
||||
(entry.model && typeof entry.model === "object")
|
||||
? entry.model
|
||||
: undefined,
|
||||
memorySearch: entry.memorySearch,
|
||||
humanDelay: entry.humanDelay,
|
||||
identity: entry.identity,
|
||||
@@ -109,6 +113,28 @@ export function resolveAgentConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentModelPrimary(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): string | undefined {
|
||||
const raw = resolveAgentConfig(cfg, agentId)?.model;
|
||||
if (!raw) return undefined;
|
||||
if (typeof raw === "string") return raw.trim() || undefined;
|
||||
const primary = raw.primary?.trim();
|
||||
return primary || undefined;
|
||||
}
|
||||
|
||||
export function resolveAgentModelFallbacksOverride(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId: string,
|
||||
): string[] | undefined {
|
||||
const raw = resolveAgentConfig(cfg, agentId)?.model;
|
||||
if (!raw || typeof raw === "string") return undefined;
|
||||
// Important: treat an explicitly provided empty array as an override to disable global fallbacks.
|
||||
if (!Object.hasOwn(raw, "fallbacks")) return undefined;
|
||||
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
|
||||
}
|
||||
|
||||
export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
|
||||
const id = normalizeAgentId(agentId);
|
||||
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
resolveApiKeyForProfile,
|
||||
} from "./auth-profiles.js";
|
||||
import {
|
||||
CHUTES_TOKEN_ENDPOINT,
|
||||
type ChutesStoredOAuth,
|
||||
} from "./chutes-oauth.js";
|
||||
|
||||
describe("auth-profiles (chutes)", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
if (previousPiAgentDir === undefined)
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
if (previousChutesClientId === undefined)
|
||||
delete process.env.CHUTES_CLIENT_ID;
|
||||
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
|
||||
});
|
||||
|
||||
it("refreshes expired Chutes OAuth credentials", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(
|
||||
tempDir,
|
||||
"agents",
|
||||
"main",
|
||||
"agent",
|
||||
);
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const authProfilePath = path.join(
|
||||
tempDir,
|
||||
"agents",
|
||||
"main",
|
||||
"agent",
|
||||
"auth-profiles.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"chutes:default": {
|
||||
type: "oauth",
|
||||
provider: "chutes",
|
||||
access: "at_old",
|
||||
refresh: "rt_old",
|
||||
expires: Date.now() - 60_000,
|
||||
clientId: "cid_test",
|
||||
} as unknown as ChutesStoredOAuth,
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
|
||||
|
||||
const fetchSpy = vi.fn(async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT)
|
||||
return new Response("not found", { status: 404 });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at_new",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const loaded = ensureAuthProfileStore();
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
store: loaded,
|
||||
profileId: "chutes:default",
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("at_new");
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
|
||||
const persisted = JSON.parse(
|
||||
await fs.readFile(authProfilePath, "utf8"),
|
||||
) as {
|
||||
profiles?: Record<string, { access?: string }>;
|
||||
};
|
||||
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
|
||||
});
|
||||
});
|
||||
+24
-16
@@ -15,6 +15,7 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import { refreshChutesTokens } from "./chutes-oauth.js";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
@@ -67,7 +68,8 @@ export type TokenCredential = {
|
||||
|
||||
export type OAuthCredential = OAuthCredentials & {
|
||||
type: "oauth";
|
||||
provider: OAuthProvider;
|
||||
provider: string;
|
||||
clientId?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
@@ -171,7 +173,7 @@ async function updateAuthProfileStoreWithLock(params: {
|
||||
}
|
||||
|
||||
function buildOAuthApiKey(
|
||||
provider: OAuthProvider,
|
||||
provider: string,
|
||||
credentials: OAuthCredentials,
|
||||
): string {
|
||||
const needsProjectId =
|
||||
@@ -186,7 +188,6 @@ function buildOAuthApiKey(
|
||||
|
||||
async function refreshOAuthTokenWithLock(params: {
|
||||
profileId: string;
|
||||
provider: OAuthProvider;
|
||||
agentDir?: string;
|
||||
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
|
||||
const authPath = resolveAuthStorePath(params.agentDir);
|
||||
@@ -212,7 +213,16 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
const oauthCreds: Record<string, OAuthCredentials> = {
|
||||
[cred.provider]: cred,
|
||||
};
|
||||
const result = await getOAuthApiKey(cred.provider, oauthCreds);
|
||||
|
||||
const result =
|
||||
String(cred.provider) === "chutes"
|
||||
? await (async () => {
|
||||
const newCredentials = await refreshChutesTokens({
|
||||
credential: cred,
|
||||
});
|
||||
return { apiKey: newCredentials.access, newCredentials };
|
||||
})()
|
||||
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
|
||||
if (!result) return null;
|
||||
store.profiles[params.profileId] = {
|
||||
...cred,
|
||||
@@ -259,7 +269,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
}
|
||||
entries[key] = {
|
||||
...typed,
|
||||
provider: typed.provider ?? (key as OAuthProvider),
|
||||
provider: String(typed.provider ?? key),
|
||||
} as AuthProfileCredential;
|
||||
}
|
||||
return Object.keys(entries).length > 0 ? entries : null;
|
||||
@@ -326,7 +336,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
if (store.profiles[profileId]) continue;
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: provider as OAuthProvider,
|
||||
provider,
|
||||
...creds,
|
||||
};
|
||||
mutated = true;
|
||||
@@ -468,7 +478,7 @@ function syncExternalCliCredentials(
|
||||
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
|
||||
const shouldSyncCodex =
|
||||
!existingCodex ||
|
||||
existingCodex.provider !== ("openai-codex" as OAuthProvider) ||
|
||||
existingCodex.provider !== "openai-codex" ||
|
||||
!isExternalProfileFresh(existingCodex, now);
|
||||
const codexCreds = shouldSyncCodex
|
||||
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
|
||||
@@ -480,7 +490,7 @@ function syncExternalCliCredentials(
|
||||
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) ||
|
||||
existingOAuth.provider !== "openai-codex" ||
|
||||
existingOAuth.expires <= now ||
|
||||
codexCreds.expires > existingOAuth.expires;
|
||||
|
||||
@@ -525,14 +535,14 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
@@ -542,7 +552,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
@@ -590,14 +600,14 @@ export function ensureAuthProfileStore(
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
@@ -607,7 +617,7 @@ export function ensureAuthProfileStore(
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
@@ -1221,7 +1231,6 @@ export async function resolveApiKeyForProfile(params: {
|
||||
try {
|
||||
const result = await refreshOAuthTokenWithLock({
|
||||
profileId,
|
||||
provider: cred.provider,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (!result) return null;
|
||||
@@ -1341,7 +1350,6 @@ async function tryResolveOAuthProfile(params: {
|
||||
|
||||
const refreshed = await refreshOAuthTokenWithLock({
|
||||
profileId,
|
||||
provider: cred.provider,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (!refreshed) return null;
|
||||
|
||||
@@ -40,20 +40,6 @@ const DEFAULT_PATH =
|
||||
process.env.PATH ??
|
||||
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
// NOTE: Using Type.Unsafe with enum instead of Type.Union([Type.Literal(...)])
|
||||
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
|
||||
// Type.Union of literals compiles to { anyOf: [{enum:["a"]}, {enum:["b"]}, ...] }
|
||||
// which is valid but not accepted. A flat enum { type: "string", enum: [...] } works.
|
||||
const _stringEnum = <T extends readonly string[]>(
|
||||
values: T,
|
||||
options?: { description?: string },
|
||||
) =>
|
||||
Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: values as unknown as string[],
|
||||
...options,
|
||||
});
|
||||
|
||||
export type ExecToolDefaults = {
|
||||
backgroundMs?: number;
|
||||
timeoutSec?: number;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CHUTES_TOKEN_ENDPOINT,
|
||||
CHUTES_USERINFO_ENDPOINT,
|
||||
exchangeChutesCodeForTokens,
|
||||
refreshChutesTokens,
|
||||
} from "./chutes-oauth.js";
|
||||
|
||||
describe("chutes-oauth", () => {
|
||||
it("exchanges code for tokens and stores username as email", async () => {
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === CHUTES_TOKEN_ENDPOINT) {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(
|
||||
String(
|
||||
init?.headers &&
|
||||
(init.headers as Record<string, string>)["Content-Type"],
|
||||
),
|
||||
).toContain("application/x-www-form-urlencoded");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at_123",
|
||||
refresh_token: "rt_123",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === CHUTES_USERINFO_ENDPOINT) {
|
||||
expect(
|
||||
String(
|
||||
init?.headers &&
|
||||
(init.headers as Record<string, string>).Authorization,
|
||||
),
|
||||
).toBe("Bearer at_123");
|
||||
return new Response(
|
||||
JSON.stringify({ username: "fred", sub: "sub_1" }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
const now = 1_000_000;
|
||||
const creds = await exchangeChutesCodeForTokens({
|
||||
app: {
|
||||
clientId: "cid_test",
|
||||
redirectUri: "http://127.0.0.1:1456/oauth-callback",
|
||||
scopes: ["openid"],
|
||||
},
|
||||
code: "code_123",
|
||||
codeVerifier: "verifier_123",
|
||||
fetchFn,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(creds.access).toBe("at_123");
|
||||
expect(creds.refresh).toBe("rt_123");
|
||||
expect(creds.email).toBe("fred");
|
||||
expect((creds as unknown as { accountId?: string }).accountId).toBe(
|
||||
"sub_1",
|
||||
);
|
||||
expect((creds as unknown as { clientId?: string }).clientId).toBe(
|
||||
"cid_test",
|
||||
);
|
||||
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT)
|
||||
return new Response("not found", { status: 404 });
|
||||
expect(init?.method).toBe("POST");
|
||||
const body = init?.body as URLSearchParams;
|
||||
expect(String(body.get("grant_type"))).toBe("refresh_token");
|
||||
expect(String(body.get("client_id"))).toBe("cid_test");
|
||||
expect(String(body.get("refresh_token"))).toBe("rt_old");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at_new",
|
||||
expires_in: 1800,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
};
|
||||
|
||||
const now = 2_000_000;
|
||||
const refreshed = await refreshChutesTokens({
|
||||
credential: {
|
||||
access: "at_old",
|
||||
refresh: "rt_old",
|
||||
expires: now - 10_000,
|
||||
email: "fred",
|
||||
clientId: "cid_test",
|
||||
} as unknown as Parameters<typeof refreshChutesTokens>[0]["credential"],
|
||||
fetchFn,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(refreshed.access).toBe("at_new");
|
||||
expect(refreshed.refresh).toBe("rt_old");
|
||||
expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
export const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
|
||||
export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`;
|
||||
export const CHUTES_TOKEN_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/token`;
|
||||
export const CHUTES_USERINFO_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/userinfo`;
|
||||
|
||||
const DEFAULT_EXPIRES_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
export type ChutesPkce = { verifier: string; challenge: string };
|
||||
|
||||
export type ChutesUserInfo = {
|
||||
sub?: string;
|
||||
username?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type ChutesOAuthAppConfig = {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export type ChutesStoredOAuth = OAuthCredentials & {
|
||||
clientId?: string;
|
||||
};
|
||||
|
||||
export function generateChutesPkce(): ChutesPkce {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
export function parseOAuthCallbackInput(
|
||||
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");
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
|
||||
const value =
|
||||
now +
|
||||
Math.max(0, Math.floor(expiresInSeconds)) * 1000 -
|
||||
DEFAULT_EXPIRES_BUFFER_MS;
|
||||
return Math.max(value, now + 30_000);
|
||||
}
|
||||
|
||||
export async function fetchChutesUserInfo(params: {
|
||||
accessToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<ChutesUserInfo | null> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const response = await fetchFn(CHUTES_USERINFO_ENDPOINT, {
|
||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as unknown;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const typed = data as ChutesUserInfo;
|
||||
return typed;
|
||||
}
|
||||
|
||||
export async function exchangeChutesCodeForTokens(params: {
|
||||
app: ChutesOAuthAppConfig;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
fetchFn?: typeof fetch;
|
||||
now?: number;
|
||||
}): Promise<ChutesStoredOAuth> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const now = params.now ?? Date.now();
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: params.app.clientId,
|
||||
code: params.code,
|
||||
redirect_uri: params.app.redirectUri,
|
||||
code_verifier: params.codeVerifier,
|
||||
});
|
||||
if (params.app.clientSecret) {
|
||||
body.set("client_secret", params.app.clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Chutes 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("Chutes token exchange returned no access_token");
|
||||
if (!refresh) {
|
||||
throw new Error("Chutes token exchange returned no refresh_token");
|
||||
}
|
||||
|
||||
const info = await fetchChutesUserInfo({ accessToken: access, fetchFn });
|
||||
|
||||
return {
|
||||
access,
|
||||
refresh,
|
||||
expires: coerceExpiresAt(expiresIn, now),
|
||||
email: info?.username,
|
||||
accountId: info?.sub,
|
||||
clientId: params.app.clientId,
|
||||
} as unknown as ChutesStoredOAuth;
|
||||
}
|
||||
|
||||
export async function refreshChutesTokens(params: {
|
||||
credential: ChutesStoredOAuth;
|
||||
fetchFn?: typeof fetch;
|
||||
now?: number;
|
||||
}): Promise<ChutesStoredOAuth> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const now = params.now ?? Date.now();
|
||||
|
||||
const refreshToken = params.credential.refresh?.trim();
|
||||
if (!refreshToken) {
|
||||
throw new Error("Chutes OAuth credential is missing refresh token");
|
||||
}
|
||||
|
||||
const clientId =
|
||||
params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
"Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).",
|
||||
);
|
||||
}
|
||||
const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: clientId,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
if (clientSecret) body.set("client_secret", clientSecret);
|
||||
|
||||
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Chutes token refresh failed: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
const access = data.access_token?.trim();
|
||||
const newRefresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access) throw new Error("Chutes token refresh returned no access_token");
|
||||
|
||||
return {
|
||||
...params.credential,
|
||||
access,
|
||||
refresh: newRefresh || refreshToken,
|
||||
expires: coerceExpiresAt(expiresIn, now),
|
||||
clientId,
|
||||
} as unknown as ChutesStoredOAuth;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { runCliAgent } from "./cli-runner.js";
|
||||
|
||||
const runCommandWithTimeoutMock = vi.fn();
|
||||
const runExecMock = vi.fn();
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: (...args: unknown[]) =>
|
||||
runCommandWithTimeoutMock(...args),
|
||||
runExec: (...args: unknown[]) => runExecMock(...args),
|
||||
}));
|
||||
|
||||
describe("runCliAgent resume cleanup", () => {
|
||||
beforeEach(() => {
|
||||
runCommandWithTimeoutMock.mockReset();
|
||||
runExecMock.mockReset();
|
||||
});
|
||||
|
||||
it("kills stale resume processes for codex sessions", async () => {
|
||||
runExecMock.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
runCommandWithTimeoutMock.mockResolvedValueOnce({
|
||||
stdout: "ok",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
|
||||
await runCliAgent({
|
||||
sessionId: "s1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
prompt: "hi",
|
||||
provider: "codex-cli",
|
||||
model: "gpt-5.2-codex",
|
||||
timeoutMs: 1_000,
|
||||
runId: "run-1",
|
||||
cliSessionId: "thread-123",
|
||||
});
|
||||
|
||||
if (process.platform === "win32") {
|
||||
expect(runExecMock).not.toHaveBeenCalled();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(runExecMock).toHaveBeenCalledTimes(1);
|
||||
const args = runExecMock.mock.calls[0] ?? [];
|
||||
expect(args[0]).toBe("pkill");
|
||||
const pkillArgs = args[1] as string[];
|
||||
expect(pkillArgs[0]).toBe("-f");
|
||||
expect(pkillArgs[1]).toContain("codex");
|
||||
expect(pkillArgs[1]).toContain("resume");
|
||||
expect(pkillArgs[1]).toContain("thread-123");
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { CliBackendConfig } from "../config/types.js";
|
||||
import { shouldLogVerbose } from "../globals.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { runCommandWithTimeout, runExec } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||
import { resolveCliBackendConfig } from "./cli-backends.js";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
classifyFailoverReason,
|
||||
type EmbeddedContextFile,
|
||||
isFailoverErrorMessage,
|
||||
resolveBootstrapMaxChars,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
|
||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
@@ -32,6 +33,37 @@ import {
|
||||
const log = createSubsystemLogger("agent/claude-cli");
|
||||
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
async function cleanupResumeProcesses(
|
||||
backend: CliBackendConfig,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (process.platform === "win32") return;
|
||||
const resumeArgs = backend.resumeArgs ?? [];
|
||||
if (resumeArgs.length === 0) return;
|
||||
if (!resumeArgs.some((arg) => arg.includes("{sessionId}"))) return;
|
||||
const commandToken = path.basename(backend.command ?? "").trim();
|
||||
if (!commandToken) return;
|
||||
|
||||
const resumeTokens = resumeArgs.map((arg) =>
|
||||
arg.replaceAll("{sessionId}", sessionId),
|
||||
);
|
||||
const pattern = [commandToken, ...resumeTokens]
|
||||
.filter(Boolean)
|
||||
.map((token) => escapeRegex(token))
|
||||
.join(".*");
|
||||
if (!pattern) return;
|
||||
|
||||
try {
|
||||
await runExec("pkill", ["-f", pattern]);
|
||||
} catch {
|
||||
// ignore missing pkill or no matches
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueCliRun<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const prior = CLI_RUN_QUEUE.get(key) ?? Promise.resolve();
|
||||
const chained = prior.catch(() => undefined).then(task);
|
||||
@@ -462,7 +494,11 @@ export async function runCliAgent(params: {
|
||||
await loadWorkspaceBootstrapFiles(workspaceDir),
|
||||
params.sessionKey ?? params.sessionId,
|
||||
);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
|
||||
maxChars: resolveBootstrapMaxChars(params.config),
|
||||
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
|
||||
});
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
@@ -602,6 +638,10 @@ export async function runCliAgent(params: {
|
||||
return next;
|
||||
})();
|
||||
|
||||
if (useResume && cliSessionIdToSend) {
|
||||
await cleanupResumeProcesses(backend, cliSessionIdToSend);
|
||||
}
|
||||
|
||||
const result = await runCommandWithTimeout([backend.command, ...args], {
|
||||
timeoutMs: params.timeoutMs,
|
||||
cwd: workspaceDir,
|
||||
|
||||
@@ -48,3 +48,14 @@ export function isAnthropicRateLimitError(message: string): boolean {
|
||||
if (lower.includes("429")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAnthropicBillingError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("credit balance")) return true;
|
||||
if (lower.includes("insufficient credit")) return true;
|
||||
if (lower.includes("insufficient credits")) return true;
|
||||
if (lower.includes("payment required")) return true;
|
||||
if (lower.includes("billing") && lower.includes("disabled")) return true;
|
||||
if (lower.includes("402")) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const GOOGLE_PREFIXES = ["gemini-3"];
|
||||
const ZAI_PREFIXES = ["glm-4.7"];
|
||||
const MINIMAX_PREFIXES = ["minimax-m2.1"];
|
||||
const XAI_PREFIXES = ["grok-4"];
|
||||
const SYNTHETIC_PREFIXES = ["hf:minimaxai/minimax-m2.1"];
|
||||
|
||||
function matchesPrefix(id: string, prefixes: string[]): boolean {
|
||||
return prefixes.some((prefix) => id.startsWith(prefix));
|
||||
@@ -73,6 +74,10 @@ export function isModernModelRef(ref: ModelRef): boolean {
|
||||
return matchesPrefix(id, XAI_PREFIXES);
|
||||
}
|
||||
|
||||
if (provider === "synthetic") {
|
||||
return matchesPrefix(id, SYNTHETIC_PREFIXES);
|
||||
}
|
||||
|
||||
if (provider === "openrouter" || provider === "opencode") {
|
||||
return matchesAny(id, [
|
||||
...ANTHROPIC_PREFIXES,
|
||||
|
||||
@@ -53,4 +53,37 @@ describe("memory search config", () => {
|
||||
expect(resolved?.query.maxResults).toBe(8);
|
||||
expect(resolved?.query.minScore).toBe(0.2);
|
||||
});
|
||||
|
||||
it("merges remote defaults with agent overrides", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
remote: {
|
||||
baseUrl: "https://default.example/v1",
|
||||
apiKey: "default-key",
|
||||
headers: { "X-Default": "on" },
|
||||
},
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
memorySearch: {
|
||||
remote: {
|
||||
baseUrl: "https://agent.example/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const resolved = resolveMemorySearchConfig(cfg, "main");
|
||||
expect(resolved?.remote).toEqual({
|
||||
baseUrl: "https://agent.example/v1",
|
||||
apiKey: "default-key",
|
||||
headers: { "X-Default": "on" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,11 @@ import { resolveAgentConfig } from "./agent-scope.js";
|
||||
export type ResolvedMemorySearchConfig = {
|
||||
enabled: boolean;
|
||||
provider: "openai" | "local";
|
||||
remote?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
fallback: "openai" | "none";
|
||||
model: string;
|
||||
local: {
|
||||
@@ -60,6 +65,14 @@ function mergeConfig(
|
||||
): ResolvedMemorySearchConfig {
|
||||
const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
|
||||
const provider = overrides?.provider ?? defaults?.provider ?? "openai";
|
||||
const hasRemote = Boolean(defaults?.remote || overrides?.remote);
|
||||
const remote = hasRemote
|
||||
? {
|
||||
baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
|
||||
apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
|
||||
headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
|
||||
}
|
||||
: undefined;
|
||||
const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
|
||||
const model = overrides?.model ?? defaults?.model ?? DEFAULT_MODEL;
|
||||
const local = {
|
||||
@@ -112,6 +125,7 @@ function mergeConfig(
|
||||
return {
|
||||
enabled,
|
||||
provider,
|
||||
remote,
|
||||
fallback,
|
||||
model,
|
||||
local,
|
||||
|
||||
@@ -17,7 +17,7 @@ describeLive("minimax live", () => {
|
||||
api: "anthropic-messages",
|
||||
provider: "minimax",
|
||||
baseUrl: MINIMAX_BASE_URL,
|
||||
reasoning: MINIMAX_MODEL === "MiniMax-M2",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
// Pricing: placeholder values (per 1M tokens, multiplied by 1000 for display)
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
|
||||
@@ -236,4 +236,28 @@ describe("getApiKeyForModel", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves Synthetic API key from env", async () => {
|
||||
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
|
||||
|
||||
try {
|
||||
process.env.SYNTHETIC_API_KEY = "synthetic-test-key";
|
||||
|
||||
vi.resetModules();
|
||||
const { resolveApiKeyForProvider } = await import("./model-auth.js");
|
||||
|
||||
const resolved = await resolveApiKeyForProvider({
|
||||
provider: "synthetic",
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
expect(resolved.apiKey).toBe("synthetic-test-key");
|
||||
expect(resolved.source).toContain("SYNTHETIC_API_KEY");
|
||||
} finally {
|
||||
if (previousSynthetic === undefined) {
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
} else {
|
||||
process.env.SYNTHETIC_API_KEY = previousSynthetic;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,10 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
if (normalized === "chutes") {
|
||||
return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY");
|
||||
}
|
||||
|
||||
if (normalized === "zai") {
|
||||
return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY");
|
||||
}
|
||||
@@ -148,6 +152,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
moonshot: "MOONSHOT_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
synthetic: "SYNTHETIC_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
opencode: "OPENCODE_API_KEY",
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
primary: "openai/gpt-5-nano",
|
||||
fallbacks: ["anthropic/claude-haiku-3-5"],
|
||||
},
|
||||
},
|
||||
@@ -29,7 +29,7 @@ describe("runWithModelFallback", () => {
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
model: "gpt-5-nano",
|
||||
run,
|
||||
}),
|
||||
).rejects.toThrow("bad request");
|
||||
@@ -46,7 +46,7 @@ describe("runWithModelFallback", () => {
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
model: "gpt-5-nano",
|
||||
run,
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("runWithModelFallback", () => {
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
model: "gpt-5-nano",
|
||||
run,
|
||||
});
|
||||
|
||||
@@ -92,7 +92,169 @@ describe("runWithModelFallback", () => {
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
model: "gpt-5-nano",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
|
||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||
});
|
||||
|
||||
it("falls back on credential validation errors", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new Error('No credentials found for profile "anthropic:claude-cli".'),
|
||||
)
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
|
||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||
});
|
||||
|
||||
it("does not append configured primary when fallbacksOverride is set", async () => {
|
||||
const cfg = makeCfg({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5-nano",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.reject(Object.assign(new Error("nope"), { status: 401 })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
|
||||
run,
|
||||
}),
|
||||
).rejects.toThrow("All models failed");
|
||||
|
||||
expect(run.mock.calls).toEqual([
|
||||
["anthropic", "claude-opus-4-5"],
|
||||
["anthropic", "claude-haiku-3-5"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses fallbacksOverride instead of agents.defaults.model.fallbacks", async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
fallbacks: ["openai/gpt-5.2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const calls: Array<{ provider: string; model: string }> = [];
|
||||
|
||||
const res = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
fallbacksOverride: ["openai/gpt-5-nano"],
|
||||
run: async (provider, model) => {
|
||||
calls.push({ provider, model });
|
||||
if (provider === "anthropic") {
|
||||
throw Object.assign(new Error("nope"), { status: 401 });
|
||||
}
|
||||
if (provider === "openai" && model === "gpt-5-nano") {
|
||||
return "ok";
|
||||
}
|
||||
throw new Error(`unexpected candidate: ${provider}/${model}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.result).toBe("ok");
|
||||
expect(calls).toEqual([
|
||||
{ provider: "anthropic", model: "claude-opus-4-5" },
|
||||
{ provider: "openai", model: "gpt-5-nano" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an empty fallbacksOverride as disabling global fallbacks", async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
fallbacks: ["openai/gpt-5.2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const calls: Array<{ provider: string; model: string }> = [];
|
||||
|
||||
await expect(
|
||||
runWithModelFallback({
|
||||
cfg,
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
fallbacksOverride: [],
|
||||
run: async (provider, model) => {
|
||||
calls.push({ provider, model });
|
||||
throw new Error("primary failed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("primary failed");
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ provider: "anthropic", model: "claude-opus-4-5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back on missing API key errors", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("No API key found for profile openai."))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-5-nano",
|
||||
run,
|
||||
});
|
||||
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
|
||||
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
|
||||
});
|
||||
|
||||
it("falls back on lowercase credential errors", async () => {
|
||||
const cfg = makeCfg();
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("no api key found for profile openai"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await runWithModelFallback({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-5-nano",
|
||||
run,
|
||||
});
|
||||
|
||||
@@ -107,7 +269,7 @@ describe("runWithModelFallback", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4.1-mini",
|
||||
primary: "openai/gpt-5-nano",
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
@@ -130,6 +292,6 @@ describe("runWithModelFallback", () => {
|
||||
expect(result.result).toBe("ok");
|
||||
expect(run).toHaveBeenCalledTimes(2);
|
||||
expect(result.provider).toBe("openai");
|
||||
expect(result.model).toBe("gpt-4.1-mini");
|
||||
expect(result.model).toBe("gpt-5-nano");
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,8 @@ function resolveFallbackCandidates(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||
fallbacksOverride?: string[];
|
||||
}): ModelCandidate[] {
|
||||
const provider = params.provider.trim() || DEFAULT_PROVIDER;
|
||||
const model = params.model.trim() || DEFAULT_MODEL;
|
||||
@@ -159,6 +161,7 @@ function resolveFallbackCandidates(params: {
|
||||
addCandidate({ provider, model }, false);
|
||||
|
||||
const modelFallbacks = (() => {
|
||||
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
|
||||
const model = params.cfg?.agents?.defaults?.model as
|
||||
| { fallbacks?: string[] }
|
||||
| string
|
||||
@@ -177,7 +180,11 @@ function resolveFallbackCandidates(params: {
|
||||
addCandidate(resolved.ref, true);
|
||||
}
|
||||
|
||||
if (primary?.provider && primary.model) {
|
||||
if (
|
||||
params.fallbacksOverride === undefined &&
|
||||
primary?.provider &&
|
||||
primary.model
|
||||
) {
|
||||
addCandidate({ provider: primary.provider, model: primary.model }, false);
|
||||
}
|
||||
|
||||
@@ -188,6 +195,8 @@ export async function runWithModelFallback<T>(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||
fallbacksOverride?: string[];
|
||||
run: (provider: string, model: string) => Promise<T>;
|
||||
onError?: (attempt: {
|
||||
provider: string;
|
||||
@@ -202,7 +211,12 @@ export async function runWithModelFallback<T>(params: {
|
||||
model: string;
|
||||
attempts: FallbackAttempt[];
|
||||
}> {
|
||||
const candidates = resolveFallbackCandidates(params);
|
||||
const candidates = resolveFallbackCandidates({
|
||||
cfg: params.cfg,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
fallbacksOverride: params.fallbacksOverride,
|
||||
});
|
||||
const attempts: FallbackAttempt[] = [];
|
||||
let lastError: unknown;
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
const catalog = [
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-4",
|
||||
name: "GPT-4",
|
||||
id: "gpt-5-nano",
|
||||
name: "GPT-5 Nano",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("buildAllowedModelSet", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": { alias: "gpt4" },
|
||||
"openai/gpt-5-nano": { alias: "gpt5nano" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -38,7 +38,9 @@ describe("buildAllowedModelSet", () => {
|
||||
});
|
||||
|
||||
expect(allowed.allowAny).toBe(false);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-5-nano"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -57,7 +59,9 @@ describe("buildAllowedModelSet", () => {
|
||||
});
|
||||
|
||||
expect(allowed.allowAny).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-5-nano"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -107,6 +111,24 @@ describe("parseModelRef", () => {
|
||||
model: "claude-opus-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes google gemini 3 models to preview ids", () => {
|
||||
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-preview",
|
||||
});
|
||||
expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes default-provider google models", () => {
|
||||
expect(parseModelRef("gemini-3-pro", "google")).toEqual({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHooksGmailModel", () => {
|
||||
@@ -218,7 +240,7 @@ describe("resolveAllowedModelRef", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": { alias: "GPT4" },
|
||||
"openai/gpt-5-nano": { alias: "GPT5NANO" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -226,12 +248,12 @@ describe("resolveAllowedModelRef", () => {
|
||||
const resolved = resolveAllowedModelRef({
|
||||
cfg,
|
||||
catalog: [
|
||||
{ provider: "openai", id: "gpt-4", name: "GPT-4" },
|
||||
{ provider: "openai", id: "gpt-5-nano", name: "GPT-5 Nano" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
|
||||
],
|
||||
raw: "anthropic/claude-sonnet-4-1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4",
|
||||
defaultModel: "gpt-5-nano",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
error: "model not allowed: anthropic/claude-sonnet-4-1",
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ModelCatalogEntry } from "./model-catalog.js";
|
||||
import { normalizeGoogleModelId } from "./models-config.providers.js";
|
||||
|
||||
export type ModelRef = {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||
export type ThinkLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh";
|
||||
|
||||
export type ModelAliasIndex = {
|
||||
byAlias: Map<string, { alias: string; ref: ModelRef }>;
|
||||
@@ -47,6 +54,12 @@ function normalizeAnthropicModelId(model: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeProviderModelId(provider: string, model: string): string {
|
||||
if (provider === "anthropic") return normalizeAnthropicModelId(model);
|
||||
if (provider === "google") return normalizeGoogleModelId(model);
|
||||
return model;
|
||||
}
|
||||
|
||||
export function parseModelRef(
|
||||
raw: string,
|
||||
defaultProvider: string,
|
||||
@@ -56,16 +69,14 @@ export function parseModelRef(
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash === -1) {
|
||||
const provider = normalizeProviderId(defaultProvider);
|
||||
const model =
|
||||
provider === "anthropic" ? normalizeAnthropicModelId(trimmed) : trimmed;
|
||||
const model = normalizeProviderModelId(provider, trimmed);
|
||||
return { provider, model };
|
||||
}
|
||||
const providerRaw = trimmed.slice(0, slash).trim();
|
||||
const provider = normalizeProviderId(providerRaw);
|
||||
const model = trimmed.slice(slash + 1).trim();
|
||||
if (!provider || !model) return null;
|
||||
const normalizedModel =
|
||||
provider === "anthropic" ? normalizeAnthropicModelId(model) : model;
|
||||
const normalizedModel = normalizeProviderModelId(provider, model);
|
||||
return { provider, model: normalizedModel };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "../providers/github-copilot-token.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "./auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "./model-auth.js";
|
||||
import {
|
||||
buildSyntheticModelDefinition,
|
||||
SYNTHETIC_BASE_URL,
|
||||
SYNTHETIC_MODEL_CATALOG,
|
||||
} from "./synthetic-models.js";
|
||||
|
||||
type ModelsConfig = NonNullable<ClawdbotConfig["models"]>;
|
||||
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
@@ -180,6 +189,14 @@ function buildMoonshotProvider(): ProviderConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSyntheticProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: SYNTHETIC_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveImplicitProviders(params: {
|
||||
agentDir: string;
|
||||
}): ModelsConfig["providers"] {
|
||||
@@ -202,5 +219,70 @@ export function resolveImplicitProviders(params: {
|
||||
providers.moonshot = { ...buildMoonshotProvider(), apiKey: moonshotKey };
|
||||
}
|
||||
|
||||
const syntheticKey =
|
||||
resolveEnvApiKeyVarName("synthetic") ??
|
||||
resolveApiKeyFromProfiles({ provider: "synthetic", store: authStore });
|
||||
if (syntheticKey) {
|
||||
providers.synthetic = { ...buildSyntheticProvider(), apiKey: syntheticKey };
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export async function resolveImplicitCopilotProvider(params: {
|
||||
agentDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<ProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const authStore = ensureAuthProfileStore(params.agentDir);
|
||||
const hasProfile =
|
||||
listProfilesForProvider(authStore, "github-copilot").length > 0;
|
||||
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
||||
const githubToken = (envToken ?? "").trim();
|
||||
|
||||
if (!hasProfile && !githubToken) return null;
|
||||
|
||||
let selectedGithubToken = githubToken;
|
||||
if (!selectedGithubToken && hasProfile) {
|
||||
// Use the first available profile as a default for discovery (it will be
|
||||
// re-resolved per-run by the embedded runner).
|
||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||
if (profile && profile.type === "token") {
|
||||
selectedGithubToken = profile.token;
|
||||
}
|
||||
}
|
||||
|
||||
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
if (selectedGithubToken) {
|
||||
try {
|
||||
const token = await resolveCopilotApiToken({
|
||||
githubToken: selectedGithubToken,
|
||||
env,
|
||||
});
|
||||
baseUrl = token.baseUrl;
|
||||
} catch {
|
||||
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||
// Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also
|
||||
// write a runtime-only auth.json entry for pi-coding-agent to pick up.
|
||||
//
|
||||
// This is safe because it's (1) within Clawdbot's agent dir, (2) contains the
|
||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||
// patterns for OAuth-like providers in pi-coding-agent.
|
||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||
// Clawdbot uses its own auth store and exchanges tokens at runtime.
|
||||
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||
|
||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||
// pi-coding-agent treats providers with models as replacements requiring apiKey.
|
||||
// We only override baseUrl; the model list comes from pi-ai built-ins.
|
||||
return {
|
||||
baseUrl,
|
||||
models: [],
|
||||
} satisfies ProviderConfig;
|
||||
}
|
||||
@@ -348,11 +348,13 @@ describe("models config", () => {
|
||||
const previousGithub = process.env.GITHUB_TOKEN;
|
||||
const previousMinimax = process.env.MINIMAX_API_KEY;
|
||||
const previousMoonshot = process.env.MOONSHOT_API_KEY;
|
||||
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
delete process.env.MOONSHOT_API_KEY;
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
|
||||
try {
|
||||
vi.resetModules();
|
||||
@@ -381,6 +383,9 @@ describe("models config", () => {
|
||||
else process.env.MINIMAX_API_KEY = previousMinimax;
|
||||
if (previousMoonshot === undefined) delete process.env.MOONSHOT_API_KEY;
|
||||
else process.env.MOONSHOT_API_KEY = previousMoonshot;
|
||||
if (previousSynthetic === undefined)
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
else process.env.SYNTHETIC_API_KEY = previousSynthetic;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -451,6 +456,44 @@ describe("models config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds synthetic provider when SYNTHETIC_API_KEY is set", async () => {
|
||||
await withTempHome(async () => {
|
||||
vi.resetModules();
|
||||
const prevKey = process.env.SYNTHETIC_API_KEY;
|
||||
process.env.SYNTHETIC_API_KEY = "sk-synthetic-test";
|
||||
try {
|
||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||
|
||||
await ensureClawdbotModelsJson({});
|
||||
|
||||
const modelPath = path.join(resolveClawdbotAgentDir(), "models.json");
|
||||
const raw = await fs.readFile(modelPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
providers: Record<
|
||||
string,
|
||||
{
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
models?: Array<{ id: string }>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
expect(parsed.providers.synthetic?.baseUrl).toBe(
|
||||
"https://api.synthetic.new/anthropic",
|
||||
);
|
||||
expect(parsed.providers.synthetic?.apiKey).toBe("SYNTHETIC_API_KEY");
|
||||
const ids = parsed.providers.synthetic?.models?.map(
|
||||
(model) => model.id,
|
||||
);
|
||||
expect(ids).toContain("hf:MiniMaxAI/MiniMax-M2.1");
|
||||
} finally {
|
||||
if (prevKey === undefined) delete process.env.SYNTHETIC_API_KEY;
|
||||
else process.env.SYNTHETIC_API_KEY = prevKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("fills missing provider.apiKey from env var name when models exist", async () => {
|
||||
await withTempHome(async () => {
|
||||
vi.resetModules();
|
||||
|
||||
@@ -2,18 +2,11 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { type ClawdbotConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_COPILOT_API_BASE_URL,
|
||||
resolveCopilotApiToken,
|
||||
} from "../providers/github-copilot-token.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "./auth-profiles.js";
|
||||
import {
|
||||
normalizeProviders,
|
||||
type ProviderConfig,
|
||||
resolveImplicitCopilotProvider,
|
||||
resolveImplicitProviders,
|
||||
} from "./models-config.providers.js";
|
||||
|
||||
@@ -85,64 +78,6 @@ async function readJson(pathname: string): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeBuildCopilotProvider(params: {
|
||||
agentDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<ProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const authStore = ensureAuthProfileStore(params.agentDir);
|
||||
const hasProfile =
|
||||
listProfilesForProvider(authStore, "github-copilot").length > 0;
|
||||
const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN;
|
||||
const githubToken = (envToken ?? "").trim();
|
||||
|
||||
if (!hasProfile && !githubToken) return null;
|
||||
|
||||
let selectedGithubToken = githubToken;
|
||||
if (!selectedGithubToken && hasProfile) {
|
||||
// Use the first available profile as a default for discovery (it will be
|
||||
// re-resolved per-run by the embedded runner).
|
||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||
if (profile && profile.type === "token") {
|
||||
selectedGithubToken = profile.token;
|
||||
}
|
||||
}
|
||||
|
||||
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
if (selectedGithubToken) {
|
||||
try {
|
||||
const token = await resolveCopilotApiToken({
|
||||
githubToken: selectedGithubToken,
|
||||
env,
|
||||
});
|
||||
baseUrl = token.baseUrl;
|
||||
} catch {
|
||||
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||
// Our Copilot auth lives in Clawdbot's auth-profiles store instead, so we also
|
||||
// write a runtime-only auth.json entry for pi-coding-agent to pick up.
|
||||
//
|
||||
// This is safe because it's (1) within Clawdbot's agent dir, (2) contains the
|
||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||
// patterns for OAuth-like providers in pi-coding-agent.
|
||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||
// Clawdbot uses its own auth store and exchanges tokens at runtime.
|
||||
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||
|
||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||
// pi-coding-agent treats providers with models as replacements requiring apiKey.
|
||||
// We only override baseUrl; the model list comes from pi-ai built-ins.
|
||||
return {
|
||||
baseUrl,
|
||||
models: [],
|
||||
} satisfies ProviderConfig;
|
||||
}
|
||||
|
||||
export async function ensureClawdbotModelsJson(
|
||||
config?: ClawdbotConfig,
|
||||
agentDirOverride?: string,
|
||||
@@ -161,7 +96,7 @@ export async function ensureClawdbotModelsJson(
|
||||
implicit: implicitProviders,
|
||||
explicit: explicitProviders,
|
||||
});
|
||||
const implicitCopilot = await maybeBuildCopilotProvider({ agentDir });
|
||||
const implicitCopilot = await resolveImplicitCopilotProvider({ agentDir });
|
||||
if (implicitCopilot && !providers["github-copilot"]) {
|
||||
providers["github-copilot"] = implicitCopilot;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
collectAnthropicApiKeys,
|
||||
isAnthropicBillingError,
|
||||
isAnthropicRateLimitError,
|
||||
} from "./live-auth-keys.js";
|
||||
import { isModernModelRef } from "./live-model-filter.js";
|
||||
@@ -72,6 +73,18 @@ function toInt(value: string | undefined, fallback: number): number {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function resolveTestReasoning(
|
||||
model: Model<Api>,
|
||||
): "minimal" | "low" | "medium" | "high" | "xhigh" | undefined {
|
||||
if (!model.reasoning) return undefined;
|
||||
const id = model.id.toLowerCase();
|
||||
if (model.provider === "openai" || model.provider === "openai-codex") {
|
||||
if (id.includes("pro")) return "high";
|
||||
return "medium";
|
||||
}
|
||||
return "low";
|
||||
}
|
||||
|
||||
async function completeSimpleWithTimeout<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Parameters<typeof completeSimple<TApi>>[1],
|
||||
@@ -110,7 +123,7 @@ async function completeOkWithRetry(params: {
|
||||
},
|
||||
{
|
||||
apiKey: params.apiKey,
|
||||
reasoning: params.model.reasoning ? "low" : undefined,
|
||||
reasoning: resolveTestReasoning(params.model),
|
||||
maxTokens: 64,
|
||||
},
|
||||
params.timeoutMs,
|
||||
@@ -255,7 +268,7 @@ describeLive("live models (profile keys)", () => {
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
reasoning: model.reasoning ? "low" : undefined,
|
||||
reasoning: resolveTestReasoning(model),
|
||||
maxTokens: 128,
|
||||
},
|
||||
perModelTimeoutMs,
|
||||
@@ -295,7 +308,7 @@ describeLive("live models (profile keys)", () => {
|
||||
},
|
||||
{
|
||||
apiKey,
|
||||
reasoning: model.reasoning ? "low" : undefined,
|
||||
reasoning: resolveTestReasoning(model),
|
||||
maxTokens: 64,
|
||||
},
|
||||
perModelTimeoutMs,
|
||||
@@ -335,6 +348,17 @@ describeLive("live models (profile keys)", () => {
|
||||
logProgress(`${progressLabel}: skip (google model not found)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
ok.text.length === 0 &&
|
||||
(model.provider === "openrouter" || model.provider === "opencode")
|
||||
) {
|
||||
skipped.push({
|
||||
model: id,
|
||||
reason: "no text returned (provider returned empty content)",
|
||||
});
|
||||
logProgress(`${progressLabel}: skip (empty response)`);
|
||||
break;
|
||||
}
|
||||
expect(ok.text.length).toBeGreaterThan(0);
|
||||
logProgress(`${progressLabel}: done`);
|
||||
break;
|
||||
@@ -350,6 +374,20 @@ describeLive("live models (profile keys)", () => {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
model.provider === "anthropic" &&
|
||||
isAnthropicBillingError(message)
|
||||
) {
|
||||
if (attempt + 1 < attemptMax) {
|
||||
logProgress(
|
||||
`${progressLabel}: billing issue, retrying with next key`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
skipped.push({ model: id, reason: message });
|
||||
logProgress(`${progressLabel}: skip (anthropic billing)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
model.provider === "google" &&
|
||||
isGoogleModelNotFoundError(err)
|
||||
@@ -358,6 +396,15 @@ describeLive("live models (profile keys)", () => {
|
||||
logProgress(`${progressLabel}: skip (google model not found)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
allowNotFoundSkip &&
|
||||
model.provider === "minimax" &&
|
||||
message.includes("request ended without sending any chunks")
|
||||
) {
|
||||
skipped.push({ model: id, reason: message });
|
||||
logProgress(`${progressLabel}: skip (minimax empty response)`);
|
||||
break;
|
||||
}
|
||||
logProgress(`${progressLabel}: failed`);
|
||||
failures.push({ model: id, error: message });
|
||||
break;
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
classifyFailoverReason,
|
||||
DEFAULT_BOOTSTRAP_MAX_CHARS,
|
||||
formatAssistantErrorText,
|
||||
isAuthErrorMessage,
|
||||
isBillingErrorMessage,
|
||||
isCloudCodeAssistFormatError,
|
||||
isCompactionFailureError,
|
||||
isContextOverflowError,
|
||||
isFailoverErrorMessage,
|
||||
isMessagingToolDuplicate,
|
||||
normalizeTextForComparison,
|
||||
resolveBootstrapMaxChars,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
sanitizeSessionMessagesImages,
|
||||
sanitizeToolCallId,
|
||||
stripThoughtSignatures,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
@@ -45,17 +52,56 @@ describe("buildBootstrapContextFiles", () => {
|
||||
});
|
||||
|
||||
it("truncates large bootstrap content", () => {
|
||||
const head = `HEAD-${"a".repeat(6000)}`;
|
||||
const tail = `${"b".repeat(3000)}-TAIL`;
|
||||
const head = `HEAD-${"a".repeat(600)}`;
|
||||
const tail = `${"b".repeat(300)}-TAIL`;
|
||||
const long = `${head}${tail}`;
|
||||
const files = [makeFile({ content: long })];
|
||||
const [result] = buildBootstrapContextFiles(files);
|
||||
const files = [makeFile({ name: "TOOLS.md", content: long })];
|
||||
const warnings: string[] = [];
|
||||
const maxChars = 200;
|
||||
const expectedTailChars = Math.floor(maxChars * 0.2);
|
||||
const [result] = buildBootstrapContextFiles(files, {
|
||||
maxChars,
|
||||
warn: (message) => warnings.push(message),
|
||||
});
|
||||
expect(result?.content).toContain(
|
||||
"[...truncated, read AGENTS.md for full content...]",
|
||||
"[...truncated, read TOOLS.md for full content...]",
|
||||
);
|
||||
expect(result?.content.length).toBeLessThan(long.length);
|
||||
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
|
||||
expect(result?.content.endsWith(long.slice(-120))).toBe(true);
|
||||
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain("TOOLS.md");
|
||||
expect(warnings[0]).toContain("limit 200");
|
||||
});
|
||||
|
||||
it("keeps content under the default limit", () => {
|
||||
const long = "a".repeat(DEFAULT_BOOTSTRAP_MAX_CHARS - 10);
|
||||
const files = [makeFile({ content: long })];
|
||||
const [result] = buildBootstrapContextFiles(files);
|
||||
expect(result?.content).toBe(long);
|
||||
expect(result?.content).not.toContain(
|
||||
"[...truncated, read AGENTS.md for full content...]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBootstrapMaxChars", () => {
|
||||
it("returns default when unset", () => {
|
||||
expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
|
||||
});
|
||||
|
||||
it("uses configured value when valid", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { bootstrapMaxChars: 12345 } },
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveBootstrapMaxChars(cfg)).toBe(12345);
|
||||
});
|
||||
|
||||
it("falls back when invalid", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { bootstrapMaxChars: -1 } },
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +168,23 @@ describe("isBillingErrorMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthErrorMessage", () => {
|
||||
it("matches credential validation errors", () => {
|
||||
const samples = [
|
||||
'No credentials found for profile "anthropic:claude-cli".',
|
||||
"No API key found for profile openai.",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
expect(isAuthErrorMessage(sample)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores unrelated errors", () => {
|
||||
expect(isAuthErrorMessage("rate limit exceeded")).toBe(false);
|
||||
expect(isAuthErrorMessage("billing issue detected")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFailoverErrorMessage", () => {
|
||||
it("matches auth/rate/billing/timeout", () => {
|
||||
const samples = [
|
||||
@@ -140,6 +203,8 @@ describe("isFailoverErrorMessage", () => {
|
||||
describe("classifyFailoverReason", () => {
|
||||
it("returns a stable reason", () => {
|
||||
expect(classifyFailoverReason("invalid api key")).toBe("auth");
|
||||
expect(classifyFailoverReason("no credentials found")).toBe("auth");
|
||||
expect(classifyFailoverReason("no api key found")).toBe("auth");
|
||||
expect(classifyFailoverReason("429 too many requests")).toBe("rate_limit");
|
||||
expect(classifyFailoverReason("resource has been exhausted")).toBe(
|
||||
"rate_limit",
|
||||
@@ -480,3 +545,176 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTextForComparison", () => {
|
||||
it("lowercases text", () => {
|
||||
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(normalizeTextForComparison(" hello ")).toBe("hello");
|
||||
});
|
||||
|
||||
it("collapses multiple spaces", () => {
|
||||
expect(normalizeTextForComparison("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("strips emoji", () => {
|
||||
expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("handles mixed normalization", () => {
|
||||
expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe(
|
||||
"hello world",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripThoughtSignatures", () => {
|
||||
it("returns non-array content unchanged", () => {
|
||||
expect(stripThoughtSignatures("hello")).toBe("hello");
|
||||
expect(stripThoughtSignatures(null)).toBe(null);
|
||||
expect(stripThoughtSignatures(undefined)).toBe(undefined);
|
||||
expect(stripThoughtSignatures(123)).toBe(123);
|
||||
});
|
||||
|
||||
it("removes msg_-prefixed thought_signature from content blocks", () => {
|
||||
const input = [
|
||||
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
||||
{ type: "thinking", thinking: "test", thought_signature: "AQID" },
|
||||
];
|
||||
const result = stripThoughtSignatures(input);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ type: "text", text: "hello" });
|
||||
expect(result[1]).toEqual({
|
||||
type: "thinking",
|
||||
thinking: "test",
|
||||
thought_signature: "AQID",
|
||||
});
|
||||
expect("thought_signature" in result[0]).toBe(false);
|
||||
expect("thought_signature" in result[1]).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves blocks without thought_signature", () => {
|
||||
const input = [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
];
|
||||
const result = stripThoughtSignatures(input);
|
||||
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it("handles mixed blocks with and without thought_signature", () => {
|
||||
const input = [
|
||||
{ type: "text", text: "hello", thought_signature: "msg_abc" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
{ type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" },
|
||||
];
|
||||
const result = stripThoughtSignatures(input);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
{ type: "thinking", thinking: "hmm" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles empty array", () => {
|
||||
expect(stripThoughtSignatures([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles null/undefined blocks in array", () => {
|
||||
const input = [null, undefined, { type: "text", text: "hello" }];
|
||||
const result = stripThoughtSignatures(input);
|
||||
expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSessionMessagesImages - thought_signature stripping", () => {
|
||||
it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "reasoning",
|
||||
thought_signature: "AQID",
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const content = (out[0] as { content?: unknown[] }).content;
|
||||
expect(content).toHaveLength(2);
|
||||
expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false);
|
||||
expect(
|
||||
(content?.[1] as { thought_signature?: unknown })?.thought_signature,
|
||||
).toBe("AQID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMessagingToolDuplicate", () => {
|
||||
it("returns false for empty sentTexts", () => {
|
||||
expect(isMessagingToolDuplicate("hello world", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for short texts", () => {
|
||||
expect(isMessagingToolDuplicate("short", ["short"])).toBe(false);
|
||||
});
|
||||
|
||||
it("detects exact duplicates", () => {
|
||||
expect(
|
||||
isMessagingToolDuplicate("Hello, this is a test message!", [
|
||||
"Hello, this is a test message!",
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects duplicates with different casing", () => {
|
||||
expect(
|
||||
isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [
|
||||
"hello, this is a test message!",
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects duplicates with emoji variations", () => {
|
||||
expect(
|
||||
isMessagingToolDuplicate("Hello! 👋 This is a test message!", [
|
||||
"Hello! This is a test message!",
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects substring duplicates (LLM elaboration)", () => {
|
||||
expect(
|
||||
isMessagingToolDuplicate(
|
||||
'I sent the message: "Hello, this is a test message!"',
|
||||
["Hello, this is a test message!"],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("detects when sent text contains block reply (reverse substring)", () => {
|
||||
expect(
|
||||
isMessagingToolDuplicate("Hello, this is a test message!", [
|
||||
'I sent the message: "Hello, this is a test message!"',
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-matching texts", () => {
|
||||
expect(
|
||||
isMessagingToolDuplicate("This is completely different content.", [
|
||||
"Hello, this is a test message!",
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -22,23 +22,88 @@ import type { WorkspaceBootstrapFile } from "./workspace.js";
|
||||
|
||||
export type EmbeddedContextFile = { path: string; content: string };
|
||||
|
||||
const MAX_BOOTSTRAP_CHARS = 4000;
|
||||
const BOOTSTRAP_HEAD_CHARS = 2800;
|
||||
const BOOTSTRAP_TAIL_CHARS = 800;
|
||||
// ── Cross-provider thought_signature sanitization ──────────────────────────────
|
||||
// Claude's extended thinking feature generates thought_signature fields (message IDs
|
||||
// like "msg_abc123...") in content blocks. When these are sent to Google's Gemini API,
|
||||
// it expects Base64-encoded bytes and rejects Claude's format with a 400 error.
|
||||
// This function strips thought_signature fields to enable cross-provider session sharing.
|
||||
|
||||
function trimBootstrapContent(content: string, fileName: string): string {
|
||||
type ContentBlockWithSignature = {
|
||||
thought_signature?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips Claude-style thought_signature fields from content blocks.
|
||||
*
|
||||
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
||||
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
||||
*/
|
||||
export function stripThoughtSignatures<T>(content: T): T {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return content.map((block) => {
|
||||
if (!block || typeof block !== "object") return block;
|
||||
const rec = block as ContentBlockWithSignature;
|
||||
const signature = rec.thought_signature;
|
||||
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
|
||||
return block;
|
||||
}
|
||||
const { thought_signature: _signature, ...rest } = rec;
|
||||
return rest;
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
|
||||
const BOOTSTRAP_HEAD_RATIO = 0.7;
|
||||
const BOOTSTRAP_TAIL_RATIO = 0.2;
|
||||
|
||||
type TrimBootstrapResult = {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
maxChars: number;
|
||||
originalLength: number;
|
||||
};
|
||||
|
||||
export function resolveBootstrapMaxChars(cfg?: ClawdbotConfig): number {
|
||||
const raw = cfg?.agents?.defaults?.bootstrapMaxChars;
|
||||
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
return DEFAULT_BOOTSTRAP_MAX_CHARS;
|
||||
}
|
||||
|
||||
function trimBootstrapContent(
|
||||
content: string,
|
||||
fileName: string,
|
||||
maxChars: number,
|
||||
): TrimBootstrapResult {
|
||||
const trimmed = content.trimEnd();
|
||||
if (trimmed.length <= MAX_BOOTSTRAP_CHARS) return trimmed;
|
||||
if (trimmed.length <= maxChars) {
|
||||
return {
|
||||
content: trimmed,
|
||||
truncated: false,
|
||||
maxChars,
|
||||
originalLength: trimmed.length,
|
||||
};
|
||||
}
|
||||
|
||||
const head = trimmed.slice(0, BOOTSTRAP_HEAD_CHARS);
|
||||
const tail = trimmed.slice(-BOOTSTRAP_TAIL_CHARS);
|
||||
return [
|
||||
const headChars = Math.max(1, Math.floor(maxChars * BOOTSTRAP_HEAD_RATIO));
|
||||
const tailChars = Math.max(1, Math.floor(maxChars * BOOTSTRAP_TAIL_RATIO));
|
||||
const head = trimmed.slice(0, headChars);
|
||||
const tail = trimmed.slice(-tailChars);
|
||||
const contentWithMarker = [
|
||||
head,
|
||||
"",
|
||||
`[...truncated, read ${fileName} for full content...]`,
|
||||
"",
|
||||
tail,
|
||||
].join("\n");
|
||||
return {
|
||||
content: contentWithMarker,
|
||||
truncated: true,
|
||||
maxChars,
|
||||
originalLength: trimmed.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureSessionHeader(params: {
|
||||
@@ -138,7 +203,9 @@ export async function sanitizeSessionMessagesImages(
|
||||
}
|
||||
const content = assistantMsg.content;
|
||||
if (Array.isArray(content)) {
|
||||
const filteredContent = content.filter((block) => {
|
||||
// Strip thought_signature fields to enable cross-provider session sharing
|
||||
const strippedContent = stripThoughtSignatures(content);
|
||||
const filteredContent = strippedContent.filter((block) => {
|
||||
if (!block || typeof block !== "object") return true;
|
||||
const rec = block as { type?: unknown; text?: unknown };
|
||||
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
||||
@@ -221,7 +288,9 @@ export function sanitizeGoogleTurnOrdering(
|
||||
|
||||
export function buildBootstrapContextFiles(
|
||||
files: WorkspaceBootstrapFile[],
|
||||
opts?: { warn?: (message: string) => void; maxChars?: number },
|
||||
): EmbeddedContextFile[] {
|
||||
const maxChars = opts?.maxChars ?? DEFAULT_BOOTSTRAP_MAX_CHARS;
|
||||
const result: EmbeddedContextFile[] = [];
|
||||
for (const file of files) {
|
||||
if (file.missing) {
|
||||
@@ -231,11 +300,20 @@ export function buildBootstrapContextFiles(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const trimmed = trimBootstrapContent(file.content ?? "", file.name);
|
||||
if (!trimmed) continue;
|
||||
const trimmed = trimBootstrapContent(
|
||||
file.content ?? "",
|
||||
file.name,
|
||||
maxChars,
|
||||
);
|
||||
if (!trimmed.content) continue;
|
||||
if (trimmed.truncated) {
|
||||
opts?.warn?.(
|
||||
`workspace bootstrap file ${file.name} is ${trimmed.originalLength} chars (limit ${trimmed.maxChars}); truncating in injected context`,
|
||||
);
|
||||
}
|
||||
result.push({
|
||||
path: file.name,
|
||||
content: trimmed,
|
||||
content: trimmed.content,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -361,6 +439,9 @@ const ERROR_PATTERNS = {
|
||||
"token has expired",
|
||||
/\b401\b/,
|
||||
/\b403\b/,
|
||||
// Credential validation failures should trigger fallback (#761)
|
||||
"no credentials found",
|
||||
"no api key found",
|
||||
],
|
||||
format: [
|
||||
"invalid_request_error",
|
||||
|
||||
@@ -207,11 +207,11 @@ describe("resolveExtraParams", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for openai/gpt-4 with no config", () => {
|
||||
it("returns undefined for openai/gpt-5-nano with no config", () => {
|
||||
const result = resolveExtraParams({
|
||||
cfg: undefined,
|
||||
provider: "openai",
|
||||
modelId: "gpt-4",
|
||||
modelId: "gpt-5-nano",
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
@@ -223,7 +223,7 @@ describe("resolveExtraParams", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": {
|
||||
"openai/gpt-5-nano": {
|
||||
params: {
|
||||
logprobs: true,
|
||||
top_logprobs: 5,
|
||||
@@ -234,7 +234,7 @@ describe("resolveExtraParams", () => {
|
||||
},
|
||||
},
|
||||
provider: "openai",
|
||||
modelId: "gpt-4",
|
||||
modelId: "gpt-5-nano",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
isRateLimitAssistantError,
|
||||
isTimeoutErrorMessage,
|
||||
pickFallbackThinkingLevel,
|
||||
resolveBootstrapMaxChars,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
sanitizeSessionMessagesImages,
|
||||
validateAnthropicTurns,
|
||||
@@ -320,6 +321,12 @@ function buildContextPruningExtension(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCompactionMode(cfg?: ClawdbotConfig): "default" | "safeguard" {
|
||||
return cfg?.agents?.defaults?.compaction?.mode === "safeguard"
|
||||
? "safeguard"
|
||||
: "default";
|
||||
}
|
||||
|
||||
function buildEmbeddedExtensionPaths(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
sessionManager: SessionManager;
|
||||
@@ -328,6 +335,9 @@ function buildEmbeddedExtensionPaths(params: {
|
||||
model: Model<Api> | undefined;
|
||||
}): string[] {
|
||||
const paths = [resolvePiExtensionPath("transcript-sanitize")];
|
||||
if (resolveCompactionMode(params.cfg) === "safeguard") {
|
||||
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
||||
}
|
||||
const pruning = buildContextPruningExtension(params);
|
||||
if (pruning.additionalExtensionPaths) {
|
||||
paths.push(...pruning.additionalExtensionPaths);
|
||||
@@ -416,6 +426,85 @@ type EmbeddedPiQueueHandle = {
|
||||
|
||||
const log = createSubsystemLogger("agent/embedded");
|
||||
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
||||
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||
"patternProperties",
|
||||
"additionalProperties",
|
||||
"$schema",
|
||||
"$id",
|
||||
"$ref",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"examples",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"multipleOf",
|
||||
"pattern",
|
||||
"format",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"minProperties",
|
||||
"maxProperties",
|
||||
]);
|
||||
|
||||
function findUnsupportedSchemaKeywords(
|
||||
schema: unknown,
|
||||
path: string,
|
||||
): string[] {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.flatMap((item, index) =>
|
||||
findUnsupportedSchemaKeywords(item, `${path}[${index}]`),
|
||||
);
|
||||
}
|
||||
const record = schema as Record<string, unknown>;
|
||||
const violations: string[] = [];
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS.has(key)) {
|
||||
violations.push(`${path}.${key}`);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
violations.push(
|
||||
...findUnsupportedSchemaKeywords(value, `${path}.${key}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function logToolSchemasForGoogle(params: {
|
||||
tools: AgentTool[];
|
||||
provider: string;
|
||||
}) {
|
||||
if (
|
||||
params.provider !== "google-antigravity" &&
|
||||
params.provider !== "google-gemini-cli"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
|
||||
log.info("google tool schema snapshot", {
|
||||
provider: params.provider,
|
||||
toolCount: params.tools.length,
|
||||
tools: toolNames,
|
||||
});
|
||||
for (const [index, tool] of params.tools.entries()) {
|
||||
const violations = findUnsupportedSchemaKeywords(
|
||||
tool.parameters,
|
||||
`${tool.name}.parameters`,
|
||||
);
|
||||
if (violations.length > 0) {
|
||||
log.warn("google tool schema has unsupported keywords", {
|
||||
index,
|
||||
tool: tool.name,
|
||||
violations: violations.slice(0, 12),
|
||||
violationCount: violations.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerUnhandledRejectionHandler((reason) => {
|
||||
const message = describeUnknownError(reason);
|
||||
@@ -957,7 +1046,7 @@ export function resolveEmbeddedSessionLane(key: string) {
|
||||
}
|
||||
|
||||
function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
|
||||
// pi-agent-core supports "xhigh" too; Clawdbot doesn't surface it for now.
|
||||
// pi-agent-core supports "xhigh"; Clawdbot enables it for specific models.
|
||||
if (!level) return "off";
|
||||
return level;
|
||||
}
|
||||
@@ -1152,7 +1241,12 @@ export async function compactEmbeddedPiSession(params: {
|
||||
await loadWorkspaceBootstrapFiles(effectiveWorkspace),
|
||||
params.sessionKey ?? params.sessionId,
|
||||
);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
|
||||
maxChars: resolveBootstrapMaxChars(params.config),
|
||||
warn: (message) =>
|
||||
log.warn(`${message} (sessionKey=${sessionLabel})`),
|
||||
});
|
||||
const runAbortController = new AbortController();
|
||||
const tools = createClawdbotCodingTools({
|
||||
exec: {
|
||||
@@ -1172,6 +1266,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||
// No currentChannelId/currentThreadTs for compaction - not in message context
|
||||
});
|
||||
logToolSchemasForGoogle({ tools, provider });
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeProvider = normalizeMessageProvider(
|
||||
params.messageProvider,
|
||||
@@ -1377,6 +1472,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
}) => void | Promise<void>;
|
||||
onAssistantMessageStart?: () => void | Promise<void>;
|
||||
onBlockReply?: (payload: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
@@ -1584,7 +1680,12 @@ export async function runEmbeddedPiAgent(params: {
|
||||
await loadWorkspaceBootstrapFiles(effectiveWorkspace),
|
||||
params.sessionKey ?? params.sessionId,
|
||||
);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
|
||||
maxChars: resolveBootstrapMaxChars(params.config),
|
||||
warn: (message) =>
|
||||
log.warn(`${message} (sessionKey=${sessionLabel})`),
|
||||
});
|
||||
// Tool schemas must be provider-compatible (OpenAI requires top-level `type: "object"`).
|
||||
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
|
||||
const tools = createClawdbotCodingTools({
|
||||
@@ -1608,6 +1709,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
});
|
||||
logToolSchemasForGoogle({ tools, provider });
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
host: machineName,
|
||||
@@ -1763,6 +1865,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
blockReplyBreak: params.blockReplyBreak,
|
||||
blockReplyChunking: params.blockReplyChunking,
|
||||
onPartialReply: params.onPartialReply,
|
||||
onAssistantMessageStart: params.onAssistantMessageStart,
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
enforceFinalTag: params.enforceFinalTag,
|
||||
});
|
||||
|
||||
@@ -146,7 +146,7 @@ function extractMessagingToolSend(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function subscribeEmbeddedPiSession(params: {
|
||||
export type SubscribeEmbeddedPiSessionParams = {
|
||||
session: AgentSession;
|
||||
runId: string;
|
||||
verboseLevel?: "off" | "on";
|
||||
@@ -173,12 +173,17 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
}) => void | Promise<void>;
|
||||
onAssistantMessageStart?: () => void | Promise<void>;
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void;
|
||||
enforceFinalTag?: boolean;
|
||||
}) {
|
||||
};
|
||||
|
||||
export function subscribeEmbeddedPiSession(
|
||||
params: SubscribeEmbeddedPiSessionParams,
|
||||
) {
|
||||
const assistantTexts: string[] = [];
|
||||
const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
|
||||
const toolMetaById = new Map<string, string | undefined>();
|
||||
@@ -492,6 +497,8 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
// may deliver late text_end updates after message_end, which would
|
||||
// otherwise re-trigger block replies.
|
||||
resetAssistantMessageState(assistantTexts.length);
|
||||
// Use assistant message_start as the earliest "writing" signal for typing.
|
||||
void params.onAssistantMessageStart?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
FileOperations,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
|
||||
const MAX_CHUNK_RATIO = 0.4;
|
||||
const FALLBACK_SUMMARY =
|
||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||
const TURN_PREFIX_INSTRUCTIONS =
|
||||
"This summary covers the prefix of a split turn. Focus on the original request," +
|
||||
" early progress, and any details needed to understand the retained suffix.";
|
||||
|
||||
function computeFileLists(fileOps: FileOperations): {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
} {
|
||||
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
||||
const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
||||
const modifiedFiles = [...modified].sort();
|
||||
return { readFiles, modifiedFiles };
|
||||
}
|
||||
|
||||
function formatFileOperations(
|
||||
readFiles: string[],
|
||||
modifiedFiles: string[],
|
||||
): string {
|
||||
const sections: string[] = [];
|
||||
if (readFiles.length > 0) {
|
||||
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
||||
}
|
||||
if (modifiedFiles.length > 0) {
|
||||
sections.push(
|
||||
`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`,
|
||||
);
|
||||
}
|
||||
if (sections.length === 0) return "";
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
function chunkMessages(
|
||||
messages: AgentMessage[],
|
||||
maxTokens: number,
|
||||
): AgentMessage[][] {
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const chunks: AgentMessage[][] = [];
|
||||
let currentChunk: AgentMessage[] = [];
|
||||
let currentTokens = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
const messageTokens = estimateTokens(message);
|
||||
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentTokens = 0;
|
||||
}
|
||||
|
||||
currentChunk.push(message);
|
||||
currentTokens += messageTokens;
|
||||
|
||||
if (messageTokens > maxTokens) {
|
||||
// Split oversized messages to avoid unbounded chunk growth.
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentTokens = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function summarizeChunks(params: {
|
||||
messages: AgentMessage[];
|
||||
model: NonNullable<ExtensionContext["model"]>;
|
||||
apiKey: string;
|
||||
signal: AbortSignal;
|
||||
reserveTokens: number;
|
||||
maxChunkTokens: number;
|
||||
customInstructions?: string;
|
||||
previousSummary?: string;
|
||||
}): Promise<string> {
|
||||
if (params.messages.length === 0) {
|
||||
return params.previousSummary ?? "No prior history.";
|
||||
}
|
||||
|
||||
const chunks = chunkMessages(params.messages, params.maxChunkTokens);
|
||||
let summary = params.previousSummary;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
summary = await generateSummary(
|
||||
chunk,
|
||||
params.model,
|
||||
params.reserveTokens,
|
||||
params.apiKey,
|
||||
params.signal,
|
||||
params.customInstructions,
|
||||
summary,
|
||||
);
|
||||
}
|
||||
|
||||
return summary ?? "No prior history.";
|
||||
}
|
||||
|
||||
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
api.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, customInstructions, signal } = event;
|
||||
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
|
||||
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
|
||||
const fallbackSummary = `${FALLBACK_SUMMARY}${fileOpsSummary}`;
|
||||
|
||||
const model = ctx.model;
|
||||
if (!model) {
|
||||
return {
|
||||
compaction: {
|
||||
summary: fallbackSummary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { readFiles, modifiedFiles },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
compaction: {
|
||||
summary: fallbackSummary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { readFiles, modifiedFiles },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const contextWindowTokens = Math.max(
|
||||
1,
|
||||
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
||||
);
|
||||
const maxChunkTokens = Math.max(
|
||||
1,
|
||||
Math.floor(contextWindowTokens * MAX_CHUNK_RATIO),
|
||||
);
|
||||
const reserveTokens = Math.max(
|
||||
1,
|
||||
Math.floor(preparation.settings.reserveTokens),
|
||||
);
|
||||
|
||||
const historySummary = await summarizeChunks({
|
||||
messages: preparation.messagesToSummarize,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
customInstructions,
|
||||
previousSummary: preparation.previousSummary,
|
||||
});
|
||||
|
||||
let summary = historySummary;
|
||||
if (
|
||||
preparation.isSplitTurn &&
|
||||
preparation.turnPrefixMessages.length > 0
|
||||
) {
|
||||
const prefixSummary = await summarizeChunks({
|
||||
messages: preparation.turnPrefixMessages,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
||||
});
|
||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||
}
|
||||
|
||||
summary += fileOpsSummary;
|
||||
|
||||
return {
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { readFiles, modifiedFiles },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Compaction summarization failed; truncating history: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return {
|
||||
compaction: {
|
||||
summary: fallbackSummary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { readFiles, modifiedFiles },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
+167
-1
@@ -5,6 +5,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
|
||||
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||
|
||||
@@ -16,7 +17,7 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(schema.anyOf).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges properties for union tool schemas", () => {
|
||||
it("keeps browser tool schema properties after normalization", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const browser = tools.find((tool) => tool.name === "browser");
|
||||
expect(browser).toBeDefined();
|
||||
@@ -103,6 +104,31 @@ describe("createClawdbotCodingTools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops null-only union variants without flattening other unions", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
type: "object",
|
||||
properties: {
|
||||
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
count: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
}) as {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const parentId = cleaned.properties?.parentId as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
expect(parentId?.anyOf).toBeUndefined();
|
||||
expect(parentId?.oneOf).toBeUndefined();
|
||||
expect(parentId?.type).toBe("string");
|
||||
|
||||
const count = cleaned.properties?.count as
|
||||
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
|
||||
| undefined;
|
||||
expect(count?.anyOf).toBeUndefined();
|
||||
expect(Array.isArray(count?.oneOf)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves action enums in normalized schemas", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const toolNames = [
|
||||
@@ -241,6 +267,95 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
const offenders: Array<{
|
||||
name: string;
|
||||
keyword: string;
|
||||
path: string;
|
||||
}> = [];
|
||||
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
|
||||
|
||||
const walk = (value: unknown, path: string, name: string): void => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const [index, entry] of value.entries()) {
|
||||
walk(entry, `${path}[${index}]`, name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object") return;
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
const nextPath = path ? `${path}.${key}` : key;
|
||||
if (keywords.has(key)) {
|
||||
offenders.push({ name, keyword: key, path: nextPath });
|
||||
}
|
||||
walk(entry, nextPath, name);
|
||||
}
|
||||
};
|
||||
|
||||
for (const tool of tools) {
|
||||
walk(tool.parameters, "", tool.name);
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps raw core tool schemas union-free", () => {
|
||||
const tools = createClawdbotTools();
|
||||
const coreTools = new Set([
|
||||
"browser",
|
||||
"canvas",
|
||||
"nodes",
|
||||
"cron",
|
||||
"message",
|
||||
"gateway",
|
||||
"agents_list",
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"session_status",
|
||||
"memory_search",
|
||||
"memory_get",
|
||||
"image",
|
||||
]);
|
||||
const offenders: Array<{
|
||||
name: string;
|
||||
keyword: string;
|
||||
path: string;
|
||||
}> = [];
|
||||
const keywords = new Set(["anyOf", "oneOf", "allOf"]);
|
||||
|
||||
const walk = (value: unknown, path: string, name: string): void => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const [index, entry] of value.entries()) {
|
||||
walk(entry, `${path}[${index}]`, name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object") return;
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
const nextPath = path ? `${path}.${key}` : key;
|
||||
if (keywords.has(key)) {
|
||||
offenders.push({ name, keyword: key, path: nextPath });
|
||||
}
|
||||
walk(entry, nextPath, name);
|
||||
}
|
||||
};
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!coreTools.has(tool.name)) continue;
|
||||
walk(tool.parameters, "", tool.name);
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not expose provider-specific message tools", () => {
|
||||
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
@@ -492,6 +607,57 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
|
||||
});
|
||||
|
||||
it("applies tool profiles before allow/deny policies", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { profile: "messaging" } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("sessions_send")).toBe(true);
|
||||
expect(names.has("sessions_spawn")).toBe(false);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
|
||||
it("expands group shorthands in global tool policy", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { allow: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(true);
|
||||
expect(names.has("write")).toBe(true);
|
||||
expect(names.has("edit")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("browser")).toBe(false);
|
||||
});
|
||||
|
||||
it("expands group shorthands in global tool deny policy", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: { tools: { deny: ["group:fs"] } },
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("read")).toBe(false);
|
||||
expect(names.has("write")).toBe(false);
|
||||
expect(names.has("edit")).toBe(false);
|
||||
expect(names.has("exec")).toBe(true);
|
||||
});
|
||||
|
||||
it("lets agent profiles override global profiles", () => {
|
||||
const tools = createClawdbotCodingTools({
|
||||
sessionKey: "agent:work:main",
|
||||
config: {
|
||||
tools: { profile: "coding" },
|
||||
agents: {
|
||||
list: [{ id: "work", tools: { profile: "messaging" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const names = new Set(tools.map((tool) => tool.name));
|
||||
expect(names.has("message")).toBe(true);
|
||||
expect(names.has("exec")).toBe(false);
|
||||
expect(names.has("read")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
|
||||
const tools = createClawdbotCodingTools();
|
||||
|
||||
|
||||
+26
-23
@@ -28,6 +28,11 @@ import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
|
||||
import { sanitizeToolResultImages } from "./tool-images.js";
|
||||
import {
|
||||
expandToolGroups,
|
||||
normalizeToolName,
|
||||
resolveToolProfilePolicy,
|
||||
} from "./tool-policy.js";
|
||||
|
||||
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
|
||||
// to normalize payloads and sanitize oversized images before they hit providers.
|
||||
@@ -291,21 +296,6 @@ function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
|
||||
return cleanSchemaForGemini(schema);
|
||||
}
|
||||
|
||||
const TOOL_NAME_ALIASES: Record<string, string> = {
|
||||
bash: "exec",
|
||||
"apply-patch": "apply_patch",
|
||||
};
|
||||
|
||||
function normalizeToolName(name: string) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
return TOOL_NAME_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
function normalizeToolNames(list?: string[]) {
|
||||
if (!list) return [];
|
||||
return list.map(normalizeToolName).filter(Boolean);
|
||||
}
|
||||
|
||||
function isOpenAIProvider(provider?: string) {
|
||||
const normalized = provider?.trim().toLowerCase();
|
||||
return normalized === "openai" || normalized === "openai-codex";
|
||||
@@ -357,8 +347,8 @@ function isToolAllowedByPolicyName(
|
||||
policy?: SandboxToolPolicy,
|
||||
): boolean {
|
||||
if (!policy) return true;
|
||||
const deny = new Set(normalizeToolNames(policy.deny));
|
||||
const allowRaw = normalizeToolNames(policy.allow);
|
||||
const deny = new Set(expandToolGroups(policy.deny));
|
||||
const allowRaw = expandToolGroups(policy.allow);
|
||||
const allow = allowRaw.length > 0 ? new Set(allowRaw) : null;
|
||||
const normalized = normalizeToolName(name);
|
||||
if (deny.has(normalized)) return false;
|
||||
@@ -391,11 +381,15 @@ function resolveEffectiveToolPolicy(params: {
|
||||
: undefined;
|
||||
const agentTools = agentConfig?.tools;
|
||||
const hasAgentToolPolicy =
|
||||
Array.isArray(agentTools?.allow) || Array.isArray(agentTools?.deny);
|
||||
Array.isArray(agentTools?.allow) ||
|
||||
Array.isArray(agentTools?.deny) ||
|
||||
typeof agentTools?.profile === "string";
|
||||
const globalTools = params.config?.tools;
|
||||
const profile = agentTools?.profile ?? globalTools?.profile;
|
||||
return {
|
||||
agentId,
|
||||
policy: hasAgentToolPolicy ? agentTools : globalTools,
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -703,10 +697,15 @@ export function createClawdbotCodingTools(options?: {
|
||||
}): AnyAgentTool[] {
|
||||
const execToolName = "exec";
|
||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||
const { agentId, policy: effectiveToolsPolicy } = resolveEffectiveToolPolicy({
|
||||
const {
|
||||
agentId,
|
||||
policy: effectiveToolsPolicy,
|
||||
profile,
|
||||
} = resolveEffectiveToolPolicy({
|
||||
config: options?.config,
|
||||
sessionKey: options?.sessionKey,
|
||||
});
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
const scopeKey =
|
||||
options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
|
||||
const subagentPolicy =
|
||||
@@ -714,6 +713,7 @@ export function createClawdbotCodingTools(options?: {
|
||||
? resolveSubagentToolPolicy(options.config)
|
||||
: undefined;
|
||||
const allowBackground = isToolAllowedByPolicies("process", [
|
||||
profilePolicy,
|
||||
effectiveToolsPolicy,
|
||||
sandbox?.tools,
|
||||
subagentPolicy,
|
||||
@@ -829,12 +829,15 @@ export function createClawdbotCodingTools(options?: {
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
}),
|
||||
];
|
||||
const toolsFiltered = effectiveToolsPolicy
|
||||
? filterToolsByPolicy(tools, effectiveToolsPolicy)
|
||||
const toolsFiltered = profilePolicy
|
||||
? filterToolsByPolicy(tools, profilePolicy)
|
||||
: tools;
|
||||
const sandboxed = sandbox
|
||||
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
|
||||
const policyFiltered = effectiveToolsPolicy
|
||||
? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
|
||||
: toolsFiltered;
|
||||
const sandboxed = sandbox
|
||||
? filterToolsByPolicy(policyFiltered, sandbox.tools)
|
||||
: policyFiltered;
|
||||
const subagentFiltered = subagentPolicy
|
||||
? filterToolsByPolicy(sandboxed, subagentPolicy)
|
||||
: sandboxed;
|
||||
|
||||
+5
-52
@@ -33,6 +33,7 @@ import {
|
||||
resolveSessionAgentId,
|
||||
} from "./agent-scope.js";
|
||||
import { syncSkillsToWorkspace } from "./skills.js";
|
||||
import { expandToolGroups } from "./tool-policy.js";
|
||||
import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
@@ -239,58 +240,10 @@ const BROWSER_BRIDGES = new Map<
|
||||
{ bridge: BrowserBridge; containerName: string }
|
||||
>();
|
||||
|
||||
function normalizeToolList(values?: string[]) {
|
||||
if (!values) return [];
|
||||
return values
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((value) => value.toLowerCase());
|
||||
}
|
||||
|
||||
const TOOL_GROUPS: Record<string, string[]> = {
|
||||
// NOTE: Keep canonical (lowercase) tool names here.
|
||||
"group:memory": ["memory_search", "memory_get"],
|
||||
// Basic workspace/file tools
|
||||
"group:fs": ["read", "write", "edit", "apply_patch"],
|
||||
// Session management tools
|
||||
"group:sessions": [
|
||||
"sessions_list",
|
||||
"sessions_history",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"session_status",
|
||||
],
|
||||
// Host/runtime execution tools
|
||||
"group:runtime": ["exec", "bash", "process"],
|
||||
};
|
||||
|
||||
function expandToolGroupEntry(entry: string): string[] {
|
||||
const raw = entry.trim();
|
||||
if (!raw) return [];
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
const group = TOOL_GROUPS[lower];
|
||||
if (group) return group;
|
||||
return [raw];
|
||||
}
|
||||
|
||||
function expandToolGroups(values?: string[]): string[] {
|
||||
if (!values) return [];
|
||||
const out: string[] = [];
|
||||
for (const value of values) {
|
||||
for (const expanded of expandToolGroupEntry(value)) {
|
||||
const trimmed = expanded.trim();
|
||||
if (!trimmed) continue;
|
||||
out.push(trimmed);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isToolAllowed(policy: SandboxToolPolicy, name: string) {
|
||||
const deny = new Set(normalizeToolList(expandToolGroups(policy.deny)));
|
||||
const deny = new Set(expandToolGroups(policy.deny));
|
||||
if (deny.has(name.toLowerCase())) return false;
|
||||
const allow = normalizeToolList(expandToolGroups(policy.allow));
|
||||
const allow = expandToolGroups(policy.allow);
|
||||
if (allow.length === 0) return true;
|
||||
return allow.includes(name.toLowerCase());
|
||||
}
|
||||
@@ -687,8 +640,8 @@ export function formatSandboxToolPolicyBlockedMessage(params: {
|
||||
});
|
||||
if (!runtime.sandboxed) return undefined;
|
||||
|
||||
const deny = new Set(normalizeToolList(runtime.toolPolicy.deny));
|
||||
const allow = normalizeToolList(runtime.toolPolicy.allow);
|
||||
const deny = new Set(expandToolGroups(runtime.toolPolicy.deny));
|
||||
const allow = expandToolGroups(runtime.toolPolicy.allow);
|
||||
const allowSet = allow.length > 0 ? new Set(allow) : null;
|
||||
const blockedByDeny = deny.has(tool);
|
||||
const blockedByAllow = allowSet ? !allowSet.has(tool) : false;
|
||||
|
||||
@@ -67,6 +67,39 @@ function tryFlattenLiteralAnyOf(
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNullSchema(variant: unknown): boolean {
|
||||
if (!variant || typeof variant !== "object" || Array.isArray(variant)) {
|
||||
return false;
|
||||
}
|
||||
const record = variant as Record<string, unknown>;
|
||||
if ("const" in record && record.const === null) return true;
|
||||
if (Array.isArray(record.enum) && record.enum.length === 1) {
|
||||
return record.enum[0] === null;
|
||||
}
|
||||
const typeValue = record.type;
|
||||
if (typeValue === "null") return true;
|
||||
if (
|
||||
Array.isArray(typeValue) &&
|
||||
typeValue.length === 1 &&
|
||||
typeValue[0] === "null"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripNullVariants(variants: unknown[]): {
|
||||
variants: unknown[];
|
||||
stripped: boolean;
|
||||
} {
|
||||
if (variants.length === 0) return { variants, stripped: false };
|
||||
const nonNull = variants.filter((variant) => !isNullSchema(variant));
|
||||
return {
|
||||
variants: nonNull,
|
||||
stripped: nonNull.length !== variants.length,
|
||||
};
|
||||
}
|
||||
|
||||
type SchemaDefs = Map<string, unknown>;
|
||||
|
||||
function extendSchemaDefs(
|
||||
@@ -166,9 +199,24 @@ function cleanSchemaForGeminiWithDefs(
|
||||
|
||||
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
|
||||
const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf);
|
||||
let cleanedAnyOf = hasAnyOf
|
||||
? (obj.anyOf as unknown[]).map((variant) =>
|
||||
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
||||
)
|
||||
: undefined;
|
||||
let cleanedOneOf = hasOneOf
|
||||
? (obj.oneOf as unknown[]).map((variant) =>
|
||||
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (hasAnyOf) {
|
||||
const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]);
|
||||
const { variants: nonNullVariants, stripped } = stripNullVariants(
|
||||
cleanedAnyOf ?? [],
|
||||
);
|
||||
if (stripped) cleanedAnyOf = nonNullVariants;
|
||||
|
||||
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
|
||||
if (flattened) {
|
||||
const result: Record<string, unknown> = {
|
||||
type: flattened.type,
|
||||
@@ -179,10 +227,28 @@ function cleanSchemaForGeminiWithDefs(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (stripped && nonNullVariants.length === 1) {
|
||||
const lone = nonNullVariants[0];
|
||||
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
|
||||
const result: Record<string, unknown> = {
|
||||
...(lone as Record<string, unknown>),
|
||||
};
|
||||
for (const key of ["description", "title", "default"]) {
|
||||
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return lone;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]);
|
||||
const { variants: nonNullVariants, stripped } = stripNullVariants(
|
||||
cleanedOneOf ?? [],
|
||||
);
|
||||
if (stripped) cleanedOneOf = nonNullVariants;
|
||||
|
||||
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
|
||||
if (flattened) {
|
||||
const result: Record<string, unknown> = {
|
||||
type: flattened.type,
|
||||
@@ -193,6 +259,19 @@ function cleanSchemaForGeminiWithDefs(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (stripped && nonNullVariants.length === 1) {
|
||||
const lone = nonNullVariants[0];
|
||||
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
|
||||
const result: Record<string, unknown> = {
|
||||
...(lone as Record<string, unknown>),
|
||||
};
|
||||
for (const key of ["description", "title", "default"]) {
|
||||
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return lone;
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned: Record<string, unknown> = {};
|
||||
@@ -206,6 +285,15 @@ function cleanSchemaForGeminiWithDefs(
|
||||
}
|
||||
|
||||
if (key === "type" && (hasAnyOf || hasOneOf)) continue;
|
||||
if (
|
||||
key === "type" &&
|
||||
Array.isArray(value) &&
|
||||
value.every((entry) => typeof entry === "string")
|
||||
) {
|
||||
const types = value.filter((entry) => entry !== "null");
|
||||
cleaned.type = types.length === 1 ? types[0] : types;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "properties" && value && typeof value === "object") {
|
||||
const props = value as Record<string, unknown>;
|
||||
@@ -218,13 +306,17 @@ function cleanSchemaForGeminiWithDefs(
|
||||
} else if (key === "items" && value && typeof value === "object") {
|
||||
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
|
||||
} else if (key === "anyOf" && Array.isArray(value)) {
|
||||
cleaned[key] = value.map((variant) =>
|
||||
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
||||
);
|
||||
cleaned[key] =
|
||||
cleanedAnyOf ??
|
||||
value.map((variant) =>
|
||||
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
||||
);
|
||||
} else if (key === "oneOf" && Array.isArray(value)) {
|
||||
cleaned[key] = value.map((variant) =>
|
||||
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
||||
);
|
||||
cleaned[key] =
|
||||
cleanedOneOf ??
|
||||
value.map((variant) =>
|
||||
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
||||
);
|
||||
} else if (key === "allOf" && Array.isArray(value)) {
|
||||
cleaned[key] = value.map((variant) =>
|
||||
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
type StringEnumOptions<T extends readonly string[]> = {
|
||||
description?: string;
|
||||
title?: string;
|
||||
default?: T[number];
|
||||
};
|
||||
|
||||
// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
|
||||
// Some providers reject anyOf in tool schemas; a flat string enum is safer.
|
||||
export function stringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options: StringEnumOptions<T> = {},
|
||||
) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function optionalStringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options: StringEnumOptions<T> = {},
|
||||
) {
|
||||
return Type.Optional(stringEnum(values, options));
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { ModelDefinitionConfig } from "../config/types.js";
|
||||
|
||||
export const SYNTHETIC_BASE_URL = "https://api.synthetic.new/anthropic";
|
||||
export const SYNTHETIC_DEFAULT_MODEL_ID = "hf:MiniMaxAI/MiniMax-M2.1";
|
||||
export const SYNTHETIC_DEFAULT_MODEL_REF = `synthetic/${SYNTHETIC_DEFAULT_MODEL_ID}`;
|
||||
export const SYNTHETIC_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
export const SYNTHETIC_MODEL_CATALOG = [
|
||||
{
|
||||
id: SYNTHETIC_DEFAULT_MODEL_ID,
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 192000,
|
||||
maxTokens: 65536,
|
||||
},
|
||||
{
|
||||
id: "hf:moonshotai/Kimi-K2-Thinking",
|
||||
name: "Kimi K2 Thinking",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:zai-org/GLM-4.7",
|
||||
name: "GLM-4.7",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 198000,
|
||||
maxTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "hf:deepseek-ai/DeepSeek-R1-0528",
|
||||
name: "DeepSeek R1 0528",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:deepseek-ai/DeepSeek-V3-0324",
|
||||
name: "DeepSeek V3 0324",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:deepseek-ai/DeepSeek-V3.1",
|
||||
name: "DeepSeek V3.1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:deepseek-ai/DeepSeek-V3.1-Terminus",
|
||||
name: "DeepSeek V3.1 Terminus",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:deepseek-ai/DeepSeek-V3.2",
|
||||
name: "DeepSeek V3.2",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 159000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:meta-llama/Llama-3.3-70B-Instruct",
|
||||
name: "Llama 3.3 70B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
|
||||
name: "Llama 4 Maverick 17B 128E Instruct FP8",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 524000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:moonshotai/Kimi-K2-Instruct-0905",
|
||||
name: "Kimi K2 Instruct 0905",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:openai/gpt-oss-120b",
|
||||
name: "GPT OSS 120B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:Qwen/Qwen3-235B-A22B-Instruct-2507",
|
||||
name: "Qwen3 235B A22B Instruct 2507",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
name: "Qwen3 Coder 480B A35B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:Qwen/Qwen3-VL-235B-A22B-Instruct",
|
||||
name: "Qwen3 VL 235B A22B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 250000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:zai-org/GLM-4.5",
|
||||
name: "GLM-4.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "hf:zai-org/GLM-4.6",
|
||||
name: "GLM-4.6",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 198000,
|
||||
maxTokens: 128000,
|
||||
},
|
||||
{
|
||||
id: "hf:deepseek-ai/DeepSeek-V3",
|
||||
name: "DeepSeek V3",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "hf:Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||
name: "Qwen3 235B A22B Thinking 2507",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 256000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type SyntheticCatalogEntry = (typeof SYNTHETIC_MODEL_CATALOG)[number];
|
||||
|
||||
export function buildSyntheticModelDefinition(
|
||||
entry: SyntheticCatalogEntry,
|
||||
): ModelDefinitionConfig {
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
reasoning: entry.reasoning,
|
||||
input: [...entry.input],
|
||||
cost: SYNTHETIC_DEFAULT_COST,
|
||||
contextWindow: entry.contextWindow,
|
||||
maxTokens: entry.maxTokens,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user