diff --git a/CHANGELOG.md b/CHANGELOG.md index aec03170b..5a1efd02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,10 @@ - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess - Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123 +- Auto-reply: fix /status usage summary filtering for the active provider. - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj +- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth). +- Status: show active auth profile and key snippet in /status. - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). @@ -56,7 +59,15 @@ - Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj - Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy - TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne +- Status: show Verbose/Elevated only when enabled. +- Status: filter usage summary to the active model provider. +- Status: map model providers to usage sources so unrelated usage doesn’t appear. +- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated. +- Commands: keep multi-directive messages from clearing directive handling. +- Commands: warn when /elevated runs in direct (unsandboxed) runtime. - Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. +- Commands: return /status in directive-only multi-line messages. +- Agent system prompt: add messaging guidance for reply routing and cross-session sends. (#526) — thanks @neist ## 2026.1.8 diff --git a/README.md b/README.md index f6e8ce38c..52fe98fc7 100644 --- a/README.md +++ b/README.md @@ -240,11 +240,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only): -- `/status` — health + session info (group shows activation mode) +- `/status` — compact session status (model + tokens, cost when available) - `/new` or `/reset` — reset the session - `/compact` — compact session context (summary) - `/think ` — off|minimal|low|medium|high - `/verbose on|off` +- `/cost on|off` — append per-response token/cost usage lines - `/restart` — restart the gateway (owner-only in groups) - `/activation mention|always` — group activation toggle (groups only) @@ -459,11 +460,11 @@ Thanks to all clawtributors: daveonkels Eng. Juan Combetto Mariano Belinky julianengel sreekaransrinath dbhurley gupsammy nachoiacovino Vasanth Rao Naik Sabavat jeffersonwarrior claude scald andranik-sahakyan nachx639 sircrumpet rafaelreis-r meaningfool ratulsarna lutr0 abhisekbasu1 emanuelst osolmaz kiranjd thewilloftheshadow CashWilliams manuelhettich minghinmatthewlam buddyh sheeek timkrase - mcinteerj azade-c imfing petter-b RandyVentures jalehman obviyus Yurii Chukhlib dan-dr iamadig - manmal VACInc zats Django Navarro pcty-nextgen-service-account Syhids erik-agens fcatuhe jayhickey jverdi - mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC alejandro maza antons Asleep123 cash-echo-bot - Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna Jarvis jonasjancarik Jonathan D. Rhyne (DJ-D) Keith the Silly Goose - Kit kitze kkarimi loukotal mrdbstn MSch nexty5870 ngutman onutc prathamdby - reeltimeapps RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo Erik Manuel Maly - Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock pasogott ogulcancelik + mcinteerj azade-c imfing petter-b RandyVentures Yurii Chukhlib jalehman obviyus dan-dr iamadig + manmal VACInc zats Django Navarro L36 Server pcty-nextgen-service-account Syhids erik-agens fcatuhe jayhickey + Jonathan D. Rhyne (DJ-D) jverdi mitschabaude-bot oswalpalash philipp-spiess pkrmf Sash Catanzarite VAC alejandro maza antons + Asleep123 cash-echo-bot Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna Jarvis jonasjancarik + Keith the Silly Goose Kit kitze kkarimi loukotal mrdbstn MSch neist nexty5870 ngutman + onutc prathamdby reeltimeapps RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo + Erik latitudeki5223 Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock

diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 9a4761215..713239414 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -664,19 +664,22 @@ public struct SessionsListParams: Codable, Sendable { public let includeglobal: Bool? public let includeunknown: Bool? public let spawnedby: String? + public let agentid: String? public init( limit: Int?, activeminutes: Int?, includeglobal: Bool?, includeunknown: Bool?, - spawnedby: String? + spawnedby: String?, + agentid: String? ) { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal self.includeunknown = includeunknown self.spawnedby = spawnedby + self.agentid = agentid } private enum CodingKeys: String, CodingKey { case limit @@ -684,6 +687,7 @@ public struct SessionsListParams: Codable, Sendable { case includeglobal = "includeGlobal" case includeunknown = "includeUnknown" case spawnedby = "spawnedBy" + case agentid = "agentId" } } @@ -692,6 +696,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let thinkinglevel: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? + public let responseusage: AnyCodable? public let elevatedlevel: AnyCodable? public let model: AnyCodable? public let spawnedby: AnyCodable? @@ -703,6 +708,7 @@ public struct SessionsPatchParams: Codable, Sendable { thinkinglevel: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, + responseusage: AnyCodable?, elevatedlevel: AnyCodable?, model: AnyCodable?, spawnedby: AnyCodable?, @@ -713,6 +719,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.thinkinglevel = thinkinglevel self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel + self.responseusage = responseusage self.elevatedlevel = elevatedlevel self.model = model self.spawnedby = spawnedby @@ -724,6 +731,7 @@ public struct SessionsPatchParams: Codable, Sendable { case thinkinglevel = "thinkingLevel" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" + case responseusage = "responseUsage" case elevatedlevel = "elevatedLevel" case model case spawnedby = "spawnedBy" @@ -1100,6 +1108,51 @@ public struct WebLoginWaitParams: Codable, Sendable { } } +public struct AgentSummary: Codable, Sendable { + public let id: String + public let name: String? + + public init( + id: String, + name: String? + ) { + self.id = id + self.name = name + } + private enum CodingKeys: String, CodingKey { + case id + case name + } +} + +public struct AgentsListParams: Codable, Sendable { +} + +public struct AgentsListResult: Codable, Sendable { + public let defaultid: String + public let mainkey: String + public let scope: AnyCodable + public let agents: [AgentSummary] + + public init( + defaultid: String, + mainkey: String, + scope: AnyCodable, + agents: [AgentSummary] + ) { + self.defaultid = defaultid + self.mainkey = mainkey + self.scope = scope + self.agents = agents + } + private enum CodingKeys: String, CodingKey { + case defaultid = "defaultId" + case mainkey = "mainKey" + case scope + case agents + } +} + public struct ModelChoice: Codable, Sendable { public let id: String public let name: String diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 2b0ea5924..edf43e889 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -16,6 +16,8 @@ sessions. resolve relative paths against the workspace, but absolute paths can still reach elsewhere on the host unless sandboxing is enabled. If you need isolation, use [`agent.sandbox`](/gateway/sandboxing) (and/or per‑agent sandbox config). +When sandboxing is enabled and `workspaceAccess` is not `"rw"`, tools operate +inside a sandbox workspace under `~/.clawdbot/sandboxes`, not your host workspace. ## Default location diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 9921567c5..84329a656 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -11,7 +11,8 @@ read_when: - No estimated costs; only the provider-reported windows. ## Where it shows up -- `/status` in chats: adds a short “Usage” line (only if available). +- `/status` in chats: compact one‑liner with session tokens + estimated cost (API key only) and provider quota windows when available. +- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). - CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - macOS menu bar: “Usage” section under Context (only if available). diff --git a/docs/docs.json b/docs/docs.json index 84c3179aa..b74b08aab 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -556,6 +556,7 @@ "concepts/agent", "concepts/agent-loop", "concepts/system-prompt", + "token-use", "concepts/oauth", "concepts/agent-workspace", "concepts/multi-agent", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index abad5bb7a..3125780b4 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1473,6 +1473,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto Fields: - `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`. + - Sandbox note: `agent.sandbox.mode: "non-main"` uses this key to detect the main session. Any session key that does not match `mainKey` (groups/channels) is sandboxed. - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5). - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 7f2e6ba24..93a629bbe 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -31,6 +31,8 @@ Not sandboxed: - `"off"`: no sandboxing. - `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host). - `"all"`: every session runs in a sandbox. +Note: `"non-main"` is based on `session.mainKey` (default `"main"`), not agent id. +Group/channel sessions use their own keys, so they count as non-main and will be sandboxed. ## Scope `agent.sandbox.scope` controls **how many containers** are created: diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 7d663ccae..43b636892 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -122,6 +122,19 @@ or state drift because only one workspace is active. **Fix:** keep a single active workspace and archive/remove the rest. See [Agent workspace](/concepts/agent-workspace#legacy-workspace-folders). +### Main chat running in a sandbox workspace + +Symptoms: `pwd` or file tools show `~/.clawdbot/sandboxes/...` even though you +expected the host workspace. + +**Why:** `agent.sandbox.mode: "non-main"` keys off `session.mainKey` (default `"main"`). +Group/channel sessions use their own keys, so they are treated as non-main and +get sandbox workspaces. + +**Fix options:** +- If you want host workspaces for an agent: set `routing.agents..sandbox.mode: "off"`. +- If you want host workspace access inside sandbox: set `workspaceAccess: "rw"` for that agent. + ### "Agent was aborted" The agent was interrupted mid-response. diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index 58d47cee7..d17ee98f2 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -252,6 +252,15 @@ The global `agent.workspace` and `agent.sandbox` are still supported for backwar --- +## Common Pitfall: "non-main" + +`sandbox.mode: "non-main"` is based on `session.mainKey` (default `"main"`), +not the agent id. Group/channel sessions always get their own keys, so they +are treated as non-main and will be sandboxed. If you want an agent to never +sandbox, set `routing.agents..sandbox.mode: "off"`. + +--- + ## Testing After configuring multi-agent sandbox and tools: diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index 4104da617..d523ba71c 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -19,6 +19,23 @@ Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It set If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security). +Sandboxing note: `agent.sandbox.mode: "non-main"` uses `session.mainKey` (default `"main"`), +so group/channel sessions are sandboxed. If you want the main agent to always +run on host, set an explicit per-agent override: + +```json +{ + "routing": { + "agents": { + "main": { + "workspace": "~/clawd", + "sandbox": { "mode": "off" } + } + } + } +} +``` + ## 0) Prereqs - Node `>=22` diff --git a/docs/token-use.md b/docs/token-use.md new file mode 100644 index 000000000..d142dcfc4 --- /dev/null +++ b/docs/token-use.md @@ -0,0 +1,72 @@ +--- +summary: "How Clawdbot builds prompt context and reports token usage + costs" +read_when: + - Explaining token usage, costs, or context windows + - Debugging context growth or compaction behavior +--- +# Token use & costs + +Clawdbot tracks **tokens**, not characters. Tokens are model-specific, but most +OpenAI-style models average ~4 characters per token for English text. + +## How the system prompt is built + +Clawdbot assembles its own system prompt on every run. It includes: + +- Tool list + short descriptions +- Skills list (only metadata; instructions are loaded on demand with `read`) +- Self-update instructions +- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new) +- Time (UTC + user timezone) +- Reply tags + heartbeat behavior +- Runtime metadata (host/OS/model/thinking) + +See the full breakdown in [System Prompt](/concepts/system-prompt). + +## What counts in the context window + +Everything the model receives counts toward the context limit: + +- System prompt (all sections listed above) +- Conversation history (user + assistant messages) +- Tool calls and tool results +- Attachments/transcripts (images, audio, files) +- Compaction summaries and pruning artifacts +- Provider wrappers or safety headers (not visible, but still counted) + +## How to see current token usage + +Use these in chat: + +- `/status` → **compact one‑liner** with the session model, context usage, + last response input/output tokens, and **estimated cost** (API key only). +- `/cost on|off` → appends a **per-response usage line** to every reply. + - Persists per session (stored as `responseUsage`). + - OAuth auth **hides cost** (tokens only). + +Other surfaces: + +- **TUI/Web TUI:** `/status` + `/cost` are supported. +- **CLI:** `clawdbot status --usage` and `clawdbot providers list` show + provider quota windows (not per-response costs). + +## Cost estimation (when shown) + +Costs are estimated from your model pricing config: + +``` +models.providers..models[].cost +``` + +These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and +`cacheWrite`. If pricing is missing, Clawdbot shows tokens only. OAuth tokens +never show dollar cost. + +## Tips for reducing token pressure + +- Use `/compact` to summarize long sessions. +- Trim large tool outputs in your workflows. +- Keep skill descriptions short (skill list is injected into the prompt). +- Prefer smaller models for verbose, exploratory work. + +See [Skills](/tools/skills) for the exact skill list overhead formula. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 4d6e04654..f5f4e4374 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -163,6 +163,23 @@ This is **scoped to the agent run**, not a global shell environment. Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session. +## Token impact (skills list) + +When skills are eligible, Clawdbot injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in `pi-coding-agent`). The cost is deterministic: + +- **Base overhead (only when ≥1 skill):** 195 characters. +- **Per skill:** 97 characters + the length of the XML-escaped ``, ``, and `` values. + +Formula (characters): + +``` +total = 195 + Σ (97 + len(name_escaped) + len(description_escaped) + len(location_escaped)) +``` + +Notes: +- XML escaping expands `& < > " '` into entities (`&`, `<`, etc.), increasing length. +- Token counts vary by model tokenizer. A rough OpenAI-style estimate is ~4 chars/token, so **97 chars ≈ 24 tokens** per skill plus your actual field lengths. + ## Managed skills lifecycle Clawdbot ships a baseline set of skills as **bundled skills** as part of the diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 0d9045fe7..3978cf8b9 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -36,6 +36,7 @@ Text + native (when enabled): - `/help` - `/commands` - `/status` +- `/cost on|off` (toggle per-response usage line) - `/stop` - `/restart` - `/activation mention|always` (groups only) @@ -53,6 +54,7 @@ Text-only: Notes: - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). +- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost). - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. diff --git a/docs/tui.md b/docs/tui.md index c164ec129..6b5207d76 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -77,6 +77,7 @@ Session controls: - `/think ` - `/verbose ` - `/reasoning ` +- `/cost ` - `/elevated ` (alias: `/elev`) - `/activation ` - `/deliver ` diff --git a/docs/web/tui.md b/docs/web/tui.md index b4daa0e5a..5135a4cf6 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -53,6 +53,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `/think ` - `/verbose ` - `/reasoning ` (stream = Telegram draft only) +- `/cost ` - `/elevated ` - `/elev ` - `/activation ` diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index bafdd13f1..5d75a5e8a 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -1,6 +1,7 @@ { "ensureLogins": [ "jdrhyne", + "latitudeki5223", "manmal" ], "seedCommit": "d6863f87", diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 1716f7800..4390747ac 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -100,6 +100,7 @@ export async function resolveApiKeyForProvider(params: { } export type EnvApiKeyResult = { apiKey: string; source: string }; +export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown"; export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { const applied = new Set(getShellEnvAppliedKeys()); @@ -143,6 +144,37 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return pick(envVar); } +export function resolveModelAuthMode( + provider?: string, + cfg?: ClawdbotConfig, + store?: AuthProfileStore, +): ModelAuthMode | undefined { + const resolved = provider?.trim(); + if (!resolved) return undefined; + + const authStore = store ?? ensureAuthProfileStore(); + const profiles = listProfilesForProvider(authStore, resolved); + if (profiles.length > 0) { + const modes = new Set( + profiles + .map((id) => authStore.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"; + } + + const envKey = resolveEnvApiKey(resolved); + if (envKey?.apiKey) { + return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; + } + + if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; + + return "unknown"; +} + export async function getApiKeyForModel(params: { model: Model; cfg?: ClawdbotConfig; diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index f393188d9..df003eaf7 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -224,6 +224,11 @@ export function buildAgentSystemPrompt(params: { "- [[reply_to:]] replies to a specific message id when you have it.", "Tags are stripped before sending; support depends on the current provider config.", "", + "## Messaging", + "- Reply in current session → automatically routes to the source provider (Signal, Telegram, etc.)", + "- Cross-session messaging → use sessions_send(sessionKey, message)", + "- Never use bash/curl for provider messaging; Clawdbot handles all routing internally.", + "", ]; if (extraSystemPrompt) { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 1f950083f..c7fb4ed6f 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -34,6 +34,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [ description: "Show current status.", textAliases: ["/status"], }, + { + key: "cost", + nativeName: "cost", + description: "Toggle per-response usage line.", + textAliases: ["/cost"], + acceptsArgs: true, + }, { key: "stop", nativeName: "stop", diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index a690ad505..da18e9ddf 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -472,6 +472,40 @@ describe("directive behavior", () => { }); }); + it("warns when elevated is used in direct runtime", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + sandbox: { mode: "off" }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("Runtime is direct; sandboxing does not apply."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("rejects invalid elevated level", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); @@ -504,6 +538,72 @@ describe("directive behavior", () => { }); }); + it("handles multiple directives in a single message", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated off\n/verbose on", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("Verbose logging enabled."); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("returns status alongside directive-only acks", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + + const res = await getReplyFromConfig( + { + Body: "/elevated off\n/status", + From: "+1222", + To: "+1222", + Provider: "whatsapp", + SenderE164: "+1222", + }, + {}, + { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1222"] }, + }, + }, + whatsapp: { allowFrom: ["+1222"] }, + session: { store: path.join(home, "sessions.json") }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + expect(text).toContain("status agent:main:main"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("acks queue directive and persists override", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index fb7ad374d..f6d3a1a71 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -14,6 +14,17 @@ vi.mock("../agents/pi-embedded.js", () => ({ isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), })); +const usageMocks = vi.hoisted(() => ({ + loadProviderUsageSummary: vi.fn().mockResolvedValue({ + updatedAt: 0, + providers: [], + }), + formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"), + resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]), +})); + +vi.mock("../infra/provider-usage.js", () => usageMocks); + import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -66,6 +77,30 @@ afterEach(() => { }); describe("trigger handling", () => { + it("filters usage summary to the current model provider", async () => { + await withTempHome(async (home) => { + usageMocks.loadProviderUsageSummary.mockClear(); + + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1000", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1000", + }, + {}, + makeCfg(home), + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("📊 Usage: Claude 80% left"); + expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith( + expect.objectContaining({ providers: ["anthropic"] }), + ); + }); + }); + it("aborts even with timestamp prefix", async () => { await withTempHome(async (home) => { const res = await getReplyFromConfig( @@ -178,7 +213,71 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("ClawdBot"); + expect(text).toContain("status"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + + it("reports active auth profile and key snippet in status", async () => { + await withTempHome(async (home) => { + const cfg = makeCfg(home); + const agentDir = join(home, ".clawdbot", "agents", "main", "agent"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + join(agentDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + "anthropic:work": { + type: "api_key", + provider: "anthropic", + key: "sk-test-1234567890abcdef", + }, + }, + lastGood: { anthropic: "anthropic:work" }, + }, + null, + 2, + ), + ); + + const sessionKey = resolveSessionKey("per-sender", { + From: "+1002", + To: "+2000", + Provider: "whatsapp", + } as Parameters[1]); + await fs.writeFile( + cfg.session.store, + JSON.stringify( + { + [sessionKey]: { + sessionId: "session-auth", + updatedAt: Date.now(), + authProfileOverride: "anthropic:work", + }, + }, + null, + 2, + ), + ); + + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1002", + To: "+2000", + Provider: "whatsapp", + SenderE164: "+1002", + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("🔑 api-key"); + expect(text).toContain("…"); + expect(text).toContain("(anthropic:work)"); + expect(text).not.toContain("mixed"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -383,6 +482,48 @@ describe("trigger handling", () => { }); }); + it("allows elevated off in groups without mention", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 1, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + const cfg = { + agent: { + model: "anthropic/claude-opus-4-5", + workspace: join(home, "clawd"), + elevated: { + allowFrom: { whatsapp: ["+1000"] }, + }, + }, + whatsapp: { + allowFrom: ["+1000"], + groups: { "*": { requireMention: false } }, + }, + session: { store: join(home, "sessions.json") }, + }; + + const res = await getReplyFromConfig( + { + Body: "/elevated off", + From: "group:123@g.us", + To: "whatsapp:+2000", + Provider: "whatsapp", + SenderE164: "+1000", + ChatType: "group", + WasMentioned: false, + }, + {}, + cfg, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Elevated mode disabled."); + }); + }); + it("allows elevated directive in groups when mentioned", async () => { await withTempHome(async (home) => { const cfg = { diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 5248fb5c6..eebbe2be0 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -40,7 +40,11 @@ import { getAbortMemory } from "./reply/abort.js"; import { runReplyAgent } from "./reply/agent-runner.js"; import { resolveBlockStreamingChunking } from "./reply/block-streaming.js"; import { applySessionHints } from "./reply/body.js"; -import { buildCommandContext, handleCommands } from "./reply/commands.js"; +import { + buildCommandContext, + buildStatusReply, + handleCommands, +} from "./reply/commands.js"; import { handleDirectiveOnly, type InlineDirectives, @@ -329,11 +333,23 @@ export async function getReplyFromConfig( .map((entry) => entry.alias?.trim()) .filter((alias): alias is string => Boolean(alias)) .filter((alias) => !reservedCommands.has(alias.toLowerCase())); - const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true; let parsedDirectives = parseInlineDirectives(rawBody, { modelAliases: configuredAliases, - disableElevated: disableElevatedInGroup, }); + if ( + isGroup && + ctx.WasMentioned !== true && + parsedDirectives.hasElevatedDirective + ) { + if (parsedDirectives.elevatedLevel !== "off") { + parsedDirectives = { + ...parsedDirectives, + hasElevatedDirective: false, + elevatedLevel: undefined, + rawElevatedLevel: undefined, + }; + } + } const hasDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || @@ -348,7 +364,12 @@ export async function getReplyFromConfig( ? stripMentions(stripped, ctx, cfg, agentId) : stripped; if (noMentions.trim().length > 0) { - parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); + const directiveOnlyCheck = parseInlineDirectives(noMentions, { + modelAliases: configuredAliases, + }); + if (directiveOnlyCheck.cleaned.trim().length > 0) { + parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned); + } } } const directives = commandAuthorized @@ -465,6 +486,21 @@ export async function getReplyFromConfig( ? undefined : directives.rawModelDirective; + const command = buildCommandContext({ + ctx, + cfg, + agentId, + sessionKey, + isGroup, + triggerBodyNormalized, + commandAuthorized, + }); + const allowTextCommands = shouldHandleTextCommands({ + cfg, + surface: command.surface, + commandSource: ctx.CommandSource, + }); + if ( isDirectiveOnly({ directives, @@ -510,8 +546,36 @@ export async function getReplyFromConfig( currentReasoningLevel, currentElevatedLevel, }); + let statusReply: ReplyPayload | undefined; + if (directives.hasStatusDirective && allowTextCommands) { + statusReply = await buildStatusReply({ + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel: + currentThinkLevel ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined), + resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel, + resolvedReasoningLevel: (currentReasoningLevel ?? + "off") as ReasoningLevel, + resolvedElevatedLevel: currentElevatedLevel, + resolveDefaultThinkingLevel: async () => + currentThinkLevel ?? + (agentCfg?.thinkingDefault as ThinkLevel | undefined), + isGroup, + defaultGroupActivation: () => defaultActivation, + }); + } typing.cleanup(); - return directiveReply; + if (statusReply?.text && directiveReply?.text) { + return { text: `${directiveReply.text}\n${statusReply.text}` }; + } + return statusReply ?? directiveReply; } const persisted = await persistInlineDirectives({ @@ -551,20 +615,6 @@ export async function getReplyFromConfig( } : undefined; - const command = buildCommandContext({ - ctx, - cfg, - agentId, - sessionKey, - isGroup, - triggerBodyNormalized, - commandAuthorized, - }); - const allowTextCommands = shouldHandleTextCommands({ - cfg, - surface: command.surface, - commandSource: ctx.CommandSource, - }); const isEmptyConfig = Object.keys(cfg).length === 0; if ( command.isWhatsAppProvider && diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 1225491b2..076b92fa2 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -2,12 +2,13 @@ import crypto from "node:crypto"; import fs from "node:fs"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { resolveModelAuthMode } from "../../agents/model-auth.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; import { queueEmbeddedPiMessage, runEmbeddedPiAgent, } from "../../agents/pi-embedded.js"; -import { hasNonzeroUsage } from "../../agents/usage.js"; +import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js"; import { loadSessionStore, resolveSessionTranscriptPath, @@ -18,6 +19,12 @@ import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { defaultRuntime } from "../../runtime.js"; +import { + estimateUsageCost, + formatTokenCount, + formatUsd, + resolveModelCostConfig, +} from "../../utils/usage-format.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import type { OriginatingChannelType, TemplateContext } from "../templating.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; @@ -62,6 +69,65 @@ const formatBunFetchSocketError = (message: string) => { ].join("\n"); }; +const formatResponseUsageLine = (params: { + usage?: NormalizedUsage; + showCost: boolean; + costConfig?: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; +}): string | null => { + const usage = params.usage; + if (!usage) return null; + const input = usage.input; + const output = usage.output; + if (typeof input !== "number" && typeof output !== "number") return null; + const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; + const outputLabel = + typeof output === "number" ? formatTokenCount(output) : "?"; + const cost = + params.showCost && typeof input === "number" && typeof output === "number" + ? estimateUsageCost({ + usage: { + input, + output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + }, + cost: params.costConfig, + }) + : undefined; + const costLabel = params.showCost ? formatUsd(cost) : undefined; + const suffix = costLabel ? ` · est ${costLabel}` : ""; + return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`; +}; + +const appendUsageLine = ( + payloads: ReplyPayload[], + line: string, +): ReplyPayload[] => { + let index = -1; + for (let i = payloads.length - 1; i >= 0; i -= 1) { + if (payloads[i]?.text) { + index = i; + break; + } + } + if (index === -1) return [...payloads, { text: line }]; + const existing = payloads[index]; + const existingText = existing.text ?? ""; + const separator = existingText.endsWith("\n") ? "" : "\n"; + const next = { + ...existing, + text: `${existingText}${separator}${line}`, + }; + const updated = payloads.slice(); + updated[index] = next; + return updated; +}; + const withTimeout = async ( promise: Promise, timeoutMs: number, @@ -191,6 +257,7 @@ export async function runReplyAgent(params: { replyToChannel, ); const applyReplyToMode = createReplyToModeFilter(replyToMode); + const cfg = followupRun.run.config; if (shouldSteer && isStreaming) { const steered = queueEmbeddedPiMessage( @@ -242,6 +309,7 @@ export async function runReplyAgent(params: { let didLogHeartbeatStrip = false; let autoCompactionCompleted = false; + let responseUsageLine: string | undefined; try { const runId = crypto.randomUUID(); if (sessionKey) { @@ -641,20 +709,20 @@ export async function runReplyAgent(params: { await typingSignals.signalRunStart(); } - if (sessionStore && sessionKey) { - const usage = runResult.meta.agentMeta?.usage; - const modelUsed = - runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; - const providerUsed = - runResult.meta.agentMeta?.provider ?? - fallbackProvider ?? - followupRun.run.provider; - const contextTokensUsed = - agentCfgContextTokens ?? - lookupContextTokens(modelUsed) ?? - sessionEntry?.contextTokens ?? - DEFAULT_CONTEXT_TOKENS; + const usage = runResult.meta.agentMeta?.usage; + const modelUsed = + runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; + const providerUsed = + runResult.meta.agentMeta?.provider ?? + fallbackProvider ?? + followupRun.run.provider; + const contextTokensUsed = + agentCfgContextTokens ?? + lookupContextTokens(modelUsed) ?? + sessionEntry?.contextTokens ?? + DEFAULT_CONTEXT_TOKENS; + if (sessionStore && sessionKey) { if (hasNonzeroUsage(usage)) { const entry = sessionEntry ?? sessionStore[sessionKey]; if (entry) { @@ -694,6 +762,29 @@ export async function runReplyAgent(params: { } } + const responseUsageEnabled = + (sessionEntry?.responseUsage ?? + (sessionKey + ? sessionStore?.[sessionKey]?.responseUsage + : undefined)) === "on"; + if (responseUsageEnabled && hasNonzeroUsage(usage)) { + const authMode = resolveModelAuthMode(providerUsed, cfg); + const showCost = authMode === "api-key"; + const costConfig = showCost + ? resolveModelCostConfig({ + provider: providerUsed, + model: modelUsed, + config: cfg, + }) + : undefined; + const formatted = formatResponseUsageLine({ + usage, + showCost, + costConfig, + }); + if (formatted) responseUsageLine = formatted; + } + // If verbose is enabled and this is a new session, prepend a session hint. let finalPayloads = replyPayloads; if (autoCompactionCompleted) { @@ -717,6 +808,9 @@ export async function runReplyAgent(params: { ...finalPayloads, ]; } + if (responseUsageLine) { + finalPayloads = appendUsageLine(finalPayloads, responseUsageLine); + } return finalizeWithFollowup( finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads, diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index ac5ab76df..0191ba6ce 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,11 +1,13 @@ import { ensureAuthProfileStore, - listProfilesForProvider, + resolveAuthProfileDisplayLabel, + resolveAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { getCustomProviderApiKey, resolveEnvApiKey, } from "../../agents/model-auth.js"; +import { normalizeProviderId } from "../../agents/model-selection.js"; import { abortEmbeddedPiRun, compactEmbeddedPiSession, @@ -23,6 +25,7 @@ import { logVerbose } from "../../globals.js"; import { formatUsageSummaryLine, loadProviderUsageSummary, + resolveUsageProviderId, } from "../../infra/provider-usage.js"; import { scheduleGatewaySigusr1Restart, @@ -92,32 +95,166 @@ export type CommandContext = { to?: string; }; +export async function buildStatusReply(params: { + cfg: ClawdbotConfig; + command: CommandContext; + sessionEntry?: SessionEntry; + sessionKey?: string; + sessionScope?: SessionScope; + provider: string; + model: string; + contextTokens: number; + resolvedThinkLevel?: ThinkLevel; + resolvedVerboseLevel: VerboseLevel; + resolvedReasoningLevel: ReasoningLevel; + resolvedElevatedLevel?: ElevatedLevel; + resolveDefaultThinkingLevel: () => Promise; + isGroup: boolean; + defaultGroupActivation: () => "always" | "mention"; +}): Promise { + const { + cfg, + command, + sessionEntry, + sessionKey, + sessionScope, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation, + } = params; + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, + ); + return undefined; + } + let usageLine: string | null = null; + try { + const usageProvider = resolveUsageProviderId(provider); + if (usageProvider) { + const usageSummary = await loadProviderUsageSummary({ + timeoutMs: 3500, + providers: [usageProvider], + }); + usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); + } + } catch { + usageLine = null; + } + const queueSettings = resolveQueueSettings({ + cfg, + provider: command.provider, + sessionEntry, + }); + const queueKey = sessionKey ?? sessionEntry?.sessionId; + const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0; + const queueOverrides = Boolean( + sessionEntry?.queueDebounceMs ?? + sessionEntry?.queueCap ?? + sessionEntry?.queueDrop, + ); + const groupActivation = isGroup + ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? + defaultGroupActivation()) + : undefined; + const statusText = buildStatusMessage({ + agent: { + ...cfg.agent, + model: { + ...cfg.agent?.model, + primary: `${provider}/${model}`, + }, + contextTokens, + thinkingDefault: cfg.agent?.thinkingDefault, + verboseDefault: cfg.agent?.verboseDefault, + elevatedDefault: cfg.agent?.elevatedDefault, + }, + sessionEntry, + sessionKey, + sessionScope, + groupActivation, + resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), + resolvedVerbose: resolvedVerboseLevel, + resolvedReasoning: resolvedReasoningLevel, + resolvedElevated: resolvedElevatedLevel, + modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry), + usageLine: usageLine ?? undefined, + queue: { + mode: queueSettings.mode, + depth: queueDepth, + debounceMs: queueSettings.debounceMs, + cap: queueSettings.cap, + dropPolicy: queueSettings.dropPolicy, + showDetails: queueOverrides, + }, + includeTranscriptUsage: false, + }); + return { text: statusText }; +} + +function formatApiKeySnippet(apiKey: string): string { + const compact = apiKey.replace(/\s+/g, ""); + if (!compact) return "unknown"; + const edge = compact.length >= 12 ? 6 : 4; + const head = compact.slice(0, edge); + const tail = compact.slice(-edge); + return `${head}…${tail}`; +} + function resolveModelAuthLabel( provider?: string, cfg?: ClawdbotConfig, + sessionEntry?: SessionEntry, ): string | undefined { const resolved = provider?.trim(); if (!resolved) return undefined; + const providerKey = normalizeProviderId(resolved); 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"; + const profileOverride = sessionEntry?.authProfileOverride?.trim(); + const lastGood = store.lastGood?.[providerKey] ?? store.lastGood?.[resolved]; + const order = resolveAuthProfileOrder({ + cfg, + store, + provider: providerKey, + preferredProfile: profileOverride, + }); + const candidates = [profileOverride, lastGood, ...order].filter( + Boolean, + ) as string[]; + + for (const profileId of candidates) { + const profile = store.profiles[profileId]; + if (!profile || normalizeProviderId(profile.provider) !== providerKey) { + continue; + } + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + if (profile.type === "oauth") { + return `oauth${label ? ` (${label})` : ""}`; + } + const snippet = formatApiKeySnippet(profile.key); + return `api-key ${snippet}${label ? ` (${label})` : ""}`; } - const envKey = resolveEnvApiKey(resolved); + const envKey = resolveEnvApiKey(providerKey); if (envKey?.apiKey) { - return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key"; + if (envKey.source.includes("OAUTH_TOKEN")) { + return `oauth (${envKey.source})`; + } + return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`; } - if (getCustomProviderApiKey(cfg, resolved)) return "api-key"; + const customKey = getCustomProviderApiKey(cfg, providerKey); + if (customKey) { + return `api-key ${formatApiKeySnippet(customKey)} (models.json)`; + } return "unknown"; } @@ -426,71 +563,24 @@ export async function handleCommands(params: { directives.hasStatusDirective || command.commandBodyNormalized === "/status"; if (allowTextCommands && statusRequested) { - if (!command.isAuthorizedSender) { - logVerbose( - `Ignoring /status from unauthorized sender: ${command.senderE164 || ""}`, - ); - return { shouldContinue: false }; - } - let usageLine: string | null = null; - try { - const usageSummary = await loadProviderUsageSummary({ - timeoutMs: 3500, - }); - usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); - } catch { - usageLine = null; - } - const queueSettings = resolveQueueSettings({ + const reply = await buildStatusReply({ cfg, - provider: command.provider, - sessionEntry, - }); - const queueKey = sessionKey ?? sessionEntry?.sessionId; - const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0; - const queueOverrides = Boolean( - sessionEntry?.queueDebounceMs ?? - sessionEntry?.queueCap ?? - sessionEntry?.queueDrop, - ); - const groupActivation = isGroup - ? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? - defaultGroupActivation()) - : undefined; - const statusText = buildStatusMessage({ - agent: { - ...cfg.agent, - model: { - ...cfg.agent?.model, - primary: `${provider}/${model}`, - }, - contextTokens, - thinkingDefault: cfg.agent?.thinkingDefault, - verboseDefault: cfg.agent?.verboseDefault, - elevatedDefault: cfg.agent?.elevatedDefault, - }, + command, sessionEntry, sessionKey, sessionScope, - groupActivation, - resolvedThink: - resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), - resolvedVerbose: resolvedVerboseLevel, - resolvedReasoning: resolvedReasoningLevel, - resolvedElevated: resolvedElevatedLevel, - modelAuth: resolveModelAuthLabel(provider, cfg), - usageLine: usageLine ?? undefined, - queue: { - mode: queueSettings.mode, - depth: queueDepth, - debounceMs: queueSettings.debounceMs, - cap: queueSettings.cap, - dropPolicy: queueSettings.dropPolicy, - showDetails: queueOverrides, - }, - includeTranscriptUsage: false, + provider, + model, + contextTokens, + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + isGroup, + defaultGroupActivation, }); - return { shouldContinue: false, reply: { text: statusText } }; + return { shouldContinue: false, reply }; } const stopRequested = command.commandBodyNormalized === "/stop"; diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index 30c71ccf1..eb62f0d9b 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -24,7 +24,12 @@ import { resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { ClawdbotConfig } from "../../config/config.js"; -import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { + resolveAgentIdFromSessionKey, + resolveAgentMainSessionKey, + type SessionEntry, + saveSessionStore, +} from "../../config/sessions.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { shortenHomePath } from "../../utils.js"; import { extractModelDirective } from "../model.js"; @@ -57,6 +62,8 @@ const SYSTEM_MARK = "⚙️"; const formatOptionsLine = (options: string) => `Options: ${options}.`; const withOptions = (line: string, options: string) => `${line}\n${formatOptionsLine(options)}`; +const formatElevatedRuntimeHint = () => + `${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`; const maskApiKey = (value: string): string => { const trimmed = value.trim(); @@ -350,6 +357,21 @@ export async function handleDirectiveOnly(params: { currentReasoningLevel, currentElevatedLevel, } = params; + const runtimeIsSandboxed = (() => { + const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off"; + if (sandboxMode === "off") return false; + const sessionKey = params.sessionKey?.trim(); + if (!sessionKey) return false; + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const mainKey = resolveAgentMainSessionKey({ + cfg: params.cfg, + agentId, + }); + if (sandboxMode === "all") return true; + return sessionKey !== mainKey; + })(); + const shouldHintDirectRuntime = + directives.hasElevatedDirective && !runtimeIsSandboxed; if (directives.hasModelDirective) { const modelDirective = directives.rawModelDirective?.trim().toLowerCase(); @@ -463,7 +485,12 @@ export async function handleDirectiveOnly(params: { } const level = currentElevatedLevel ?? "off"; return { - text: withOptions(`Current elevated level: ${level}.`, "on, off"), + text: [ + withOptions(`Current elevated level: ${level}.`, "on, off"), + shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, + ] + .filter(Boolean) + .join("\n"), }; } return { @@ -681,6 +708,7 @@ export async function handleDirectiveOnly(params: { ? `${SYSTEM_MARK} Elevated mode disabled.` : `${SYSTEM_MARK} Elevated mode enabled.`, ); + if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); } if (modelSelection) { const label = `${modelSelection.provider}/${modelSelection.model}`; @@ -716,6 +744,7 @@ export async function handleDirectiveOnly(params: { parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`); } const ack = parts.join(" ").trim(); + if (!ack && directives.hasStatusDirective) return undefined; return { text: ack || "OK." }; } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 65744c62e..5cf3bd8cc 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -194,6 +194,7 @@ export async function initSessionState(params: { // Persist previously stored thinking/verbose levels when present. thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, + responseUsage: baseEntry?.responseUsage, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, sendPolicy: baseEntry?.sendPolicy, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index e94c36fc3..debf61ad1 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -63,20 +63,34 @@ describe("buildStatusMessage", () => { resolvedThink: "medium", resolvedVerbose: "off", queue: { mode: "collect", depth: 0 }, - now: 10 * 60_000, // 10 minutes later + modelAuth: "api-key", }); - expect(text).toContain("🦞 ClawdBot"); - expect(text).toContain("🧠 Model:"); - expect(text).toContain("Runtime: direct"); - expect(text).toContain("Context: 16k/32k (50%)"); - expect(text).toContain("🧹 Compactions: 2"); - expect(text).toContain("Session: agent:main:main"); - expect(text).toContain("updated 10m ago"); - expect(text).toContain("Think: medium"); - expect(text).toContain("Verbose: off"); - expect(text).toContain("Elevated: on"); - expect(text).toContain("Queue: collect"); + expect(text).toContain("status agent:main:main"); + expect(text).toContain("model anthropic/pi:opus (api-key)"); + expect(text).toContain("Context 16k/32k (50%)"); + expect(text).toContain("compactions 2"); + expect(text).toContain("think medium"); + expect(text).toContain("verbose off"); + expect(text).toContain("reasoning off"); + expect(text).toContain("elevated on"); + expect(text).toContain("queue collect"); + }); + + it("shows verbose/elevated labels only when enabled", () => { + const text = buildStatusMessage({ + agent: { model: "anthropic/claude-opus-4-5" }, + sessionEntry: { sessionId: "v1", updatedAt: 0 }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + resolvedThink: "low", + resolvedVerbose: "on", + resolvedElevated: "on", + queue: { mode: "collect", depth: 0 }, + }); + + expect(text).toContain("verbose on"); + expect(text).toContain("elevated on"); }); it("prefers model overrides over last-run model", () => { @@ -97,9 +111,10 @@ describe("buildStatusMessage", () => { sessionKey: "agent:main:main", sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model: openai/gpt-4.1-mini"); + expect(text).toContain("model openai/gpt-4.1-mini"); }); it("keeps provider prefix from configured model", () => { @@ -109,21 +124,23 @@ describe("buildStatusMessage", () => { }, sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5"); + expect(text).toContain("model google-antigravity/claude-sonnet-4-5"); }); it("handles missing agent config gracefully", () => { const text = buildStatusMessage({ agent: {}, sessionScope: "per-sender", - webLinked: false, + queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("🧠 Model:"); - expect(text).toContain("Context:"); - expect(text).toContain("Queue:"); + expect(text).toContain("model"); + expect(text).toContain("Context"); + expect(text).toContain("queue collect"); }); it("includes group activation for group sessions", () => { @@ -138,9 +155,10 @@ describe("buildStatusMessage", () => { sessionKey: "agent:main:whatsapp:group:123@g.us", sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, + modelAuth: "api-key", }); - expect(text).toContain("Activation: always"); + expect(text).toContain("activation always"); }); it("shows queue details when overridden", () => { @@ -157,10 +175,11 @@ describe("buildStatusMessage", () => { dropPolicy: "old", showDetails: true, }, + modelAuth: "api-key", }); expect(text).toContain( - "Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)", + "queue collect (depth 3 · debounce 2s · cap 5 · drop old)", ); }); @@ -172,12 +191,10 @@ describe("buildStatusMessage", () => { sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, usageLine: "📊 Usage: Claude 80% left (5h)", + modelAuth: "api-key", }); - const lines = text.split("\n"); - const contextIndex = lines.findIndex((line) => line.startsWith("📚 ")); - expect(contextIndex).toBeGreaterThan(-1); - expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)"); + expect(text).toContain("📊 Usage: Claude 80% left (5h)"); }); it("prefers cached prompt tokens from the session log", async () => { @@ -237,9 +254,10 @@ describe("buildStatusMessage", () => { sessionScope: "per-sender", queue: { mode: "collect", depth: 0 }, includeTranscriptUsage: true, + modelAuth: "api-key", }); - expect(text).toContain("Context: 1.0k/32k"); + expect(text).toContain("Context 1.0k/32k"); } finally { restoreHomeEnv(previousHome); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index d25a045f2..6a6d82e73 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -6,6 +6,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER, } from "../agents/defaults.js"; +import { resolveModelAuthMode } from "../agents/model-auth.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { derivePromptTokens, @@ -14,7 +15,6 @@ import { } from "../agents/usage.js"; import type { ClawdbotConfig } from "../config/config.js"; import { - resolveMainSessionKey, resolveSessionFilePath, type SessionEntry, type SessionScope, @@ -22,6 +22,12 @@ import { import { resolveCommitHash } from "../infra/git-commit.js"; import { VERSION } from "../version.js"; import { listChatCommands } from "./commands-registry.js"; +import { + estimateUsageCost, + formatTokenCount as formatTokenCountShared, + formatUsd, + resolveModelCostConfig, +} from "../utils/usage-format.js"; import type { ElevatedLevel, ReasoningLevel, @@ -31,6 +37,8 @@ import type { type AgentConfig = NonNullable; +export const formatTokenCount = formatTokenCountShared; + type QueueStatus = { mode?: string; depth?: number; @@ -41,6 +49,7 @@ type QueueStatus = { }; type StatusArgs = { + config?: ClawdbotConfig; agent: AgentConfig; sessionEntry?: SessionEntry; sessionKey?: string; @@ -54,37 +63,20 @@ type StatusArgs = { usageLine?: string; queue?: QueueStatus; includeTranscriptUsage?: boolean; - now?: number; }; -const formatAge = (ms?: number | null) => { - if (!ms || ms < 0) return "unknown"; - const minutes = Math.round(ms / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.round(minutes / 60); - if (hours < 48) return `${hours}h ago`; - const days = Math.round(hours / 24); - return `${days}d ago`; -}; - -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, ) => { const ctx = contextTokens ?? null; if (total == null) { - const ctxLabel = ctx ? formatKTokens(ctx) : "?"; - return `unknown/${ctxLabel}`; + const ctxLabel = ctx ? formatTokenCount(ctx) : "?"; + return `?/${ctxLabel}`; } const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null; - const totalLabel = formatKTokens(total); - const ctxLabel = ctx ? formatKTokens(ctx) : "?"; + const totalLabel = formatTokenCount(total); + const ctxLabel = ctx ? formatTokenCount(ctx) : "?"; return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`; }; @@ -172,8 +164,15 @@ const readUsageFromSessionLog = ( } }; +const formatUsagePair = (input?: number | null, output?: number | null) => { + if (input == null && output == null) return null; + const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; + const outputLabel = + typeof output === "number" ? formatTokenCount(output) : "?"; + return `usage ${inputLabel} in / ${outputLabel} out`; +}; + export function buildStatusMessage(args: StatusArgs): string { - const now = args.now ?? Date.now(); const entry = args.sessionEntry; const resolved = resolveConfiguredModelRef({ cfg: { agent: args.agent ?? {} }, @@ -189,6 +188,8 @@ export function buildStatusMessage(args: StatusArgs): string { lookupContextTokens(model) ?? DEFAULT_CONTEXT_TOKENS; + let inputTokens = entry?.inputTokens; + let outputTokens = entry?.outputTokens; let totalTokens = entry?.totalTokens ?? (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0); @@ -206,6 +207,8 @@ export function buildStatusMessage(args: StatusArgs): string { if (!contextTokens && logUsage.model) { contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; } + if (!inputTokens || inputTokens === 0) inputTokens = logUsage.input; + if (!outputTokens || outputTokens === 0) outputTokens = logUsage.output; } } @@ -219,33 +222,6 @@ export function buildStatusMessage(args: StatusArgs): string { args.agent?.elevatedDefault ?? "on"; - const runtime = (() => { - const sandboxMode = args.agent?.sandbox?.mode ?? "off"; - if (sandboxMode === "off") return { label: "direct" }; - const sessionScope = args.sessionScope ?? "per-sender"; - const mainKey = resolveMainSessionKey({ - session: { scope: sessionScope }, - }); - const sessionKey = args.sessionKey?.trim(); - const sandboxed = sessionKey - ? sandboxMode === "all" || sessionKey !== mainKey.trim() - : false; - const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown"; - return { - label: `${runtime}/${sandboxMode}`, - }; - })(); - - const updatedAt = entry?.updatedAt; - const sessionLine = [ - `Session: ${args.sessionKey ?? "unknown"}`, - typeof updatedAt === "number" - ? `updated ${formatAge(now - updatedAt)}` - : "no activity", - ] - .filter(Boolean) - .join(" • "); - const isGroupSession = entry?.chatType === "group" || entry?.chatType === "room" || @@ -256,54 +232,68 @@ export function buildStatusMessage(args: StatusArgs): string { ? (args.groupActivation ?? entry?.groupActivation ?? "mention") : undefined; - const contextLine = [ - `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`, - `🧹 Compactions: ${entry?.compactionCount ?? 0}`, - ] - .filter(Boolean) - .join(" · "); + const authMode = + args.modelAuth ?? resolveModelAuthMode(provider, args.config); + const showCost = authMode === "api-key"; + const costConfig = showCost + ? resolveModelCostConfig({ + provider, + model, + config: args.config, + }) + : undefined; + const hasUsage = + typeof inputTokens === "number" || typeof outputTokens === "number"; + const cost = + showCost && hasUsage + ? estimateUsageCost({ + usage: { + input: inputTokens ?? undefined, + output: outputTokens ?? undefined, + }, + cost: costConfig, + }) + : undefined; + const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined; + + const parts: Array = []; + parts.push(`status ${args.sessionKey ?? "unknown"}`); + + const modelLabel = model ? `${provider}/${model}` : "unknown"; + const authLabel = authMode && authMode !== "unknown" ? ` (${authMode})` : ""; + parts.push(`model ${modelLabel}${authLabel}`); + + const usagePair = formatUsagePair(inputTokens, outputTokens); + if (usagePair) parts.push(usagePair); + if (costLabel) parts.push(`cost ${costLabel}`); + + const contextSummary = formatContextUsageShort( + totalTokens && totalTokens > 0 ? totalTokens : null, + contextTokens ?? null, + ); + parts.push(contextSummary); + parts.push(`compactions ${entry?.compactionCount ?? 0}`); + parts.push(`think ${thinkLevel}`); + parts.push(`verbose ${verboseLevel}`); + parts.push(`reasoning ${reasoningLevel}`); + parts.push(`elevated ${elevatedLevel}`); + if (groupActivationValue) parts.push(`activation ${groupActivationValue}`); const queueMode = args.queue?.mode ?? "unknown"; const queueDetails = formatQueueDetails(args.queue); - const optionParts = [ - `Runtime: ${runtime.label}`, - `Think: ${thinkLevel}`, - `Verbose: ${verboseLevel}`, - reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null, - `Elevated: ${elevatedLevel}`, - ]; - const optionsLine = optionParts.filter(Boolean).join(" · "); - const activationParts = [ - groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null, - `🪢 Queue: ${queueMode}${queueDetails}`, - ]; - const activationLine = activationParts.filter(Boolean).join(" · "); + parts.push(`queue ${queueMode}${queueDetails}`); - const modelLabel = model ? `${provider}/${model}` : "unknown"; - const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : ""; - const modelLine = `🧠 Model: ${modelLabel}${authLabel}`; - const commit = resolveCommitHash(); - const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`; + if (args.usageLine) parts.push(args.usageLine); - return [ - versionLine, - modelLine, - `📚 ${contextLine}`, - args.usageLine, - `🧵 ${sessionLine}`, - `⚙️ ${optionsLine}`, - activationLine, - ] - .filter(Boolean) - .join("\n"); + return parts.filter(Boolean).join(" · "); } export function buildHelpMessage(): string { return [ "ℹ️ Help", "Shortcuts: /new reset | /compact [instructions] | /restart relink", - "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model ", - "More: /commands for all slash commands", + "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off", + "More: /commands for all slash commands" ].join("\n"); } diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 1550fde76..90ac4ff44 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -2,6 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high"; export type VerboseLevel = "off" | "on"; export type ElevatedLevel = "off" | "on"; export type ReasoningLevel = "off" | "on" | "stream"; +export type UsageDisplayLevel = "off" | "on"; // Normalize user-provided thinking level strings to the canonical enum. export function normalizeThinkLevel( @@ -46,6 +47,19 @@ export function normalizeVerboseLevel( return undefined; } +// Normalize response-usage display flags used to toggle cost/token lines. +export function normalizeUsageDisplay( + raw?: string | null, +): UsageDisplayLevel | undefined { + if (!raw) return undefined; + const key = raw.toLowerCase(); + if (["off", "false", "no", "0", "disable", "disabled"].includes(key)) + return "off"; + if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) + return "on"; + return undefined; +} + // Normalize elevated flags used to toggle elevated bash permissions. export function normalizeElevatedLevel( raw?: string | null, diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index e55222396..242aa6b9a 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -52,7 +52,10 @@ async function fetchLogs( return payload as LogsTailPayload; } -function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") { +function formatLogTimestamp( + value?: string, + mode: "pretty" | "plain" = "plain", +) { if (!value) return ""; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return value; @@ -70,7 +73,10 @@ function formatLogLine( const parsed = parseLogLine(raw); if (!parsed) return raw; const label = parsed.subsystem ?? parsed.module ?? ""; - const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain"); + const time = formatLogTimestamp( + parsed.time, + opts.pretty ? "pretty" : "plain", + ); const level = parsed.level ?? ""; const levelLabel = level.padEnd(5).trim(); const message = parsed.message || parsed.raw; diff --git a/src/commands/providers/logs.ts b/src/commands/providers/logs.ts index 642597248..7ea2801a8 100644 --- a/src/commands/providers/logs.ts +++ b/src/commands/providers/logs.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; -import { getResolvedLoggerSettings } from "../../logging.js"; import { parseLogLine } from "../../logging/parse-log-line.js"; +import { getResolvedLoggerSettings } from "../../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 93e4c0d93..6dfa7b3be 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -87,6 +87,7 @@ export type SessionEntry = { verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; + responseUsage?: "on" | "off"; providerOverride?: string; modelOverride?: string; authProfileOverride?: string; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index ac11af14c..4e2e98700 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -325,6 +325,9 @@ export const SessionsPatchParamsSchema = Type.Object( thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + responseUsage: Type.Optional( + Type.Union([Type.Literal("on"), Type.Literal("off"), Type.Null()]), + ), elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 3e86dfdb1..d3cc38f04 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -19,6 +19,7 @@ import { normalizeGroupActivation } from "../../auto-reply/group-activation.js"; import { normalizeReasoningLevel, normalizeThinkLevel, + normalizeUsageDisplay, normalizeVerboseLevel, } from "../../auto-reply/thinking.js"; import { loadConfig } from "../../config/config.js"; @@ -234,6 +235,28 @@ export const sessionsHandlers: GatewayRequestHandlers = { } } + if ("responseUsage" in p) { + const raw = p.responseUsage; + if (raw === null) { + delete next.responseUsage; + } else if (raw !== undefined) { + const normalized = normalizeUsageDisplay(String(raw)); + if (!normalized) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + 'invalid responseUsage (use "on"|"off")', + ), + ); + return; + } + if (normalized === "off") delete next.responseUsage; + else next.responseUsage = normalized; + } + } + if ("model" in p) { const raw = p.model; if (raw === null) { @@ -394,6 +417,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { thinkingLevel: entry?.thinkingLevel, verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, + responseUsage: entry?.responseUsage, model: entry?.model, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index dd3bb0024..6df2cf9e5 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -51,6 +51,8 @@ export type GatewaySessionRow = { inputTokens?: number; outputTokens?: number; totalTokens?: number; + responseUsage?: "on" | "off"; + modelProvider?: string; model?: string; contextTokens?: number; lastProvider?: SessionEntry["lastProvider"]; @@ -503,6 +505,8 @@ export function listSessionsFromStore(params: { inputTokens: entry?.inputTokens, outputTokens: entry?.outputTokens, totalTokens: total, + responseUsage: entry?.responseUsage, + modelProvider: entry?.modelProvider, model: entry?.model, contextTokens: entry?.contextTokens, lastProvider: entry?.lastProvider, diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index 11cab33cc..bc3e220a3 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -129,6 +129,16 @@ const usageProviders: UsageProviderId[] = [ "zai", ]; +export function resolveUsageProviderId( + provider?: string | null, +): UsageProviderId | undefined { + if (!provider) return undefined; + const normalized = normalizeProviderId(provider); + return usageProviders.includes(normalized as UsageProviderId) + ? (normalized as UsageProviderId) + : undefined; +} + const ignoredErrors = new Set([ "No credentials", "No token", diff --git a/src/logging/parse-log-line.test.ts b/src/logging/parse-log-line.test.ts index 09da3a554..20a72e707 100644 --- a/src/logging/parse-log-line.test.ts +++ b/src/logging/parse-log-line.test.ts @@ -20,7 +20,9 @@ describe("parseLogLine", () => { expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z"); expect(parsed?.level).toBe("info"); expect(parsed?.subsystem).toBe("gateway/providers/whatsapp"); - expect(parsed?.message).toBe("{\"subsystem\":\"gateway/providers/whatsapp\"} connected"); + expect(parsed?.message).toBe( + '{"subsystem":"gateway/providers/whatsapp"} connected', + ); expect(parsed?.raw).toBe(line); }); @@ -28,7 +30,7 @@ describe("parseLogLine", () => { const line = JSON.stringify({ 0: "hello", _meta: { - name: "{\"subsystem\":\"gateway\"}", + name: '{"subsystem":"gateway"}', logLevelName: "WARN", date: "2026-01-09T02:10:00.000Z", }, diff --git a/src/logging/parse-log-line.ts b/src/logging/parse-log-line.ts index 658d27213..be99ac803 100644 --- a/src/logging/parse-log-line.ts +++ b/src/logging/parse-log-line.ts @@ -21,9 +21,7 @@ function extractMessage(value: Record): string { return parts.join(" "); } -function parseMetaName( - raw?: unknown, -): { subsystem?: string; module?: string } { +function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } { if (typeof raw !== "string") return {}; try { const parsed = JSON.parse(raw) as Record; diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index 80d7f3889..9bf2608cc 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -46,7 +46,7 @@ describe("google-shared convertTools", () => { converted?.[0]?.functionDeclarations?.[0]?.parameters, ); - expect(params.type).toBeUndefined(); + expect(params.type).toBe("object"); expect(params.properties).toBeDefined(); expect(params.required).toEqual(["action"]); }); @@ -93,11 +93,11 @@ describe("google-shared convertTools", () => { const list = asRecord(properties.list); const items = asRecord(list.items); - expect(params).toHaveProperty("patternProperties"); - expect(params).toHaveProperty("additionalProperties"); - expect(mode).toHaveProperty("const"); - expect(options).toHaveProperty("anyOf"); - expect(items).toHaveProperty("const"); + expect(params.patternProperties).toBeUndefined(); + expect(params.additionalProperties).toBeUndefined(); + expect(mode.const).toBeUndefined(); + expect(options.anyOf).toBeUndefined(); + expect(items.const).toBeUndefined(); expect(params.required).toEqual(["mode"]); }); @@ -184,13 +184,7 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); - expect(contents[0].role).toBe("model"); - expect(contents[0].parts).toHaveLength(1); - expect(contents[0].parts?.[0]).toMatchObject({ - thought: true, - thoughtSignature: "sig", - }); + expect(contents).toHaveLength(0); }); it("keeps thought signatures for Claude models", () => { @@ -254,9 +248,9 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(1); expect(contents[0].role).toBe("user"); - expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); }); it("does not merge consecutive user messages for non-Gemini Google models", () => { @@ -275,9 +269,9 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(1); expect(contents[0].role).toBe("user"); - expect(contents[1].role).toBe("user"); + expect(contents[0].parts).toHaveLength(2); }); it("does not merge consecutive model messages for Gemini", () => { @@ -338,10 +332,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[2].role).toBe("model"); + expect(contents[1].parts).toHaveLength(2); }); it("handles user message after tool result without model response in between", () => { @@ -398,11 +392,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(4); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); expect(contents[2].role).toBe("user"); - expect(contents[3].role).toBe("user"); const toolResponsePart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionResponse" in part, @@ -476,11 +469,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[2].role).toBe("model"); - const toolCallPart = contents[2].parts?.find( + const toolCallPart = contents[1].parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, ); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 6b4d29947..516ed7f89 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -64,6 +64,14 @@ export function getSlashCommands(): SlashCommand[] { (value) => ({ value, label: value }), ), }, + { + name: "cost", + description: "Toggle per-response usage line", + getArgumentCompletions: (prefix) => + TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map( + (value) => ({ value, label: value }), + ), + }, { name: "elevated", description: "Set elevated on/off", @@ -116,6 +124,7 @@ export function helpText(): string { "/think ", "/verbose ", "/reasoning ", + "/cost ", "/elevated ", "/elev ", "/activation ", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 2b9f0c65b..bd8afd21c 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -44,7 +44,11 @@ export type GatewaySessionList = { sendPolicy?: string; model?: string; contextTokens?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; totalTokens?: number | null; + responseUsage?: "on" | "off"; + modelProvider?: string; displayName?: string; provider?: string; room?: string; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 03d2cc86e..5a189ba39 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -6,12 +6,14 @@ import { Text, TUI, } from "@mariozechner/pi-tui"; +import { normalizeUsageDisplay } from "../auto-reply/thinking.js"; import { loadConfig } from "../config/config.js"; import { buildAgentMainSessionKey, normalizeAgentId, parseAgentSessionKey, } from "../routing/session-key.js"; +import { formatTokenCount } from "../utils/usage-format.js"; import { getSlashCommands, helpText, parseCommand } from "./commands.js"; import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; @@ -52,8 +54,12 @@ type SessionInfo = { verboseLevel?: string; reasoningLevel?: string; model?: string; + modelProvider?: string; contextTokens?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; totalTokens?: number | null; + responseUsage?: "on" | "off"; updatedAt?: number | null; displayName?: string; }; @@ -99,13 +105,16 @@ function extractTextFromMessage( } function formatTokens(total?: number | null, context?: number | null) { - if (!total && !context) return "tokens ?"; - if (!context) return `tokens ${total ?? 0}`; + if (total == null && context == null) return "tokens ?"; + const totalLabel = total == null ? "?" : formatTokenCount(total); + if (context == null) return `tokens ${totalLabel}`; const pct = typeof total === "number" && context > 0 ? Math.min(999, Math.round((total / context) * 100)) : null; - return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`; + return `tokens ${totalLabel}/${formatTokenCount(context)}${ + pct !== null ? ` (${pct}%)` : "" + }`; } function asString(value: unknown, fallback = ""): string { @@ -213,7 +222,11 @@ export async function runTui(opts: TuiOptions) { ? `${sessionKeyLabel} (${sessionInfo.displayName})` : sessionKeyLabel; const agentLabel = formatAgentLabel(currentAgentId); - const modelLabel = sessionInfo.model ?? "unknown"; + const modelLabel = sessionInfo.model + ? sessionInfo.modelProvider + ? `${sessionInfo.modelProvider}/${sessionInfo.model}` + : sessionInfo.model + : "unknown"; const tokens = formatTokens( sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null, @@ -321,8 +334,12 @@ export async function runTui(opts: TuiOptions) { verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, model: entry?.model ?? result.defaults?.model ?? undefined, + modelProvider: entry?.modelProvider, contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens, + inputTokens: entry?.inputTokens ?? null, + outputTokens: entry?.outputTokens ?? null, totalTokens: entry?.totalTokens ?? null, + responseUsage: entry?.responseUsage, updatedAt: entry?.updatedAt ?? null, displayName: entry?.displayName, }; @@ -773,6 +790,28 @@ export async function runTui(opts: TuiOptions) { chatLog.addSystem(`reasoning failed: ${String(err)}`); } break; + case "cost": { + const normalized = args ? normalizeUsageDisplay(args) : undefined; + if (args && !normalized) { + chatLog.addSystem("usage: /cost "); + break; + } + const current = sessionInfo.responseUsage === "on" ? "on" : "off"; + const next = normalized ?? (current === "on" ? "off" : "on"); + try { + await client.patchSession({ + key: currentSessionKey, + responseUsage: next === "off" ? null : next, + }); + chatLog.addSystem( + next === "on" ? "usage line enabled" : "usage line disabled", + ); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`cost failed: ${String(err)}`); + } + break; + } case "elevated": if (!args) { chatLog.addSystem("usage: /elevated "); diff --git a/src/utils/usage-format.test.ts b/src/utils/usage-format.test.ts new file mode 100644 index 000000000..d77a89356 --- /dev/null +++ b/src/utils/usage-format.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; +import { + estimateUsageCost, + formatTokenCount, + formatUsd, + resolveModelCostConfig, +} from "./usage-format.js"; + +describe("usage-format", () => { + it("formats token counts", () => { + expect(formatTokenCount(999)).toBe("999"); + expect(formatTokenCount(1234)).toBe("1.2k"); + expect(formatTokenCount(12000)).toBe("12k"); + expect(formatTokenCount(2_500_000)).toBe("2.5m"); + }); + + it("formats USD values", () => { + expect(formatUsd(1.234)).toBe("$1.23"); + expect(formatUsd(0.5)).toBe("$0.50"); + expect(formatUsd(0.0042)).toBe("$0.0042"); + }); + + it("resolves model cost config and estimates usage cost", () => { + const config = { + models: { + providers: { + test: { + models: [ + { + id: "m1", + cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 }, + }, + ], + }, + }, + }, + } as ClawdbotConfig; + + const cost = resolveModelCostConfig({ + provider: "test", + model: "m1", + config, + }); + + expect(cost).toEqual({ + input: 1, + output: 2, + cacheRead: 0.5, + cacheWrite: 0, + }); + + const total = estimateUsageCost({ + usage: { input: 1000, output: 500, cacheRead: 2000 }, + cost, + }); + + expect(total).toBeCloseTo(0.003); + }); +}); diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts new file mode 100644 index 000000000..3d391b1a1 --- /dev/null +++ b/src/utils/usage-format.ts @@ -0,0 +1,69 @@ +import type { NormalizedUsage } from "../agents/usage.js"; +import type { ClawdbotConfig } from "../config/config.js"; + +export type ModelCostConfig = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +}; + +export type UsageTotals = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; +}; + +export function formatTokenCount(value?: number): string { + if (value === undefined || !Number.isFinite(value)) return "0"; + const safe = Math.max(0, value); + if (safe >= 1_000_000) return `${(safe / 1_000_000).toFixed(1)}m`; + if (safe >= 1_000) + return `${(safe / 1_000).toFixed(safe >= 10_000 ? 0 : 1)}k`; + return String(Math.round(safe)); +} + +export function formatUsd(value?: number): string | undefined { + if (value === undefined || !Number.isFinite(value)) return undefined; + if (value >= 1) return `$${value.toFixed(2)}`; + if (value >= 0.01) return `$${value.toFixed(2)}`; + return `$${value.toFixed(4)}`; +} + +export function resolveModelCostConfig(params: { + provider?: string; + model?: string; + config?: ClawdbotConfig; +}): ModelCostConfig | undefined { + const provider = params.provider?.trim(); + const model = params.model?.trim(); + if (!provider || !model) return undefined; + const providers = params.config?.models?.providers ?? {}; + const entry = providers[provider]?.models?.find((item) => item.id === model); + return entry?.cost; +} + +const toNumber = (value: number | undefined): number => + typeof value === "number" && Number.isFinite(value) ? value : 0; + +export function estimateUsageCost(params: { + usage?: NormalizedUsage | UsageTotals | null; + cost?: ModelCostConfig; +}): number | undefined { + const usage = params.usage; + const cost = params.cost; + if (!usage || !cost) return undefined; + const input = toNumber(usage.input); + const output = toNumber(usage.output); + const cacheRead = toNumber(usage.cacheRead); + const cacheWrite = toNumber(usage.cacheWrite); + const total = + input * cost.input + + output * cost.output + + cacheRead * cost.cacheRead + + cacheWrite * cost.cacheWrite; + if (!Number.isFinite(total)) return undefined; + return total / 1_000_000; +}