Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad11051c3 | ||
|
|
3b6b8e7696 | ||
|
|
f1b023dc6c |
No files matched your search
@@ -6,18 +6,9 @@
|
||||
|
||||
### Breaking
|
||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
||||
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
|
||||
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
|
||||
|
||||
### Fixes
|
||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
|
||||
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
|
||||
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
|
||||
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
|
||||
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
|
||||
- Docs: add group chat participation guidance to the AGENTS template.
|
||||
- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use).
|
||||
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
|
||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
|
||||
@@ -30,7 +21,6 @@
|
||||
- Model: `/model list` is an alias for `/model`.
|
||||
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
|
||||
- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
|
||||
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
|
||||
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
|
||||
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
|
||||
- Control UI: show a reading indicator bubble while the assistant is responding.
|
||||
@@ -45,13 +35,8 @@
|
||||
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
|
||||
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
|
||||
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
|
||||
- Discord: surface missing-permission hints (muted/role overrides) when replies fail.
|
||||
- Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235.
|
||||
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
|
||||
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks @regenrek for PR #242.
|
||||
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
|
||||
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
|
||||
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
|
||||
|
||||
### Maintenance
|
||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||
|
||||
@@ -209,7 +209,6 @@ Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — health + session info (group shows activation mode)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high
|
||||
- `/verbose on|off`
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"id",
|
||||
"jobId",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
@@ -136,10 +136,10 @@
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["id"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["id"] },
|
||||
"run": { "label": "run", "detailKeys": ["id"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["id"] },
|
||||
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -323,9 +323,6 @@ struct CronJobEditor: View {
|
||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||
Text("discord").tag(GatewayAgentChannel.discord)
|
||||
Text("slack").tag(GatewayAgentChannel.slack)
|
||||
Text("signal").tag(GatewayAgentChannel.signal)
|
||||
Text("imessage").tag(GatewayAgentChannel.imessage)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
@@ -10,9 +10,6 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case slack
|
||||
case signal
|
||||
case imessage
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"id",
|
||||
"jobId",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
@@ -136,10 +136,10 @@
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["id"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["id"] },
|
||||
"run": { "label": "run", "detailKeys": ["id"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["id"] },
|
||||
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -147,7 +147,6 @@ Example:
|
||||
- Session files: `~/.clawdbot/sessions/{{SessionId}}.jsonl`
|
||||
- Session metadata (token usage, last route, etc): `~/.clawdbot/sessions/sessions.json` (legacy: `~/.clawdbot/sessions.json`)
|
||||
- `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, the agent replies with a short hello to confirm the reset.
|
||||
- `/compact [instructions]` compacts the session context and reports the remaining context budget.
|
||||
|
||||
## Heartbeats (proactive mode)
|
||||
|
||||
|
||||
+42
-65
@@ -91,40 +91,18 @@ Env var equivalent:
|
||||
|
||||
### Auth storage (OAuth + API keys)
|
||||
|
||||
Clawdbot stores **auth profiles** (OAuth + API keys) in:
|
||||
- `~/.clawdbot/agent/auth-profiles.json`
|
||||
|
||||
Legacy OAuth imports:
|
||||
Clawdbot stores **OAuth credentials** in:
|
||||
- `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)
|
||||
|
||||
The embedded Pi agent maintains a runtime cache at:
|
||||
- `~/.clawdbot/agent/auth.json` (managed automatically; don’t edit manually)
|
||||
Clawdbot stores **API keys** in the agent auth store:
|
||||
- `~/.clawdbot/agent/auth.json`
|
||||
|
||||
Overrides:
|
||||
- OAuth dir (legacy import only): `CLAWDBOT_OAUTH_DIR`
|
||||
- OAuth dir: `CLAWDBOT_OAUTH_DIR`
|
||||
- Agent dir: `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)
|
||||
|
||||
On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`.
|
||||
|
||||
### `auth`
|
||||
|
||||
Optional metadata for auth profiles. This does **not** store secrets; it maps
|
||||
profile IDs to a provider + mode (and optional email) and defines the provider
|
||||
rotation order used for failover.
|
||||
|
||||
```json5
|
||||
{
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" }
|
||||
},
|
||||
order: {
|
||||
anthropic: ["anthropic:default", "anthropic:work"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
On first use, Clawdbot imports `oauth.json` entries into `auth.json` so the embedded
|
||||
agent can use them. `oauth.json` remains the source of truth for OAuth refresh.
|
||||
|
||||
### `identity`
|
||||
|
||||
@@ -132,7 +110,7 @@ Optional agent identity used for defaults and UX. This is written by the macOS o
|
||||
|
||||
If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly):
|
||||
- `messages.responsePrefix` from `identity.emoji`
|
||||
- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
|
||||
- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups)
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -205,7 +183,6 @@ Group messages default to **require mention** (either metadata mention or regex
|
||||
**Mention types:**
|
||||
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`).
|
||||
- **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode.
|
||||
- Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`).
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -516,12 +493,14 @@ Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_V
|
||||
### `agent`
|
||||
|
||||
Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
||||
`agent.models` defines the configured model catalog (and acts as the allowlist for `/model`).
|
||||
`agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers.
|
||||
`agent.imageModel` is optional and is **only used if the primary model lacks image input**.
|
||||
`allowedModels` lets `/model` list/filter and enforce a per-session allowlist
|
||||
(omit to show the full catalog).
|
||||
`modelAliases` adds short names for `/model` (alias -> provider/model).
|
||||
`modelFallbacks` lists ordered fallback models to try when the default fails.
|
||||
`imageModel` selects an image-capable model for the `image` tool.
|
||||
`imageModelFallbacks` lists ordered fallback image models for the `image` tool.
|
||||
|
||||
Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model
|
||||
is already present in `agent.models`:
|
||||
Clawdbot also ships a few built-in `modelAliases` shorthands (when an `agent` section exists):
|
||||
|
||||
- `opus` -> `anthropic/claude-opus-4-5`
|
||||
- `sonnet` -> `anthropic/claude-sonnet-4-5`
|
||||
@@ -535,24 +514,23 @@ If you configure the same alias name (case-insensitive) yourself, your value win
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||
"openrouter/deepseek/deepseek-r1:free": {}
|
||||
},
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
fallbacks: [
|
||||
"openrouter/deepseek/deepseek-r1:free",
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
|
||||
]
|
||||
},
|
||||
imageModel: {
|
||||
primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
fallbacks: [
|
||||
"openrouter/google/gemini-2.0-flash-vision:free"
|
||||
]
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
allowedModels: [
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-1"
|
||||
],
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
Sonnet: "anthropic/claude-sonnet-4-1"
|
||||
},
|
||||
modelFallbacks: [
|
||||
"openrouter/deepseek/deepseek-r1:free",
|
||||
"openrouter/meta-llama/llama-3.3-70b-instruct:free"
|
||||
],
|
||||
imageModel: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
imageModelFallbacks: [
|
||||
"openrouter/google/gemini-2.0-flash-vision:free"
|
||||
],
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
elevatedDefault: "on",
|
||||
@@ -587,8 +565,8 @@ Block streaming:
|
||||
}
|
||||
```
|
||||
|
||||
`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
||||
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
|
||||
`agent.model` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
|
||||
If `modelAliases` is configured, you may also use the alias key (e.g. `Opus`).
|
||||
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
|
||||
deprecation fallback.
|
||||
Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
@@ -750,16 +728,11 @@ When `models.providers` is present, Clawdbot writes/merges a `models.json` into
|
||||
- default behavior: **merge** (keeps existing providers, overrides on name)
|
||||
- set `models.mode: "replace"` to overwrite the file contents
|
||||
|
||||
Select the model via `agent.model.primary` (provider/model).
|
||||
Select the model via `agent.model` (provider/model).
|
||||
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "custom-proxy/llama-3.1-8b" },
|
||||
models: {
|
||||
"custom-proxy/llama-3.1-8b": {}
|
||||
}
|
||||
},
|
||||
agent: { model: "custom-proxy/llama-3.1-8b" },
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
@@ -792,10 +765,14 @@ via **LM Studio** using the **Responses API**.
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "Minimax" }
|
||||
model: "Minimax",
|
||||
allowedModels: [
|
||||
"anthropic/claude-opus-4-5",
|
||||
"lmstudio/minimax-m2.1-gs32"
|
||||
],
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
Minimax: "lmstudio/minimax-m2.1-gs32"
|
||||
}
|
||||
},
|
||||
models: {
|
||||
|
||||
+1
-12
@@ -216,17 +216,6 @@ Retention:
|
||||
|
||||
Each log line includes (at minimum) job id, status/error, timing, and a `summary` string (systemEvent text for main jobs, and the last agent text output for isolated jobs).
|
||||
|
||||
## Compatibility policy (cron.add/cron.update)
|
||||
|
||||
To keep older clients working, the Gateway applies **best-effort normalization** for `cron.add` and `cron.update`:
|
||||
- Accepts wrapped payloads under `data` or `job` and unwraps them.
|
||||
- Infers `schedule.kind` from `atMs`, `everyMs`, or `expr` if missing.
|
||||
- Infers `payload.kind` from `text` (systemEvent) or `message` (agentTurn) if missing.
|
||||
- Defaults `wakeMode` to `"next-heartbeat"` when omitted.
|
||||
- Defaults `sessionTarget` based on payload kind (`systemEvent` → `"main"`, `agentTurn` → `"isolated"`).
|
||||
|
||||
Normalization is **compat-only**. New clients should send the full schema (including `kind`, `sessionTarget`, and `wakeMode`) to avoid ambiguity. Unknown fields are still rejected by schema validation.
|
||||
|
||||
## Gateway API
|
||||
|
||||
New methods (names can be bikeshed; `cron.*` is suggested):
|
||||
@@ -275,7 +264,7 @@ Add a `cron` command group (all commands should also support `--json` where sens
|
||||
- `--wake now|next-heartbeat`
|
||||
- payload flags (choose one):
|
||||
- `--system-event "<text>"`
|
||||
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord|slack|signal|imessage] [--to <dest>]`
|
||||
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord|signal|imessage] [--to <dest>]`
|
||||
|
||||
- `clawdbot cron edit <id> ...` (patch-by-flags, non-interactive)
|
||||
- `clawdbot cron rm <id>`
|
||||
|
||||
@@ -123,7 +123,6 @@ Example “single server, only allow me, only allow #help”:
|
||||
|
||||
Notes:
|
||||
- `requireMention: true` means the bot only replies when mentioned (recommended for shared channels).
|
||||
- `routing.groupChat.mentionPatterns` also count as mentions for guild messages.
|
||||
- If `channels` is present, any channel not listed is denied by default.
|
||||
|
||||
### 6) Verify it works
|
||||
|
||||
@@ -59,12 +59,6 @@ docker compose exec clawdbot-gateway node dist/index.js health --token "$CLAWDBO
|
||||
scripts/e2e/onboard-docker.sh
|
||||
```
|
||||
|
||||
### QR import smoke test (Docker)
|
||||
|
||||
```bash
|
||||
pnpm test:docker:qr
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Gateway bind defaults to `lan` for container use.
|
||||
|
||||
@@ -27,13 +27,8 @@ Doctor will:
|
||||
- Show the migration it applied.
|
||||
- Rewrite `~/.clawdbot/clawdbot.json` with the updated schema.
|
||||
|
||||
The Gateway also auto-runs doctor migrations on startup when it detects a legacy
|
||||
config format, so stale configs are repaired without manual intervention.
|
||||
|
||||
Current migrations:
|
||||
- `routing.allowFrom` → `whatsapp.allowFrom`
|
||||
- `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks`
|
||||
→ `agent.models` + `agent.model.primary/fallbacks` + `agent.imageModel.primary/fallbacks`
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
+15
-16
@@ -15,8 +15,7 @@ Everything lives under `~/.clawdbot/`:
|
||||
|------|---------|
|
||||
| `~/.clawdbot/clawdbot.json` | Main config (JSON5) |
|
||||
| `~/.clawdbot/credentials/oauth.json` | OAuth credentials (Anthropic/OpenAI, etc.) |
|
||||
| `~/.clawdbot/agent/auth-profiles.json` | Auth profiles (OAuth + API keys) |
|
||||
| `~/.clawdbot/agent/auth.json` | Runtime API key cache (managed automatically) |
|
||||
| `~/.clawdbot/agent/auth.json` | API key store |
|
||||
| `~/.clawdbot/credentials/` | WhatsApp/Telegram auth tokens |
|
||||
| `~/.clawdbot/sessions/` | Conversation history & state |
|
||||
| `~/.clawdbot/sessions/sessions.json` | Session metadata |
|
||||
@@ -302,7 +301,7 @@ Claude Opus has a 200k token context window, and Clawdbot uses **autocompaction*
|
||||
|
||||
Practical tips:
|
||||
- Keep `AGENTS.md` focused, not bloated.
|
||||
- Use `/compact` to shrink older context or `/new` to reset when it gets stale.
|
||||
- Use `/new` to reset the session when context gets stale.
|
||||
- For large memory/notes collections, use search tools like `qmd` rather than loading everything.
|
||||
|
||||
### Where are my memory files?
|
||||
@@ -551,9 +550,6 @@ Quick reference (send these in chat):
|
||||
|---------|--------|
|
||||
| `/status` | Health + session info |
|
||||
| `/new` or `/reset` | Reset the session |
|
||||
| `/compact` | Compact session context |
|
||||
|
||||
Slash commands are owner-only (gated by `whatsapp.allowFrom` and command authorization on other surfaces).
|
||||
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
||||
| `/verbose on\|off` | Toggle verbose mode |
|
||||
| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) |
|
||||
@@ -580,16 +576,21 @@ List available models with `/model`, `/model list`, or `/model status`.
|
||||
Clawdbot ships a few default model shorthands (you can override them in config):
|
||||
`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`.
|
||||
|
||||
**Setup:** Configure models and aliases in `clawdbot.json`:
|
||||
**Setup:** Configure allowed models and aliases in `clawdbot.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"model": { "primary": "anthropic/claude-opus-4-5" },
|
||||
"models": {
|
||||
"anthropic/claude-opus-4-5": { "alias": "opus" },
|
||||
"anthropic/claude-sonnet-4-5": { "alias": "sonnet" },
|
||||
"anthropic/claude-haiku-4-5": { "alias": "haiku" }
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"allowedModels": [
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"anthropic/claude-haiku-4-5"
|
||||
],
|
||||
"modelAliases": {
|
||||
"opus": "anthropic/claude-opus-4-5",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5",
|
||||
"haiku": "anthropic/claude-haiku-4-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -605,8 +606,7 @@ If you don't want to use Anthropic directly, you can use alternative providers:
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "openrouter/anthropic/claude-sonnet-4" },
|
||||
models: { "openrouter/anthropic/claude-sonnet-4": {} },
|
||||
model: "openrouter/anthropic/claude-sonnet-4",
|
||||
env: { OPENROUTER_API_KEY: "sk-or-..." }
|
||||
}
|
||||
}
|
||||
@@ -616,8 +616,7 @@ If you don't want to use Anthropic directly, you can use alternative providers:
|
||||
```json5
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "zai/glm-4.7" },
|
||||
models: { "zai/glm-4.7": {} },
|
||||
model: "zai/glm-4.7",
|
||||
env: { ZAI_API_KEY: "..." }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Behavior and config for WhatsApp group message handling (mentionPatterns are shared across surfaces)"
|
||||
summary: "Behavior and config for WhatsApp group message handling"
|
||||
read_when:
|
||||
- Changing group message rules or mentions
|
||||
---
|
||||
@@ -7,8 +7,6 @@ read_when:
|
||||
|
||||
Goal: let Clawd sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session.
|
||||
|
||||
Note: `routing.groupChat.mentionPatterns` is now used by Telegram/Discord/Slack/iMessage as well; this doc focuses on WhatsApp-specific behavior.
|
||||
|
||||
## What’s implemented (2025-12-03)
|
||||
- Activation modes: `mention` (default) or `always`. `mention` requires a ping (real WhatsApp @-mentions via `mentionedJids`, regex patterns, or the bot’s E.164 anywhere in the text). `always` wakes the agent on every message but it should reply only when it can add meaningful value; otherwise it returns the silent token `NO_REPLY`. Defaults can be set in config (`whatsapp.groups`) and overridden per group via `/activation`.
|
||||
- Group allowlist bypass: we still enforce `whatsapp.allowFrom` on the participant at inbox ingest, but group JIDs themselves no longer block replies.
|
||||
@@ -58,7 +56,7 @@ Only the owner number (from `whatsapp.allowFrom`, defaulting to the bot’s own
|
||||
1) Add Clawd UK (`+447700900123`) to the group.
|
||||
2) Say `@clawd …` (or `@clawd uk`, `@clawdbot`, or include the number). Anyone in the group can trigger it.
|
||||
3) The agent prompt will include recent group context plus the trailing `[from: …]` marker so it can address the right person.
|
||||
4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`, `/compact`) apply only to that group’s session; your personal DM session remains independent.
|
||||
4) Session-level directives (`/verbose on`, `/think:high`, `/new` or `/reset`) apply only to that group’s session; your personal DM session remains independent.
|
||||
|
||||
## Testing / verification
|
||||
- Automated: `pnpm test -- src/web/auto-reply.test.ts --runInBand` (covers mention gating, history injection, sender suffix).
|
||||
|
||||
@@ -51,7 +51,6 @@ Group messages require a mention unless overridden per group. Defaults live per
|
||||
Notes:
|
||||
- `mentionPatterns` are case-insensitive regexes.
|
||||
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
|
||||
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
|
||||
- Discord defaults live in `discord.guilds."*"` (overridable per guild/channel).
|
||||
|
||||
## Activation (owner-only)
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ imsg chats --limit 20
|
||||
|
||||
## Group chat behavior
|
||||
- Group messages set `ChatType=group`, `GroupSubject`, and `GroupMembers`.
|
||||
- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns` (patterns are required to detect mentions on iMessage).
|
||||
- Group activation respects `imessage.groups."*".requireMention` and `routing.groupChat.mentionPatterns`.
|
||||
- Replies go back to the same `chat_id` (group or direct).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
+23
-23
@@ -16,32 +16,35 @@ that prefers tool-call + image-capable models and maintains ordered fallbacks.
|
||||
- default: configured models only
|
||||
- flags: `--all` (full catalog), `--local`, `--provider <name>`, `--json`, `--plain`
|
||||
- `clawdbot models status`
|
||||
- show default model + aliases + fallbacks + configured models
|
||||
- show default model + aliases + fallbacks + allowlist
|
||||
- `clawdbot models set <modelOrAlias>`
|
||||
- writes `agent.model.primary` and ensures `agent.models` entry
|
||||
- writes `agent.model` in config
|
||||
- `clawdbot models set-image <modelOrAlias>`
|
||||
- writes `agent.imageModel.primary` and ensures `agent.models` entry
|
||||
- writes `agent.imageModel` in config
|
||||
- `clawdbot models aliases list|add|remove`
|
||||
- writes `agent.models.*.alias`
|
||||
- writes `agent.modelAliases`
|
||||
- `clawdbot models fallbacks list|add|remove|clear`
|
||||
- writes `agent.model.fallbacks`
|
||||
- writes `agent.modelFallbacks`
|
||||
- `clawdbot models image-fallbacks list|add|remove|clear`
|
||||
- writes `agent.imageModel.fallbacks`
|
||||
- writes `agent.imageModelFallbacks`
|
||||
- `clawdbot models scan`
|
||||
- OpenRouter :free scan; probe tool-call + image; interactive selection
|
||||
|
||||
## Config changes
|
||||
|
||||
- `agent.models` (configured model catalog + aliases).
|
||||
- `agent.model.primary` + `agent.model.fallbacks`.
|
||||
- `agent.imageModel.primary` + `agent.imageModel.fallbacks` (optional).
|
||||
- `auth.profiles` + `auth.order` for per-provider auth failover.
|
||||
- Add `agent.modelFallbacks: string[]` (ordered list of provider/model IDs).
|
||||
- Add `agent.imageModel?: string` (optional image-capable model for image tool).
|
||||
- Add `agent.imageModelFallbacks?: string[]` (ordered list for image tool).
|
||||
- Keep existing:
|
||||
- `agent.model` (default)
|
||||
- `agent.allowedModels` (list filter)
|
||||
- `agent.modelAliases` (shortcut names)
|
||||
|
||||
## Scan behavior (models scan)
|
||||
|
||||
Input
|
||||
- OpenRouter `/models` list (filter `:free`)
|
||||
- Requires OpenRouter API key from auth profiles or `OPENROUTER_API_KEY`
|
||||
- Requires `OPENROUTER_API_KEY` (or stored OpenRouter key in auth storage)
|
||||
- Optional filters: `--max-age-days`, `--min-params`, `--provider`, `--max-candidates`
|
||||
- Probe controls: `--timeout`, `--concurrency`
|
||||
|
||||
@@ -63,20 +66,17 @@ Interactive selection (TTY)
|
||||
- Non-TTY: auto-select; require `--yes`/`--no-input` to apply.
|
||||
|
||||
Output
|
||||
- Writes `agent.model.fallbacks` ordered.
|
||||
- Writes `agent.imageModel.fallbacks` ordered (image-capable models).
|
||||
- Ensures `agent.models` entries exist for selected models.
|
||||
- Optional `--set-default` to set `agent.model.primary`.
|
||||
- Optional `--set-image` to set `agent.imageModel.primary`.
|
||||
- Writes `agent.modelFallbacks` ordered.
|
||||
- Writes `agent.imageModelFallbacks` ordered (image-capable models).
|
||||
- Optional `--set-default` to set `agent.model`.
|
||||
- Optional `--set-image` to set `agent.imageModel`.
|
||||
|
||||
## Runtime fallback
|
||||
|
||||
- On model failure: try `agent.model.fallbacks` in order.
|
||||
- Per-provider auth failover uses `auth.order` (or stored profile order) **before**
|
||||
moving to the next model.
|
||||
- Image routing uses `agent.imageModel` **only when configured** and the primary
|
||||
model lacks image input.
|
||||
- Persist last successful provider/model to session entry; auth profile success is global.
|
||||
- On model failure: try `agent.modelFallbacks` in order.
|
||||
- Ignore fallback entries not in `agent.allowedModels` (if allowlist set).
|
||||
- Persist last successful provider/model to session entry.
|
||||
- `/status` shows last used model (not just default).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -86,5 +86,5 @@ Output
|
||||
|
||||
## Docs
|
||||
|
||||
- Update `docs/configuration.md` with `agent.models` + `agent.model` + `agent.imageModel`.
|
||||
- Update `docs/configuration.md` with `agent.modelFallbacks`.
|
||||
- Keep this doc current when CLI surface or scan logic changes.
|
||||
+2
-3
@@ -41,7 +41,7 @@ The macOS app should:
|
||||
- `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
|
||||
|
||||
Why this location matters: it’s the Clawdbot-owned OAuth store.
|
||||
Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on first use.
|
||||
Clawdbot also imports `oauth.json` into the agent auth store (`~/.clawdbot/agent/auth.json`) on first use.
|
||||
|
||||
### Recommended: OAuth (OpenAI Codex)
|
||||
|
||||
@@ -50,7 +50,6 @@ The macOS app should:
|
||||
- Auto-capture the callback on `http://127.0.0.1:1455/auth/callback` when possible.
|
||||
- If the callback fails, prompt the user to paste the redirect URL or code.
|
||||
- Store credentials in `~/.clawdbot/credentials/oauth.json` (same OAuth store as Anthropic).
|
||||
- Set `agent.model` to `openai-codex/gpt-5.2` when the model is unset or `openai/*`.
|
||||
|
||||
### Alternative: API key (instructions only)
|
||||
|
||||
@@ -149,7 +148,7 @@ If the Gateway runs on another machine, OAuth credentials must be created/stored
|
||||
|
||||
For now, remote onboarding should:
|
||||
- explain why OAuth isn't shown
|
||||
- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agent/auth-profiles.json`) on the gateway host
|
||||
- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the workspace location on the gateway host
|
||||
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
|
||||
|
||||
### Manual credential setup
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
summary: "Harden cron.add input handling, align schemas, and improve cron UI/agent tooling"
|
||||
owner: "clawdbot"
|
||||
status: "complete"
|
||||
last_updated: "2026-01-05"
|
||||
---
|
||||
|
||||
# Cron Add Hardening & Schema Alignment
|
||||
|
||||
## Context
|
||||
Recent gateway logs show repeated `cron.add` failures with invalid parameters (missing `sessionTarget`, `wakeMode`, `payload`, and malformed `schedule`). This indicates that at least one client (likely the agent tool call path) is sending wrapped or partially specified job payloads. Separately, there is drift between cron channel enums in TypeScript, gateway schema, CLI flags, and UI form types, plus a UI mismatch for `cron.status` (expects `jobCount` while gateway returns `jobs`).
|
||||
|
||||
## Goals
|
||||
- Stop `cron.add` INVALID_REQUEST spam by normalizing common wrapper payloads and inferring missing `kind` fields.
|
||||
- Align cron channel lists across gateway schema, cron types, CLI docs, and UI forms.
|
||||
- Make agent cron tool schema explicit so the LLM produces correct job payloads.
|
||||
- Fix the Control UI cron status job count display.
|
||||
- Add tests to cover normalization and tool behavior.
|
||||
|
||||
## Non-goals
|
||||
- Change cron scheduling semantics or job execution behavior.
|
||||
- Add new schedule kinds or cron expression parsing.
|
||||
- Overhaul the UI/UX for cron beyond the necessary field fixes.
|
||||
|
||||
## Findings (current gaps)
|
||||
- `CronPayloadSchema` in gateway excludes `signal` + `imessage`, while TS types include them.
|
||||
- Control UI CronStatus expects `jobCount`, but gateway returns `jobs`.
|
||||
- Agent cron tool schema allows arbitrary `job` objects, enabling malformed inputs.
|
||||
- Gateway strictly validates `cron.add` with no normalization, so wrapped payloads fail.
|
||||
|
||||
## Proposed Approach
|
||||
1. **Normalize** incoming `cron.add` payloads (unwrap `data`/`job`, infer `schedule.kind` and `payload.kind`, default `wakeMode` + `sessionTarget` when safe).
|
||||
2. **Harden** the agent cron tool schema using the canonical gateway `CronAddParamsSchema` and normalize before sending to the gateway.
|
||||
3. **Align** channel enums and cron status fields across gateway schema, TS types, CLI descriptions, and UI form controls.
|
||||
4. **Test** normalization in gateway tests and tool behavior in agent tests.
|
||||
|
||||
## Multi-phase Execution Plan
|
||||
|
||||
### Phase 1 — Schema + type alignment
|
||||
- [x] Expand gateway `CronPayloadSchema` channel enum to include `signal` and `imessage`.
|
||||
- [x] Update CLI `--channel` descriptions to include `slack` (already supported by gateway).
|
||||
- [x] Update UI Cron payload/channel union types to include all supported channels.
|
||||
- [x] Fix UI CronStatus type to match gateway (`jobs` instead of `jobCount`).
|
||||
- [x] Update cron UI channel select to include Discord/Slack/Signal/iMessage.
|
||||
- [x] Update macOS CronJobEditor channel picker + enum to include Slack/Signal/iMessage.
|
||||
- [x] Document cron compatibility normalization policy in `docs/cron.md`.
|
||||
|
||||
### Phase 2 — Input normalization + tooling hardening
|
||||
- [x] Add shared cron input normalization helpers (`normalizeCronJobCreate`/`normalizeCronJobPatch`).
|
||||
- [x] Apply normalization in gateway `cron.add` (and patch normalization in `cron.update`).
|
||||
- [x] Tighten agent cron tool schema to `CronAddParamsSchema` and normalize job/patch before sending.
|
||||
|
||||
### Phase 3 — Tests
|
||||
- [x] Add gateway test covering wrapped `cron.add` payload normalization.
|
||||
- [x] Add cron tool test to assert normalization and defaulting for `cron.add`.
|
||||
- [x] Add gateway test covering `cron.update` normalization.
|
||||
- [x] Add UI + Swift conformance test for cron channels + status fields.
|
||||
|
||||
### Phase 4 — Verification
|
||||
- [x] Run tests (full suite executed via `pnpm test -- cron-tool`).
|
||||
|
||||
## Rollout/Monitoring
|
||||
- Watch gateway logs for reduced `cron.add` INVALID_REQUEST errors.
|
||||
- Confirm Control UI cron status shows job count after refresh.
|
||||
- If errors persist, extend normalization for additional common shapes (e.g., `schedule.at`, `payload.message` without `kind`).
|
||||
|
||||
## Optional Follow-ups
|
||||
- Manual Control UI smoke: add cron job per channel + verify status job count.
|
||||
|
||||
## Open Questions
|
||||
- Should `cron.add` accept explicit `state` from clients (currently disallowed by schema)?
|
||||
- Should we allow `webchat` as an explicit delivery channel (currently filtered in delivery resolution)?
|
||||
@@ -87,7 +87,7 @@ Model listing
|
||||
- alias
|
||||
- provider
|
||||
- auth order (from `auth.order`)
|
||||
- auth source for the current provider (auth-profiles.json/env/shell env/models.json)
|
||||
- auth source for the current provider (env/auth.json/models.json)
|
||||
|
||||
## Fallback behavior (global)
|
||||
|
||||
@@ -121,20 +121,19 @@ Support detection
|
||||
## Migration (doctor + gateway auto-run)
|
||||
|
||||
Inputs
|
||||
- Legacy keys (pre-migration):
|
||||
- `agent.model` (string)
|
||||
- `agent.modelFallbacks` (string[])
|
||||
- `agent.imageModel` (string)
|
||||
- `agent.imageModelFallbacks` (string[])
|
||||
- `agent.allowedModels` (string[])
|
||||
- `agent.modelAliases` (record)
|
||||
- `agent.model` (string)
|
||||
- `agent.modelFallbacks` (string[])
|
||||
- `agent.imageModel` (string)
|
||||
- `agent.imageModelFallbacks` (string[])
|
||||
- `agent.allowedModels` (string[])
|
||||
- `agent.modelAliases` (record)
|
||||
|
||||
Outputs
|
||||
- `agent.models` map with keys for all referenced models
|
||||
- `agent.model.primary/fallbacks`
|
||||
- `agent.imageModel.primary/fallbacks`
|
||||
- Auth profile store seeded from current auth-profiles.json/auth.json + oauth.json + env (as `provider:default`)
|
||||
- `auth.order` seeded with `["provider:default"]` when config is updated
|
||||
- `auth.profiles` seeded from current auth.json + env (as `provider:default`)
|
||||
- `auth.order` seeded with `["provider:default"]`
|
||||
|
||||
Auto-run
|
||||
- Gateway start detects legacy keys and runs doctor migration.
|
||||
|
||||
@@ -77,7 +77,6 @@ Runtime override (owner only):
|
||||
- `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
|
||||
- `pnpm clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
|
||||
- Send `/status` in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
|
||||
- Send `/compact` (optional instructions) to summarize older context and free up window space.
|
||||
- JSONL transcripts can be opened directly to review full turns.
|
||||
|
||||
## Tips
|
||||
|
||||
+1
-1
@@ -202,6 +202,6 @@ Slack tool actions can be gated with `slack.actions.*`:
|
||||
| emojiList | enabled | Custom emoji list |
|
||||
|
||||
## Notes
|
||||
- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions.
|
||||
- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`).
|
||||
- Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`).
|
||||
- Attachments are downloaded to the media store when permitted and under the size limit.
|
||||
+2
-2
@@ -35,7 +35,7 @@ Status: ready for bot-mode use with grammY (long-polling by default; webhook sup
|
||||
|
||||
## Planned implementation details
|
||||
- Library: grammY is the only client for send + gateway (fetch fallback removed); grammY throttler is enabled by default to stay under Bot API limits.
|
||||
- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention or a `routing.groupChat.mentionPatterns` match by default (override per chat in config).
|
||||
- Inbound normalization: maps Bot API updates to `MsgContext` with `Surface: "telegram"`, `ChatType: direct|group`, `SenderName`, `MediaPath`/`MediaType` when attachments arrive, `Timestamp`, and reply-to metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`) when the user replies; reply context is appended to `Body` as a `[Replying to ...]` block (includes `id:` when available); groups require @bot mention by default (override per chat in config).
|
||||
- Outbound: text and media (photo/video/audio/document) with optional caption; chunked to limits. Typing cue sent best-effort.
|
||||
- Config: `TELEGRAM_BOT_TOKEN` env or `telegram.botToken` required; `telegram.groups`, `telegram.allowFrom`, `telegram.mediaMaxMb`, `telegram.replyToMode`, `telegram.proxy`, `telegram.webhookSecret`, `telegram.webhookUrl`, `telegram.webhookPath` supported.
|
||||
- Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention` → `telegram.groups."*".requireMention` → default `true`.
|
||||
@@ -65,7 +65,7 @@ Example config:
|
||||
## Group etiquette
|
||||
- Keep privacy mode off if you expect the bot to read all messages; with privacy on, it only sees commands/mentions.
|
||||
- Make the bot an admin if you need it to send in restricted groups or channels.
|
||||
- Mention the bot (`@yourbot`) or use a `routing.groupChat.mentionPatterns` trigger; per-group overrides live in `telegram.groups` if you want always-on behavior.
|
||||
- Mention the bot (`@yourbot`) or use commands to trigger; per-group overrides live in `telegram.groups` if you want always-on behavior.
|
||||
|
||||
## Reply tags
|
||||
To request a threaded reply, the model can include one tag in its output:
|
||||
|
||||
Vendored
-23
@@ -69,29 +69,6 @@ Vectors + BM25 + reranking finds things even with different wording.
|
||||
|
||||
You have access to your human's stuff. That doesn't mean you *share* their stuff. In groups, you're a participant — not their voice, not their proxy. Think before you speak.
|
||||
|
||||
### 💬 Know When to Speak!
|
||||
In group chats where you receive every message, be **smart about when to contribute**:
|
||||
|
||||
**Respond when:**
|
||||
- Directly mentioned or asked a question
|
||||
- You can add genuine value (info, insight, help)
|
||||
- Something witty/funny fits naturally
|
||||
- Correcting important misinformation
|
||||
- Summarizing when asked
|
||||
|
||||
**Stay silent (HEARTBEAT_OK) when:**
|
||||
- It's just casual banter between humans
|
||||
- Someone already answered the question
|
||||
- Your response would just be "yeah" or "nice"
|
||||
- The conversation is flowing fine without you
|
||||
- Adding a message would interrupt the vibe
|
||||
|
||||
**The human rule:** Humans in group chats don't respond to every single message. Neither should you. Quality > quantity. If you wouldn't send it in a real group chat with friends, don't send it.
|
||||
|
||||
**Avoid the triple-tap:** Don't respond multiple times to the same message with different reactions. One thoughtful response beats three fragments.
|
||||
|
||||
Participate, don't dominate.
|
||||
|
||||
## Tools
|
||||
|
||||
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
|
||||
|
||||
@@ -33,11 +33,3 @@ scripts/e2e/onboard-docker.sh
|
||||
```
|
||||
|
||||
This script drives the interactive wizard via a pseudo-tty, verifies config/workspace/session files, then starts the gateway and runs `clawdbot health`.
|
||||
|
||||
## QR import smoke (Docker)
|
||||
|
||||
Ensures `qrcode-terminal` loads under Node 22+ in Docker:
|
||||
|
||||
```bash
|
||||
pnpm test:docker:qr
|
||||
```
|
||||
+2
-2
@@ -126,7 +126,7 @@ Core parameters:
|
||||
- `maxBytesMb` (optional size cap)
|
||||
|
||||
Notes:
|
||||
- Only available when `agent.imageModel` is configured (primary or fallbacks).
|
||||
- Only available when `agent.imageModel` or `agent.imageModelFallbacks` is set.
|
||||
- Uses the image model directly (independent of the main chat model).
|
||||
|
||||
### `cron`
|
||||
@@ -139,7 +139,7 @@ Core actions:
|
||||
|
||||
Notes:
|
||||
- `add` expects a full cron job object (same schema as `cron.add` RPC).
|
||||
- `update` uses `{ id, patch }`.
|
||||
- `update` uses `{ jobId, patch }`.
|
||||
|
||||
### `gateway`
|
||||
Restart the running Gateway process (in-place).
|
||||
|
||||
@@ -29,8 +29,8 @@ cat ~/.clawdbot/clawdbot.json | jq '.whatsapp.allowFrom'
|
||||
|
||||
**Check 2:** For group chats, is mention required?
|
||||
```bash
|
||||
# The message must match mentionPatterns or explicit mentions; defaults live in provider groups/guilds.
|
||||
cat ~/.clawdbot/clawdbot.json | jq '.routing.groupChat, .whatsapp.groups, .telegram.groups, .imessage.groups, .discord.guilds'
|
||||
# The message must match mentionPatterns or explicit mentions; defaults live in whatsapp.groups
|
||||
cat ~/.clawdbot/clawdbot.json | jq '.routing.groupChat, .whatsapp.groups'
|
||||
```
|
||||
|
||||
**Check 3:** Check the logs
|
||||
|
||||
+1
-2
@@ -48,14 +48,13 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
- `/help`
|
||||
- `/status`
|
||||
- `/session <key>` (or `/sessions`)
|
||||
- `/model <provider/model>` (or `/model list`, `/models`)
|
||||
- `/model <provider/model>` (or `/models`)
|
||||
- `/think <off|minimal|low|medium|high>`
|
||||
- `/verbose <on|off>`
|
||||
- `/elevated <on|off>`
|
||||
- `/activation <mention|always>`
|
||||
- `/deliver <on|off>`
|
||||
- `/new` or `/reset`
|
||||
- `/compact [instructions]`
|
||||
- `/abort`
|
||||
- `/settings`
|
||||
- `/exit`
|
||||
|
||||
+1
-3
@@ -49,12 +49,10 @@ It does **not** install or change anything on the remote host.
|
||||
2) **Model/Auth**
|
||||
- **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`.
|
||||
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
|
||||
- Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||
- **API key**: stores the key for you.
|
||||
- **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint.
|
||||
- **Skip**: no auth configured yet.
|
||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; auth profiles live in `~/.clawdbot/agent/auth-profiles.json` (API keys + OAuth).
|
||||
- OAuth credentials live in `~/.clawdbot/credentials/oauth.json`; API keys live in `~/.clawdbot/agent/auth.json`.
|
||||
|
||||
3) **Workspace**
|
||||
- Default `~/clawd` (configurable).
|
||||
|
||||
+1
-3
@@ -67,7 +67,6 @@
|
||||
"test:force": "tsx scripts/test-force.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
|
||||
"protocol:gen": "tsx scripts/protocol-gen.ts",
|
||||
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
|
||||
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
|
||||
@@ -149,8 +148,7 @@
|
||||
"@sinclair/typebox": "0.34.46"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
|
||||
"qrcode-terminal": "patches/qrcode-terminal.patch"
|
||||
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
diff --git a/lib/main.js b/lib/main.js
|
||||
index 488cc1aea9802b3d6ae13aee27556403bec55d1c..3de1f934868d81e8204f00e6a4bf2696a05f7340 100644
|
||||
--- a/lib/main.js
|
||||
+++ b/lib/main.js
|
||||
@@ -1,5 +1,5 @@
|
||||
-var QRCode = require('./../vendor/QRCode'),
|
||||
- QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel'),
|
||||
+var QRCode = require('./../vendor/QRCode/index.js'),
|
||||
+ QRErrorCorrectLevel = require('./../vendor/QRCode/QRErrorCorrectLevel.js'),
|
||||
black = "\033[40m \033[0m",
|
||||
white = "\033[47m \033[0m",
|
||||
toCell = function (isBlack) {
|
||||
Generated
+2
-5
@@ -11,9 +11,6 @@ patchedDependencies:
|
||||
'@mariozechner/pi-ai':
|
||||
hash: 628fb051b6f4886984a846a5ee7aa0a571c3360d35b8d114e4684e5edcd100c5
|
||||
path: patches/@mariozechner__pi-ai.patch
|
||||
qrcode-terminal:
|
||||
hash: ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12
|
||||
path: patches/qrcode-terminal.patch
|
||||
|
||||
importers:
|
||||
|
||||
@@ -105,7 +102,7 @@ importers:
|
||||
version: 1.57.0
|
||||
qrcode-terminal:
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12)
|
||||
version: 0.12.0
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -5463,7 +5460,7 @@ snapshots:
|
||||
'@thi.ng/bitstream': 2.4.37
|
||||
optional: true
|
||||
|
||||
qrcode-terminal@0.12.0(patch_hash=ed82029850dbdf551f5df1de320945af52b8ea8500cc7bd4f39258e7a3d92e12): {}
|
||||
qrcode-terminal@0.12.0: {}
|
||||
|
||||
qs@6.14.1:
|
||||
dependencies:
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
@@ -10,7 +10,6 @@ docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
echo "Running onboarding E2E..."
|
||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
|
||||
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
|
||||
@@ -38,7 +37,7 @@ TRASH
|
||||
local delay="${2:-0.4}"
|
||||
# Let prompts render before sending keystrokes.
|
||||
sleep "$delay"
|
||||
printf "%b" "$payload" >&3 2>/dev/null || true
|
||||
printf "%b" "$payload" >&3
|
||||
}
|
||||
|
||||
start_gateway() {
|
||||
@@ -135,8 +134,6 @@ TRASH
|
||||
send $'"'"'\e[B'"'"' 0.6
|
||||
send $'"'"'\e[B'"'"' 0.6
|
||||
send $'"'"'\e[B'"'"' 0.6
|
||||
send $'"'"'\e[B'"'"' 0.6
|
||||
send $'"'"'\e[B'"'"' 0.6
|
||||
send $'"'"'\r'"'"' 0.6
|
||||
send $'"'"'\r'"'"' 0.5
|
||||
send $'"'"'\r'"'"' 0.5
|
||||
@@ -173,7 +170,7 @@ TRASH
|
||||
# Configure providers now? (default Yes)
|
||||
send $'"'"'\r'"'"' 0.8
|
||||
send "" 0.8
|
||||
# Select Telegram, Discord, Slack.
|
||||
# Select Telegram, Discord, Signal.
|
||||
send $'"'"'\e[B'"'"' 0.4
|
||||
send $'"'"' '"'"' 0.4
|
||||
send $'"'"'\e[B'"'"' 0.4
|
||||
@@ -181,14 +178,11 @@ TRASH
|
||||
send $'"'"'\e[B'"'"' 0.4
|
||||
send $'"'"' '"'"' 0.4
|
||||
send $'"'"'\r'"'"' 0.6
|
||||
send $'"'"'tg_token\r'"'"' 0.8
|
||||
send $'"'"'discord_token\r'"'"' 0.8
|
||||
send "" 0.6
|
||||
send $'"'"'\r'"'"' 0.6
|
||||
send "" 0.6
|
||||
send $'"'"'slack_bot\r'"'"' 0.8
|
||||
send "" 0.6
|
||||
send $'"'"'slack_app\r'"'"' 0.8
|
||||
send $'"'"'tg_token\r'"'"' 0.6
|
||||
send $'"'"'discord_token\r'"'"' 0.6
|
||||
send $'"'"'n\r'"'"' 0.6
|
||||
send $'"'"'+15551234567\r'"'"' 0.6
|
||||
send $'"'"'n\r'"'"' 0.6
|
||||
}
|
||||
|
||||
send_skills_flow() {
|
||||
@@ -399,11 +393,11 @@ if (cfg?.telegram?.botToken !== "tg_token") {
|
||||
if (cfg?.discord?.token !== "discord_token") {
|
||||
errors.push(`discord.token mismatch (got ${cfg?.discord?.token ?? "unset"})`);
|
||||
}
|
||||
if (cfg?.slack?.botToken !== "slack_bot") {
|
||||
errors.push(`slack.botToken mismatch (got ${cfg?.slack?.botToken ?? "unset"})`);
|
||||
if (cfg?.signal?.account !== "+15551234567") {
|
||||
errors.push(`signal.account mismatch (got ${cfg?.signal?.account ?? "unset"})`);
|
||||
}
|
||||
if (cfg?.slack?.appToken !== "slack_app") {
|
||||
errors.push(`slack.appToken mismatch (got ${cfg?.slack?.appToken ?? "unset"})`);
|
||||
if (cfg?.signal?.cliPath !== "signal-cli") {
|
||||
errors.push(`signal.cliPath mismatch (got ${cfg?.signal?.cliPath ?? "unset"})`);
|
||||
}
|
||||
if (cfg?.wizard?.lastRunMode !== "local") {
|
||||
errors.push(`wizard.lastRunMode mismatch (got ${cfg?.wizard?.lastRunMode ?? "unset"})`);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
IMAGE_NAME="${CLAWDBOT_QR_SMOKE_IMAGE:-clawdbot-qr-smoke}"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile.qr-import" "$ROOT_DIR"
|
||||
|
||||
echo "Running qrcode-terminal import smoke..."
|
||||
docker run --rm -t "$IMAGE_NAME" node -e "import('qrcode-terminal').then((m)=>m.default.generate('qr-smoke',{small:true}))"
|
||||
@@ -1,13 +1,14 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
|
||||
const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent");
|
||||
|
||||
export function resolveClawdbotAgentDir(): string {
|
||||
const defaultAgentDir = path.join(resolveConfigDir(), "agent");
|
||||
const override =
|
||||
process.env.CLAWDBOT_AGENT_DIR?.trim() ||
|
||||
process.env.PI_CODING_AGENT_DIR?.trim() ||
|
||||
defaultAgentDir;
|
||||
DEFAULT_AGENT_DIR;
|
||||
return resolveUserPath(override);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
describe("resolveAuthProfileOrder", () => {
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-work",
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": { provider: "anthropic", mode: "api_key" },
|
||||
"anthropic:work": { provider: "anthropic", mode: "api_key" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("uses stored profiles when no config exists", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
store,
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:default", "anthropic:work"]);
|
||||
});
|
||||
|
||||
it("prioritizes preferred profiles", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider: "anthropic",
|
||||
preferredProfile: "anthropic:work",
|
||||
});
|
||||
expect(order[0]).toBe("anthropic:work");
|
||||
expect(order).toContain("anthropic:default");
|
||||
});
|
||||
|
||||
it("prioritizes last-good profile when no preferred override", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store: { ...store, lastGood: { anthropic: "anthropic:work" } },
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order[0]).toBe("anthropic:work");
|
||||
});
|
||||
|
||||
it("uses explicit profiles when order is missing", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:default", "anthropic:work"]);
|
||||
});
|
||||
|
||||
it("uses configured order when provided", () => {
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg: {
|
||||
auth: {
|
||||
order: { anthropic: ["anthropic:work", "anthropic:default"] },
|
||||
profiles: cfg.auth.profiles,
|
||||
},
|
||||
},
|
||||
store,
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
|
||||
});
|
||||
|
||||
it("prioritizes oauth profiles when order missing", () => {
|
||||
const mixedStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-default",
|
||||
},
|
||||
"anthropic:oauth": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const order = resolveAuthProfileOrder({
|
||||
store: mixedStore,
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(order).toEqual(["anthropic:oauth", "anthropic:default"]);
|
||||
});
|
||||
});
|
||||
@@ -1,373 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
getOAuthApiKey,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveOAuthPath } from "../config/paths.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
|
||||
const AUTH_STORE_VERSION = 1;
|
||||
const AUTH_PROFILE_FILENAME = "auth-profiles.json";
|
||||
const LEGACY_AUTH_FILENAME = "auth.json";
|
||||
|
||||
export type ApiKeyCredential = {
|
||||
type: "api_key";
|
||||
provider: string;
|
||||
key: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type OAuthCredential = OAuthCredentials & {
|
||||
type: "oauth";
|
||||
provider: OAuthProvider;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
|
||||
|
||||
export type AuthProfileStore = {
|
||||
version: number;
|
||||
profiles: Record<string, AuthProfileCredential>;
|
||||
lastGood?: Record<string, string>;
|
||||
};
|
||||
|
||||
type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
||||
|
||||
function resolveAuthStorePath(): string {
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
return path.join(agentDir, AUTH_PROFILE_FILENAME);
|
||||
}
|
||||
|
||||
function resolveLegacyAuthStorePath(): string {
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
return path.join(agentDir, LEGACY_AUTH_FILENAME);
|
||||
}
|
||||
|
||||
function loadJsonFile(pathname: string): unknown {
|
||||
try {
|
||||
if (!fs.existsSync(pathname)) return undefined;
|
||||
const raw = fs.readFileSync(pathname, "utf8");
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveJsonFile(pathname: string, data: unknown) {
|
||||
const dir = path.dirname(pathname);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(pathname, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||
fs.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
if ("profiles" in record) return null;
|
||||
const entries: LegacyAuthStore = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
|
||||
entries[key] = {
|
||||
...typed,
|
||||
provider: typed.provider ?? (key as OAuthProvider),
|
||||
} as AuthProfileCredential;
|
||||
}
|
||||
return Object.keys(entries).length > 0 ? entries : null;
|
||||
}
|
||||
|
||||
function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
if (!record.profiles || typeof record.profiles !== "object") return null;
|
||||
const profiles = record.profiles as Record<string, unknown>;
|
||||
const normalized: Record<string, AuthProfileCredential> = {};
|
||||
for (const [key, value] of Object.entries(profiles)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
|
||||
if (!typed.provider) continue;
|
||||
normalized[key] = typed as AuthProfileCredential;
|
||||
}
|
||||
return {
|
||||
version: Number(record.version ?? AUTH_STORE_VERSION),
|
||||
profiles: normalized,
|
||||
lastGood:
|
||||
record.lastGood && typeof record.lastGood === "object"
|
||||
? (record.lastGood as Record<string, string>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
if (!oauthRaw || typeof oauthRaw !== "object") return false;
|
||||
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
|
||||
let mutated = false;
|
||||
for (const [provider, creds] of Object.entries(oauthEntries)) {
|
||||
if (!creds || typeof creds !== "object") continue;
|
||||
const profileId = `${provider}:default`;
|
||||
if (store.profiles[profileId]) continue;
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: provider as OAuthProvider,
|
||||
...creds,
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
export function loadAuthProfileStore(): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) return asStore;
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
if (legacy) {
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
for (const [provider, cred] of Object.entries(legacy)) {
|
||||
const profileId = `${provider}:default`;
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
|
||||
...(cred.projectId ? { projectId: cred.projectId } : {}),
|
||||
...(cred.accountId ? { accountId: cred.accountId } : {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
return { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) return asStore;
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
if (legacy) {
|
||||
for (const [provider, cred] of Object.entries(legacy)) {
|
||||
const profileId = `${provider}:default`;
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
|
||||
...(cred.projectId ? { projectId: cred.projectId } : {}),
|
||||
...(cred.accountId ? { accountId: cred.accountId } : {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
||||
const shouldWrite = legacy !== null || mergedOAuth;
|
||||
if (shouldWrite) {
|
||||
saveJsonFile(authPath, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export function saveAuthProfileStore(store: AuthProfileStore): void {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const payload = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: store.profiles,
|
||||
lastGood: store.lastGood ?? undefined,
|
||||
} satisfies AuthProfileStore;
|
||||
saveJsonFile(authPath, payload);
|
||||
}
|
||||
|
||||
export function upsertAuthProfile(params: {
|
||||
profileId: string;
|
||||
credential: AuthProfileCredential;
|
||||
}): void {
|
||||
const store = ensureAuthProfileStore();
|
||||
store.profiles[params.profileId] = params.credential;
|
||||
saveAuthProfileStore(store);
|
||||
}
|
||||
|
||||
export function listProfilesForProvider(
|
||||
store: AuthProfileStore,
|
||||
provider: string,
|
||||
): string[] {
|
||||
return Object.entries(store.profiles)
|
||||
.filter(([, cred]) => cred.provider === provider)
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
export function resolveAuthProfileOrder(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
preferredProfile?: string;
|
||||
}): string[] {
|
||||
const { cfg, store, provider, preferredProfile } = params;
|
||||
const configuredOrder = cfg?.auth?.order?.[provider];
|
||||
const explicitProfiles = cfg?.auth?.profiles
|
||||
? Object.entries(cfg.auth.profiles)
|
||||
.filter(([, profile]) => profile.provider === provider)
|
||||
.map(([profileId]) => profileId)
|
||||
: [];
|
||||
const lastGood = store.lastGood?.[provider];
|
||||
const baseOrder =
|
||||
configuredOrder ??
|
||||
(explicitProfiles.length > 0
|
||||
? explicitProfiles
|
||||
: listProfilesForProvider(store, provider));
|
||||
if (baseOrder.length === 0) return [];
|
||||
const order =
|
||||
configuredOrder && configuredOrder.length > 0
|
||||
? baseOrder
|
||||
: orderProfilesByMode(baseOrder, store);
|
||||
|
||||
const filtered = order.filter((profileId) => {
|
||||
const cred = store.profiles[profileId];
|
||||
return cred ? cred.provider === provider : true;
|
||||
});
|
||||
const deduped: string[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (!deduped.includes(entry)) deduped.push(entry);
|
||||
}
|
||||
if (preferredProfile && deduped.includes(preferredProfile)) {
|
||||
const rest = deduped.filter((entry) => entry !== preferredProfile);
|
||||
if (lastGood && rest.includes(lastGood)) {
|
||||
return [
|
||||
preferredProfile,
|
||||
lastGood,
|
||||
...rest.filter((entry) => entry !== lastGood),
|
||||
];
|
||||
}
|
||||
return [preferredProfile, ...rest];
|
||||
}
|
||||
if (lastGood && deduped.includes(lastGood)) {
|
||||
return [lastGood, ...deduped.filter((entry) => entry !== lastGood)];
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function orderProfilesByMode(
|
||||
order: string[],
|
||||
store: AuthProfileStore,
|
||||
): string[] {
|
||||
const scored = order.map((profileId) => {
|
||||
const type = store.profiles[profileId]?.type;
|
||||
const score = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
|
||||
return { profileId, score };
|
||||
});
|
||||
return scored
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.map((entry) => entry.profileId);
|
||||
}
|
||||
|
||||
export async function resolveApiKeyForProfile(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
||||
const { cfg, store, profileId } = params;
|
||||
const cred = store.profiles[profileId];
|
||||
if (!cred) return null;
|
||||
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||
|
||||
if (cred.type === "api_key") {
|
||||
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
|
||||
}
|
||||
|
||||
const oauthCreds: Record<string, OAuthCredentials> = {
|
||||
[cred.provider]: cred,
|
||||
};
|
||||
const result = await getOAuthApiKey(cred.provider, oauthCreds);
|
||||
if (!result) return null;
|
||||
store.profiles[profileId] = {
|
||||
...cred,
|
||||
...result.newCredentials,
|
||||
type: "oauth",
|
||||
};
|
||||
saveAuthProfileStore(store);
|
||||
return {
|
||||
apiKey: result.apiKey,
|
||||
provider: cred.provider,
|
||||
email: cred.email,
|
||||
};
|
||||
}
|
||||
|
||||
export function markAuthProfileGood(params: {
|
||||
store: AuthProfileStore;
|
||||
provider: string;
|
||||
profileId: string;
|
||||
}): void {
|
||||
const { store, provider, profileId } = params;
|
||||
const profile = store.profiles[profileId];
|
||||
if (!profile || profile.provider !== provider) return;
|
||||
store.lastGood = { ...store.lastGood, [provider]: profileId };
|
||||
saveAuthProfileStore(store);
|
||||
}
|
||||
|
||||
export function resolveAuthStorePathForDisplay(): string {
|
||||
const pathname = resolveAuthStorePath();
|
||||
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
|
||||
}
|
||||
|
||||
export function resolveAuthProfileDisplayLabel(params: {
|
||||
cfg?: ClawdbotConfig;
|
||||
store: AuthProfileStore;
|
||||
profileId: string;
|
||||
}): string {
|
||||
const { cfg, store, profileId } = params;
|
||||
const profile = store.profiles[profileId];
|
||||
const configEmail = cfg?.auth?.profiles?.[profileId]?.email?.trim();
|
||||
const email = configEmail || profile?.email?.trim();
|
||||
if (email) return `${profileId} (${email})`;
|
||||
return profileId;
|
||||
}
|
||||
+15
-115
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const oauthFixture = {
|
||||
@@ -12,16 +13,12 @@ const oauthFixture = {
|
||||
};
|
||||
|
||||
describe("getApiKeyForModel", () => {
|
||||
it("migrates legacy oauth.json into auth-profiles.json", async () => {
|
||||
it("migrates legacy oauth.json into auth.json", async () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
|
||||
|
||||
try {
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const oauthDir = path.join(tempDir, "credentials");
|
||||
await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 });
|
||||
@@ -31,6 +28,10 @@ describe("getApiKeyForModel", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
|
||||
vi.resetModules();
|
||||
const { getApiKeyForModel } = await import("./model-auth.js");
|
||||
|
||||
@@ -40,33 +41,18 @@ describe("getApiKeyForModel", () => {
|
||||
api: "openai-codex-responses",
|
||||
} as Model<Api>;
|
||||
|
||||
const apiKey = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
provider: "openai-codex",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(apiKey.apiKey).toBe(oauthFixture.access);
|
||||
const apiKey = await getApiKeyForModel(model, authStorage);
|
||||
expect(apiKey).toBe(oauthFixture.access);
|
||||
|
||||
const authProfiles = await fs.readFile(
|
||||
path.join(tempDir, "agent", "auth-profiles.json"),
|
||||
const authJson = await fs.readFile(
|
||||
path.join(agentDir, "auth.json"),
|
||||
"utf8",
|
||||
);
|
||||
const authData = JSON.parse(authProfiles) as Record<string, unknown>;
|
||||
expect(authData.profiles).toMatchObject({
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: oauthFixture.access,
|
||||
refresh: oauthFixture.refresh,
|
||||
},
|
||||
const authData = JSON.parse(authJson) as Record<string, unknown>;
|
||||
expect(authData["openai-codex"]).toMatchObject({
|
||||
type: "oauth",
|
||||
access: oauthFixture.access,
|
||||
refresh: oauthFixture.refresh,
|
||||
});
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
@@ -74,92 +60,6 @@ describe("getApiKeyForModel", () => {
|
||||
} 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;
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("suggests openai-codex when only Codex OAuth is configured", async () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
|
||||
try {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const authProfilesPath = path.join(
|
||||
tempDir,
|
||||
"agent",
|
||||
"auth-profiles.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(authProfilesPath), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
await fs.writeFile(
|
||||
authProfilesPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
...oauthFixture,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { resolveApiKeyForProvider } = await import("./model-auth.js");
|
||||
|
||||
let error: unknown = null;
|
||||
try {
|
||||
await resolveApiKeyForProvider({ provider: "openai" });
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
expect(String(error)).toContain("openai-codex/gpt-5.2");
|
||||
} finally {
|
||||
if (previousOpenAiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
||||
}
|
||||
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;
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
+164
-142
@@ -1,157 +1,179 @@
|
||||
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||
import fsSync from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
resolveApiKeyForProfile,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
type Api,
|
||||
getEnvApiKey,
|
||||
getOAuthApiKey,
|
||||
type Model,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
|
||||
export function getCustomProviderApiKey(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
provider: string,
|
||||
): string | undefined {
|
||||
const providers = cfg?.models?.providers ?? {};
|
||||
const entry = providers[provider] as ModelProviderConfig | undefined;
|
||||
const key = entry?.apiKey?.trim();
|
||||
return key || undefined;
|
||||
const OAUTH_FILENAME = "oauth.json";
|
||||
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
|
||||
let oauthStorageConfigured = false;
|
||||
let oauthStorageMigrated = false;
|
||||
|
||||
type OAuthStorage = Record<string, OAuthCredentials>;
|
||||
|
||||
function resolveClawdbotOAuthPath(): string {
|
||||
const overrideDir =
|
||||
process.env.CLAWDBOT_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
|
||||
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
|
||||
}
|
||||
|
||||
export async function resolveApiKeyForProvider(params: {
|
||||
provider: string;
|
||||
cfg?: ClawdbotConfig;
|
||||
profileId?: string;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
||||
const { provider, cfg, profileId, preferredProfile } = params;
|
||||
const store = params.store ?? ensureAuthProfileStore();
|
||||
|
||||
if (profileId) {
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg,
|
||||
store,
|
||||
profileId,
|
||||
});
|
||||
if (!resolved) {
|
||||
throw new Error(`No credentials found for profile "${profileId}".`);
|
||||
}
|
||||
return {
|
||||
apiKey: resolved.apiKey,
|
||||
profileId,
|
||||
source: `profile:${profileId}`,
|
||||
};
|
||||
function loadOAuthStorageAt(pathname: string): OAuthStorage | null {
|
||||
if (!fsSync.existsSync(pathname)) return null;
|
||||
try {
|
||||
const content = fsSync.readFileSync(pathname, "utf8");
|
||||
const json = JSON.parse(content) as OAuthStorage;
|
||||
if (!json || typeof json !== "object") return null;
|
||||
return json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider,
|
||||
preferredProfile,
|
||||
});
|
||||
for (const candidate of order) {
|
||||
try {
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
cfg,
|
||||
store,
|
||||
profileId: candidate,
|
||||
});
|
||||
if (resolved) {
|
||||
return {
|
||||
apiKey: resolved.apiKey,
|
||||
profileId: candidate,
|
||||
source: `profile:${candidate}`,
|
||||
};
|
||||
function hasAnthropicOAuth(storage: OAuthStorage): boolean {
|
||||
const entry = storage.anthropic as
|
||||
| {
|
||||
refresh?: string;
|
||||
refresh_token?: string;
|
||||
refreshToken?: string;
|
||||
access?: string;
|
||||
access_token?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
| undefined;
|
||||
if (!entry) return false;
|
||||
const refresh =
|
||||
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
|
||||
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
|
||||
return Boolean(refresh.trim() && access.trim());
|
||||
}
|
||||
|
||||
const envResolved = resolveEnvApiKey(provider);
|
||||
if (envResolved) {
|
||||
return { apiKey: envResolved.apiKey, source: envResolved.source };
|
||||
}
|
||||
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
|
||||
const dir = path.dirname(pathname);
|
||||
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fsSync.writeFileSync(
|
||||
pathname,
|
||||
`${JSON.stringify(storage, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fsSync.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
||||
if (customKey) {
|
||||
return { apiKey: customKey, source: "models.json" };
|
||||
function legacyOAuthPaths(): string[] {
|
||||
const paths: string[] = [];
|
||||
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (piOverride) {
|
||||
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
|
||||
}
|
||||
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
if (provider === "openai") {
|
||||
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
|
||||
if (hasCodex) {
|
||||
throw new Error(
|
||||
'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.2 (ChatGPT OAuth) or set OPENAI_API_KEY for openai/gpt-5.2.',
|
||||
);
|
||||
function importLegacyOAuthIfNeeded(destPath: string): void {
|
||||
if (fsSync.existsSync(destPath)) return;
|
||||
for (const legacyPath of legacyOAuthPaths()) {
|
||||
const storage = loadOAuthStorageAt(legacyPath);
|
||||
if (!storage || !hasAnthropicOAuth(storage)) continue;
|
||||
saveOAuthStorageAt(destPath, storage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureOAuthStorage(): void {
|
||||
if (oauthStorageConfigured) return;
|
||||
oauthStorageConfigured = true;
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
importLegacyOAuthIfNeeded(oauthPath);
|
||||
}
|
||||
|
||||
function isValidOAuthCredential(
|
||||
entry: OAuthCredentials | undefined,
|
||||
): entry is OAuthCredentials {
|
||||
if (!entry) return false;
|
||||
return Boolean(
|
||||
entry.access?.trim() &&
|
||||
entry.refresh?.trim() &&
|
||||
Number.isFinite(entry.expires),
|
||||
);
|
||||
}
|
||||
|
||||
function migrateOAuthStorageToAuthStorage(
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
): void {
|
||||
if (oauthStorageMigrated) return;
|
||||
oauthStorageMigrated = true;
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
const storage = loadOAuthStorageAt(oauthPath);
|
||||
if (!storage) return;
|
||||
for (const [provider, creds] of Object.entries(storage)) {
|
||||
if (!isValidOAuthCredential(creds)) continue;
|
||||
if (authStorage.get(provider)) continue;
|
||||
authStorage.set(provider, { type: "oauth", ...creds });
|
||||
}
|
||||
}
|
||||
|
||||
export function hydrateAuthStorage(
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
): void {
|
||||
ensureOAuthStorage();
|
||||
migrateOAuthStorageToAuthStorage(authStorage);
|
||||
}
|
||||
|
||||
function isOAuthProvider(provider: string): provider is OAuthProvider {
|
||||
return (
|
||||
provider === "anthropic" ||
|
||||
provider === "anthropic-oauth" ||
|
||||
provider === "google" ||
|
||||
provider === "openai" ||
|
||||
provider === "openai-compatible" ||
|
||||
provider === "openai-codex" ||
|
||||
provider === "github-copilot" ||
|
||||
provider === "google-gemini-cli" ||
|
||||
provider === "google-antigravity"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getApiKeyForModel(
|
||||
model: Model<Api>,
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
): Promise<string> {
|
||||
ensureOAuthStorage();
|
||||
migrateOAuthStorageToAuthStorage(authStorage);
|
||||
const storedKey = await authStorage.getApiKey(model.provider);
|
||||
if (storedKey) return storedKey;
|
||||
if (model.provider === "anthropic") {
|
||||
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
if (oauthEnv?.trim()) return oauthEnv.trim();
|
||||
}
|
||||
const envKey = getEnvApiKey(model.provider);
|
||||
if (envKey) return envKey;
|
||||
if (isOAuthProvider(model.provider)) {
|
||||
const oauthPath = resolveClawdbotOAuthPath();
|
||||
const storage = loadOAuthStorageAt(oauthPath);
|
||||
if (storage) {
|
||||
try {
|
||||
const result = await getOAuthApiKey(model.provider, storage);
|
||||
if (result?.apiKey) {
|
||||
storage[model.provider] = result.newCredentials;
|
||||
saveOAuthStorageAt(oauthPath, storage);
|
||||
return result.apiKey;
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No API key found for provider "${provider}".`);
|
||||
}
|
||||
|
||||
export type EnvApiKeyResult = { apiKey: string; source: string };
|
||||
|
||||
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
const applied = new Set(getShellEnvAppliedKeys());
|
||||
const pick = (envVar: string): EnvApiKeyResult | null => {
|
||||
const value = process.env[envVar]?.trim();
|
||||
if (!value) return null;
|
||||
const source = applied.has(envVar)
|
||||
? `shell env: ${envVar}`
|
||||
: `env: ${envVar}`;
|
||||
return { apiKey: value, source };
|
||||
};
|
||||
|
||||
if (provider === "github-copilot") {
|
||||
return (
|
||||
pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN")
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
if (provider === "google-vertex") {
|
||||
const envKey = getEnvApiKey(provider);
|
||||
if (!envKey) return null;
|
||||
return { apiKey: envKey, source: "gcloud adc" };
|
||||
}
|
||||
|
||||
const envMap: Record<string, string> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
};
|
||||
const envVar = envMap[provider];
|
||||
if (!envVar) return null;
|
||||
return pick(envVar);
|
||||
}
|
||||
|
||||
export async function getApiKeyForModel(params: {
|
||||
model: Model<Api>;
|
||||
cfg?: ClawdbotConfig;
|
||||
profileId?: string;
|
||||
preferredProfile?: string;
|
||||
store?: AuthProfileStore;
|
||||
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
|
||||
return resolveApiKeyForProvider({
|
||||
provider: params.model.provider,
|
||||
cfg: params.cfg,
|
||||
profileId: params.profileId,
|
||||
preferredProfile: params.preferredProfile,
|
||||
store: params.store,
|
||||
});
|
||||
throw new Error(`No API key found for provider "${model.provider}"`);
|
||||
}
|
||||
@@ -33,10 +33,7 @@ function buildAllowedModelKeys(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
defaultProvider: string,
|
||||
): Set<string> | null {
|
||||
const rawAllowlist = (() => {
|
||||
const modelMap = cfg?.agent?.models ?? {};
|
||||
return Object.keys(modelMap);
|
||||
})();
|
||||
const rawAllowlist = cfg?.agent?.allowedModels ?? [];
|
||||
if (rawAllowlist.length === 0) return null;
|
||||
const keys = new Set<string>();
|
||||
for (const raw of rawAllowlist) {
|
||||
@@ -84,28 +81,11 @@ function resolveImageFallbackCandidates(params: {
|
||||
|
||||
if (params.modelOverride?.trim()) {
|
||||
addRaw(params.modelOverride, false);
|
||||
} else {
|
||||
const imageModel = params.cfg?.agent?.imageModel as
|
||||
| { primary?: string }
|
||||
| string
|
||||
| undefined;
|
||||
const primary =
|
||||
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
|
||||
if (primary?.trim()) addRaw(primary, false);
|
||||
} else if (params.cfg?.agent?.imageModel?.trim()) {
|
||||
addRaw(params.cfg.agent.imageModel, false);
|
||||
}
|
||||
|
||||
const imageFallbacks = (() => {
|
||||
const imageModel = params.cfg?.agent?.imageModel as
|
||||
| { fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
if (imageModel && typeof imageModel === "object") {
|
||||
return imageModel.fallbacks ?? [];
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
for (const raw of imageFallbacks) {
|
||||
for (const raw of params.cfg?.agent?.imageModelFallbacks ?? []) {
|
||||
addRaw(raw, true);
|
||||
}
|
||||
|
||||
@@ -141,16 +121,7 @@ function resolveFallbackCandidates(params: {
|
||||
|
||||
addCandidate({ provider, model }, false);
|
||||
|
||||
const modelFallbacks = (() => {
|
||||
const model = params.cfg?.agent?.model as
|
||||
| { fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
if (model && typeof model === "object") return model.fallbacks ?? [];
|
||||
return [];
|
||||
})();
|
||||
|
||||
for (const raw of modelFallbacks) {
|
||||
for (const raw of params.cfg?.agent?.modelFallbacks ?? []) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
@@ -253,7 +224,7 @@ export async function runWithImageModelFallback<T>(params: {
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
throw new Error(
|
||||
"No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.",
|
||||
"No image model configured. Set agent.imageModel or agent.imageModelFallbacks.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import { resolveConfiguredModelRef } from "./model-selection.js";
|
||||
|
||||
describe("resolveConfiguredModelRef", () => {
|
||||
it("parses provider/model from agent.model.primary", () => {
|
||||
it("parses provider/model from agent.model", () => {
|
||||
const cfg = {
|
||||
agent: { model: { primary: "openai/gpt-4.1-mini" } },
|
||||
agent: { model: "openai/gpt-4.1-mini" },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
@@ -19,9 +19,9 @@ describe("resolveConfiguredModelRef", () => {
|
||||
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
|
||||
});
|
||||
|
||||
it("falls back to anthropic when agent.model.primary omits provider", () => {
|
||||
it("falls back to anthropic when agent.model omits provider", () => {
|
||||
const cfg = {
|
||||
agent: { model: { primary: "claude-opus-4-5" } },
|
||||
agent: { model: "claude-opus-4-5" },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
@@ -54,9 +54,9 @@ describe("resolveConfiguredModelRef", () => {
|
||||
it("resolves agent.model aliases when configured", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: { primary: "Opus" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
model: "Opus",
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
@@ -72,18 +72,4 @@ describe("resolveConfiguredModelRef", () => {
|
||||
model: "claude-opus-4-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("still resolves legacy agent.model string", () => {
|
||||
const cfg = {
|
||||
agent: { model: "openai/gpt-4.1-mini" },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ provider: "openai", model: "gpt-4.1-mini" });
|
||||
});
|
||||
});
|
||||
@@ -41,17 +41,18 @@ export function buildModelAliasIndex(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
defaultProvider: string;
|
||||
}): ModelAliasIndex {
|
||||
const rawAliases = params.cfg.agent?.modelAliases ?? {};
|
||||
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
|
||||
const byKey = new Map<string, string[]>();
|
||||
|
||||
const rawModels = params.cfg.agent?.models ?? {};
|
||||
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
|
||||
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
|
||||
if (!parsed) continue;
|
||||
const alias = String(
|
||||
(entryRaw as { alias?: string } | undefined)?.alias ?? "",
|
||||
).trim();
|
||||
for (const [aliasRaw, targetRaw] of Object.entries(rawAliases)) {
|
||||
const alias = aliasRaw.trim();
|
||||
if (!alias) continue;
|
||||
const parsed = parseModelRef(
|
||||
String(targetRaw ?? ""),
|
||||
params.defaultProvider,
|
||||
);
|
||||
if (!parsed) continue;
|
||||
const aliasKey = normalizeAliasKey(alias);
|
||||
byAlias.set(aliasKey, { alias, ref: parsed });
|
||||
const key = modelKey(parsed.provider, parsed.model);
|
||||
@@ -87,14 +88,7 @@ export function resolveConfiguredModelRef(params: {
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
}): ModelRef {
|
||||
const rawModel = (() => {
|
||||
const raw = params.cfg.agent?.model as
|
||||
| { primary?: string }
|
||||
| string
|
||||
| undefined;
|
||||
if (typeof raw === "string") return raw.trim();
|
||||
return raw?.primary?.trim() ?? "";
|
||||
})();
|
||||
const rawModel = params.cfg.agent?.model?.trim() || "";
|
||||
if (rawModel) {
|
||||
const trimmed = rawModel.trim();
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
@@ -122,10 +116,7 @@ export function buildAllowedModelSet(params: {
|
||||
allowedCatalog: ModelCatalogEntry[];
|
||||
allowedKeys: Set<string>;
|
||||
} {
|
||||
const rawAllowlist = (() => {
|
||||
const modelMap = params.cfg.agent?.models ?? {};
|
||||
return Object.keys(modelMap);
|
||||
})();
|
||||
const rawAllowlist = params.cfg.agent?.allowedModels ?? [];
|
||||
const allowAny = rawAllowlist.length === 0;
|
||||
const catalogKeys = new Set(
|
||||
params.catalog.map((entry) => modelKey(entry.provider, entry.id)),
|
||||
|
||||
@@ -120,40 +120,12 @@ export function isRateLimitAssistantError(
|
||||
if (!msg || msg.stopReason !== "error") return false;
|
||||
const raw = (msg.errorMessage ?? "").toLowerCase();
|
||||
if (!raw) return false;
|
||||
return isRateLimitErrorMessage(raw);
|
||||
}
|
||||
|
||||
export function isRateLimitErrorMessage(raw: string): boolean {
|
||||
const value = raw.toLowerCase();
|
||||
return (
|
||||
/rate[_ ]limit|too many requests|429/.test(value) ||
|
||||
value.includes("exceeded your current quota")
|
||||
/rate[_ ]limit|too many requests|429/.test(raw) ||
|
||||
raw.includes("exceeded your current quota")
|
||||
);
|
||||
}
|
||||
|
||||
export function isAuthErrorMessage(raw: string): boolean {
|
||||
const value = raw.toLowerCase();
|
||||
if (!value) return false;
|
||||
return (
|
||||
/invalid[_ ]?api[_ ]?key/.test(value) ||
|
||||
value.includes("incorrect api key") ||
|
||||
value.includes("invalid token") ||
|
||||
value.includes("authentication") ||
|
||||
value.includes("unauthorized") ||
|
||||
value.includes("forbidden") ||
|
||||
value.includes("access denied") ||
|
||||
/\b401\b/.test(value) ||
|
||||
/\b403\b/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAuthAssistantError(
|
||||
msg: AssistantMessage | undefined,
|
||||
): boolean {
|
||||
if (!msg || msg.stopReason !== "error") return false;
|
||||
return isAuthErrorMessage(msg.errorMessage ?? "");
|
||||
}
|
||||
|
||||
function extractSupportedValues(raw: string): string[] {
|
||||
const match =
|
||||
raw.match(/supported values are:\s*([^\n.]+)/i) ??
|
||||
|
||||
@@ -24,23 +24,15 @@ import {
|
||||
} from "../process/command-queue.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import { markAuthProfileGood } from "./auth-profiles.js";
|
||||
import type { BashElevatedDefaults } from "./bash-tools.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
getApiKeyForModel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./model-auth.js";
|
||||
import { getApiKeyForModel } from "./model-auth.js";
|
||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
ensureSessionHeader,
|
||||
formatAssistantErrorText,
|
||||
isAuthAssistantError,
|
||||
isAuthErrorMessage,
|
||||
isRateLimitAssistantError,
|
||||
isRateLimitErrorMessage,
|
||||
pickFallbackThinkingLevel,
|
||||
sanitizeSessionMessagesImages,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
@@ -82,12 +74,6 @@ export type EmbeddedPiRunMeta = {
|
||||
aborted?: boolean;
|
||||
};
|
||||
|
||||
type ApiKeyInfo = {
|
||||
apiKey: string;
|
||||
profileId?: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type EmbeddedPiRunResult = {
|
||||
payloads?: Array<{
|
||||
text?: string;
|
||||
@@ -98,18 +84,6 @@ export type EmbeddedPiRunResult = {
|
||||
meta: EmbeddedPiRunMeta;
|
||||
};
|
||||
|
||||
export type EmbeddedPiCompactResult = {
|
||||
ok: boolean;
|
||||
compacted: boolean;
|
||||
reason?: string;
|
||||
result?: {
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type EmbeddedPiQueueHandle = {
|
||||
queueMessage: (text: string) => Promise<void>;
|
||||
isStreaming: () => boolean;
|
||||
@@ -326,215 +300,6 @@ function resolvePromptSkills(
|
||||
.filter((skill): skill is Skill => Boolean(skill));
|
||||
}
|
||||
|
||||
export async function compactEmbeddedPiSession(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
surface?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config?: ClawdbotConfig;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
bashElevated?: BashElevatedDefaults;
|
||||
customInstructions?: string;
|
||||
lane?: string;
|
||||
enqueue?: typeof enqueueCommand;
|
||||
extraSystemPrompt?: string;
|
||||
ownerNumbers?: string[];
|
||||
}): Promise<EmbeddedPiCompactResult> {
|
||||
const sessionLane = resolveSessionLane(
|
||||
params.sessionKey?.trim() || params.sessionId,
|
||||
);
|
||||
const globalLane = resolveGlobalLane(params.lane);
|
||||
const enqueueGlobal =
|
||||
params.enqueue ??
|
||||
((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
||||
return enqueueCommandInLane(sessionLane, () =>
|
||||
enqueueGlobal(async () => {
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const provider =
|
||||
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
await ensureClawdbotModelsJson(params.config);
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||
provider,
|
||||
modelId,
|
||||
agentDir,
|
||||
);
|
||||
if (!model) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: error ?? `Unknown model: ${provider}/${modelId}`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.config,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: describeUnknownError(err),
|
||||
};
|
||||
}
|
||||
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
await ensureSessionHeader({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
cwd: resolvedWorkspace,
|
||||
});
|
||||
|
||||
let restoreSkillEnv: (() => void) | undefined;
|
||||
process.chdir(resolvedWorkspace);
|
||||
try {
|
||||
const shouldLoadSkillEntries =
|
||||
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
||||
const skillEntries = shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(resolvedWorkspace)
|
||||
: [];
|
||||
const skillsSnapshot =
|
||||
params.skillsSnapshot ??
|
||||
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
|
||||
config: params.config,
|
||||
entries: skillEntries,
|
||||
});
|
||||
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: params.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
snapshot: params.skillsSnapshot,
|
||||
config: params.config,
|
||||
})
|
||||
: applySkillEnvOverrides({
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
|
||||
const bootstrapFiles =
|
||||
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
|
||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
|
||||
const tools = createClawdbotCodingTools({
|
||||
bash: {
|
||||
...params.config?.agent?.bash,
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
surface: params.surface,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
config: params.config,
|
||||
});
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeInfo = {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: `${provider}/${modelId}`,
|
||||
};
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
|
||||
const reasoningTagHint = provider === "ollama";
|
||||
const userTimezone = resolveUserTimezone(
|
||||
params.config?.agent?.userTimezone,
|
||||
);
|
||||
const userTime = formatUserTime(new Date(), userTimezone);
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
appendPrompt: buildAgentSystemPromptAppend({
|
||||
workspaceDir: resolvedWorkspace,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
runtimeInfo,
|
||||
sandboxInfo,
|
||||
toolNames: tools.map((tool) => tool.name),
|
||||
userTimezone,
|
||||
userTime,
|
||||
}),
|
||||
contextFiles,
|
||||
skills: promptSkills,
|
||||
cwd: resolvedWorkspace,
|
||||
tools,
|
||||
});
|
||||
|
||||
const sessionManager = SessionManager.open(params.sessionFile);
|
||||
const settingsManager = SettingsManager.create(
|
||||
resolvedWorkspace,
|
||||
agentDir,
|
||||
);
|
||||
|
||||
const builtInToolNames = new Set(["read", "bash", "edit", "write"]);
|
||||
const builtInTools = tools.filter((t) => builtInToolNames.has(t.name));
|
||||
const customTools = toToolDefinitions(
|
||||
tools.filter((t) => !builtInToolNames.has(t.name)),
|
||||
);
|
||||
|
||||
const { session } = await createAgentSession({
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
model,
|
||||
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
||||
systemPrompt,
|
||||
tools: builtInTools,
|
||||
customTools,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
skills: promptSkills,
|
||||
contextFiles,
|
||||
});
|
||||
|
||||
try {
|
||||
const prior = await sanitizeSessionMessagesImages(
|
||||
session.messages,
|
||||
"session:history",
|
||||
);
|
||||
if (prior.length > 0) {
|
||||
session.agent.replaceMessages(prior);
|
||||
}
|
||||
const result = await session.compact(params.customInstructions);
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: result.summary,
|
||||
firstKeptEntryId: result.firstKeptEntryId,
|
||||
tokensBefore: result.tokensBefore,
|
||||
details: result.details,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: describeUnknownError(err),
|
||||
};
|
||||
} finally {
|
||||
restoreSkillEnv?.();
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runEmbeddedPiAgent(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
@@ -546,7 +311,6 @@ export async function runEmbeddedPiAgent(params: {
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
authProfileId?: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
verboseLevel?: VerboseLevel;
|
||||
bashElevated?: BashElevatedDefaults;
|
||||
@@ -604,68 +368,11 @@ export async function runEmbeddedPiAgent(params: {
|
||||
if (!model) {
|
||||
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
||||
}
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const explicitProfileId = params.authProfileId?.trim();
|
||||
const profileOrder = resolveAuthProfileOrder({
|
||||
cfg: params.config,
|
||||
store: authStore,
|
||||
provider,
|
||||
preferredProfile: explicitProfileId,
|
||||
});
|
||||
if (explicitProfileId && !profileOrder.includes(explicitProfileId)) {
|
||||
throw new Error(
|
||||
`Auth profile "${explicitProfileId}" is not configured for ${provider}.`,
|
||||
);
|
||||
}
|
||||
const profileCandidates =
|
||||
profileOrder.length > 0 ? profileOrder : [undefined];
|
||||
let profileIndex = 0;
|
||||
const initialThinkLevel = params.thinkLevel ?? "off";
|
||||
let thinkLevel = initialThinkLevel;
|
||||
const apiKey = await getApiKeyForModel(model, authStorage);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
|
||||
let thinkLevel = params.thinkLevel ?? "off";
|
||||
const attemptedThinking = new Set<ThinkLevel>();
|
||||
let apiKeyInfo: ApiKeyInfo | null = null;
|
||||
let lastProfileId: string | undefined;
|
||||
|
||||
const resolveApiKeyForCandidate = async (candidate?: string) => {
|
||||
return getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.config,
|
||||
profileId: candidate,
|
||||
store: authStore,
|
||||
});
|
||||
};
|
||||
|
||||
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
|
||||
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
lastProfileId = apiKeyInfo.profileId;
|
||||
};
|
||||
|
||||
const advanceAuthProfile = async (): Promise<boolean> => {
|
||||
let nextIndex = profileIndex + 1;
|
||||
while (nextIndex < profileCandidates.length) {
|
||||
const candidate = profileCandidates[nextIndex];
|
||||
try {
|
||||
await applyApiKeyInfo(candidate);
|
||||
profileIndex = nextIndex;
|
||||
thinkLevel = initialThinkLevel;
|
||||
attemptedThinking.clear();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (candidate && candidate === explicitProfileId) throw err;
|
||||
nextIndex += 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
try {
|
||||
await applyApiKeyInfo(profileCandidates[profileIndex]);
|
||||
} catch (err) {
|
||||
if (profileCandidates[profileIndex] === explicitProfileId) throw err;
|
||||
const advanced = await advanceAuthProfile();
|
||||
if (!advanced) throw err;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const thinkingLevel = mapThinkingLevel(thinkLevel);
|
||||
@@ -904,16 +611,8 @@ export async function runEmbeddedPiAgent(params: {
|
||||
params.abortSignal?.removeEventListener?.("abort", onAbort);
|
||||
}
|
||||
if (promptError && !aborted) {
|
||||
const errorText = describeUnknownError(promptError);
|
||||
if (
|
||||
(isAuthErrorMessage(errorText) ||
|
||||
isRateLimitErrorMessage(errorText)) &&
|
||||
(await advanceAuthProfile())
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const fallbackThinking = pickFallbackThinkingLevel({
|
||||
message: errorText,
|
||||
message: describeUnknownError(promptError),
|
||||
attempted: attemptedThinking,
|
||||
});
|
||||
if (fallbackThinking) {
|
||||
@@ -946,25 +645,13 @@ export async function runEmbeddedPiAgent(params: {
|
||||
}
|
||||
|
||||
const fallbackConfigured =
|
||||
(params.config?.agent?.model?.fallbacks?.length ?? 0) > 0;
|
||||
const authFailure = isAuthAssistantError(lastAssistant);
|
||||
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
|
||||
if (!aborted && (authFailure || rateLimitFailure)) {
|
||||
const rotated = await advanceAuthProfile();
|
||||
if (rotated) {
|
||||
continue;
|
||||
}
|
||||
if (fallbackConfigured) {
|
||||
const message =
|
||||
lastAssistant?.errorMessage?.trim() ||
|
||||
(lastAssistant
|
||||
? formatAssistantErrorText(lastAssistant)
|
||||
: "") ||
|
||||
(rateLimitFailure
|
||||
? "LLM request rate limited."
|
||||
: "LLM request unauthorized.");
|
||||
throw new Error(message);
|
||||
}
|
||||
(params.config?.agent?.modelFallbacks?.length ?? 0) > 0;
|
||||
if (fallbackConfigured && isRateLimitAssistantError(lastAssistant)) {
|
||||
const message =
|
||||
lastAssistant?.errorMessage?.trim() ||
|
||||
(lastAssistant ? formatAssistantErrorText(lastAssistant) : "") ||
|
||||
"LLM request rate limited.";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const usage = lastAssistant?.usage;
|
||||
@@ -1030,13 +717,6 @@ export async function runEmbeddedPiAgent(params: {
|
||||
log.debug(
|
||||
`embedded run done: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - started} aborted=${aborted}`,
|
||||
);
|
||||
if (lastProfileId) {
|
||||
markAuthProfileGood({
|
||||
store: authStore,
|
||||
provider,
|
||||
profileId: lastProfileId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
payloads: payloads.length ? payloads : undefined,
|
||||
meta: {
|
||||
|
||||
@@ -555,10 +555,6 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
compactionInFlight = true;
|
||||
ensureCompactionPromise();
|
||||
log.debug(`embedded run compaction start: runId=${params.runId}`);
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "auto_compaction_end") {
|
||||
@@ -571,10 +567,6 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
} else {
|
||||
maybeResolveCompactionWait();
|
||||
}
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry },
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "agent_end") {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
export type {
|
||||
EmbeddedPiAgentMeta,
|
||||
EmbeddedPiCompactResult,
|
||||
EmbeddedPiRunMeta,
|
||||
EmbeddedPiRunResult,
|
||||
} from "./pi-embedded-runner.js";
|
||||
export {
|
||||
abortEmbeddedPiRun,
|
||||
compactEmbeddedPiSession,
|
||||
isEmbeddedPiRunActive,
|
||||
isEmbeddedPiRunStreaming,
|
||||
queueEmbeddedPiMessage,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"element",
|
||||
"node",
|
||||
"nodeId",
|
||||
"id",
|
||||
"jobId",
|
||||
"requestId",
|
||||
"to",
|
||||
"channelId",
|
||||
@@ -136,10 +136,10 @@
|
||||
"label": "add",
|
||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||
},
|
||||
"update": { "label": "update", "detailKeys": ["id"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["id"] },
|
||||
"run": { "label": "run", "detailKeys": ["id"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["id"] },
|
||||
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||
}
|
||||
},
|
||||
@@ -229,3 +229,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,31 +35,14 @@ describe("cron tool", () => {
|
||||
expect(call.params).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
it("normalizes cron.add job payloads", async () => {
|
||||
it("rejects jobId params", async () => {
|
||||
const tool = createCronTool();
|
||||
await tool.execute("call2", {
|
||||
action: "add",
|
||||
job: {
|
||||
data: {
|
||||
name: "wake-up",
|
||||
schedule: { atMs: 123 },
|
||||
payload: { text: "hello" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledTimes(1);
|
||||
const call = callGatewayMock.mock.calls[0]?.[0] as {
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
};
|
||||
expect(call.method).toBe("cron.add");
|
||||
expect(call.params).toEqual({
|
||||
name: "wake-up",
|
||||
schedule: { kind: "at", atMs: 123 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
await expect(
|
||||
tool.execute("call2", {
|
||||
action: "update",
|
||||
jobId: "job-1",
|
||||
patch: { foo: "bar" },
|
||||
}),
|
||||
).rejects.toThrow("id required");
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,6 @@ import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||
import { CronAddParamsSchema } from "../../gateway/protocol/schema.js";
|
||||
import {
|
||||
normalizeCronJobCreate,
|
||||
normalizeCronJobPatch,
|
||||
} from "../../cron/normalize.js";
|
||||
|
||||
const CronJobPatchSchema = Type.Partial(CronAddParamsSchema);
|
||||
|
||||
const CronToolSchema = Type.Union([
|
||||
Type.Object({
|
||||
@@ -29,7 +22,7 @@ const CronToolSchema = Type.Union([
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
job: CronAddParamsSchema,
|
||||
job: Type.Object({}, { additionalProperties: true }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("update"),
|
||||
@@ -37,7 +30,7 @@ const CronToolSchema = Type.Union([
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
id: Type.String(),
|
||||
patch: CronJobPatchSchema,
|
||||
patch: Type.Object({}, { additionalProperties: true }),
|
||||
}),
|
||||
Type.Object({
|
||||
action: Type.Literal("remove"),
|
||||
@@ -104,9 +97,8 @@ export function createCronTool(): AnyAgentTool {
|
||||
if (!params.job || typeof params.job !== "object") {
|
||||
throw new Error("job required");
|
||||
}
|
||||
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
||||
return jsonResult(
|
||||
await callGatewayTool("cron.add", gatewayOpts, job),
|
||||
await callGatewayTool("cron.add", gatewayOpts, params.job),
|
||||
);
|
||||
}
|
||||
case "update": {
|
||||
@@ -114,11 +106,10 @@ export function createCronTool(): AnyAgentTool {
|
||||
if (!params.patch || typeof params.patch !== "object") {
|
||||
throw new Error("patch required");
|
||||
}
|
||||
const patch = normalizeCronJobPatch(params.patch) ?? params.patch;
|
||||
return jsonResult(
|
||||
await callGatewayTool("cron.update", gatewayOpts, {
|
||||
id,
|
||||
patch,
|
||||
patch: params.patch,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,32 +24,6 @@ type ActionGate = (
|
||||
defaultValue?: boolean,
|
||||
) => boolean;
|
||||
|
||||
function formatDiscordTimestamp(ts?: string | null): string | undefined {
|
||||
if (!ts) return undefined;
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
|
||||
const yyyy = String(date.getFullYear()).padStart(4, "0");
|
||||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getDate()).padStart(2, "0");
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const min = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
// getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset.
|
||||
const offsetMinutes = -date.getTimezoneOffset();
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absOffsetMinutes = Math.abs(offsetMinutes);
|
||||
const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0");
|
||||
const offsetM = String(absOffsetMinutes % 60).padStart(2, "0");
|
||||
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const tzSuffix = tz ? `{${tz}}` : "";
|
||||
|
||||
// Compact ISO-like *local* timestamp with minutes precision.
|
||||
// Example: 2025-01-02T03:04-08:00{America/Los_Angeles}
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`;
|
||||
}
|
||||
|
||||
export async function handleDiscordMessagingAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
@@ -159,12 +133,7 @@ export async function handleDiscordMessagingAction(
|
||||
after: readStringParam(params, "after"),
|
||||
around: readStringParam(params, "around"),
|
||||
});
|
||||
const formattedMessages = messages.map((message) => ({
|
||||
...message,
|
||||
timestamp:
|
||||
formatDiscordTimestamp(message.timestamp) ?? message.timestamp,
|
||||
}));
|
||||
return jsonResult({ ok: true, messages: formattedMessages });
|
||||
return jsonResult({ ok: true, messages });
|
||||
}
|
||||
case "sendMessage": {
|
||||
if (!isActionEnabled("messages")) {
|
||||
|
||||
@@ -24,15 +24,9 @@ import type { AnyAgentTool } from "./common.js";
|
||||
const DEFAULT_PROMPT = "Describe the image.";
|
||||
|
||||
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
|
||||
const imageModel = cfg?.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
const primary =
|
||||
typeof imageModel === "string" ? imageModel.trim() : imageModel?.primary;
|
||||
const fallbacks =
|
||||
typeof imageModel === "object" ? (imageModel?.fallbacks ?? []) : [];
|
||||
return Boolean(primary?.trim() || fallbacks.length > 0);
|
||||
const primary = cfg?.agent?.imageModel?.trim();
|
||||
const fallbacks = cfg?.agent?.imageModelFallbacks ?? [];
|
||||
return Boolean(primary || fallbacks.length > 0);
|
||||
}
|
||||
|
||||
function pickMaxBytes(
|
||||
@@ -101,18 +95,15 @@ async function runImagePrompt(params: {
|
||||
`Model does not support images: ${provider}/${modelId}`,
|
||||
);
|
||||
}
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
const apiKey = await getApiKeyForModel(model, authStorage);
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKey);
|
||||
const context = buildImageContext(
|
||||
params.prompt,
|
||||
params.base64,
|
||||
params.mimeType,
|
||||
);
|
||||
const message = (await complete(model, context, {
|
||||
apiKey: apiKeyInfo.apiKey,
|
||||
apiKey,
|
||||
maxTokens: 512,
|
||||
temperature: 0,
|
||||
})) as AssistantMessage;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import type { MsgContext } from "./templating.js";
|
||||
|
||||
export type CommandAuthorization = {
|
||||
isWhatsAppSurface: boolean;
|
||||
ownerList: string[];
|
||||
senderE164?: string;
|
||||
isAuthorizedSender: boolean;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export function resolveCommandAuthorization(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandAuthorization {
|
||||
const { ctx, cfg, commandAuthorized } = params;
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const hasWhatsappPrefix =
|
||||
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||
(ctx.To ?? "").startsWith("whatsapp:");
|
||||
const looksLikeE164 = (value: string) =>
|
||||
Boolean(value && /^\+?\d{3,}$/.test(value.replace(/[^\d+]/g, "")));
|
||||
const inferWhatsApp =
|
||||
!surface &&
|
||||
Boolean(cfg.whatsapp?.allowFrom?.length) &&
|
||||
(looksLikeE164(from) || looksLikeE164(to));
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" || hasWhatsappPrefix || inferWhatsApp;
|
||||
|
||||
const configuredAllowFrom = isWhatsAppSurface
|
||||
? cfg.whatsapp?.allowFrom
|
||||
: undefined;
|
||||
const allowFromList =
|
||||
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
|
||||
const allowAll =
|
||||
!isWhatsAppSurface ||
|
||||
allowFromList.length === 0 ||
|
||||
allowFromList.some((entry) => entry.trim() === "*");
|
||||
|
||||
const senderE164 = normalizeE164(
|
||||
ctx.SenderE164 ?? (isWhatsAppSurface ? from : ""),
|
||||
);
|
||||
const ownerCandidates =
|
||||
isWhatsAppSurface && !allowAll
|
||||
? allowFromList.filter((entry) => entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
const isOwner =
|
||||
!isWhatsAppSurface ||
|
||||
allowAll ||
|
||||
ownerList.length === 0 ||
|
||||
(senderE164 ? ownerList.includes(senderE164) : false);
|
||||
const isAuthorizedSender = commandAuthorized && isOwner;
|
||||
|
||||
return {
|
||||
isWhatsAppSurface,
|
||||
ownerList,
|
||||
senderE164: senderE164 || undefined,
|
||||
isAuthorizedSender,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
const CONTROL_COMMAND_RE =
|
||||
/(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)(?=$|\s|:)\b/i;
|
||||
/(?:^|\s)\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new)(?=$|\s|:)\b/i;
|
||||
|
||||
const CONTROL_COMMAND_EXACT = new Set([
|
||||
"help",
|
||||
@@ -16,8 +16,6 @@ const CONTROL_COMMAND_EXACT = new Set([
|
||||
"/reset",
|
||||
"new",
|
||||
"/new",
|
||||
"compact",
|
||||
"/compact",
|
||||
]);
|
||||
|
||||
export function hasControlCommand(text?: string): boolean {
|
||||
|
||||
+2
-11
@@ -1,28 +1,19 @@
|
||||
export function extractModelDirective(body?: string): {
|
||||
cleaned: string;
|
||||
rawModel?: string;
|
||||
rawProfile?: string;
|
||||
hasDirective: boolean;
|
||||
} {
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
const match = body.match(
|
||||
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)?)?/i,
|
||||
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:-]+(?:\/[A-Za-z0-9_.:-]+)?)?/i,
|
||||
);
|
||||
const raw = match?.[1]?.trim();
|
||||
let rawModel = raw;
|
||||
let rawProfile: string | undefined;
|
||||
if (raw?.includes("@")) {
|
||||
const parts = raw.split("@");
|
||||
rawModel = parts[0]?.trim();
|
||||
rawProfile = parts.slice(1).join("@").trim() || undefined;
|
||||
}
|
||||
const rawModel = match?.[1]?.trim();
|
||||
const cleaned = match
|
||||
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
|
||||
: body.trim();
|
||||
return {
|
||||
cleaned,
|
||||
rawModel,
|
||||
rawProfile,
|
||||
hasDirective: !!match,
|
||||
};
|
||||
}
|
||||
@@ -37,24 +37,11 @@ vi.mock("../agents/model-catalog.js", () => ({
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-"));
|
||||
const previousHome = process.env.HOME;
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
process.env.HOME = base;
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(base, ".clawdbot");
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(base, ".clawdbot", "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
process.env.HOME = previousHome;
|
||||
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;
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -579,12 +566,9 @@ describe("directive parsing", () => {
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
@@ -609,12 +593,9 @@ describe("directive parsing", () => {
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
@@ -639,12 +620,9 @@ describe("directive parsing", () => {
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
@@ -668,11 +646,9 @@ describe("directive parsing", () => {
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
},
|
||||
allowedModels: ["anthropic/claude-opus-4-5"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
@@ -695,12 +671,9 @@ describe("directive parsing", () => {
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
allowedModels: ["openai/gpt-4.1-mini"],
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
@@ -726,11 +699,11 @@ describe("directive parsing", () => {
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
model: "openai/gpt-4.1-mini",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
allowedModels: ["openai/gpt-4.1-mini", "anthropic/claude-opus-4-5"],
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -748,55 +721,6 @@ describe("directive parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stores auth profile overrides on /model directive", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
const authDir = path.join(home, ".clawdbot", "agent");
|
||||
await fs.mkdir(authDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(
|
||||
path.join(authDir, "auth-profiles.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-test-1234567890",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Auth profile set to anthropic:work");
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store.main;
|
||||
expect(entry.authProfileOverride).toBe("anthropic:work");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("queues a system event when switching models", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
drainSystemEvents();
|
||||
@@ -808,11 +732,11 @@ describe("directive parsing", () => {
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "openai/gpt-4.1-mini" },
|
||||
model: "openai/gpt-4.1-mini",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"openai/gpt-4.1-mini": {},
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
allowedModels: ["openai/gpt-4.1-mini", "anthropic/claude-opus-4-5"],
|
||||
modelAliases: {
|
||||
Opus: "anthropic/claude-opus-4-5",
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
@@ -847,12 +771,9 @@ describe("directive parsing", () => {
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
allowedModels: ["openai/gpt-4.1-mini"],
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
|
||||
@@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
compactEmbeddedPiSession: vi.fn(),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
@@ -14,12 +13,9 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
import {
|
||||
compactEmbeddedPiSession,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../agents/pi-embedded.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
|
||||
import { resolveSessionKey } from "../config/sessions.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
@@ -674,111 +670,6 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reset for unauthorized /reset", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/reset",
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
CommandAuthorized: false,
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1999"],
|
||||
},
|
||||
session: {
|
||||
store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks /reset for non-owner senders", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/reset",
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1999"],
|
||||
},
|
||||
session: {
|
||||
store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`),
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(res).toBeUndefined();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("runs /compact as a gated command", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = join(
|
||||
tmpdir(),
|
||||
`clawdbot-session-test-${Date.now()}.json`,
|
||||
);
|
||||
vi.mocked(compactEmbeddedPiSession).mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "summary",
|
||||
firstKeptEntryId: "x",
|
||||
tokensBefore: 12000,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/compact focus on decisions",
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
},
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text?.startsWith("⚙️ Compacted")).toBe(true);
|
||||
expect(compactEmbeddedPiSession).toHaveBeenCalledOnce();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
const store = loadSessionStore(storePath);
|
||||
const sessionKey = resolveSessionKey("per-sender", {
|
||||
Body: "/compact focus on decisions",
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
});
|
||||
expect(store[sessionKey]?.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores think directives that only appear in the context wrapper", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
|
||||
+3
-33
@@ -24,7 +24,6 @@ import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveCommandAuthorization } from "./command-auth.js";
|
||||
import { hasControlCommand } from "./command-detection.js";
|
||||
import { getAbortMemory } from "./reply/abort.js";
|
||||
import { runReplyAgent } from "./reply/agent-runner.js";
|
||||
@@ -43,7 +42,6 @@ import {
|
||||
defaultGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
} from "./reply/groups.js";
|
||||
import { stripMentions } from "./reply/mentions.js";
|
||||
import {
|
||||
createModelSelectionState,
|
||||
resolveContextTokens,
|
||||
@@ -78,9 +76,6 @@ export type { GetReplyOptions, ReplyPayload } from "./types.js";
|
||||
const BARE_SESSION_RESET_PROMPT =
|
||||
"A new session was started via /new or /reset. Say hi briefly (1-2 sentences) and ask what the user wants to do next. Do not mention internal steps, files, tools, or reasoning.";
|
||||
|
||||
const CONTROL_COMMAND_PREFIX_RE =
|
||||
/^\/(?:status|help|thinking|think|t|verbose|v|elevated|elev|model|queue|activation|send|restart|reset|new|compact)\b/i;
|
||||
|
||||
function normalizeAllowToken(value?: string) {
|
||||
if (!value) return "";
|
||||
return value.trim().toLowerCase();
|
||||
@@ -245,17 +240,7 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||
const commandAuth = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
});
|
||||
const sessionState = await initSessionState({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
});
|
||||
const sessionState = await initSessionState({ ctx, cfg });
|
||||
let {
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
@@ -273,6 +258,7 @@ export async function getReplyFromConfig(
|
||||
} = sessionState;
|
||||
|
||||
const rawBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const commandAuthorized = ctx.CommandAuthorized ?? true;
|
||||
const parsedDirectives = parseInlineDirectives(rawBody);
|
||||
const directives = commandAuthorized
|
||||
? parsedDirectives
|
||||
@@ -375,9 +361,7 @@ export async function getReplyFromConfig(
|
||||
: `Model switched to ${label}.`;
|
||||
const isModelListAlias =
|
||||
directives.hasModelDirective &&
|
||||
["status", "list"].includes(
|
||||
directives.rawModelDirective?.trim().toLowerCase() ?? "",
|
||||
);
|
||||
directives.rawModelDirective?.trim().toLowerCase() === "status";
|
||||
const effectiveModelDirective = isModelListAlias
|
||||
? undefined
|
||||
: directives.rawModelDirective;
|
||||
@@ -392,7 +376,6 @@ export async function getReplyFromConfig(
|
||||
})
|
||||
) {
|
||||
const directiveReply = await handleDirectiveOnly({
|
||||
cfg,
|
||||
directives,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
@@ -418,7 +401,6 @@ export async function getReplyFromConfig(
|
||||
const persisted = await persistInlineDirectives({
|
||||
directives,
|
||||
effectiveModelDirective,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
@@ -530,16 +512,6 @@ export async function getReplyFromConfig(
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
const rawBodyTrimmed = (ctx.Body ?? "").trim();
|
||||
const baseBodyTrimmedRaw = baseBody.trim();
|
||||
const strippedCommandBody = isGroup
|
||||
? stripMentions(triggerBodyNormalized, ctx, cfg)
|
||||
: triggerBodyNormalized;
|
||||
if (
|
||||
!commandAuth.isAuthorizedSender &&
|
||||
CONTROL_COMMAND_PREFIX_RE.test(strippedCommandBody.trim())
|
||||
) {
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
}
|
||||
if (!commandAuthorized && !baseBodyTrimmedRaw && hasControlCommand(rawBody)) {
|
||||
typing.cleanup();
|
||||
return undefined;
|
||||
@@ -662,7 +634,6 @@ export async function getReplyFromConfig(
|
||||
resolvedQueue.mode === "followup" ||
|
||||
resolvedQueue.mode === "collect" ||
|
||||
resolvedQueue.mode === "steer-backlog";
|
||||
const authProfileId = sessionEntry?.authProfileOverride;
|
||||
const followupRun = {
|
||||
prompt: queuedBody,
|
||||
summaryLine: baseBodyTrimmedRaw,
|
||||
@@ -677,7 +648,6 @@ export async function getReplyFromConfig(
|
||||
skillsSnapshot,
|
||||
provider,
|
||||
model,
|
||||
authProfileId,
|
||||
thinkLevel: resolvedThinkLevel,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
elevatedLevel: resolvedElevatedLevel,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { GetReplyOptions } from "../types.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
@@ -58,14 +54,7 @@ type EmbeddedPiAgentParams = {
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function createMinimalRun(params?: {
|
||||
opts?: GetReplyOptions;
|
||||
resolvedVerboseLevel?: "off" | "on";
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
}) {
|
||||
function createMinimalRun(params?: { opts?: GetReplyOptions }) {
|
||||
const typing = createTyping();
|
||||
const opts = params?.opts;
|
||||
const sessionCtx = {
|
||||
@@ -73,14 +62,13 @@ function createMinimalRun(params?: {
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const sessionKey = params?.sessionKey ?? "main";
|
||||
const followupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey,
|
||||
sessionKey: "main",
|
||||
surface: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
@@ -89,7 +77,7 @@ function createMinimalRun(params?: {
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: params?.resolvedVerboseLevel ?? "off",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
@@ -116,13 +104,9 @@ function createMinimalRun(params?: {
|
||||
isStreaming: false,
|
||||
opts,
|
||||
typing,
|
||||
sessionEntry: params?.sessionEntry,
|
||||
sessionStore: params?.sessionStore,
|
||||
sessionKey,
|
||||
storePath: params?.storePath,
|
||||
sessionCtx,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off",
|
||||
resolvedVerboseLevel: "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
@@ -169,42 +153,4 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces auto-compaction in verbose mode and tracks count", async () => {
|
||||
const storePath = path.join(
|
||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||
"sessions.json",
|
||||
);
|
||||
const sessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void;
|
||||
}) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
const payloads = res as { text?: string }[];
|
||||
expect(payloads[0]?.text).toContain("Auto-compaction complete");
|
||||
expect(payloads[0]?.text).toContain("count 1");
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
scheduleFollowupDrain,
|
||||
} from "./queue.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export async function runReplyAgent(params: {
|
||||
@@ -168,7 +167,6 @@ export async function runReplyAgent(params: {
|
||||
};
|
||||
|
||||
let didLogHeartbeatStrip = false;
|
||||
let autoCompactionCompleted = false;
|
||||
try {
|
||||
const runId = crypto.randomUUID();
|
||||
if (sessionKey) {
|
||||
@@ -197,7 +195,6 @@ export async function runReplyAgent(params: {
|
||||
enforceFinalTag: followupRun.run.enforceFinalTag,
|
||||
provider,
|
||||
model,
|
||||
authProfileId: followupRun.run.authProfileId,
|
||||
thinkLevel: followupRun.run.thinkLevel,
|
||||
verboseLevel: followupRun.run.verboseLevel,
|
||||
bashElevated: followupRun.run.bashElevated,
|
||||
@@ -235,14 +232,6 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase = String(evt.data.phase ?? "");
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
},
|
||||
onBlockReply:
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
@@ -488,21 +477,6 @@ export async function runReplyAgent(params: {
|
||||
|
||||
// If verbose is enabled and this is a new session, prepend a session hint.
|
||||
let finalPayloads = filteredPayloads;
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (resolvedVerboseLevel === "on") {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
finalPayloads = [
|
||||
{ text: `🧹 Auto-compaction complete${suffix}.` },
|
||||
...finalPayloads,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (resolvedVerboseLevel === "on" && isNewSession) {
|
||||
finalPayloads = [
|
||||
{ text: `🧭 New session: ${followupRun.run.sessionId}` },
|
||||
|
||||
@@ -1,50 +1,33 @@
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
compactEmbeddedPiSession,
|
||||
isEmbeddedPiRunActive,
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import fs from "node:fs";
|
||||
|
||||
import { getEnvApiKey } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { resolveOAuthPath } from "../../config/paths.js";
|
||||
import {
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { triggerClawdbotRestart } from "../../infra/restart.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { normalizeE164 } from "../../utils.js";
|
||||
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
|
||||
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import {
|
||||
normalizeGroupActivation,
|
||||
parseActivationCommand,
|
||||
} from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import {
|
||||
buildHelpMessage,
|
||||
buildStatusMessage,
|
||||
formatContextUsageShort,
|
||||
formatTokenCount,
|
||||
} from "../status.js";
|
||||
import { buildHelpMessage, buildStatusMessage } from "../status.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import { stripMentions } from "./mentions.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
@@ -59,60 +42,59 @@ export type CommandContext = {
|
||||
to?: string;
|
||||
};
|
||||
|
||||
function resolveModelAuthLabel(
|
||||
provider?: string,
|
||||
cfg?: ClawdbotConfig,
|
||||
): string | undefined {
|
||||
function hasOAuthCredentials(provider: string): boolean {
|
||||
try {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
if (!fs.existsSync(oauthPath)) return false;
|
||||
const raw = fs.readFileSync(oauthPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const entry = parsed?.[provider] as
|
||||
| {
|
||||
refresh?: string;
|
||||
refresh_token?: string;
|
||||
refreshToken?: string;
|
||||
access?: string;
|
||||
access_token?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!entry) return false;
|
||||
const refresh =
|
||||
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
|
||||
const access =
|
||||
entry.access ?? entry.access_token ?? entry.accessToken ?? "";
|
||||
return Boolean(refresh.trim() && access.trim());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveModelAuthLabel(provider?: string): string | undefined {
|
||||
const resolved = provider?.trim();
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const store = ensureAuthProfileStore();
|
||||
const profiles = listProfilesForProvider(store, resolved);
|
||||
if (profiles.length > 0) {
|
||||
const modes = new Set(
|
||||
profiles
|
||||
.map((id) => store.profiles[id]?.type)
|
||||
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
|
||||
);
|
||||
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
|
||||
if (modes.has("oauth")) return "oauth";
|
||||
if (modes.has("api_key")) return "api-key";
|
||||
try {
|
||||
const authStorage = discoverAuthStorage(resolveClawdbotAgentDir());
|
||||
const stored = authStorage.get(resolved);
|
||||
if (stored?.type === "oauth") return "oauth";
|
||||
if (stored?.type === "api_key") return "api-key";
|
||||
} catch {
|
||||
// ignore auth storage errors
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey(resolved);
|
||||
if (envKey?.apiKey) {
|
||||
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
|
||||
if (resolved === "anthropic") {
|
||||
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
if (oauthEnv?.trim()) return "oauth";
|
||||
}
|
||||
|
||||
if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
|
||||
if (hasOAuthCredentials(resolved)) return "oauth";
|
||||
|
||||
const envKey = getEnvApiKey(resolved);
|
||||
if (envKey?.trim()) return "api-key";
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function extractCompactInstructions(params: {
|
||||
rawBody?: string;
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
isGroup: boolean;
|
||||
}): string | undefined {
|
||||
const raw = stripStructuralPrefixes(params.rawBody ?? "");
|
||||
const stripped = params.isGroup
|
||||
? stripMentions(raw, params.ctx, params.cfg)
|
||||
: raw;
|
||||
const trimmed = stripped.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const lowered = trimmed.toLowerCase();
|
||||
const prefix = lowered.startsWith("/compact")
|
||||
? "/compact"
|
||||
: lowered.startsWith("compact")
|
||||
? "compact"
|
||||
: null;
|
||||
if (!prefix) return undefined;
|
||||
let rest = trimmed.slice(prefix.length).trimStart();
|
||||
if (rest.startsWith(":")) rest = rest.slice(1).trimStart();
|
||||
return rest.length ? rest : undefined;
|
||||
}
|
||||
|
||||
export function buildCommandContext(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -121,31 +103,66 @@ export function buildCommandContext(params: {
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
}): CommandContext {
|
||||
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
|
||||
const auth = resolveCommandAuthorization({
|
||||
const {
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
});
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
} = params;
|
||||
const surface = (ctx.Surface ?? "").trim().toLowerCase();
|
||||
const abortKey =
|
||||
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||
const isWhatsAppSurface =
|
||||
surface === "whatsapp" ||
|
||||
(ctx.From ?? "").startsWith("whatsapp:") ||
|
||||
(ctx.To ?? "").startsWith("whatsapp:");
|
||||
|
||||
const configuredAllowFrom = isWhatsAppSurface
|
||||
? cfg.whatsapp?.allowFrom
|
||||
: undefined;
|
||||
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
|
||||
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
|
||||
const allowFromList =
|
||||
configuredAllowFrom?.filter((entry) => entry?.trim()) ?? [];
|
||||
const allowAll =
|
||||
!isWhatsAppSurface ||
|
||||
allowFromList.length === 0 ||
|
||||
allowFromList.some((entry) => entry.trim() === "*");
|
||||
|
||||
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = isGroup
|
||||
? stripMentions(rawBodyNormalized, ctx, cfg)
|
||||
: rawBodyNormalized;
|
||||
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
|
||||
const ownerCandidates =
|
||||
isWhatsAppSurface && !allowAll
|
||||
? allowFromList.filter((entry) => entry !== "*")
|
||||
: [];
|
||||
if (isWhatsAppSurface && !allowAll && ownerCandidates.length === 0 && to) {
|
||||
ownerCandidates.push(to);
|
||||
}
|
||||
const ownerList = ownerCandidates
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
const isOwner =
|
||||
!isWhatsAppSurface ||
|
||||
allowAll ||
|
||||
ownerList.length === 0 ||
|
||||
(senderE164 ? ownerList.includes(senderE164) : false);
|
||||
const isAuthorizedSender = commandAuthorized && isOwner;
|
||||
|
||||
return {
|
||||
surface,
|
||||
isWhatsAppSurface: auth.isWhatsAppSurface,
|
||||
ownerList: auth.ownerList,
|
||||
isAuthorizedSender: auth.isAuthorizedSender,
|
||||
senderE164: auth.senderE164,
|
||||
isWhatsAppSurface,
|
||||
ownerList,
|
||||
isAuthorizedSender,
|
||||
senderE164: senderE164 || undefined,
|
||||
abortKey,
|
||||
rawBodyNormalized,
|
||||
commandBodyNormalized,
|
||||
from: auth.from,
|
||||
to: auth.to,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,7 +191,6 @@ export async function handleCommands(params: {
|
||||
shouldContinue: boolean;
|
||||
}> {
|
||||
const {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives,
|
||||
@@ -195,18 +211,6 @@ export async function handleCommands(params: {
|
||||
isGroup,
|
||||
} = params;
|
||||
|
||||
const resetRequested =
|
||||
command.commandBodyNormalized === "/reset" ||
|
||||
command.commandBodyNormalized === "reset" ||
|
||||
command.commandBodyNormalized === "/new" ||
|
||||
command.commandBodyNormalized === "new";
|
||||
if (resetRequested && !command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /reset from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const activationCommand = parseActivationCommand(
|
||||
command.commandBodyNormalized,
|
||||
);
|
||||
@@ -354,10 +358,7 @@ export async function handleCommands(params: {
|
||||
const statusText = buildStatusMessage({
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...cfg.agent?.model,
|
||||
primary: model,
|
||||
},
|
||||
model,
|
||||
contextTokens,
|
||||
thinkingDefault: cfg.agent?.thinkingDefault,
|
||||
verboseDefault: cfg.agent?.verboseDefault,
|
||||
@@ -373,7 +374,7 @@ export async function handleCommands(params: {
|
||||
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
resolvedElevated: resolvedElevatedLevel,
|
||||
modelAuth: resolveModelAuthLabel(provider, cfg),
|
||||
modelAuth: resolveModelAuthLabel(provider),
|
||||
webLinked,
|
||||
webAuthAgeMs,
|
||||
heartbeatSeconds,
|
||||
@@ -381,86 +382,6 @@ export async function handleCommands(params: {
|
||||
return { shouldContinue: false, reply: { text: statusText } };
|
||||
}
|
||||
|
||||
const compactRequested =
|
||||
command.commandBodyNormalized === "/compact" ||
|
||||
command.commandBodyNormalized === "compact" ||
|
||||
command.commandBodyNormalized.startsWith("/compact ") ||
|
||||
command.commandBodyNormalized.startsWith("compact ");
|
||||
if (compactRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /compact from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (!sessionEntry?.sessionId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Compaction unavailable (missing session id)." },
|
||||
};
|
||||
}
|
||||
const sessionId = sessionEntry.sessionId;
|
||||
if (isEmbeddedPiRunActive(sessionId)) {
|
||||
abortEmbeddedPiRun(sessionId);
|
||||
await waitForEmbeddedPiRunEnd(sessionId, 15_000);
|
||||
}
|
||||
const customInstructions = extractCompactInstructions({
|
||||
rawBody: ctx.Body,
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup,
|
||||
});
|
||||
const result = await compactEmbeddedPiSession({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
surface: command.surface,
|
||||
sessionFile: resolveSessionTranscriptPath(sessionId),
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
skillsSnapshot: sessionEntry.skillsSnapshot,
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
customInstructions,
|
||||
ownerNumbers:
|
||||
command.ownerList.length > 0 ? command.ownerList : undefined,
|
||||
});
|
||||
|
||||
const totalTokens =
|
||||
sessionEntry.totalTokens ??
|
||||
(sessionEntry.inputTokens ?? 0) + (sessionEntry.outputTokens ?? 0);
|
||||
const contextSummary = formatContextUsageShort(
|
||||
totalTokens > 0 ? totalTokens : null,
|
||||
contextTokens ?? sessionEntry.contextTokens ?? null,
|
||||
);
|
||||
const compactLabel = result.ok
|
||||
? result.compacted
|
||||
? result.result?.tokensBefore
|
||||
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
|
||||
: "Compacted"
|
||||
: "Compaction skipped"
|
||||
: "Compaction failed";
|
||||
if (result.ok && result.compacted) {
|
||||
await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
}
|
||||
const reason = result.reason?.trim();
|
||||
const line = reason
|
||||
? `${compactLabel}: ${reason} • ${contextSummary}`
|
||||
: `${compactLabel} • ${contextSummary}`;
|
||||
enqueueSystemEvent(line);
|
||||
return { shouldContinue: false, reply: { text: `⚙️ ${line}` } };
|
||||
}
|
||||
|
||||
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
|
||||
if (abortRequested) {
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { getEnvApiKey } from "@mariozechner/pi-ai";
|
||||
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import {
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthStorePathForDisplay,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../../agents/defaults.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
getCustomProviderApiKey,
|
||||
resolveAuthProfileOrder,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.js";
|
||||
import { hydrateAuthStorage } from "../../agents/model-auth.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
type ModelAliasIndex,
|
||||
@@ -60,63 +53,43 @@ const maskApiKey = (value: string): string => {
|
||||
|
||||
const resolveAuthLabel = async (
|
||||
provider: string,
|
||||
cfg: ClawdbotConfig,
|
||||
modelsPath: string,
|
||||
authStorage: ReturnType<typeof discoverAuthStorage>,
|
||||
authPaths: { authPath: string; modelsPath: string },
|
||||
): Promise<{ label: string; source: string }> => {
|
||||
const formatPath = (value: string) => shortenHomePath(value);
|
||||
const store = ensureAuthProfileStore();
|
||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||
if (order.length > 0) {
|
||||
const labels = order.map((profileId) => {
|
||||
const profile = store.profiles[profileId];
|
||||
const configProfile = cfg.auth?.profiles?.[profileId];
|
||||
if (
|
||||
!profile ||
|
||||
(configProfile?.provider &&
|
||||
configProfile.provider !== profile.provider) ||
|
||||
(configProfile?.mode && configProfile.mode !== profile.type)
|
||||
) {
|
||||
return `${profileId}=missing`;
|
||||
}
|
||||
if (profile.type === "api_key") {
|
||||
return `${profileId}=${maskApiKey(profile.key)}`;
|
||||
}
|
||||
const display = resolveAuthProfileDisplayLabel({
|
||||
cfg,
|
||||
store,
|
||||
profileId,
|
||||
});
|
||||
const suffix =
|
||||
display === profileId
|
||||
? ""
|
||||
: display.startsWith(profileId)
|
||||
? display.slice(profileId.length).trim()
|
||||
: `(${display})`;
|
||||
return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
|
||||
});
|
||||
const stored = authStorage.get(provider);
|
||||
if (stored?.type === "oauth") {
|
||||
const email = stored.email?.trim();
|
||||
return {
|
||||
label: labels.join(", "),
|
||||
source: `auth-profiles.json: ${formatPath(
|
||||
resolveAuthStorePathForDisplay(),
|
||||
)}`,
|
||||
label: email ? `OAuth ${email}` : "OAuth (unknown)",
|
||||
source: `auth.json: ${formatPath(authPaths.authPath)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey(provider);
|
||||
if (envKey) {
|
||||
const isOAuthEnv =
|
||||
envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") ||
|
||||
envKey.source.toLowerCase().includes("oauth");
|
||||
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
|
||||
return { label, source: envKey.source };
|
||||
}
|
||||
const customKey = getCustomProviderApiKey(cfg, provider);
|
||||
if (customKey) {
|
||||
if (stored?.type === "api_key") {
|
||||
return {
|
||||
label: maskApiKey(customKey),
|
||||
source: `models.json: ${formatPath(modelsPath)}`,
|
||||
label: maskApiKey(stored.key),
|
||||
source: `auth.json: ${formatPath(authPaths.authPath)}`,
|
||||
};
|
||||
}
|
||||
const envKey = getEnvApiKey(provider);
|
||||
if (envKey) return { label: maskApiKey(envKey), source: "env" };
|
||||
if (provider === "anthropic") {
|
||||
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
|
||||
if (oauthEnv) {
|
||||
return { label: "OAuth (env)", source: "env: ANTHROPIC_OAUTH_TOKEN" };
|
||||
}
|
||||
}
|
||||
try {
|
||||
const key = await authStorage.getApiKey(provider);
|
||||
if (key) {
|
||||
return {
|
||||
label: maskApiKey(key),
|
||||
source: `models.json: ${formatPath(authPaths.modelsPath)}`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore missing auth
|
||||
}
|
||||
return { label: "missing", source: "missing" };
|
||||
};
|
||||
|
||||
@@ -127,26 +100,6 @@ const formatAuthLabel = (auth: { label: string; source: string }) => {
|
||||
return `${auth.label} (${auth.source})`;
|
||||
};
|
||||
|
||||
const resolveProfileOverride = (params: {
|
||||
rawProfile?: string;
|
||||
provider: string;
|
||||
cfg: ClawdbotConfig;
|
||||
}): { profileId?: string; error?: string } => {
|
||||
const raw = params.rawProfile?.trim();
|
||||
if (!raw) return {};
|
||||
const store = ensureAuthProfileStore();
|
||||
const profile = store.profiles[raw];
|
||||
if (!profile) {
|
||||
return { error: `Auth profile "${raw}" not found.` };
|
||||
}
|
||||
if (profile.provider !== params.provider) {
|
||||
return {
|
||||
error: `Auth profile "${raw}" is for ${profile.provider}, not ${params.provider}.`,
|
||||
};
|
||||
}
|
||||
return { profileId: raw };
|
||||
};
|
||||
|
||||
export type InlineDirectives = {
|
||||
cleaned: string;
|
||||
hasThinkDirective: boolean;
|
||||
@@ -161,7 +114,6 @@ export type InlineDirectives = {
|
||||
hasStatusDirective: boolean;
|
||||
hasModelDirective: boolean;
|
||||
rawModelDirective?: string;
|
||||
rawModelProfile?: string;
|
||||
hasQueueDirective: boolean;
|
||||
queueMode?: QueueMode;
|
||||
queueReset: boolean;
|
||||
@@ -199,7 +151,6 @@ export function parseInlineDirectives(body: string): InlineDirectives {
|
||||
const {
|
||||
cleaned: modelCleaned,
|
||||
rawModel,
|
||||
rawProfile,
|
||||
hasDirective: hasModelDirective,
|
||||
} = extractModelDirective(statusCleaned);
|
||||
const {
|
||||
@@ -231,7 +182,6 @@ export function parseInlineDirectives(body: string): InlineDirectives {
|
||||
hasStatusDirective,
|
||||
hasModelDirective,
|
||||
rawModelDirective: rawModel,
|
||||
rawModelProfile: rawProfile,
|
||||
hasQueueDirective,
|
||||
queueMode,
|
||||
queueReset,
|
||||
@@ -268,7 +218,6 @@ export function isDirectiveOnly(params: {
|
||||
}
|
||||
|
||||
export async function handleDirectiveOnly(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
directives: InlineDirectives;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
@@ -316,14 +265,19 @@ export async function handleDirectiveOnly(params: {
|
||||
return { text: "No models available." };
|
||||
}
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const modelsPath = `${agentDir}/models.json`;
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const authPaths = {
|
||||
authPath: `${agentDir}/auth.json`,
|
||||
modelsPath: `${agentDir}/models.json`,
|
||||
};
|
||||
hydrateAuthStorage(authStorage);
|
||||
const authByProvider = new Map<string, string>();
|
||||
for (const entry of allowedModelCatalog) {
|
||||
if (authByProvider.has(entry.provider)) continue;
|
||||
const auth = await resolveAuthLabel(
|
||||
entry.provider,
|
||||
params.cfg,
|
||||
modelsPath,
|
||||
authStorage,
|
||||
authPaths,
|
||||
);
|
||||
authByProvider.set(entry.provider, formatAuthLabel(auth));
|
||||
}
|
||||
@@ -352,9 +306,6 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
return { text: lines.join("\n") };
|
||||
}
|
||||
if (directives.rawModelProfile && !modelDirective) {
|
||||
throw new Error("Auth profile override requires a model selection.");
|
||||
}
|
||||
}
|
||||
|
||||
if (directives.hasThinkDirective && !directives.thinkLevel) {
|
||||
@@ -427,7 +378,6 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
|
||||
let modelSelection: ModelDirectiveSelection | undefined;
|
||||
let profileOverride: string | undefined;
|
||||
if (directives.hasModelDirective && directives.rawModelDirective) {
|
||||
const resolved = resolveModelDirectiveSelection({
|
||||
raw: directives.rawModelDirective,
|
||||
@@ -441,17 +391,6 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
modelSelection = resolved.selection;
|
||||
if (modelSelection) {
|
||||
if (directives.rawModelProfile) {
|
||||
const profileResolved = resolveProfileOverride({
|
||||
rawProfile: directives.rawModelProfile,
|
||||
provider: modelSelection.provider,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
if (profileResolved.error) {
|
||||
return { text: profileResolved.error };
|
||||
}
|
||||
profileOverride = profileResolved.profileId;
|
||||
}
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(
|
||||
@@ -463,9 +402,6 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (directives.rawModelProfile && !modelSelection) {
|
||||
return { text: "Auth profile override requires a model selection." };
|
||||
}
|
||||
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
@@ -488,11 +424,6 @@ export async function handleDirectiveOnly(params: {
|
||||
sessionEntry.providerOverride = modelSelection.provider;
|
||||
sessionEntry.modelOverride = modelSelection.model;
|
||||
}
|
||||
if (profileOverride) {
|
||||
sessionEntry.authProfileOverride = profileOverride;
|
||||
} else if (directives.hasModelDirective) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
}
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
@@ -550,9 +481,6 @@ export async function handleDirectiveOnly(params: {
|
||||
? `Model reset to default (${labelWithAlias}).`
|
||||
: `Model set to ${labelWithAlias}.`,
|
||||
);
|
||||
if (profileOverride) {
|
||||
parts.push(`Auth profile set to ${profileOverride}.`);
|
||||
}
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueMode) {
|
||||
parts.push(`${SYSTEM_MARK} Queue mode set to ${directives.queueMode}.`);
|
||||
@@ -580,7 +508,6 @@ export async function handleDirectiveOnly(params: {
|
||||
export async function persistInlineDirectives(params: {
|
||||
directives: InlineDirectives;
|
||||
effectiveModelDirective?: string;
|
||||
cfg: ClawdbotConfig;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
@@ -599,7 +526,6 @@ export async function persistInlineDirectives(params: {
|
||||
}): Promise<{ provider: string; model: string; contextTokens: number }> {
|
||||
const {
|
||||
directives,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
@@ -660,18 +586,6 @@ export async function persistInlineDirectives(params: {
|
||||
if (resolved) {
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||
let profileOverride: string | undefined;
|
||||
if (directives.rawModelProfile) {
|
||||
const profileResolved = resolveProfileOverride({
|
||||
rawProfile: directives.rawModelProfile,
|
||||
provider: resolved.ref.provider,
|
||||
cfg,
|
||||
});
|
||||
if (profileResolved.error) {
|
||||
throw new Error(profileResolved.error);
|
||||
}
|
||||
profileOverride = profileResolved.profileId;
|
||||
}
|
||||
const isDefault =
|
||||
resolved.ref.provider === defaultProvider &&
|
||||
resolved.ref.model === defaultModel;
|
||||
@@ -682,11 +596,6 @@ export async function persistInlineDirectives(params: {
|
||||
sessionEntry.providerOverride = resolved.ref.provider;
|
||||
sessionEntry.modelOverride = resolved.ref.model;
|
||||
}
|
||||
if (profileOverride) {
|
||||
sessionEntry.authProfileOverride = profileOverride;
|
||||
} else if (directives.hasModelDirective) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
}
|
||||
provider = resolved.ref.provider;
|
||||
model = resolved.ref.model;
|
||||
const nextLabel = `${provider}/${model}`;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
}));
|
||||
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
|
||||
function createTyping(): TypingController {
|
||||
return {
|
||||
onReplyStart: vi.fn(async () => {}),
|
||||
startTypingLoop: vi.fn(async () => {}),
|
||||
startTypingOnText: vi.fn(async () => {}),
|
||||
refreshTypingTtl: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createFollowupRunner compaction", () => {
|
||||
it("adds verbose auto-compaction notice and tracks count", async () => {
|
||||
const storePath = path.join(
|
||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||
"sessions.json",
|
||||
);
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
main: sessionEntry,
|
||||
};
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void;
|
||||
}) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const runner = createFollowupRunner({
|
||||
opts: { onBlockReply },
|
||||
typing: createTyping(),
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
|
||||
const queued = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
surface: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "on",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as FollowupRun;
|
||||
|
||||
await runner(queued);
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalled();
|
||||
expect(onBlockReply.mock.calls[0][0].text).toContain(
|
||||
"Auto-compaction complete",
|
||||
);
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export function createFollowupRunner(params: {
|
||||
@@ -62,7 +61,6 @@ export function createFollowupRunner(params: {
|
||||
if (queued.run.sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
||||
}
|
||||
let autoCompactionCompleted = false;
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = queued.run.provider;
|
||||
let fallbackModel = queued.run.model;
|
||||
@@ -86,21 +84,12 @@ export function createFollowupRunner(params: {
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
provider,
|
||||
model,
|
||||
authProfileId: queued.run.authProfileId,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
bashElevated: queued.run.bashElevated,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase = String(evt.data.phase ?? "");
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
@@ -142,21 +131,6 @@ export function createFollowupRunner(params: {
|
||||
|
||||
if (replyTaggedPayloads.length === 0) return;
|
||||
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (queued.run.verboseLevel === "on") {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
replyTaggedPayloads.unshift({
|
||||
text: `🧹 Auto-compaction complete${suffix}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
normalizeMentionText,
|
||||
} from "./mentions.js";
|
||||
|
||||
describe("mention helpers", () => {
|
||||
it("builds regexes and skips invalid patterns", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
routing: {
|
||||
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
|
||||
},
|
||||
});
|
||||
expect(regexes).toHaveLength(1);
|
||||
expect(regexes[0]?.test("clawd")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes zero-width characters", () => {
|
||||
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
|
||||
});
|
||||
|
||||
it("matches patterns case-insensitively", () => {
|
||||
const regexes = buildMentionRegexes({
|
||||
routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
|
||||
});
|
||||
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,6 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] {
|
||||
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
|
||||
return patterns
|
||||
.map((pattern) => {
|
||||
try {
|
||||
return new RegExp(pattern, "i");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((value): value is RegExp => Boolean(value));
|
||||
}
|
||||
|
||||
export function normalizeMentionText(text: string): string {
|
||||
return (text ?? "")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function matchesMentionPatterns(
|
||||
text: string,
|
||||
mentionRegexes: RegExp[],
|
||||
): boolean {
|
||||
if (mentionRegexes.length === 0) return false;
|
||||
const cleaned = normalizeMentionText(text ?? "");
|
||||
if (!cleaned) return false;
|
||||
return mentionRegexes.some((re) => re.test(cleaned));
|
||||
}
|
||||
|
||||
export function stripStructuralPrefixes(text: string): string {
|
||||
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
|
||||
// detection still works in group batches that include history/context.
|
||||
|
||||
@@ -57,8 +57,7 @@ export async function createModelSelectionState(params: {
|
||||
let provider = params.provider;
|
||||
let model = params.model;
|
||||
|
||||
const hasAllowlist =
|
||||
agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
||||
const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0;
|
||||
const hasStoredOverride = Boolean(
|
||||
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
|
||||
);
|
||||
@@ -111,27 +110,6 @@ export async function createModelSelectionState(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sessionEntry &&
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
sessionEntry.authProfileOverride
|
||||
) {
|
||||
const { ensureAuthProfileStore } = await import(
|
||||
"../../agents/auth-profiles.js"
|
||||
);
|
||||
const store = ensureAuthProfileStore();
|
||||
const profile = store.profiles[sessionEntry.authProfileOverride];
|
||||
if (!profile || profile.provider !== provider) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let defaultThinkingLevel: ThinkLevel | undefined;
|
||||
const resolveDefaultThinkingLevel = async () => {
|
||||
if (defaultThinkingLevel) return defaultThinkingLevel;
|
||||
|
||||
@@ -32,7 +32,6 @@ export type FollowupRun = {
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
provider: string;
|
||||
model: string;
|
||||
authProfileId?: string;
|
||||
thinkLevel?: ThinkLevel;
|
||||
verboseLevel?: VerboseLevel;
|
||||
elevatedLevel?: ElevatedLevel;
|
||||
|
||||
@@ -122,32 +122,3 @@ export async function ensureSkillSnapshot(params: {
|
||||
|
||||
return { sessionEntry: nextEntry, skillsSnapshot, systemSent };
|
||||
}
|
||||
|
||||
export async function incrementCompactionCount(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
now?: number;
|
||||
}): Promise<number | undefined> {
|
||||
const {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
now = Date.now(),
|
||||
} = params;
|
||||
if (!sessionStore || !sessionKey) return undefined;
|
||||
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
||||
if (!entry) return undefined;
|
||||
const nextCount = (entry.compactionCount ?? 0) + 1;
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
compactionCount: nextCount,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
return nextCount;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type SessionScope,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
|
||||
@@ -38,9 +37,8 @@ export type SessionInitResult = {
|
||||
export async function initSessionState(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
commandAuthorized: boolean;
|
||||
}): Promise<SessionInitResult> {
|
||||
const { ctx, cfg, commandAuthorized } = params;
|
||||
const { ctx, cfg } = params;
|
||||
const sessionCfg = cfg.session;
|
||||
const mainKey = sessionCfg?.mainKey ?? "main";
|
||||
const resetTriggers = sessionCfg?.resetTriggers?.length
|
||||
@@ -78,11 +76,6 @@ export async function initSessionState(params: {
|
||||
|
||||
const rawBody = ctx.Body ?? "";
|
||||
const trimmedBody = rawBody.trim();
|
||||
const resetAuthorized = resolveCommandAuthorization({
|
||||
ctx,
|
||||
cfg,
|
||||
commandAuthorized,
|
||||
}).isAuthorizedSender;
|
||||
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the
|
||||
// web inbox before we get here. They prevented reset triggers like "/new"
|
||||
// from matching, so strip structural wrappers when checking for resets.
|
||||
@@ -91,7 +84,6 @@ export async function initSessionState(params: {
|
||||
: triggerBodyNormalized;
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) continue;
|
||||
if (!resetAuthorized) break;
|
||||
if (trimmedBody === trigger || strippedForReset === trigger) {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
|
||||
@@ -22,7 +22,6 @@ describe("buildStatusMessage", () => {
|
||||
contextTokens: 32_000,
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: "on",
|
||||
compactionCount: 2,
|
||||
},
|
||||
sessionKey: "main",
|
||||
sessionScope: "per-sender",
|
||||
@@ -40,7 +39,6 @@ describe("buildStatusMessage", () => {
|
||||
expect(text).toContain("Runtime: direct");
|
||||
expect(text).toContain("Context: 16k/32k (50%)");
|
||||
expect(text).toContain("Session: main");
|
||||
expect(text).toContain("compactions 2");
|
||||
expect(text).toContain("Web: linked");
|
||||
expect(text).toContain("heartbeat 45s");
|
||||
expect(text).toContain("thinking=medium");
|
||||
|
||||
@@ -56,8 +56,6 @@ const formatAge = (ms?: number | null) => {
|
||||
const formatKTokens = (value: number) =>
|
||||
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
||||
|
||||
export const formatTokenCount = (value: number) => formatKTokens(value);
|
||||
|
||||
const formatTokens = (
|
||||
total: number | null | undefined,
|
||||
contextTokens: number | null,
|
||||
@@ -73,11 +71,6 @@ const formatTokens = (
|
||||
return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`;
|
||||
};
|
||||
|
||||
export const formatContextUsageShort = (
|
||||
total: number | null | undefined,
|
||||
contextTokens: number | null | undefined,
|
||||
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
|
||||
|
||||
const readUsageFromSessionLog = (
|
||||
sessionId?: string,
|
||||
):
|
||||
@@ -217,9 +210,6 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
entry?.updatedAt
|
||||
? `updated ${formatAge(now - entry.updatedAt)}`
|
||||
: "no activity",
|
||||
typeof entry?.compactionCount === "number"
|
||||
? `compactions ${entry.compactionCount}`
|
||||
: undefined,
|
||||
args.storePath ? `store ${shortenHomePath(args.storePath)}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -272,7 +262,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
export function buildHelpMessage(): string {
|
||||
return [
|
||||
"ℹ️ Help",
|
||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink",
|
||||
"Shortcuts: /new reset | /restart relink",
|
||||
"Options: /think <level> | /verbose on|off | /elevated on|off | /model <id>",
|
||||
].join("\n");
|
||||
}
|
||||
+2
-3
@@ -4,7 +4,6 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import type { GatewayRpcOpts } from "./gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
||||
|
||||
|
||||
async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||
try {
|
||||
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
||||
@@ -156,7 +155,7 @@ export function registerCronCli(program: Command) {
|
||||
.option("--deliver", "Deliver agent output", false)
|
||||
.option(
|
||||
"--channel <channel>",
|
||||
"Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)",
|
||||
"Delivery channel (last|whatsapp|telegram|discord|signal|imessage)",
|
||||
"last",
|
||||
)
|
||||
.option(
|
||||
@@ -415,7 +414,7 @@ export function registerCronCli(program: Command) {
|
||||
.option("--deliver", "Deliver agent output", false)
|
||||
.option(
|
||||
"--channel <channel>",
|
||||
"Delivery channel (last|whatsapp|telegram|discord|slack|signal|imessage)",
|
||||
"Delivery channel (last|whatsapp|telegram|discord|signal|imessage)",
|
||||
)
|
||||
.option(
|
||||
"--to <dest>",
|
||||
|
||||
+1
-26
@@ -10,12 +10,7 @@ import { sessionsCommand } from "../commands/sessions.js";
|
||||
import { setupCommand } from "../commands/setup.js";
|
||||
import { statusCommand } from "../commands/status.js";
|
||||
import { updateCommand } from "../commands/update.js";
|
||||
import {
|
||||
isNixMode,
|
||||
migrateLegacyConfig,
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import { danger, setVerbose } from "../globals.js";
|
||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -92,26 +87,6 @@ export function buildProgram() {
|
||||
if (actionCommand.name() === "doctor") return;
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.legacyIssues.length === 0) return;
|
||||
if (isNixMode) {
|
||||
defaultRuntime.error(
|
||||
danger(
|
||||
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const migrated = migrateLegacyConfig(snapshot.parsed);
|
||||
if (migrated.config) {
|
||||
await writeConfigFile(migrated.config);
|
||||
if (migrated.changes.length > 0) {
|
||||
defaultRuntime.log(
|
||||
`Migrated legacy config entries:\n${migrated.changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const issues = snapshot.legacyIssues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n");
|
||||
|
||||
@@ -59,8 +59,7 @@ function mockConfig(
|
||||
) {
|
||||
configSpy.mockReturnValue({
|
||||
agent: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
models: { "anthropic/claude-opus-4-5": {} },
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
...agentOverrides,
|
||||
},
|
||||
|
||||
+1
-16
@@ -1,5 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
@@ -290,8 +289,7 @@ export async function agentCommand(
|
||||
});
|
||||
let provider = defaultProvider;
|
||||
let model = defaultModel;
|
||||
const hasAllowlist =
|
||||
agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
||||
const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0;
|
||||
const hasStoredOverride = Boolean(
|
||||
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
|
||||
);
|
||||
@@ -337,18 +335,6 @@ export async function agentCommand(
|
||||
model = storedModelOverride;
|
||||
}
|
||||
}
|
||||
if (sessionEntry?.authProfileOverride) {
|
||||
const store = ensureAuthProfileStore();
|
||||
const profile = store.profiles[sessionEntry.authProfileOverride];
|
||||
if (!profile || profile.provider !== provider) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
if (sessionStore && sessionKey) {
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedThinkLevel) {
|
||||
let catalogForThinking = modelCatalog ?? allowedModelCatalog;
|
||||
@@ -395,7 +381,6 @@ export async function agentCommand(
|
||||
prompt: body,
|
||||
provider: providerOverride,
|
||||
model: modelOverride,
|
||||
authProfileId: sessionEntry?.authProfileOverride,
|
||||
thinkLevel: resolvedThinkLevel,
|
||||
verboseLevel: resolvedVerboseLevel,
|
||||
timeoutMs,
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from "./antigravity-oauth.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxConfig,
|
||||
setAnthropicApiKey,
|
||||
writeOAuthCredentials,
|
||||
@@ -276,11 +275,6 @@ async function promptAuthConfig(
|
||||
spin.stop("OAuth complete");
|
||||
if (oauthCreds) {
|
||||
await writeOAuthCredentials("anthropic", oauthCreds);
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
spin.stop("OAuth failed");
|
||||
@@ -322,33 +316,12 @@ async function promptAuthConfig(
|
||||
spin.stop("Antigravity OAuth complete");
|
||||
if (oauthCreds) {
|
||||
await writeOAuthCredentials("google-antigravity", oauthCreds);
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId: "google-antigravity:default",
|
||||
provider: "google-antigravity",
|
||||
mode: "oauth",
|
||||
});
|
||||
// Set default model to Claude Opus 4.5 via Antigravity
|
||||
next = {
|
||||
...next,
|
||||
agent: {
|
||||
...next.agent,
|
||||
model: {
|
||||
...(next.agent?.model &&
|
||||
"fallbacks" in (next.agent.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (next.agent.model as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: "google-antigravity/claude-opus-4-5-thinking",
|
||||
},
|
||||
models: {
|
||||
...next.agent?.models,
|
||||
"google-antigravity/claude-opus-4-5-thinking":
|
||||
next.agent?.models?.[
|
||||
"google-antigravity/claude-opus-4-5-thinking"
|
||||
] ?? {},
|
||||
},
|
||||
model: "google-antigravity/claude-opus-4-5-thinking",
|
||||
},
|
||||
};
|
||||
note(
|
||||
@@ -369,11 +342,6 @@ async function promptAuthConfig(
|
||||
runtime,
|
||||
);
|
||||
await setAnthropicApiKey(String(key).trim());
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
});
|
||||
} else if (authChoice === "minimax") {
|
||||
next = applyMinimaxConfig(next);
|
||||
}
|
||||
@@ -381,10 +349,7 @@ async function promptAuthConfig(
|
||||
const modelInput = guardCancel(
|
||||
await text({
|
||||
message: "Default model (blank to keep)",
|
||||
initialValue:
|
||||
typeof next.agent?.model === "string"
|
||||
? next.agent?.model
|
||||
: (next.agent?.model?.primary ?? ""),
|
||||
initialValue: next.agent?.model ?? "",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
@@ -394,20 +359,7 @@ async function promptAuthConfig(
|
||||
...next,
|
||||
agent: {
|
||||
...next.agent,
|
||||
model: {
|
||||
...(next.agent?.model &&
|
||||
"fallbacks" in (next.agent.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (next.agent.model as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: model,
|
||||
},
|
||||
models: {
|
||||
...next.agent?.models,
|
||||
[model]: next.agent?.models?.[model] ?? {},
|
||||
},
|
||||
model,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,15 +13,7 @@ export async function modelsAliasesListCommand(
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const models = cfg.agent?.models ?? {};
|
||||
const aliases = Object.entries(models).reduce<Record<string, string>>(
|
||||
(acc, [modelKey, entry]) => {
|
||||
const alias = entry?.alias?.trim();
|
||||
if (alias) acc[alias] = modelKey;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const aliases = cfg.agent?.modelAliases ?? {};
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ aliases }, null, 2));
|
||||
@@ -50,29 +42,21 @@ export async function modelsAliasesAddCommand(
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const alias = normalizeAlias(aliasRaw);
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg: loadConfig() });
|
||||
const _updated = await updateConfig((cfg) => {
|
||||
const modelKey = `${resolved.provider}/${resolved.model}`;
|
||||
const nextModels = { ...cfg.agent?.models };
|
||||
for (const [key, entry] of Object.entries(nextModels)) {
|
||||
const existing = entry?.alias?.trim();
|
||||
if (existing && existing === alias && key !== modelKey) {
|
||||
throw new Error(`Alias ${alias} already points to ${key}.`);
|
||||
}
|
||||
}
|
||||
const existing = nextModels[modelKey] ?? {};
|
||||
nextModels[modelKey] = { ...existing, alias };
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const nextAliases = { ...cfg.agent?.modelAliases };
|
||||
nextAliases[alias] = `${resolved.provider}/${resolved.model}`;
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
models: nextModels,
|
||||
modelAliases: nextAliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`);
|
||||
runtime.log(`Alias ${alias} -> ${updated.agent?.modelAliases?.[alias]}`);
|
||||
}
|
||||
|
||||
export async function modelsAliasesRemoveCommand(
|
||||
@@ -81,31 +65,24 @@ export async function modelsAliasesRemoveCommand(
|
||||
) {
|
||||
const alias = normalizeAlias(aliasRaw);
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const nextModels = { ...cfg.agent?.models };
|
||||
let found = false;
|
||||
for (const [key, entry] of Object.entries(nextModels)) {
|
||||
if (entry?.alias?.trim() === alias) {
|
||||
nextModels[key] = { ...entry, alias: undefined };
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
const nextAliases = { ...cfg.agent?.modelAliases };
|
||||
if (!nextAliases[alias]) {
|
||||
throw new Error(`Alias not found: ${alias}`);
|
||||
}
|
||||
delete nextAliases[alias];
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
models: nextModels,
|
||||
modelAliases: nextAliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
if (
|
||||
!updated.agent?.models ||
|
||||
Object.values(updated.agent.models).every((entry) => !entry?.alias?.trim())
|
||||
!updated.agent?.modelAliases ||
|
||||
Object.keys(updated.agent.modelAliases).length === 0
|
||||
) {
|
||||
runtime.log("No aliases configured.");
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function modelsFallbacksListCommand(
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const fallbacks = cfg.agent?.model?.fallbacks ?? [];
|
||||
const fallbacks = cfg.agent?.modelFallbacks ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ fallbacks }, null, 2));
|
||||
@@ -44,13 +44,11 @@ export async function modelsFallbacksAddCommand(
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const targetKey = modelKey(resolved.provider, resolved.model);
|
||||
const nextModels = { ...cfg.agent?.models };
|
||||
if (!nextModels[targetKey]) nextModels[targetKey] = {};
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const existing = cfg.agent?.model?.fallbacks ?? [];
|
||||
const existing = cfg.agent?.modelFallbacks ?? [];
|
||||
const existingKeys = existing
|
||||
.map((entry) =>
|
||||
resolveModelRefFromString({
|
||||
@@ -64,29 +62,17 @@ export async function modelsFallbacksAddCommand(
|
||||
|
||||
if (existingKeys.includes(targetKey)) return cfg;
|
||||
|
||||
const existingModel = cfg.agent?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...(existingModel?.primary
|
||||
? { primary: existingModel.primary }
|
||||
: undefined),
|
||||
fallbacks: [...existing, targetKey],
|
||||
},
|
||||
models: nextModels,
|
||||
modelFallbacks: [...existing, targetKey],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(
|
||||
`Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`,
|
||||
);
|
||||
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`);
|
||||
}
|
||||
|
||||
export async function modelsFallbacksRemoveCommand(
|
||||
@@ -100,7 +86,7 @@ export async function modelsFallbacksRemoveCommand(
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const existing = cfg.agent?.model?.fallbacks ?? [];
|
||||
const existing = cfg.agent?.modelFallbacks ?? [];
|
||||
const filtered = existing.filter((entry) => {
|
||||
const resolvedEntry = resolveModelRefFromString({
|
||||
raw: String(entry ?? ""),
|
||||
@@ -118,48 +104,27 @@ export async function modelsFallbacksRemoveCommand(
|
||||
throw new Error(`Fallback not found: ${targetKey}`);
|
||||
}
|
||||
|
||||
const existingModel = cfg.agent?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...(existingModel?.primary
|
||||
? { primary: existingModel.primary }
|
||||
: undefined),
|
||||
fallbacks: filtered,
|
||||
},
|
||||
modelFallbacks: filtered,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(
|
||||
`Fallbacks: ${(updated.agent?.model?.fallbacks ?? []).join(", ")}`,
|
||||
);
|
||||
runtime.log(`Fallbacks: ${(updated.agent?.modelFallbacks ?? []).join(", ")}`);
|
||||
}
|
||||
|
||||
export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
|
||||
await updateConfig((cfg) => {
|
||||
const existingModel = cfg.agent?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...(existingModel?.primary
|
||||
? { primary: existingModel.primary }
|
||||
: undefined),
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
await updateConfig((cfg) => ({
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
modelFallbacks: [],
|
||||
},
|
||||
}));
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log("Fallback list cleared.");
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function modelsImageFallbacksListCommand(
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const fallbacks = cfg.agent?.imageModel?.fallbacks ?? [];
|
||||
const fallbacks = cfg.agent?.imageModelFallbacks ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ fallbacks }, null, 2));
|
||||
@@ -44,13 +44,11 @@ export async function modelsImageFallbacksAddCommand(
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const targetKey = modelKey(resolved.provider, resolved.model);
|
||||
const nextModels = { ...cfg.agent?.models };
|
||||
if (!nextModels[targetKey]) nextModels[targetKey] = {};
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const existing = cfg.agent?.imageModel?.fallbacks ?? [];
|
||||
const existing = cfg.agent?.imageModelFallbacks ?? [];
|
||||
const existingKeys = existing
|
||||
.map((entry) =>
|
||||
resolveModelRefFromString({
|
||||
@@ -64,28 +62,18 @@ export async function modelsImageFallbacksAddCommand(
|
||||
|
||||
if (existingKeys.includes(targetKey)) return cfg;
|
||||
|
||||
const existingModel = cfg.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
imageModel: {
|
||||
...(existingModel?.primary
|
||||
? { primary: existingModel.primary }
|
||||
: undefined),
|
||||
fallbacks: [...existing, targetKey],
|
||||
},
|
||||
models: nextModels,
|
||||
imageModelFallbacks: [...existing, targetKey],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(
|
||||
`Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`,
|
||||
`Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,7 +88,7 @@ export async function modelsImageFallbacksRemoveCommand(
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const existing = cfg.agent?.imageModel?.fallbacks ?? [];
|
||||
const existing = cfg.agent?.imageModelFallbacks ?? [];
|
||||
const filtered = existing.filter((entry) => {
|
||||
const resolvedEntry = resolveModelRefFromString({
|
||||
raw: String(entry ?? ""),
|
||||
@@ -118,48 +106,29 @@ export async function modelsImageFallbacksRemoveCommand(
|
||||
throw new Error(`Image fallback not found: ${targetKey}`);
|
||||
}
|
||||
|
||||
const existingModel = cfg.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
imageModel: {
|
||||
...(existingModel?.primary
|
||||
? { primary: existingModel.primary }
|
||||
: undefined),
|
||||
fallbacks: filtered,
|
||||
},
|
||||
imageModelFallbacks: filtered,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(
|
||||
`Image fallbacks: ${(updated.agent?.imageModel?.fallbacks ?? []).join(", ")}`,
|
||||
`Image fallbacks: ${(updated.agent?.imageModelFallbacks ?? []).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) {
|
||||
await updateConfig((cfg) => {
|
||||
const existingModel = cfg.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
imageModel: {
|
||||
...(existingModel?.primary
|
||||
? { primary: existingModel.primary }
|
||||
: undefined),
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
await updateConfig((cfg) => ({
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
imageModelFallbacks: [],
|
||||
},
|
||||
}));
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log("Image fallback list cleared.");
|
||||
|
||||
+30
-94
@@ -1,4 +1,4 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
@@ -6,15 +6,6 @@ import {
|
||||
import chalk from "chalk";
|
||||
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
parseModelRef,
|
||||
@@ -90,17 +81,6 @@ const isLocalBaseUrl = (baseUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasAuthForProvider = (
|
||||
provider: string,
|
||||
cfg: ClawdbotConfig,
|
||||
authStore: AuthProfileStore,
|
||||
): boolean => {
|
||||
if (listProfilesForProvider(authStore, provider).length > 0) return true;
|
||||
if (resolveEnvApiKey(provider)) return true;
|
||||
if (getCustomProviderApiKey(cfg, provider)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
@@ -130,21 +110,7 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
|
||||
|
||||
addEntry(resolvedDefault, "default");
|
||||
|
||||
const modelConfig = cfg.agent?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
const imageModelConfig = cfg.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
const modelFallbacks =
|
||||
typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
|
||||
const imageFallbacks =
|
||||
typeof imageModelConfig === "object"
|
||||
? (imageModelConfig?.fallbacks ?? [])
|
||||
: [];
|
||||
const imagePrimary = imageModelConfig?.primary?.trim() ?? "";
|
||||
|
||||
modelFallbacks.forEach((raw, idx) => {
|
||||
(cfg.agent?.modelFallbacks ?? []).forEach((raw, idx) => {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
@@ -154,16 +120,17 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
|
||||
addEntry(resolved.ref, `fallback#${idx + 1}`);
|
||||
});
|
||||
|
||||
if (imagePrimary) {
|
||||
const imageModelRaw = cfg.agent?.imageModel?.trim();
|
||||
if (imageModelRaw) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: imagePrimary,
|
||||
raw: imageModelRaw,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (resolved) addEntry(resolved.ref, "image");
|
||||
}
|
||||
|
||||
imageFallbacks.forEach((raw, idx) => {
|
||||
(cfg.agent?.imageModelFallbacks ?? []).forEach((raw, idx) => {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
@@ -173,10 +140,20 @@ const resolveConfiguredEntries = (cfg: ClawdbotConfig) => {
|
||||
addEntry(resolved.ref, `img-fallback#${idx + 1}`);
|
||||
});
|
||||
|
||||
for (const key of Object.keys(cfg.agent?.models ?? {})) {
|
||||
const parsed = parseModelRef(String(key ?? ""), DEFAULT_PROVIDER);
|
||||
if (!parsed) continue;
|
||||
addEntry(parsed, "configured");
|
||||
(cfg.agent?.allowedModels ?? []).forEach((raw) => {
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
|
||||
if (!parsed) return;
|
||||
addEntry(parsed, "allowed");
|
||||
});
|
||||
|
||||
for (const targetRaw of Object.values(cfg.agent?.modelAliases ?? {})) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(targetRaw ?? ""),
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) continue;
|
||||
addEntry(resolved.ref, "alias");
|
||||
}
|
||||
|
||||
const entries: ConfiguredEntry[] = order.map((key) => {
|
||||
@@ -213,18 +190,8 @@ function toModelRow(params: {
|
||||
tags: string[];
|
||||
aliases?: string[];
|
||||
availableKeys?: Set<string>;
|
||||
cfg?: ClawdbotConfig;
|
||||
authStore?: AuthProfileStore;
|
||||
}): ModelRow {
|
||||
const {
|
||||
model,
|
||||
key,
|
||||
tags,
|
||||
aliases = [],
|
||||
availableKeys,
|
||||
cfg,
|
||||
authStore,
|
||||
} = params;
|
||||
const { model, key, tags, aliases = [], availableKeys } = params;
|
||||
if (!model) {
|
||||
return {
|
||||
key,
|
||||
@@ -240,11 +207,9 @@ function toModelRow(params: {
|
||||
|
||||
const input = model.input.join("+") || "text";
|
||||
const local = isLocalBaseUrl(model.baseUrl);
|
||||
const envKey = getEnvApiKey(model.provider);
|
||||
const available =
|
||||
availableKeys?.has(modelKey(model.provider, model.id)) ||
|
||||
(cfg && authStore
|
||||
? hasAuthForProvider(model.provider, cfg, authStore)
|
||||
: false);
|
||||
availableKeys?.has(modelKey(model.provider, model.id)) || Boolean(envKey);
|
||||
const aliasTags = aliases.length > 0 ? [`alias:${aliases.join(",")}`] : [];
|
||||
const mergedTags = new Set(tags);
|
||||
if (aliasTags.length > 0) {
|
||||
@@ -339,7 +304,6 @@ export async function modelsListCommand(
|
||||
) {
|
||||
ensureFlagCompatibility(opts);
|
||||
const cfg = loadConfig();
|
||||
const authStore = ensureAuthProfileStore();
|
||||
const providerFilter = opts.provider?.trim().toLowerCase();
|
||||
|
||||
let models: Model<Api>[] = [];
|
||||
@@ -382,8 +346,6 @@ export async function modelsListCommand(
|
||||
tags: configured ? Array.from(configured.tags) : [],
|
||||
aliases: configured?.aliases ?? [],
|
||||
availableKeys,
|
||||
cfg,
|
||||
authStore,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -405,8 +367,6 @@ export async function modelsListCommand(
|
||||
tags: Array.from(entry.tags),
|
||||
aliases: entry.aliases,
|
||||
availableKeys,
|
||||
cfg,
|
||||
authStore,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -432,35 +392,13 @@ export async function modelsStatusCommand(
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
|
||||
const modelConfig = cfg.agent?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
const imageConfig = cfg.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
const rawModel =
|
||||
typeof modelConfig === "string"
|
||||
? modelConfig.trim()
|
||||
: (modelConfig?.primary?.trim() ?? "");
|
||||
const rawModel = cfg.agent?.model?.trim() ?? "";
|
||||
const defaultLabel = rawModel || `${resolved.provider}/${resolved.model}`;
|
||||
const fallbacks =
|
||||
typeof modelConfig === "object" ? (modelConfig?.fallbacks ?? []) : [];
|
||||
const imageModel =
|
||||
typeof imageConfig === "string"
|
||||
? imageConfig.trim()
|
||||
: (imageConfig?.primary?.trim() ?? "");
|
||||
const imageFallbacks =
|
||||
typeof imageConfig === "object" ? (imageConfig?.fallbacks ?? []) : [];
|
||||
const aliases = Object.entries(cfg.agent?.models ?? {}).reduce<
|
||||
Record<string, string>
|
||||
>((acc, [key, entry]) => {
|
||||
const alias = entry?.alias?.trim();
|
||||
if (alias) acc[alias] = key;
|
||||
return acc;
|
||||
}, {});
|
||||
const allowed = Object.keys(cfg.agent?.models ?? {});
|
||||
const fallbacks = cfg.agent?.modelFallbacks ?? [];
|
||||
const imageModel = cfg.agent?.imageModel?.trim() ?? "";
|
||||
const imageFallbacks = cfg.agent?.imageModelFallbacks ?? [];
|
||||
const aliases = cfg.agent?.modelAliases ?? {};
|
||||
const allowed = cfg.agent?.allowedModels ?? [];
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
@@ -508,8 +446,6 @@ export async function modelsStatusCommand(
|
||||
}`,
|
||||
);
|
||||
runtime.log(
|
||||
`Configured models (${allowed.length || 0}): ${
|
||||
allowed.length ? allowed.join(", ") : "all"
|
||||
}`,
|
||||
`Allowed (${allowed.length || 0}): ${allowed.length ? allowed.join(", ") : "all"}`,
|
||||
);
|
||||
}
|
||||
+60
-48
@@ -1,12 +1,20 @@
|
||||
import { cancel, isCancel, multiselect } from "@clack/prompts";
|
||||
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
|
||||
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
|
||||
import {
|
||||
type ModelScanResult,
|
||||
scanOpenRouterModels,
|
||||
} from "../../agents/model-scan.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
|
||||
import { warn } from "../../globals.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { formatMs, formatTokenK, updateConfig } from "./shared.js";
|
||||
import {
|
||||
buildAllowlistSet,
|
||||
formatMs,
|
||||
formatTokenK,
|
||||
updateConfig,
|
||||
} from "./shared.js";
|
||||
|
||||
const MODEL_PAD = 42;
|
||||
const CTX_PAD = 8;
|
||||
@@ -173,17 +181,8 @@ export async function modelsScanCommand(
|
||||
throw new Error("--concurrency must be > 0");
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
let storedKey: string | undefined;
|
||||
try {
|
||||
const resolved = await resolveApiKeyForProvider({
|
||||
provider: "openrouter",
|
||||
cfg,
|
||||
});
|
||||
storedKey = resolved.apiKey;
|
||||
} catch {
|
||||
storedKey = undefined;
|
||||
}
|
||||
const authStorage = discoverAuthStorage(resolveClawdbotAgentDir());
|
||||
const storedKey = await authStorage.getApiKey("openrouter");
|
||||
const results = await scanOpenRouterModels({
|
||||
apiKey: storedKey ?? undefined,
|
||||
minParamB: minParams,
|
||||
@@ -267,48 +266,32 @@ export async function modelsScanCommand(
|
||||
throw new Error("No image-capable models selected for image model.");
|
||||
}
|
||||
|
||||
const _updated = await updateConfig((cfg) => {
|
||||
const nextModels = { ...cfg.agent?.models };
|
||||
for (const entry of selected) {
|
||||
if (!nextModels[entry]) nextModels[entry] = {};
|
||||
}
|
||||
for (const entry of selectedImages) {
|
||||
if (!nextModels[entry]) nextModels[entry] = {};
|
||||
}
|
||||
const existingImageModel = cfg.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
const nextImageModel =
|
||||
selectedImages.length > 0
|
||||
? {
|
||||
...(existingImageModel?.primary
|
||||
? { primary: existingImageModel.primary }
|
||||
: undefined),
|
||||
fallbacks: selectedImages,
|
||||
...(opts.setImage ? { primary: selectedImages[0] } : {}),
|
||||
}
|
||||
: cfg.agent?.imageModel;
|
||||
const existingModel = cfg.agent?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const agent = {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...(existingModel?.primary
|
||||
? { primary: existingModel.primary }
|
||||
: undefined),
|
||||
fallbacks: selected,
|
||||
...(opts.setDefault ? { primary: selected[0] } : {}),
|
||||
},
|
||||
...(nextImageModel ? { imageModel: nextImageModel } : {}),
|
||||
models: nextModels,
|
||||
modelFallbacks: selected,
|
||||
...(opts.setDefault ? { model: selected[0] } : {}),
|
||||
...(opts.setImage && selectedImages.length > 0
|
||||
? { imageModel: selectedImages[0] }
|
||||
: {}),
|
||||
} satisfies NonNullable<typeof cfg.agent>;
|
||||
if (imageSorted.length > 0) {
|
||||
agent.imageModelFallbacks = selectedImages;
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
agent,
|
||||
};
|
||||
});
|
||||
|
||||
const allowlist = buildAllowlistSet(updated);
|
||||
const allowlistMissing =
|
||||
allowlist.size > 0 ? selected.filter((entry) => !allowlist.has(entry)) : [];
|
||||
const allowlistMissingImages =
|
||||
allowlist.size > 0
|
||||
? selectedImages.filter((entry) => !allowlist.has(entry))
|
||||
: [];
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
@@ -318,7 +301,21 @@ export async function modelsScanCommand(
|
||||
setDefault: Boolean(opts.setDefault),
|
||||
setImage: Boolean(opts.setImage),
|
||||
results,
|
||||
warnings: [],
|
||||
warnings:
|
||||
allowlistMissing.length > 0 || allowlistMissingImages.length > 0
|
||||
? [
|
||||
...(allowlistMissing.length > 0
|
||||
? [
|
||||
`Selected models not in agent.allowedModels: ${allowlistMissing.join(", ")}`,
|
||||
]
|
||||
: []),
|
||||
...(allowlistMissingImages.length > 0
|
||||
? [
|
||||
`Selected image models not in agent.allowedModels: ${allowlistMissingImages.join(", ")}`,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -327,6 +324,21 @@ export async function modelsScanCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowlistMissing.length > 0) {
|
||||
runtime.log(
|
||||
warn(
|
||||
`Warning: ${allowlistMissing.length} selected models are not in agent.allowedModels and will be ignored by fallback: ${allowlistMissing.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (allowlistMissingImages.length > 0) {
|
||||
runtime.log(
|
||||
warn(
|
||||
`Warning: ${allowlistMissingImages.length} selected image models are not in agent.allowedModels and will be ignored by fallback: ${allowlistMissingImages.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Fallbacks: ${selected.join(", ")}`);
|
||||
if (selectedImages.length > 0) {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveModelTarget, updateConfig } from "./shared.js";
|
||||
import {
|
||||
buildAllowlistSet,
|
||||
modelKey,
|
||||
resolveModelTarget,
|
||||
updateConfig,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function modelsSetImageCommand(
|
||||
modelRaw: string,
|
||||
@@ -8,27 +13,22 @@ export async function modelsSetImageCommand(
|
||||
) {
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const key = `${resolved.provider}/${resolved.model}`;
|
||||
const nextModels = { ...cfg.agent?.models };
|
||||
if (!nextModels[key]) nextModels[key] = {};
|
||||
const existingModel = cfg.agent?.imageModel as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
const allowlist = buildAllowlistSet(cfg);
|
||||
if (allowlist.size > 0) {
|
||||
const key = modelKey(resolved.provider, resolved.model);
|
||||
if (!allowlist.has(key)) {
|
||||
throw new Error(`Model ${key} is not in agent.allowedModels.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
imageModel: {
|
||||
...(existingModel?.fallbacks
|
||||
? { fallbacks: existingModel.fallbacks }
|
||||
: undefined),
|
||||
primary: key,
|
||||
},
|
||||
models: nextModels,
|
||||
imageModel: `${resolved.provider}/${resolved.model}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Image model: ${updated.agent?.imageModel?.primary ?? modelRaw}`);
|
||||
runtime.log(`Image model: ${updated.agent?.imageModel ?? modelRaw}`);
|
||||
}
|
||||
+15
-15
@@ -1,31 +1,31 @@
|
||||
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveModelTarget, updateConfig } from "./shared.js";
|
||||
import {
|
||||
buildAllowlistSet,
|
||||
modelKey,
|
||||
resolveModelTarget,
|
||||
updateConfig,
|
||||
} from "./shared.js";
|
||||
|
||||
export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
|
||||
const updated = await updateConfig((cfg) => {
|
||||
const resolved = resolveModelTarget({ raw: modelRaw, cfg });
|
||||
const key = `${resolved.provider}/${resolved.model}`;
|
||||
const nextModels = { ...cfg.agent?.models };
|
||||
if (!nextModels[key]) nextModels[key] = {};
|
||||
const existingModel = cfg.agent?.model as
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined;
|
||||
const allowlist = buildAllowlistSet(cfg);
|
||||
if (allowlist.size > 0) {
|
||||
const key = modelKey(resolved.provider, resolved.model);
|
||||
if (!allowlist.has(key)) {
|
||||
throw new Error(`Model ${key} is not in agent.allowedModels.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...(existingModel?.fallbacks
|
||||
? { fallbacks: existingModel.fallbacks }
|
||||
: undefined),
|
||||
primary: key,
|
||||
},
|
||||
models: nextModels,
|
||||
model: `${resolved.provider}/${resolved.model}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
runtime.log(`Default model: ${updated.agent?.model?.primary ?? modelRaw}`);
|
||||
runtime.log(`Default model: ${updated.agent?.model ?? modelRaw}`);
|
||||
}
|
||||
@@ -69,8 +69,7 @@ export function resolveModelTarget(params: {
|
||||
|
||||
export function buildAllowlistSet(cfg: ClawdbotConfig): Set<string> {
|
||||
const allowed = new Set<string>();
|
||||
const models = cfg.agent?.models ?? {};
|
||||
for (const raw of Object.keys(models)) {
|
||||
for (const raw of cfg.agent?.allowedModels ?? []) {
|
||||
const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
|
||||
if (!parsed) continue;
|
||||
allowed.add(modelKey(parsed.provider, parsed.model));
|
||||
|
||||
@@ -5,12 +5,11 @@ import path from "node:path";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveOAuthPath } from "../config/paths.js";
|
||||
import { writeOAuthCredentials } from "./onboard-auth.js";
|
||||
|
||||
describe("writeOAuthCredentials", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
let tempStateDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -23,24 +22,12 @@ describe("writeOAuthCredentials", () => {
|
||||
} 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;
|
||||
}
|
||||
delete process.env.CLAWDBOT_OAUTH_DIR;
|
||||
});
|
||||
|
||||
it("writes auth-profiles.json under CLAWDBOT_STATE_DIR/agent", async () => {
|
||||
it("writes oauth.json under CLAWDBOT_STATE_DIR/credentials", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-oauth-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const creds = {
|
||||
refresh: "refresh-token",
|
||||
@@ -50,19 +37,16 @@ describe("writeOAuthCredentials", () => {
|
||||
|
||||
await writeOAuthCredentials("anthropic", creds);
|
||||
|
||||
const authProfilePath = path.join(
|
||||
tempStateDir,
|
||||
"agent",
|
||||
"auth-profiles.json",
|
||||
const oauthPath = resolveOAuthPath();
|
||||
expect(oauthPath).toBe(
|
||||
path.join(tempStateDir, "credentials", "oauth.json"),
|
||||
);
|
||||
const raw = await fs.readFile(authProfilePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
profiles?: Record<string, OAuthCredentials & { type?: string }>;
|
||||
};
|
||||
expect(parsed.profiles?.["anthropic:default"]).toMatchObject({
|
||||
|
||||
const raw = await fs.readFile(oauthPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, OAuthCredentials>;
|
||||
expect(parsed.anthropic).toMatchObject({
|
||||
refresh: "refresh-token",
|
||||
access: "access-token",
|
||||
type: "oauth",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,73 +1,47 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
import { discoverAuthStorage } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveOAuthPath } from "../config/paths.js";
|
||||
|
||||
export async function writeOAuthCredentials(
|
||||
provider: OAuthProvider,
|
||||
creds: OAuthCredentials,
|
||||
): Promise<void> {
|
||||
upsertAuthProfile({
|
||||
profileId: `${provider}:default`,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider,
|
||||
...creds,
|
||||
},
|
||||
});
|
||||
const filePath = resolveOAuthPath();
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
let storage: Record<string, OAuthCredentials> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, OAuthCredentials>;
|
||||
if (parsed && typeof parsed === "object") storage = parsed;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
storage[provider] = creds;
|
||||
await fs.writeFile(filePath, `${JSON.stringify(storage, null, 2)}\n`, "utf8");
|
||||
await fs.chmod(filePath, 0o600);
|
||||
}
|
||||
|
||||
export async function setAnthropicApiKey(key: string) {
|
||||
upsertAuthProfile({
|
||||
profileId: "anthropic:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function applyAuthProfileConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params: {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
mode: "api_key" | "oauth";
|
||||
email?: string;
|
||||
},
|
||||
): ClawdbotConfig {
|
||||
const profiles = {
|
||||
...cfg.auth?.profiles,
|
||||
[params.profileId]: {
|
||||
provider: params.provider,
|
||||
mode: params.mode,
|
||||
...(params.email ? { email: params.email } : {}),
|
||||
},
|
||||
};
|
||||
const order = { ...cfg.auth?.order };
|
||||
const list = order[params.provider] ? [...order[params.provider]] : [];
|
||||
if (!list.includes(params.profileId)) list.push(params.profileId);
|
||||
order[params.provider] = list;
|
||||
return {
|
||||
...cfg,
|
||||
auth: {
|
||||
...cfg.auth,
|
||||
profiles,
|
||||
order,
|
||||
},
|
||||
};
|
||||
const agentDir = resolveClawdbotAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
authStorage.set("anthropic", { type: "api_key", key });
|
||||
}
|
||||
|
||||
export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const models = { ...cfg.agent?.models };
|
||||
models["anthropic/claude-opus-4-5"] = {
|
||||
...models["anthropic/claude-opus-4-5"],
|
||||
alias: models["anthropic/claude-opus-4-5"]?.alias ?? "Opus",
|
||||
};
|
||||
models["lmstudio/minimax-m2.1-gs32"] = {
|
||||
...models["lmstudio/minimax-m2.1-gs32"],
|
||||
alias: models["lmstudio/minimax-m2.1-gs32"]?.alias ?? "Minimax",
|
||||
};
|
||||
const allowed = new Set(cfg.agent?.allowedModels ?? []);
|
||||
allowed.add("anthropic/claude-opus-4-5");
|
||||
allowed.add("lmstudio/minimax-m2.1-gs32");
|
||||
|
||||
const aliases = { ...cfg.agent?.modelAliases };
|
||||
if (!aliases.Opus) aliases.Opus = "anthropic/claude-opus-4-5";
|
||||
if (!aliases.Minimax) aliases.Minimax = "lmstudio/minimax-m2.1-gs32";
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
if (!providers.lmstudio) {
|
||||
@@ -93,17 +67,9 @@ export function applyMinimaxConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
...cfg,
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...(cfg.agent?.model &&
|
||||
"fallbacks" in (cfg.agent.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (cfg.agent.model as { fallbacks?: string[] })
|
||||
.fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: "lmstudio/minimax-m2.1-gs32",
|
||||
},
|
||||
models,
|
||||
model: "Minimax",
|
||||
allowedModels: Array.from(allowed),
|
||||
modelAliases: aliases,
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
|
||||
@@ -33,13 +33,7 @@ export function summarizeExistingConfig(config: ClawdbotConfig): string {
|
||||
const rows: string[] = [];
|
||||
if (config.agent?.workspace)
|
||||
rows.push(`workspace: ${config.agent.workspace}`);
|
||||
if (config.agent?.model) {
|
||||
const model =
|
||||
typeof config.agent.model === "string"
|
||||
? config.agent.model
|
||||
: config.agent.model.primary;
|
||||
if (model) rows.push(`model: ${model}`);
|
||||
}
|
||||
if (config.agent?.model) rows.push(`model: ${config.agent.model}`);
|
||||
if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`);
|
||||
if (typeof config.gateway?.port === "number") {
|
||||
rows.push(`gateway.port: ${config.gateway.port}`);
|
||||
|
||||
@@ -14,11 +14,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxConfig,
|
||||
setAnthropicApiKey,
|
||||
} from "./onboard-auth.js";
|
||||
import { applyMinimaxConfig, setAnthropicApiKey } from "./onboard-auth.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
DEFAULT_WORKSPACE,
|
||||
@@ -102,11 +98,6 @@ export async function runNonInteractiveOnboarding(
|
||||
return;
|
||||
}
|
||||
await setAnthropicApiKey(key);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
});
|
||||
} else if (authChoice === "minimax") {
|
||||
nextConfig = applyMinimaxConfig(nextConfig);
|
||||
} else if (
|
||||
|
||||
@@ -12,11 +12,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
agent: {
|
||||
model: { primary: "pi:opus" },
|
||||
models: { "pi:opus": {} },
|
||||
contextTokens: 32000,
|
||||
},
|
||||
agent: { model: "pi:opus", contextTokens: 32000 },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ensureAgentWorkspace,
|
||||
} from "../agents/workspace.js";
|
||||
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js";
|
||||
import { applyModelDefaults } from "../config/defaults.js";
|
||||
import { applyModelAliasDefaults } from "../config/defaults.js";
|
||||
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -31,7 +31,7 @@ async function readConfigFileRaw(): Promise<{
|
||||
|
||||
async function writeConfigFile(cfg: ClawdbotConfig) {
|
||||
await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
|
||||
const json = JSON.stringify(applyModelDefaults(cfg), null, 2)
|
||||
const json = JSON.stringify(applyModelAliasDefaults(cfg), null, 2)
|
||||
.trimEnd()
|
||||
.concat("\n");
|
||||
await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8");
|
||||
|
||||
@@ -628,18 +628,6 @@ describe("legacy config detection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects legacy agent.model string", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
agent: { model: "anthropic/claude-opus-4-5" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("agent.model");
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates telegram.requireMention to telegram.groups.*.requireMention", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
@@ -653,38 +641,6 @@ describe("legacy config detection", () => {
|
||||
expect(res.config?.telegram?.requireMention).toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates legacy model config to agent.models + model lists", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
modelFallbacks: ["openai/gpt-4.1-mini"],
|
||||
imageModel: "openai/gpt-4.1-mini",
|
||||
imageModelFallbacks: ["anthropic/claude-opus-4-5"],
|
||||
allowedModels: ["anthropic/claude-opus-4-5", "openai/gpt-4.1-mini"],
|
||||
modelAliases: { Opus: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config?.agent?.model?.primary).toBe("anthropic/claude-opus-4-5");
|
||||
expect(res.config?.agent?.model?.fallbacks).toEqual([
|
||||
"openai/gpt-4.1-mini",
|
||||
]);
|
||||
expect(res.config?.agent?.imageModel?.primary).toBe("openai/gpt-4.1-mini");
|
||||
expect(res.config?.agent?.imageModel?.fallbacks).toEqual([
|
||||
"anthropic/claude-opus-4-5",
|
||||
]);
|
||||
expect(
|
||||
res.config?.agent?.models?.["anthropic/claude-opus-4-5"],
|
||||
).toMatchObject({ alias: "Opus" });
|
||||
expect(res.config?.agent?.models?.["openai/gpt-4.1-mini"]).toBeTruthy();
|
||||
expect(res.config?.agent?.allowedModels).toBeUndefined();
|
||||
expect(res.config?.agent?.modelAliases).toBeUndefined();
|
||||
expect(res.config?.agent?.modelFallbacks).toBeUndefined();
|
||||
expect(res.config?.agent?.imageModelFallbacks).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces legacy issues in snapshot", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configPath = path.join(home, ".clawdbot", "clawdbot.json");
|
||||
|
||||
+33
-13
@@ -92,23 +92,43 @@ export function applyTalkApiKey(config: ClawdbotConfig): ClawdbotConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
function normalizeAliasKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function applyModelAliasDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const existingAgent = cfg.agent;
|
||||
if (!existingAgent) return cfg;
|
||||
const existingModels = existingAgent.models ?? {};
|
||||
if (Object.keys(existingModels).length === 0) return cfg;
|
||||
const existingAliases = existingAgent?.modelAliases ?? {};
|
||||
|
||||
const byNormalized = new Map<string, string>();
|
||||
for (const key of Object.keys(existingAliases)) {
|
||||
const norm = normalizeAliasKey(key);
|
||||
if (!norm) continue;
|
||||
if (!byNormalized.has(norm)) byNormalized.set(norm, key);
|
||||
}
|
||||
|
||||
let mutated = false;
|
||||
const nextModels: Record<string, { alias?: string }> = {
|
||||
...existingModels,
|
||||
};
|
||||
const nextAliases: Record<string, string> = { ...existingAliases };
|
||||
|
||||
for (const [alias, target] of Object.entries(DEFAULT_MODEL_ALIASES)) {
|
||||
const entry = nextModels[target];
|
||||
if (!entry) continue;
|
||||
if (entry.alias !== undefined) continue;
|
||||
nextModels[target] = { ...entry, alias };
|
||||
mutated = true;
|
||||
for (const [canonicalKey, target] of Object.entries(DEFAULT_MODEL_ALIASES)) {
|
||||
const norm = normalizeAliasKey(canonicalKey);
|
||||
const existingKey = byNormalized.get(norm);
|
||||
|
||||
if (!existingKey) {
|
||||
nextAliases[canonicalKey] = target;
|
||||
byNormalized.set(norm, canonicalKey);
|
||||
mutated = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingValue = String(existingAliases[existingKey] ?? "");
|
||||
if (existingKey !== canonicalKey && existingValue === target) {
|
||||
delete nextAliases[existingKey];
|
||||
nextAliases[canonicalKey] = target;
|
||||
byNormalized.set(norm, canonicalKey);
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mutated) return cfg;
|
||||
@@ -117,7 +137,7 @@ export function applyModelDefaults(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
...cfg,
|
||||
agent: {
|
||||
...existingAgent,
|
||||
models: nextModels,
|
||||
modelAliases: nextAliases,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+5
-5
@@ -11,7 +11,7 @@ import {
|
||||
import {
|
||||
applyIdentityDefaults,
|
||||
applyLoggingDefaults,
|
||||
applyModelDefaults,
|
||||
applyModelAliasDefaults,
|
||||
applySessionDefaults,
|
||||
applyTalkApiKey,
|
||||
} from "./defaults.js";
|
||||
@@ -114,7 +114,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const cfg = applyModelDefaults(
|
||||
const cfg = applyModelAliasDefaults(
|
||||
applySessionDefaults(
|
||||
applyLoggingDefaults(
|
||||
applyIdentityDefaults(validated.data as ClawdbotConfig),
|
||||
@@ -148,7 +148,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const exists = deps.fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
const config = applyTalkApiKey(
|
||||
applyModelDefaults(applySessionDefaults({})),
|
||||
applyModelAliasDefaults(applySessionDefaults({})),
|
||||
);
|
||||
const legacyIssues: LegacyConfigIssue[] = [];
|
||||
return {
|
||||
@@ -204,7 +204,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
parsed: parsedRes.parsed,
|
||||
valid: true,
|
||||
config: applyTalkApiKey(
|
||||
applyModelDefaults(
|
||||
applyModelAliasDefaults(
|
||||
applySessionDefaults(applyLoggingDefaults(validated.config)),
|
||||
),
|
||||
),
|
||||
@@ -229,7 +229,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
await deps.fs.promises.mkdir(path.dirname(configPath), {
|
||||
recursive: true,
|
||||
});
|
||||
const json = JSON.stringify(applyModelDefaults(cfg), null, 2)
|
||||
const json = JSON.stringify(applyModelAliasDefaults(cfg), null, 2)
|
||||
.trimEnd()
|
||||
.concat("\n");
|
||||
await deps.fs.promises.writeFile(configPath, json, "utf-8");
|
||||
|
||||
+1
-189
@@ -3,7 +3,6 @@ import type { LegacyConfigIssue } from "./types.js";
|
||||
type LegacyConfigRule = {
|
||||
path: string[];
|
||||
message: string;
|
||||
match?: (value: unknown, root: Record<string, unknown>) => boolean;
|
||||
};
|
||||
|
||||
type LegacyConfigMigration = {
|
||||
@@ -28,38 +27,6 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
message:
|
||||
'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdbot doctor` to migrate).',
|
||||
},
|
||||
{
|
||||
path: ["agent", "model"],
|
||||
message:
|
||||
"agent.model string was replaced by agent.model.primary/fallbacks and agent.models (run `clawdbot doctor` to migrate).",
|
||||
match: (value) => typeof value === "string",
|
||||
},
|
||||
{
|
||||
path: ["agent", "imageModel"],
|
||||
message:
|
||||
"agent.imageModel string was replaced by agent.imageModel.primary/fallbacks (run `clawdbot doctor` to migrate).",
|
||||
match: (value) => typeof value === "string",
|
||||
},
|
||||
{
|
||||
path: ["agent", "allowedModels"],
|
||||
message:
|
||||
"agent.allowedModels was replaced by agent.models (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["agent", "modelAliases"],
|
||||
message:
|
||||
"agent.modelAliases was replaced by agent.models.*.alias (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["agent", "modelFallbacks"],
|
||||
message:
|
||||
"agent.modelFallbacks was replaced by agent.model.fallbacks (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
{
|
||||
path: ["agent", "imageModelFallbacks"],
|
||||
message:
|
||||
"agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
|
||||
},
|
||||
];
|
||||
|
||||
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
@@ -198,161 +165,6 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent.model-config-v2",
|
||||
describe:
|
||||
"Migrate legacy agent.model/allowedModels/modelAliases/modelFallbacks/imageModelFallbacks to agent.models + model lists",
|
||||
apply: (raw, changes) => {
|
||||
const agent =
|
||||
raw.agent && typeof raw.agent === "object"
|
||||
? (raw.agent as Record<string, unknown>)
|
||||
: null;
|
||||
if (!agent) return;
|
||||
|
||||
const legacyModel =
|
||||
typeof agent.model === "string" ? String(agent.model) : undefined;
|
||||
const legacyImageModel =
|
||||
typeof agent.imageModel === "string"
|
||||
? String(agent.imageModel)
|
||||
: undefined;
|
||||
const legacyAllowed = Array.isArray(agent.allowedModels)
|
||||
? (agent.allowedModels as unknown[]).map(String)
|
||||
: [];
|
||||
const legacyModelFallbacks = Array.isArray(agent.modelFallbacks)
|
||||
? (agent.modelFallbacks as unknown[]).map(String)
|
||||
: [];
|
||||
const legacyImageModelFallbacks = Array.isArray(agent.imageModelFallbacks)
|
||||
? (agent.imageModelFallbacks as unknown[]).map(String)
|
||||
: [];
|
||||
const legacyAliases =
|
||||
agent.modelAliases && typeof agent.modelAliases === "object"
|
||||
? (agent.modelAliases as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const hasLegacy =
|
||||
legacyModel ||
|
||||
legacyImageModel ||
|
||||
legacyAllowed.length > 0 ||
|
||||
legacyModelFallbacks.length > 0 ||
|
||||
legacyImageModelFallbacks.length > 0 ||
|
||||
Object.keys(legacyAliases).length > 0;
|
||||
if (!hasLegacy) return;
|
||||
|
||||
const models =
|
||||
agent.models && typeof agent.models === "object"
|
||||
? (agent.models as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const ensureModel = (rawKey?: string) => {
|
||||
if (typeof rawKey !== "string") return;
|
||||
const key = rawKey.trim();
|
||||
if (!key) return;
|
||||
if (!models[key]) models[key] = {};
|
||||
};
|
||||
|
||||
ensureModel(legacyModel);
|
||||
ensureModel(legacyImageModel);
|
||||
for (const key of legacyAllowed) ensureModel(key);
|
||||
for (const key of legacyModelFallbacks) ensureModel(key);
|
||||
for (const key of legacyImageModelFallbacks) ensureModel(key);
|
||||
for (const target of Object.values(legacyAliases)) {
|
||||
if (typeof target !== "string") continue;
|
||||
ensureModel(target);
|
||||
}
|
||||
|
||||
for (const [alias, targetRaw] of Object.entries(legacyAliases)) {
|
||||
if (typeof targetRaw !== "string") continue;
|
||||
const target = targetRaw.trim();
|
||||
if (!target) continue;
|
||||
const entry =
|
||||
models[target] && typeof models[target] === "object"
|
||||
? (models[target] as Record<string, unknown>)
|
||||
: {};
|
||||
if (!("alias" in entry)) {
|
||||
entry.alias = alias;
|
||||
models[target] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
const currentModel =
|
||||
agent.model && typeof agent.model === "object"
|
||||
? (agent.model as Record<string, unknown>)
|
||||
: null;
|
||||
if (currentModel) {
|
||||
if (!currentModel.primary && legacyModel) {
|
||||
currentModel.primary = legacyModel;
|
||||
}
|
||||
if (
|
||||
legacyModelFallbacks.length > 0 &&
|
||||
(!Array.isArray(currentModel.fallbacks) ||
|
||||
currentModel.fallbacks.length === 0)
|
||||
) {
|
||||
currentModel.fallbacks = legacyModelFallbacks;
|
||||
}
|
||||
agent.model = currentModel;
|
||||
} else if (legacyModel || legacyModelFallbacks.length > 0) {
|
||||
agent.model = {
|
||||
primary: legacyModel,
|
||||
fallbacks: legacyModelFallbacks.length ? legacyModelFallbacks : [],
|
||||
};
|
||||
}
|
||||
|
||||
const currentImageModel =
|
||||
agent.imageModel && typeof agent.imageModel === "object"
|
||||
? (agent.imageModel as Record<string, unknown>)
|
||||
: null;
|
||||
if (currentImageModel) {
|
||||
if (!currentImageModel.primary && legacyImageModel) {
|
||||
currentImageModel.primary = legacyImageModel;
|
||||
}
|
||||
if (
|
||||
legacyImageModelFallbacks.length > 0 &&
|
||||
(!Array.isArray(currentImageModel.fallbacks) ||
|
||||
currentImageModel.fallbacks.length === 0)
|
||||
) {
|
||||
currentImageModel.fallbacks = legacyImageModelFallbacks;
|
||||
}
|
||||
agent.imageModel = currentImageModel;
|
||||
} else if (legacyImageModel || legacyImageModelFallbacks.length > 0) {
|
||||
agent.imageModel = {
|
||||
primary: legacyImageModel,
|
||||
fallbacks: legacyImageModelFallbacks.length
|
||||
? legacyImageModelFallbacks
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
agent.models = models;
|
||||
|
||||
if (legacyModel !== undefined) {
|
||||
changes.push("Migrated agent.model string → agent.model.primary.");
|
||||
}
|
||||
if (legacyModelFallbacks.length > 0) {
|
||||
changes.push("Migrated agent.modelFallbacks → agent.model.fallbacks.");
|
||||
}
|
||||
if (legacyImageModel !== undefined) {
|
||||
changes.push(
|
||||
"Migrated agent.imageModel string → agent.imageModel.primary.",
|
||||
);
|
||||
}
|
||||
if (legacyImageModelFallbacks.length > 0) {
|
||||
changes.push(
|
||||
"Migrated agent.imageModelFallbacks → agent.imageModel.fallbacks.",
|
||||
);
|
||||
}
|
||||
if (legacyAllowed.length > 0) {
|
||||
changes.push("Migrated agent.allowedModels → agent.models.");
|
||||
}
|
||||
if (Object.keys(legacyAliases).length > 0) {
|
||||
changes.push("Migrated agent.modelAliases → agent.models.*.alias.");
|
||||
}
|
||||
|
||||
delete agent.allowedModels;
|
||||
delete agent.modelAliases;
|
||||
delete agent.modelFallbacks;
|
||||
delete agent.imageModelFallbacks;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
@@ -368,7 +180,7 @@ export function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[key];
|
||||
}
|
||||
if (cursor !== undefined && (!rule.match || rule.match(cursor, root))) {
|
||||
if (cursor !== undefined) {
|
||||
issues.push({ path: rule.path.join("."), message: rule.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyModelDefaults } from "./defaults.js";
|
||||
import { applyModelAliasDefaults } from "./defaults.js";
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
|
||||
describe("applyModelDefaults", () => {
|
||||
it("adds default aliases when models are present", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
const next = applyModelDefaults(cfg);
|
||||
describe("applyModelAliasDefaults", () => {
|
||||
it("adds default shorthands", () => {
|
||||
const cfg = { agent: {} } satisfies ClawdbotConfig;
|
||||
const next = applyModelAliasDefaults(cfg);
|
||||
|
||||
expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
|
||||
"opus",
|
||||
);
|
||||
expect(next.agent?.models?.["openai/gpt-5.2"]?.alias).toBe("gpt");
|
||||
expect(next.agent?.modelAliases).toEqual({
|
||||
opus: "anthropic/claude-opus-4-5",
|
||||
sonnet: "anthropic/claude-sonnet-4-5",
|
||||
gpt: "openai/gpt-5.2",
|
||||
"gpt-mini": "openai/gpt-5-mini",
|
||||
gemini: "google/gemini-3-pro-preview",
|
||||
"gemini-flash": "google/gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override existing aliases", () => {
|
||||
it("normalizes casing when alias matches the default target", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
agent: { modelAliases: { Opus: "anthropic/claude-opus-4-5" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const next = applyModelDefaults(cfg);
|
||||
const next = applyModelAliasDefaults(cfg);
|
||||
|
||||
expect(next.agent?.models?.["anthropic/claude-opus-4-5"]?.alias).toBe(
|
||||
"Opus",
|
||||
);
|
||||
expect(next.agent?.modelAliases).toMatchObject({
|
||||
opus: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
expect(next.agent?.modelAliases).not.toHaveProperty("Opus");
|
||||
});
|
||||
|
||||
it("respects explicit empty alias disables", () => {
|
||||
it("does not override existing alias values", () => {
|
||||
const cfg = {
|
||||
agent: {
|
||||
models: {
|
||||
"google/gemini-3-pro-preview": { alias: "" },
|
||||
"google/gemini-3-flash-preview": {},
|
||||
},
|
||||
},
|
||||
agent: { modelAliases: { gpt: "openai/gpt-4.1" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const next = applyModelDefaults(cfg);
|
||||
const next = applyModelAliasDefaults(cfg);
|
||||
|
||||
expect(next.agent?.models?.["google/gemini-3-pro-preview"]?.alias).toBe("");
|
||||
expect(next.agent?.models?.["google/gemini-3-flash-preview"]?.alias).toBe(
|
||||
expect(next.agent?.modelAliases?.gpt).toBe("openai/gpt-4.1");
|
||||
expect(next.agent?.modelAliases).toMatchObject({
|
||||
"gpt-mini": "openai/gpt-5-mini",
|
||||
opus: "anthropic/claude-opus-4-5",
|
||||
sonnet: "anthropic/claude-sonnet-4-5",
|
||||
gemini: "google/gemini-3-pro-preview",
|
||||
"gemini-flash": "google/gemini-3-flash-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rename when casing differs and value differs", () => {
|
||||
const cfg = {
|
||||
agent: { modelAliases: { GPT: "openai/gpt-4.1-mini" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const next = applyModelAliasDefaults(cfg);
|
||||
|
||||
expect(next.agent?.modelAliases).toMatchObject({
|
||||
GPT: "openai/gpt-4.1-mini",
|
||||
});
|
||||
expect(next.agent?.modelAliases).not.toHaveProperty("gpt");
|
||||
});
|
||||
|
||||
it("respects explicit empty-string disables", () => {
|
||||
const cfg = {
|
||||
agent: { modelAliases: { gemini: "" } },
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const next = applyModelAliasDefaults(cfg);
|
||||
|
||||
expect(next.agent?.modelAliases?.gemini).toBe("");
|
||||
expect(next.agent?.modelAliases).toHaveProperty(
|
||||
"gemini-flash",
|
||||
"google/gemini-3-flash-preview",
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
-9
@@ -1,5 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import type { ClawdbotConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
@@ -32,15 +33,6 @@ export function resolveStateDir(
|
||||
return path.join(homedir(), ".clawdbot");
|
||||
}
|
||||
|
||||
function resolveUserPath(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
if (trimmed.startsWith("~")) {
|
||||
return path.resolve(trimmed.replace("~", os.homedir()));
|
||||
}
|
||||
return path.resolve(trimmed);
|
||||
}
|
||||
|
||||
export const STATE_DIR_CLAWDBOT = resolveStateDir();
|
||||
|
||||
/**
|
||||
|
||||
+9
-18
@@ -87,13 +87,10 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.reload.mode": "Config Reload Mode",
|
||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||
"agent.workspace": "Workspace",
|
||||
"auth.profiles": "Auth Profiles",
|
||||
"auth.order": "Auth Profile Order",
|
||||
"agent.models": "Models",
|
||||
"agent.model.primary": "Primary Model",
|
||||
"agent.model.fallbacks": "Model Fallbacks",
|
||||
"agent.imageModel.primary": "Image Model",
|
||||
"agent.imageModel.fallbacks": "Image Model Fallbacks",
|
||||
"agent.model": "Default Model",
|
||||
"agent.imageModel": "Image Model",
|
||||
"agent.modelFallbacks": "Model Fallbacks",
|
||||
"agent.imageModelFallbacks": "Image Model Fallbacks",
|
||||
"ui.seamColor": "Accent Color",
|
||||
"browser.controlUrl": "Browser Control URL",
|
||||
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
||||
@@ -117,18 +114,12 @@ const FIELD_HELP: Record<string, string> = {
|
||||
'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||
"gateway.reload.debounceMs":
|
||||
"Debounce window (ms) before applying config changes.",
|
||||
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
|
||||
"auth.order":
|
||||
"Ordered auth profile IDs per provider (used for automatic failover).",
|
||||
"agent.models":
|
||||
"Configured model catalog (keys are full provider/model IDs).",
|
||||
"agent.model.primary": "Primary model (provider/model).",
|
||||
"agent.model.fallbacks":
|
||||
"agent.modelFallbacks":
|
||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||
"agent.imageModel.primary":
|
||||
"Optional image model (provider/model) used when the primary model lacks image input.",
|
||||
"agent.imageModel.fallbacks":
|
||||
"Ordered fallback image models (provider/model).",
|
||||
"agent.imageModel":
|
||||
"Optional image-capable model (provider/model) used by the image tool.",
|
||||
"agent.imageModelFallbacks":
|
||||
"Ordered fallback image models (provider/model) used by the image tool.",
|
||||
"session.agentToAgent.maxPingPongTurns":
|
||||
"Max reply-back turns between requester and target (0–5).",
|
||||
};
|
||||
|
||||
@@ -34,7 +34,6 @@ export type SessionEntry = {
|
||||
elevatedLevel?: string;
|
||||
providerOverride?: string;
|
||||
modelOverride?: string;
|
||||
authProfileOverride?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
groupActivationNeedsSystemIntro?: boolean;
|
||||
sendPolicy?: "allow" | "deny";
|
||||
@@ -55,7 +54,6 @@ export type SessionEntry = {
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
compactionCount?: number;
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
|
||||
+12
-27
@@ -639,28 +639,7 @@ export type ModelsConfig = {
|
||||
providers?: Record<string, ModelProviderConfig>;
|
||||
};
|
||||
|
||||
export type AuthProfileConfig = {
|
||||
provider: string;
|
||||
mode: "api_key" | "oauth";
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type AuthConfig = {
|
||||
profiles?: Record<string, AuthProfileConfig>;
|
||||
order?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type AgentModelEntryConfig = {
|
||||
alias?: string;
|
||||
};
|
||||
|
||||
export type AgentModelListConfig = {
|
||||
primary?: string;
|
||||
fallbacks?: string[];
|
||||
};
|
||||
|
||||
export type ClawdbotConfig = {
|
||||
auth?: AuthConfig;
|
||||
env?: {
|
||||
/** Opt-in: import missing secrets from a login shell environment (exec `$SHELL -l -c 'env -0'`). */
|
||||
shellEnv?: {
|
||||
@@ -690,16 +669,22 @@ export type ClawdbotConfig = {
|
||||
skills?: SkillsConfig;
|
||||
models?: ModelsConfig;
|
||||
agent?: {
|
||||
/** Primary model and fallbacks (provider/model). */
|
||||
model?: AgentModelListConfig;
|
||||
/** Optional image-capable model and fallbacks (provider/model). */
|
||||
imageModel?: AgentModelListConfig;
|
||||
/** Model catalog with optional aliases (full provider/model keys). */
|
||||
models?: Record<string, AgentModelEntryConfig>;
|
||||
/** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */
|
||||
model?: string;
|
||||
/** Optional image-capable model (provider/model) used by the image tool. */
|
||||
imageModel?: string;
|
||||
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
||||
workspace?: string;
|
||||
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
|
||||
userTimezone?: string;
|
||||
/** Optional allowlist for /model (provider/model or model-only). */
|
||||
allowedModels?: string[];
|
||||
/** Optional model aliases for /model (alias -> provider/model). */
|
||||
modelAliases?: Record<string, string>;
|
||||
/** Ordered fallback models (provider/model). */
|
||||
modelFallbacks?: string[];
|
||||
/** Ordered fallback image models (provider/model) for the image tool. */
|
||||
imageModelFallbacks?: string[];
|
||||
/** Optional display-only context window override (used for % in status UIs). */
|
||||
contextTokens?: number;
|
||||
/** Default thinking level when no /think directive is present. */
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user