From fb3a3685cc4a4b29fb9454808fcf7ff3d7a69fba Mon Sep 17 00:00:00 2001 From: Offbeat-Breed <34660465+fxinfo24@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:19:05 +0600 Subject: [PATCH] feat(desktop): implement missing Settings API endpoints and desktop providers API fixes --- .clawcodex-install | 1 + docs/Telegram Bot idea for ClawCodex.md | 1044 +++++++++++++++++++++++ src/agent/run_agent.py | 2 +- src/bootstrap/state.py | 25 + src/command_system/builtins.py | 21 + src/context_system/context_analyzer.py | 14 + src/goals/goals.py | 2 +- src/query/config.py | 4 +- src/query/query.py | 30 + src/server/agent_server.py | 2 +- src/services/compact/autocompact.py | 211 ++++- src/services/compact/compact.py | 323 ++++++- src/services/compact/pipeline.py | 20 + src/settings/constants.py | 2 + src/settings/types.py | 3 + tests/test_context_analyzer.py | 23 +- ui-tui/src/entry.tsx | 6 +- ui-tui/src/lib/terminalModes.ts | 10 +- 18 files changed, 1708 insertions(+), 35 deletions(-) create mode 100644 .clawcodex-install create mode 100644 docs/Telegram Bot idea for ClawCodex.md diff --git a/.clawcodex-install b/.clawcodex-install new file mode 100644 index 000000000..9a45b7060 --- /dev/null +++ b/.clawcodex-install @@ -0,0 +1 @@ +installed by clawcodex install.sh v1.4.0 diff --git a/docs/Telegram Bot idea for ClawCodex.md b/docs/Telegram Bot idea for ClawCodex.md new file mode 100644 index 000000000..c16907b9c --- /dev/null +++ b/docs/Telegram Bot idea for ClawCodex.md @@ -0,0 +1,1044 @@ +Telegram-related information + +Supported capabilities + +Capability Telegram +Voice ✅ +Images ✅ +Files ✅ +Threads ✅ +Reactions — +Typing indicator ✅ +Streaming responses ✅ + +Voice support includes TTS audio replies and/or transcription of voice messages. + + + +Setup + +Use the interactive setup wizard: + +hermes gateway setup + +Select Telegram in the wizard and provide its required configuration. + +The documentation also links to a dedicated Telegram Setup page. + +Access control + +Allow specific Telegram users + +TELEGRAM_ALLOWED_USERS=123456789,987654321 + +Or use the general gateway allowlist: + +GATEWAY_ALLOWED_USERS=123456789,987654321 + +Allowing every user is possible but not recommended: + +GATEWAY_ALLOW_ALL_USERS=true + +DM pairing alternative + +Unknown Telegram users can receive a temporary pairing code in a direct message. Approve it with: + +hermes pairing approve telegram XKGH5N7P + +Other pairing commands: + +hermes pairing list +hermes pairing revoke telegram 123456789 + +Pairing codes expire after one hour. + + +Telegram tool access + +Telegram uses the hermes-telegram toolset, which includes full tools, including terminal access. + + +Telegram-specific configuration examples + +Home chat and restart notification + +gateway: + platforms: + telegram: + home_chat_id: "123456789" + gateway_restart_notification: false + +gateway_restart_notification defaults to true. Set it to false to suppress messages sent to the home chat after gateway restarts or interrupted sessions. + +Typing indicator + +Telegram shows a typing indicator while Hermes processes messages by default. + +gateway: + platforms: + telegram: + typing_indicator: false + +Set it to false to disable the indicator. + +Per-channel model or prompt overrides + +Telegram is included in the general per-platform channel override system. The available override fields are: + +model: provider/model-name +provider: provider-name +system_prompt: "Custom instructions for this chat." + +A session-level /model choice takes priority over a channel override. + + +Telegram mobile-friendly defaults + +Telegram defaults are designed to keep mobile chats cleaner: + +- Tool-progress message streams are not shown by default. +- Busy acknowledgments are brief. +- Real assistant mid-response messages remain enabled. +- Long tasks show one editable “working” status message with periodic updates. + +To change these settings: + +display: + platforms: + telegram: + tool_progress: new + busy_ack_detail: true + interim_assistant_messages: false + long_running_notifications: false + + +Progress-message cleanup + +Telegram supports automatic deletion of tool-progress and working-status messages after a successful final answer: + +display: + platforms: + telegram: + cleanup_progress: true + +This is disabled by default. Failed runs keep progress messages visible. + + +Platform reset policy override + +Set Telegram-specific session reset behavior in ~/.hermes/gateway.json: + +{ + "reset_by_platform": { + "telegram": { + "mode": "idle", + "idle_minutes": 240 + } + } +} + + +Managing Telegram while the gateway is running + +Use /platform from a connected chat or CLI session: + +/platform list +/platform pause telegram +/platform resume telegram + +If repeated retryable Telegram failures occur, Hermes can pause its Telegram adapter through a circuit breaker. Check: + +/platform list +~/.hermes/logs/gateway.log +Telegram’s service-status information + +Resume Telegram manually after the issue is resolved: + +/platform resume telegram + + +Tool progress controls + +Control tool-progress output globally: + +display: + tool_progress: log + tool_progress_command: false + tool_progress_grouping: accumulate + +Options: + +Setting Meaning +tool_progress: false No tool-progress messages. +tool_progress: new Show tool-progress updates in chat. +tool_progress: verbose Show more detailed progress. +tool_progress: log Write tool activity to an audit log rather than Telegram. +tool_progress_grouping: accumulate Edit or update one progress message where supported. +tool_progress_grouping: separate Send separate progress messages per tool. + +With log mode, tool calls are written to: + +~/.clawcodex/logs/tool_calls.log + +The log rotates at 5 MB with 3 backups and uses secret redaction. + + +Custom Telegram status phrases + +Customize long-running Telegram status messages, such as “Still working…”: + +display: + status_phrases: + path: status_phrases/telegram.yaml + mode: append + +A status phrase file can look like: + +status: + - "Checking that now…" + - "Still working on it…" + - "One moment while I finish this…" + +generic: + - "Processing your request…" + +Limits: + +- Up to 80 phrases per message category +- Maximum 160 characters per phrase +- Tool arguments, reasoning, and raw commands are not inserted into these phrases + + +Linux watchdog option + +For a Linux systemd-managed Telegram gateway, configure an event-loop watchdog: + +gateway: + systemd_watchdog_seconds: 120 + +Then regenerate the service unit: + +clawcodex gateway install --force + +A positive setting configures systemd to restart the gateway if its event loop stops making timely progress. This is intended for application stalls, not ordinary Telegram network disconnects. + + +Telegram-related operational notes + +- Telegram supports image and file attachments in both directions. +- Telegram supports threaded conversations. +- Telegram supports streaming responses and typing indicators. +- Telegram does not provide Hermes-style reaction support in the listed capability matrix. +- Telegram is suitable for mobile use because its default progress behavior minimizes excess messages. +- Keep Telegram access restricted unless you specifically intend to operate a publicly accessible bot. + + +Telegram Messaging Gateway — Clawcodex + +Clawcodex can connect to Telegram through its messaging gateway. The gateway runs as a single background process that receives Telegram messages, keeps conversation sessions, runs scheduled jobs, sends replies, handles voice messages, and manages delivery recovery. + +Telegram capability summary + +Capability Supported +Voice messages / TTS replies ✅ +Image sending and receiving ✅ +File attachments ✅ +Threaded conversations ✅ +Emoji reactions — +Typing indicator ✅ +Streaming reply updates ✅ + +Voice includes text-to-speech audio replies and/or transcription of received voice messages. + + +Quick setup + +Run the interactive configuration wizard: + +clawcodex gateway setup + +The wizard lets you choose Telegram, enter the required Telegram bot configuration, review existing platform settings, and start or restart the gateway afterward. + +Run the gateway manually in the foreground: + +clawcodex gateway + + +Gateway service commands + +clawcodex gateway install +clawcodex gateway start +clawcodex gateway stop +clawcodex gateway status + + +Linux system service + +sudo clawcodex gateway install --system +sudo clawcodex gateway start --system +sudo clawcodex gateway status --system + + +Linux user-service logs + +journalctl --user -u clawcodex-gateway -f + + +Linux system-service logs + +journalctl -u clawcodex-gateway -f + + +macOS logs + +tail -f ~/.clawcodex/logs/gateway.log + + +Telegram access control + +Telegram bots should be restricted to approved users because the Telegram integration can access the full Clawcodex toolset, including terminal tools. + +Allow specific Telegram users + +Set Telegram numeric user IDs in an environment variable: + +TELEGRAM_ALLOWED_USERS=123456789,987654321 + +You may alternatively set the general gateway allowlist: + +GATEWAY_ALLOWED_USERS=123456789,987654321 + +Allowing all users is available but unsafe when the bot has access to system tools: + +GATEWAY_ALLOW_ALL_USERS=true + + +Telegram DM pairing + +Instead of preconfiguring user IDs, unknown Telegram users may receive a one-time pairing code by direct message. + +The user receives a message similar to: + +Pairing code: XKGH5N7P + +Approve the pairing request: + +clawcodex pairing approve telegram XKGH5N7P + +List pairing requests and approved users: + +clawcodex pairing list + +Remove an approved Telegram user: + +clawcodex pairing revoke telegram 123456789 + +Pairing codes: + +- Expire after one hour +- Are rate-limited +- Use cryptographically secure randomness + + +Telegram conversations and session behavior + +Telegram conversations remain persistent across messages. The agent retains prior context until you reset or start a new session. + +In-chat commands + +Command Purpose +/new or /reset Start a fresh conversation +/model [provider:model] Show or change the model +/personality [name] Select a personality; use none to reset +/retry Retry the previous response +/undo Remove the latest exchange +/status Show current session information +/whoami Show your access tier and allowed commands +/stop Stop active work +/approve Approve a pending dangerous action +/deny Reject a pending dangerous action +/sethome Set the current Telegram chat as the home channel +/compress Compress conversation context +/title [name] Set or display the session title +/resume [name] Resume a named session +/sessions List sessions for the current chat +/sessions search Find sessions by title or session ID +/usage Show session token usage +/usage reset --force Redeem/reset supported banked usage limits +/insights [days] Show usage analytics +/reasoning [level|show|hide] Change reasoning settings or visibility +/voice [on|off|tts|status] Control Telegram voice/TTS behavior +/rollback [number] List or restore filesystem checkpoints +/background Run a separate background task +/reload-mcp Reload MCP server configuration +/update Update Clawcodex +/help Show available commands +/ Run an installed skill + +Model selection persistence + +A model chosen through Telegram persists for that session across gateway restarts: + +/model openai:gpt-5-mini + +Useful options: + +/model anthropic:claude-sonnet-4.6 +/model openai:gpt-5-mini --once +/model openai:gpt-5-mini --global + +- Standard /model applies to the current session. +- --once applies only to the next turn. +- --global writes the choice to global configuration. +- /new and /remove the session-level model override. + +Credentials are resolved when needed and are not saved in session records. + + +Telegram user permissions + +Telegram users can be divided into admins and regular users. + +- Admins can run all registered commands, including plugin commands and gated capabilities. +- Regular users can chat normally but may be limited to selected slash commands. +- /help and /whoami are always available. + +Example configuration: + +gateway: + platforms: + telegram: + extra: + allow_from: + - "111111111" + - "222222222" + - "333333333" + allow_admin_from: + - "111111111" + user_allowed_commands: + - status + - model + +Use /whoami in Telegram to view the active access scope, tier, and available commands. + +If allow_admin_from is not set, the admin/regular-user split is disabled and permitted users retain unrestricted command access for compatibility. + + +Telegram message handling + +Typing indicator + +Telegram shows a typing indicator while Clawcodex is processing a request by default. + +Disable it if desired: + +gateway: + platforms: + telegram: + typing_indicator: false + +This changes only the typing indicator; message processing and replies continue normally. + + +Streaming and mid-task updates + +Telegram supports progressive response updates. It also supports assistant status messages during long-running work. + +Telegram’s default mobile-focused behavior is intended to reduce chat noise: + +- Per-tool progress updates are generally not shown by default. +- Busy acknowledgments are brief. +- Real assistant in-progress messages remain visible. +- Long-running tasks use a single edited status message such as “Working — 3 min.” +- Final messages are sent after processing completes. + +Change Telegram display behavior: + +display: + platforms: + telegram: + tool_progress: new + busy_ack_detail: true + interim_assistant_messages: false + long_running_notifications: false + + +Auto-delete progress messages + +Telegram can remove temporary tool-progress and working-status messages when a task succeeds: + +display: + platforms: + telegram: + cleanup_progress: true + +Notes: + +- Disabled by default. +- Available on Telegram. +- Failed tasks keep progress messages as useful history. + + +Busy-message behavior + +When a new Telegram message arrives while the agent is working, the default mode is to redirect or interrupt the active turn as appropriate. + +Available modes: + +display: + busy_input_mode: interrupt + busy_ack_enabled: true + +Valid values: + +display: + busy_input_mode: queue + busy_input_mode: steer + busy_input_mode: interrupt + +Behavior: + +Mode Result +interrupt New Telegram input restarts or redirects active generation. +queue The new message waits until current work completes. +steer The message is fed into the active task at the next safe tool-result boundary. + +Disable visible busy acknowledgments: + +display: + busy_ack_enabled: false + + +Intentional silence + +Clawcodex can intentionally produce no Telegram reply when its full final output is exactly one of these tokens: + +[SILENT] +SILENT +NO_REPLY +NO REPLY + +Rules: + +- Matching ignores capitalization and surrounding whitespace. +- The entire final response must be exactly one supported token. +- A sentence containing a token is sent normally. +- The silent turn remains in the internal conversation history. +- Failed requests still show errors instead of being hidden. + +Example internal history: + +user: side-channel chatter +assistant: [SILENT] +user: next message + +The [SILENT] answer is retained internally but is not delivered to Telegram. + + +Telegram voice support + +Telegram supports voice-related features, including: + +- Receiving voice messages +- Voice-message transcription +- Text-to-speech audio replies +- Voice reply controls through /voice + +Examples: + +/voice on +/voice off +/voice tts +/voice status + + +Background tasks from Telegram + +Run an independent task without blocking the current Telegram conversation: + +/background Check server health and report any failures + +Example confirmation: + +🔄 Background task started: "Check server health and report any failures" +Task ID: bg_143022_a1b2c3 + +When complete, Clawcodex sends the result to the originating Telegram chat: + +✅ Background task complete + +If the task fails: + +❌ Background task failed + +Background tasks: + +- Use a separate, isolated session. +- Do not receive the main Telegram chat’s conversation history. +- Inherit the active model, tool configuration, provider settings, and reasoning configuration. +- Let you continue chatting while work runs. + + +Background process notifications + +If a Telegram-initiated task starts a background process such as a server, build, or long-running command, configure process notifications with: + +display: + background_process_notifications: concise + +Available settings: + +Value Telegram behavior +concise One-line completion update; failures include a short output tail. +all Running updates plus final raw output. +result Final raw output regardless of success or failure. +error Final raw output only for non-zero exit status. +off No background-process notifications. + +Or use an environment variable: + +CLAWCODEX_BACKGROUND_NOTIFICATIONS=result + + +Telegram session reset policies + +By default, sessions do not reset automatically. Use /reset when you need a fresh context. + +Global reset configuration: + +session_reset: + mode: idle + idle_minutes: 1440 + at_hour: 4 + +Supported modes: + +Mode Behavior +none Never reset automatically; default. +daily Reset once daily at at_hour. +idle Reset after inactivity for idle_minutes. +both Reset when either daily or idle rule triggers first. + +Telegram-specific override in ~/.clawcodex/gateway.json: + +{ + "reset_by_platform": { + "telegram": { + "mode": "idle", + "idle_minutes": 240 + } + } +} + +A live background process normally prevents session reset while it is running. The maximum age for such reset protection defaults to 24 hours. + +bg_process_max_age_hours: 24 + +Set it to 0 to keep the prior behavior where any live background process prevents a reset indefinitely. + + +Telegram channel-specific model and prompt settings + +Different Telegram chats can use different models or instructions. + +Example in ~/.clawcodex/gateway-config.yaml: + +platforms: + telegram: + enabled: true + channel_overrides: + "123456789": + model: openai/gpt-5-mini + "-1001234567890": + model: anthropic/claude-sonnet-4.6 + provider: anthropic + system_prompt: "You are the code-review assistant for this Telegram group." + +Each override can contain: + +model: provider/model-name +provider: provider-name +system_prompt: "Instructions for this Telegram chat." + +Priority order for model selection: + +1. Session-level /model override +2. Telegram channel_overrides +3. Global configured default model + +The custom system_prompt is applied for the current turn and is not written into the chat transcript. + + +Telegram timestamps in model context + +To provide the agent with Telegram message times, enable timestamps: + +gateway: + message_timestamps: + enabled: true + +When enabled, the model receives a prefix similar to: + +[Tue 2026-04-28 13:40:53 CEST] + +This can help it recognize long gaps between messages or answer time-based questions. Timestamps are not inserted into assistant messages or permanently duplicated in transcripts. + + +Telegram delivery reliability + +Final Telegram replies are stored in a durable delivery ledger before and around delivery. + +If Clawcodex crashes after creating a response but before Telegram confirms delivery, it attempts to send the saved response after restart. + +Behavior: + +- Replies never started are re-sent normally. +- Replies interrupted during sending may be re-sent with a recovery prefix: + +♻️ Recovered reply — … + +This indicates the Telegram message may be duplicated. + +Limits: + +- Up to 3 redelivery attempts +- Up to 24 hours of freshness +- Successfully delivered records are cleaned up after 7 days + +Disable this feature: + +gateway: + delivery_ledger: false + + +Telegram restart and interrupted-session behavior + +If the gateway restarts while Telegram work is in progress: + +- The affected session is marked as interrupted. +- On the next startup, Clawcodex schedules an automatic resume attempt. +- Telegram may receive a short notice asking the user to send a message so work can resume. +- A gateway restart notification can be sent to the Telegram home chat. + +Configure the Telegram home chat and disable restart notices: + +gateway: + platforms: + telegram: + home_chat_id: "123456789" + gateway_restart_notification: false + +gateway_restart_notification defaults to true. + + +Telegram platform management + +Use /platform from a connected session to inspect or control Telegram without restarting the full gateway: + +/platform list +/platform pause telegram +/platform resume telegram + +Command Action +/platform list Shows Telegram adapter state and failure details. +/platform pause telegram Stops processing new Telegram messages while keeping the connection loaded. +/platform resume telegram Restores Telegram message processing and clears a tripped breaker. + + +Telegram circuit breaker + +Repeated retryable failures can automatically pause the Telegram adapter. Typical triggers include: + +- Network failures +- Telegram rate limiting +- Telegram API 5xx errors +- Connection interruptions +- WebSocket-related disconnects, where applicable + +When paused: + +- Incoming Telegram messages are dropped until Telegram is resumed. +- The gateway logs the reason. +- A notification may be sent to a configured home channel on another live platform. +- Telegram is not automatically resumed, preventing repeated reconnection attempts. + +Check status with: + +/platform list + +Check logs: + +tail -f ~/.clawcodex/logs/gateway.log + +After Telegram recovers: + +/platform resume telegram + + +Telegram tool progress controls + +Control tool-progress output globally: + +display: + tool_progress: log + tool_progress_command: false + tool_progress_grouping: accumulate + +Options: + +Setting Meaning +tool_progress: false No tool-progress messages. +tool_progress: new Show tool-progress updates in chat. +tool_progress: verbose Show more detailed progress. +tool_progress: log Write tool activity to an audit log rather than Telegram. +tool_progress_grouping: accumulate Edit or update one progress message where supported. +tool_progress_grouping: separate Send separate progress messages per tool. + +With log mode, tool calls are written to: + +~/.clawcodex/logs/tool_calls.log + +The log rotates at 5 MB with 3 backups and uses secret redaction. + + +Custom Telegram status phrases + +Customize long-running Telegram status messages, such as “Still working…”: + +display: + status_phrases: + path: status_phrases/telegram.yaml + mode: append + +A status phrase file can look like: + +status: + - "Checking that now…" + - "Still working on it…" + - "One moment while I finish this…" + +generic: + - "Processing your request…" + +Limits: + +- Up to 80 phrases per message category +- Maximum 160 characters per phrase +- Tool arguments, reasoning, and raw commands are not inserted into these phrases + + +Linux watchdog option + +For a Linux systemd-managed Telegram gateway, configure an event-loop watchdog: + +gateway: + systemd_watchdog_seconds: 120 + +Then regenerate the service unit: + +clawcodex gateway install --force + +A positive setting configures systemd to restart the gateway if its event loop stops making timely progress. This is intended for application stalls, not ordinary Telegram network disconnects. + + +Telegram-related operational notes + +- Telegram supports image and file attachments in both directions. +- Telegram supports threaded conversations. +- Telegram supports streaming responses and typing indicators. +- Telegram does not provide Hermes-style reaction support in the listed capability matrix. +- Telegram is suitable for mobile use because its default progress behavior minimizes excess messages. +- Keep Telegram access restricted unless you specifically intend to operate a publicly accessible bot. + + +## Security Policies for Telegram Integration + +### Core Principles +1. **Least Privilege**: Grant only necessary permissions +2. **Zero Trust**: Verify every request +3. **Auditability**: Log all Telegram-initiated actions +4. **Ephemeral Access**: Prefer temporary pairing over permanent whitelists + +### Specific Policies + +#### Access Control +- **Never enable** `GATEWAY_ALLOW_ALL_USERS=true` in any environment with terminal/file system access +- **Use explicit whitelisting**: `TELEGRAM_ALLOWED_USERS=,` +- **Consider command-level restrictions** via `user_allowed_commands` to limit available slash commands +- **Regular rotation**: Review and update allowed user list monthly + +#### Authentication & Authorization +- **Prefer DM pairing system** for temporary access rather than permanent whitelists when possible +- **Implement admin/regular user split**: + - Admins: Full access to registered commands (use with extreme caution) + - Regular users: Limited to safe, read-only commands +- **Never share bot tokens** - regenerate immediately if exposed +- **Store tokens securely** - use environment variables or secret management, never in code + +#### Operational Security +- **Enable delivery ledger** for message reliability but monitor for duplicate processing +- **Configure session reset policies**: Use idle timeout (e.g., 60 minutes) to prevent stale sessions +- **Background task restrictions**: Consider disabling background tasks that initiate long-running processes unless absolutely necessary +- **Tool progress monitoring**: Enable `tool_progress: log` in production to audit tool usage without cluttering chat +- **Rate limiting awareness**: Be mindful of Telegram API limits when designing command frequency + +#### Monitoring & Auditing +- **Enable message timestamps** (`message_timestamps: true`) for forensic analysis +- **Regular log review**: Monitor `~/.clawcodex/logs/gateway.log` for: + - Unauthorized access attempts + - Unusual tool usage patterns + - Failed command executions +- **Set up alerts** for: + - Terminal tool usage from Telegram + - File write/delete operations + - Process spawning commands + +### Risk Mitigation Matrix +| Risk Level | Scenario | Mitigation | +|------------|----------|------------| +| Critical | Terminal access via Telegram | Restrict to admin-only, consider disabling entirely for Telegram | +| High | File system modifications | Limit to specific directories, enable read-only mode where possible | +| Medium | Resource-intensive commands | Implement timeout limits, monitor resource usage | +| Low | Information disclosure | Review what information commands return, avoid leaking secrets | + +--- + +## Recommended Command Sets for Different User Tiers + +### 👤 Regular User (Read-Only, Safe Operations) +These commands are safe for general use and don't modify system state: +- `/status` - Show system health and resource usage +- `/logs [service] [lines]` - View recent logs (read-only) +- `/metrics` - Show performance metrics +- `/whoami` - Show your access level and allowed commands +- `/help` - Show available commands +- `/model` - View current model (no changes) +- `/sessions` - List your sessions +- `/usage` - Show token usage +- `/voice [status]` - Check voice settings +- `/insights [days]` - View usage analytics +- `/compress` - Compress conversation context (local only) +- `/title [name]` - Set session title (local only) + +### 👨‍💻 Developer User (Limited Write Access) +Includes regular user commands plus: +- `/new` or `/reset` - Start fresh conversation +- `/model [provider:model]` - Change model for session +- `/personality [name]` - Select personality +- `/retry` - Retry previous response +- `/undo` - Remove latest exchange +- `/stop` - Stop active work +- `/approve` / `/deny` - Approve/deny pending dangerous actions +- `/sethome` - Set current chat as home channel +- `/background [task]` - Run background task (consider restrictions) +- `/reload-mcp` - Reload MCP server configuration +- `/update` - Update Clawcodex +- `/reasoning [level|show|hide]` - Adjust reasoning settings +- `/voice [on|off|tts]` - Control voice/TTS behavior +- `/rollback [number]` - List/restore filesystem checkpoints (read-only listing) + +### ⚙️ Admin/User (Extended Access - USE WITH EXTREME CAUTION) +Includes developer commands plus: +- `/platform list` - View Telegram adapter state +- `/platform pause telegram` / `/platform resume telegram` - Control Telegram adapter +- **Any skill invocation** via `/` or `/skill name` - **RESTRICT CAREFULLY** +- **File system operations** (if enabled) - **HIGH RISK** +- **Terminal commands** (if enabled) - **CRITICAL RISK** +- **Process management** - **HIGH RISK** + +### 🔧 Recommended Configuration Examples + +#### For Personal Use (Maximum Security) +```yaml +gateway: + platforms: + telegram: + enabled: true + extra: + allow_from: ["123456789"] # Your user ID only + allow_admin_from: [] # No admins - use regular user only + user_allowed_commands: # Only safe, read-only commands + - status + - logs + - metrics + - whoami + - help + - model + - sessions + - usage + - compress + - title + - insights + - voice + display: + platforms: + telegram: + tool_progress: log # Log tool usage instead of showing in chat + cleanup_progress: true # Auto-delete progress messages + session_reset: + mode: idle + idle_minutes: 30 # Reset after 30 minutes idle + message_timestamps: true # Enable timestamps for auditing +``` + +#### For Team Development (Controlled Access) +```yaml +gateway: + platforms: + telegram: + enabled: true + extra: + allow_from: ["111111111","222222222","333333333"] # Team member IDs + allow_admin_from: ["111111111"] # Team lead only as admin + user_allowed_commands: # Regular team members + - status + - logs + - metrics + - whoami + - help + - model + - sessions + - usage + - voice + - insights + - compress + - title + - new + - reset + - retry + - undo + - stop + - approve + - deny + # Admins get additional commands (configure via platform settings) + display: + platforms: + telegram: + tool_progress: new # Show progress in chat for development + busy_ack_detail: true + session_reset: + mode: idle + idle_minutes: 60 + message_timestamps: true + background_process_notifications: concise +``` + +### 🚨 Emergency Procedures +1. **Immediate Revocation**: If token compromised, revoke via @BotFather immediately +2. **Gateway Shutdown**: `clawcodex gateway stop` to halt all Telegram processing +3. **Access Review**: Check `clawcodex pairing list` and revoke any suspicious pairings +4. **Log Analysis**: Review logs for unauthorized activity period +5. **Token Rotation**: Generate new bot token and update configuration + +### 📋 Implementation Checklist +- [ ] Never commit bot token to version control +- [ ] Use environment variables or secret management for token storage +- [ ] Start with most restrictive command set, gradually expand as needed +- [ ] Test all commands in isolated environment before production use +- [ ] Document approved use cases for each command tier +- [ ] Schedule monthly security review of Telegram integration +- [ ] Train all users on security policies and proper usage +- [ ] Establish incident response plan for Telegram-specific breaches + +## 🔐 Final Security Reminder +The Telegram-Clawcodex integration provides powerful remote access to your development environment. Treat it with the same rigor as SSH keys or production database credentials. The convenience of Telegram messaging should never outweigh security considerations. Regular audits, least-privilege access, and vigilant monitoring are essential for safe operation. \ No newline at end of file diff --git a/src/agent/run_agent.py b/src/agent/run_agent.py index 2149680f3..20f9e5f24 100644 --- a/src/agent/run_agent.py +++ b/src/agent/run_agent.py @@ -35,7 +35,7 @@ # Fallback max turns for subagents when no explicit limit is set. # Prevents unbounded loops that appear as hangs to the user. -SUBAGENT_DEFAULT_MAX_TURNS = 30 +SUBAGENT_DEFAULT_MAX_TURNS = 100 @dataclass diff --git a/src/bootstrap/state.py b/src/bootstrap/state.py index 2445cc617..b1955456d 100644 --- a/src/bootstrap/state.py +++ b/src/bootstrap/state.py @@ -76,6 +76,18 @@ class ModelUsage: web_search_requests: int = 0 +@dataclass +class CompactionTelemetryData: + """Stores compaction telemetry data for post-compaction measurement.""" + trigger: str = "manual" + tokens_shed: int = 0 + pre_compact_token_count: int = 0 + post_compact_token_count: int = 0 + compaction_cost_usd: float = 0.0 + cache_hit_rate_before: float | None = None + model: str | None = None + + @dataclass class InvokedSkillInfo: """One invoked skill, preserved across compaction. @@ -206,6 +218,7 @@ class _BootstrapState: cached_clawcodex_md_content: str | None = None system_prompt_section_cache: dict[str, str | None] = field(default_factory=dict) pending_post_compaction: bool = False + compaction_telemetry_data: CompactionTelemetryData | None = None additional_directories_for_clawcodex_md: list[str] = field(default_factory=list) # --- Model (TS: lines 68-70) ------------------------------------------- @@ -806,6 +819,16 @@ def consume_post_compaction() -> bool: return was +def get_compaction_telemetry_data() -> CompactionTelemetryData | None: + """Get the compaction telemetry data stored for post-compaction measurement.""" + return _STATE.compaction_telemetry_data + + +def set_compaction_telemetry_data(data: CompactionTelemetryData | None) -> None: + """Set the compaction telemetry data for post-compaction measurement.""" + _STATE.compaction_telemetry_data = data + + def get_additional_directories_for_clawcodex_md() -> list[str]: return _STATE.additional_directories_for_clawcodex_md @@ -1273,6 +1296,8 @@ def reset_state_for_tests() -> None: "clear_system_prompt_section_state", "mark_post_compaction", "consume_post_compaction", + "get_compaction_telemetry_data", + "set_compaction_telemetry_data", "get_additional_directories_for_clawcodex_md", "set_additional_directories_for_clawcodex_md", # Model diff --git a/src/command_system/builtins.py b/src/command_system/builtins.py index 8aa799b25..0e291d63f 100644 --- a/src/command_system/builtins.py +++ b/src/command_system/builtins.py @@ -404,6 +404,26 @@ async def _load(): auto_compact_threshold = context.config.get("auto_compact_threshold") is_auto_compact_enabled = context.config.get("is_auto_compact_enabled", False) + # PR 3: Get compaction telemetry for cache-hostile warning + compaction_telemetry = None + try: + from ..bootstrap.state import get_compaction_telemetry_data + telemetry = get_compaction_telemetry_data() + if telemetry: + compaction_telemetry = { + "trigger": telemetry.trigger, + "tokens_shed": telemetry.tokens_shed, + "pre_compact_token_count": telemetry.pre_compact_token_count, + "post_compact_token_count": telemetry.post_compact_token_count, + "compaction_cost_usd": telemetry.compaction_cost_usd, + "cache_hit_rate_before": telemetry.cache_hit_rate_before, + "cache_hit_rate_after": telemetry.cache_hit_rate_after, + "estimated_cost_delta_usd": telemetry.estimated_cost_delta_usd, + "cost_increased": telemetry.cost_increased, + } + except Exception: + pass + data = analyze_context( conversation_api_messages=conversation_api, model=model, @@ -417,6 +437,7 @@ async def _load(): custom_agents=custom_agents, auto_compact_threshold=auto_compact_threshold, is_auto_compact_enabled=is_auto_compact_enabled, + compaction_telemetry=compaction_telemetry, ) markdown = format_context_as_markdown(data) diff --git a/src/context_system/context_analyzer.py b/src/context_system/context_analyzer.py index 0698d5933..117b32dc3 100644 --- a/src/context_system/context_analyzer.py +++ b/src/context_system/context_analyzer.py @@ -68,6 +68,8 @@ class ContextData: api_usage: Optional[dict[str, int]] = None auto_compact_threshold: Optional[int] = None is_auto_compact_enabled: bool = False + # PR 3: Compaction telemetry for cache-hostile warning + compaction_telemetry: Optional[dict[str, Any]] = None def get_context_window_for_model(model: str) -> int: @@ -195,6 +197,7 @@ def analyze_context( custom_agents: Optional[list[dict[str, Any]]] = None, auto_compact_threshold: Optional[int] = None, is_auto_compact_enabled: bool = False, + compaction_telemetry: Optional[dict[str, Any]] = None, ) -> ContextData: """ Analyze context usage across all categories. @@ -291,6 +294,7 @@ def analyze_context( api_usage=api_usage, auto_compact_threshold=auto_compact_threshold, is_auto_compact_enabled=is_auto_compact_enabled, + compaction_telemetry=compaction_telemetry, ) @@ -309,6 +313,16 @@ def format_context_as_markdown(data: ContextData) -> str: "", ] + # PR 3: Cache-hostile compaction warning + if data.compaction_telemetry: + telemetry = data.compaction_telemetry + if telemetry.get("cost_increased"): + lines.append("> ⚠️ **Cache-hostile compaction detected!**") + lines.append(f"> Last compaction ({telemetry.get('trigger', 'unknown')}) shed {telemetry.get('tokens_shed', 0):,} tokens but increased effective cost by ${telemetry.get('estimated_cost_delta_usd', 0):.6f}.") + lines.append(f"> Pre-compaction cache hit rate: {telemetry.get('cache_hit_rate_before', 0):.1f}% → Post-compaction: {telemetry.get('cache_hit_rate_after', 0):.1f}%") + lines.append("> Consider: smaller compaction window, different model, or disable auto-compact.") + lines.append("") + # Auto-compact status if data.auto_compact_threshold is not None: lines.append(f"**Auto-compact threshold:** {data.auto_compact_threshold:,} tokens") diff --git a/src/goals/goals.py b/src/goals/goals.py index 5e70cfced..813633918 100644 --- a/src/goals/goals.py +++ b/src/goals/goals.py @@ -57,7 +57,7 @@ #: Turn budget backstop (donor default). CC ships unbounded; the budget #: pauses (never clears) so ``/goal resume`` continues with a fresh budget. -DEFAULT_GOAL_MAX_TURNS = 20 +DEFAULT_GOAL_MAX_TURNS = 100 #: Claude Code's documented condition cap (docs/en/goal). GOAL_CONDITION_MAX_CHARS = 4000 diff --git a/src/query/config.py b/src/query/config.py index 48e034cf2..eea57a216 100644 --- a/src/query/config.py +++ b/src/query/config.py @@ -10,7 +10,7 @@ class QueryConfig: model: str = "claude-sonnet-4-6" max_tokens: int = 16384 - max_turns: int = 50 + max_turns: int = 200 effort: str = "high" temperature: float | None = None stop_sequences: list[str] | None = None @@ -41,7 +41,7 @@ class QueryConfig: class FrozenQueryConfig: model: str = "claude-sonnet-4-6" max_tokens: int = 16384 - max_turns: int = 50 + max_turns: int = 200 effort: str = "high" temperature: float | None = None stop_sequences: tuple[str, ...] | None = None diff --git a/src/query/query.py b/src/query/query.py index 94ea5b97a..94630ae74 100644 --- a/src/query/query.py +++ b/src/query/query.py @@ -1568,11 +1568,41 @@ def _do_provider_call(): try: from ..bootstrap.state import add_to_total_duration_state from ..cost_tracker import record_api_usage + from ..services.compact.compact import log_post_compaction_telemetry record_api_usage( getattr(response, "model", None) or getattr(provider, "model", "unknown"), response.usage, ) + + # Check if this is the first turn after compaction and log post-compaction telemetry + from ..bootstrap.state import consume_post_compaction, get_compaction_telemetry_data + if consume_post_compaction(): + telemetry_data = get_compaction_telemetry_data() + model_name = getattr(response, "model", None) or getattr(provider, "model", None) + if telemetry_data: + log_post_compaction_telemetry( + trigger=telemetry_data.trigger, + tokens_shed=telemetry_data.tokens_shed, + pre_compact_token_count=telemetry_data.pre_compact_token_count, + post_compact_token_count=telemetry_data.post_compact_token_count, + compaction_cost_usd=telemetry_data.compaction_cost_usd, + cache_hit_rate_before=telemetry_data.cache_hit_rate_before or 0.0, + response_usage=response.usage, + model=model_name, + ) + else: + # Fallback if no telemetry data was stored + log_post_compaction_telemetry( + trigger="manual", + tokens_shed=0, + pre_compact_token_count=0, + post_compact_token_count=0, + compaction_cost_usd=0.0, + cache_hit_rate_before=0.0, + response_usage=response.usage, + model=model_name, + ) # The original records per-request API duration alongside cost # (addToTotalDuration beside addToTotalSessionCost); this feeds # /cost's "Total duration (API)". This layer can't split retries: diff --git a/src/server/agent_server.py b/src/server/agent_server.py index 4c94e9f1d..8ba17dfe6 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -97,7 +97,7 @@ #: Default agent-loop turn ceiling for an interactive session. Shared by the #: dataclass default below and the ``--max-turns`` CLI flag (agent_server_cli.py) #: so the two can't drift apart from independently hand-edited literals. -DEFAULT_MAX_TURNS = 50 +DEFAULT_MAX_TURNS = 200 _SHUTDOWN = object() # sentinel pushed onto the worker inbox to stop it diff --git a/src/services/compact/autocompact.py b/src/services/compact/autocompact.py index b18fdb3e9..77ebe31ac 100644 --- a/src/services/compact/autocompact.py +++ b/src/services/compact/autocompact.py @@ -6,6 +6,14 @@ Determines when automatic compaction should trigger based on token usage and context window size, then delegates to ``compact_conversation()``. Includes a circuit breaker to prevent infinite retry loops. + +PR 2: Cost-aware compaction trigger (mode="cost_aware") replaces pure +token-threshold trigger with break-even analysis: + effectiveCostPerToken = cacheHitRate * cacheReadPrice + (1-cacheHitRate) * inputPrice + savingsPerTurn = tokensToCompact * effectiveCostPerToken + compactionCost = summaryTokens * summaryModelOutputPrice + turnsToBreakEven = compactionCost / savingsPerTurn + Only compact if turnsToBreakEven <= break_even_turns (default 10) """ from __future__ import annotations @@ -19,11 +27,14 @@ from ...types.messages import Message from ...providers.base import BaseProvider +from ...services.pricing import get_pricing, compute_cost +from ...bootstrap.state import get_model_usage from .compact import ( CompactContext, CompactionResult, compact_conversation, + _get_recent_cache_hit_rate, ) logger = logging.getLogger(__name__) @@ -59,6 +70,12 @@ # Minimum input tokens before autocompact can trigger (legacy fallback) MIN_INPUT_TOKENS_FOR_AUTOCOMPACT = 10_000 +# Estimated summary output tokens (p99.99 = 17,387, we use 20k as buffer) +ESTIMATED_SUMMARY_OUTPUT_TOKENS = 20_000 + +# Default fraction of context that gets compacted (used for token estimation) +DEFAULT_COMPACTION_FRACTION = 0.3 + @dataclass class AutoCompactTracking: @@ -101,6 +118,169 @@ def _is_env_truthy(name: str) -> bool: return val in ("1", "true", "yes") +def _estimate_compaction_token_savings( + input_token_count: int, + context_window: int, +) -> int: + """ + Estimate how many tokens would be shed by compaction. + + Uses a conservative fraction of the input tokens (default 30%), + capped at the context window size. + """ + # Conservative estimate: compaction typically sheds 20-40% of tokens + # Use the lower bound for safety + estimated_shed = int(input_token_count * DEFAULT_COMPACTION_FRACTION) + return min(estimated_shed, input_token_count) + + +def _get_cost_aware_compaction_params( + model: str, + input_token_count: int, + context_window: int, +) -> tuple[float, float, float, float] | None: + """ + Get pricing parameters for cost-aware compaction analysis. + + Returns (input_price, cache_read_price, summary_output_price, cache_hit_rate) + or None if pricing is unavailable. + """ + # Get cache hit rate from recent usage + cache_hit_rate_pct = _get_recent_cache_hit_rate(model) + if cache_hit_rate_pct is None: + # No cache data available, assume 0% hit rate (conservative) + cache_hit_rate = 0.0 + else: + cache_hit_rate = cache_hit_rate_pct / 100.0 + + # Get pricing for the main model (for input/cache_read rates) + pricing = get_pricing(model) + if pricing is None: + return None + + input_price = pricing.get("input", 0) + cache_read_price = pricing.get("cache_read", 0) + + # If cache_read rate is not explicitly set, it's often a fraction of input rate + if cache_read_price == 0: + cache_read_price = input_price * 0.1 # typical 90% discount + + # Get pricing for the summary model (output price) + # The summary model is typically the same as the main model, but could be different + # For now, use the main model's output price as a reasonable estimate + summary_output_price = pricing.get("output", 0) + if summary_output_price == 0: + summary_output_price = input_price * 3 # typical 3x ratio + + return input_price, cache_read_price, summary_output_price, cache_hit_rate + + +def _should_auto_compact_cost_aware( + input_token_count: int, + context_window: int, + model: str, + *, + max_output_tokens: int | None = None, + tracking: AutoCompactTracking | None = None, + break_even_turns: int = 10, +) -> bool: + """ + Determine whether autocompact should trigger using cost-aware analysis. + + This implements the break-even analysis from PR 2: + - effectiveCostPerToken = cacheHitRate * cacheReadPrice + (1-cacheHitRate) * inputPrice + - savingsPerTurn = tokensToCompact * effectiveCostPerToken + - compactionCost = summaryTokens * summaryModelOutputPrice + - turnsToBreakEven = compactionCost / savingsPerTurn + - Only compact if turnsToBreakEven <= break_even_turns + """ + if not is_auto_compact_enabled(): + return False + + if input_token_count < MIN_INPUT_TOKENS_FOR_AUTOCOMPACT: + return False + + # Circuit breaker + if tracking is not None: + if tracking.consecutive_failures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES: + logger.info( + "Autocompact circuit breaker active (%d consecutive failures)", + tracking.consecutive_failures, + ) + return False + + # Get pricing parameters + params = _get_cost_aware_compaction_params(model, input_token_count, context_window) + if params is None: + # No pricing available, fall back to token threshold + logger.debug("Cost-aware compaction: no pricing for %s, falling back to token threshold", model) + threshold = get_auto_compact_threshold(context_window, max_output_tokens) + return input_token_count >= threshold + + input_price, cache_read_price, summary_output_price, cache_hit_rate = params + + # Calculate effective cost per token (what we pay per token on average) + effective_cost_per_token = ( + cache_hit_rate * cache_read_price + (1 - cache_hit_rate) * input_price + ) + + # Estimate tokens that would be shed by compaction + tokens_to_compact = _estimate_compaction_token_savings(input_token_count, context_window) + if tokens_to_compact <= 0: + return False + + # Savings per turn = tokens shed * effective cost per token + savings_per_turn = tokens_to_compact * effective_cost_per_token + + if savings_per_turn <= 0: + return False + + # Compaction cost = summary output tokens * summary model output price + # Use ESTIMATED_SUMMARY_OUTPUT_TOKENS as a conservative upper bound + compaction_cost = ESTIMATED_SUMMARY_OUTPUT_TOKENS * summary_output_price + + # Turns to break even + turns_to_break_even = compaction_cost / savings_per_turn + + logger.debug( + "cost-aware compaction: model=%s input_tokens=%d cache_hit_rate=%.2f%% " + "effective_cost=$%.6f/M tokens_to_compact=%d savings_per_turn=$%.6f " + "compaction_cost=$%.6f turns_to_break_even=%.1f break_even_turns=%d", + model, + input_token_count, + cache_hit_rate * 100, + effective_cost_per_token * 1_000_000, + tokens_to_compact, + savings_per_turn, + compaction_cost, + turns_to_break_even, + break_even_turns, + ) + + # Only compact if we break even within the configured number of turns + should_compact = turns_to_break_even <= break_even_turns + + if should_compact: + logger.info( + "Cost-aware compaction triggered: model=%s turns_to_break_even=%.1f " + "(threshold=%d) tokens_shed_est=%d cache_hit=%.1f%%", + model, + turns_to_break_even, + break_even_turns, + tokens_to_compact, + cache_hit_rate * 100, + ) + else: + logger.debug( + "Cost-aware compaction deferred: model=%s turns_to_break_even=%.1f > %d", + model, + turns_to_break_even, + break_even_turns, + ) + + return should_compact + + def get_effective_context_window_size( context_window: int, max_output_tokens: int | None = None, @@ -236,11 +416,15 @@ def should_auto_compact( max_output_tokens: int | None = None, tracking: AutoCompactTracking | None = None, threshold_fraction: float | None = None, + mode: str = "token_threshold", + break_even_turns: int = 10, + model: str | None = None, ) -> bool: """ Determine whether autocompact should trigger. - Uses the TS-aligned threshold calculation by default. + Uses the TS-aligned threshold calculation by default (mode="token_threshold"). + When mode="cost_aware", uses break-even analysis based on cache hit rate and pricing. ``threshold_fraction`` is accepted for backward compatibility but ignored when the TS-aligned calculation is available. """ @@ -259,6 +443,20 @@ def should_auto_compact( ) return False + if mode == "cost_aware": + if model is None: + logger.warning("Cost-aware compaction requires model parameter, falling back to token threshold") + else: + return _should_auto_compact_cost_aware( + input_token_count, + context_window, + model, + max_output_tokens=max_output_tokens, + tracking=tracking, + break_even_turns=break_even_turns, + ) + + # Legacy token-threshold mode threshold = get_auto_compact_threshold(context_window, max_output_tokens) logger.debug( @@ -285,6 +483,8 @@ async def auto_compact_if_needed( read_file_state: dict[str, Any] | None = None, plan_file_path: str | None = None, memory_paths: set[str] | None = None, + compaction_mode: str = "token_threshold", + break_even_turns: int = 10, ) -> CompactionResult | None: """ Trigger autocompact if token thresholds are exceeded. @@ -293,6 +493,8 @@ async def auto_compact_if_needed( read_file_state, plan_file_path, memory_paths: forwarded to the ``CompactContext`` so post-compact attachments (file restore, plan restore) fire from auto-compact, not just `/compact`. + compaction_mode: "token_threshold" (legacy) or "cost_aware" (PR 2) + break_even_turns: number of turns to break even (default 10, used in cost_aware mode) Returns: ``CompactionResult`` if compaction was performed, else ``None``. @@ -305,13 +507,16 @@ async def auto_compact_if_needed( max_output_tokens=max_output_tokens, threshold_fraction=threshold_fraction, tracking=tracking, + mode=compaction_mode, + break_even_turns=break_even_turns, + model=model, ): return None logger.info( - "Autocompact triggered: %d input tokens (threshold=%d, context_window=%d)", + "Autocompact triggered: %d input tokens (mode=%s, context_window=%d)", input_token_count, - get_auto_compact_threshold(context_window, max_output_tokens), + compaction_mode, context_window, ) diff --git a/src/services/compact/compact.py b/src/services/compact/compact.py index f5319524d..c82597def 100644 --- a/src/services/compact/compact.py +++ b/src/services/compact/compact.py @@ -55,6 +55,242 @@ logger = logging.getLogger(__name__) +# ============================================================================= +# Compaction Telemetry (PR 3) +# ============================================================================= + +@dataclass +class CompactionTelemetry: + """Telemetry data for a compaction event.""" + trigger: str + tokens_shed: int + pre_compact_token_count: int + post_compact_token_count: int + compaction_cost_usd: float + cache_hit_rate_before: float | None = None + cache_hit_rate_after: float | None = None + estimated_cost_delta_usd: float | None = None + cost_increased: bool = False + + +def _get_recent_cache_hit_rate(model: str | None = None) -> float | None: + """ + Calculate the cache hit rate from recent API usage. + + Returns cache_read / (input + cache_creation + cache_read) as a percentage, + or None if no usage data is available. + """ + try: + from src.bootstrap.state import get_model_usage + + model_usage = get_model_usage() + if not model_usage: + return None + + # If model specified, use that; otherwise sum across all models + if model and model in model_usage: + usages = [model_usage[model]] + else: + usages = list(model_usage.values()) + + total_input = sum(u.input_tokens for u in usages) + total_cache_creation = sum(u.cache_creation_input_tokens for u in usages) + total_cache_read = sum(u.cache_read_input_tokens for u in usages) + + prompt_total = total_input + total_cache_creation + total_cache_read + if prompt_total == 0: + return None + + return (total_cache_read / prompt_total) * 100.0 + except Exception: + return None + + +def _estimate_compaction_cost_delta( + pre_compact_tokens: int, + post_compact_tokens: int, + cache_hit_rate_before: float | None, + cache_hit_rate_after: float | None, + model: str, +) -> float | None: + """ + Estimate the cost delta from compaction. + + Positive = compaction increased cost (cache-hostile). + Negative = compaction decreased cost (cache-friendly). + """ + try: + from src.services.pricing import get_pricing, compute_cost + + pricing = get_pricing(model) + if not pricing: + return None + + input_rate = pricing.get("input", 0) + cache_read_rate = pricing.get("cache_read", 0) + + # If cache_read rate is not explicitly set, it's often a fraction of input rate + if cache_read_rate == 0: + cache_read_rate = input_rate * 0.1 # typical 90% discount + + tokens_shed = pre_compact_tokens - post_compact_tokens + if tokens_shed <= 0: + return None + + # Estimate: tokens shed would have been a mix of cached and uncached + # based on the cache hit rate before compaction + hit_rate_before = (cache_hit_rate_before or 0) / 100.0 + + # Cost of shed tokens at old hit rate + uncached_shed = tokens_shed * (1 - hit_rate_before) + cached_shed = tokens_shed * hit_rate_before + cost_shed = (uncached_shed * input_rate + cached_shed * cache_read_rate) / 1_000_000 + + # Cost of compaction summary call (already tracked in compaction_usage) + # This is a separate API call that we already pay for + + # The delta is the cost of the compaction call minus the ongoing + # savings from not sending those tokens in future turns + # For now, just return the cost of shed tokens as a baseline + return cost_shed + except Exception: + return None + + +def _log_compaction_telemetry(telemetry: CompactionTelemetry) -> None: + """Log compaction telemetry with warning if cost increased.""" + logger.info( + "compaction_telemetry: trigger=%s tokens_shed=%d pre=%d post=%d " + "compaction_cost=$%.6f hit_before=%.1f%% hit_after=%s delta=$%.6f increased=%s", + telemetry.trigger, + telemetry.tokens_shed, + telemetry.pre_compact_token_count, + telemetry.post_compact_token_count, + telemetry.compaction_cost_usd, + telemetry.cache_hit_rate_before if telemetry.cache_hit_rate_before is not None else -1, + f"{telemetry.cache_hit_rate_after:.1f}%" if telemetry.cache_hit_rate_after is not None else "pending", + telemetry.estimated_cost_delta_usd if telemetry.estimated_cost_delta_usd is not None else -1, + telemetry.cost_increased, + ) + + if telemetry.cost_increased: + logger.warning( + "CACHE-HOSTILE COMPACTION: trigger=%s shed %d tokens but increased " + "effective cost (delta=$%.6f). Pre-hit=%.1f%%, Post-hit=%.1f%%. " + "Consider: smaller compaction window, different model, or disable auto-compact.", + telemetry.trigger, + telemetry.tokens_shed, + telemetry.estimated_cost_delta_usd or 0, + telemetry.cache_hit_rate_before or 0, + telemetry.cache_hit_rate_after or 0, + ) + + +def _calculate_cache_hit_rate_from_usage(usage: dict[str, Any]) -> float | None: + """ + Calculate cache hit rate from a single API response's usage dict. + + Handles both Anthropic-native (cache_read_input_tokens) and + OpenAI-compatible (prompt_tokens_details.cached_tokens) formats. + """ + try: + # Anthropic-native format + if "cache_read_input_tokens" in usage: + input_tokens = int(usage.get("input_tokens", 0) or 0) + cache_creation = int(usage.get("cache_creation_input_tokens", 0) or 0) + cache_read = int(usage.get("cache_read_input_tokens", 0) or 0) + prompt_total = input_tokens + cache_creation + cache_read + if prompt_total == 0: + return None + return (cache_read / prompt_total) * 100.0 + + # OpenAI-compatible format + if "prompt_tokens_details" in usage: + prompt_tokens = int(usage.get("prompt_tokens", 0) or 0) + cached = usage.get("prompt_tokens_details", {}) + cache_read = int(cached.get("cached_tokens", 0) if isinstance(cached, dict) else 0) + # OpenAI doesn't have cache_creation, so prompt_total = prompt_tokens + if prompt_tokens == 0: + return None + return (cache_read / prompt_tokens) * 100.0 + + return None + except Exception: + return None + + +def log_post_compaction_telemetry( + trigger: str, + tokens_shed: int, + pre_compact_token_count: int, + post_compact_token_count: int, + compaction_cost_usd: float, + cache_hit_rate_before: float | None, + response_usage: dict[str, Any], + model: str, +) -> None: + """ + Log telemetry for the first turn after compaction. + + Called when consume_post_compaction() returns True. Measures the + cache hit rate from the first post-compaction API response and + logs updated telemetry. + """ + cache_hit_rate_after = _calculate_cache_hit_rate_from_usage(response_usage) + + # Recalculate cost delta with actual post-compaction hit rate + estimated_delta = _estimate_compaction_cost_delta( + pre_compact_token_count, + post_compact_token_count, + cache_hit_rate_before, + cache_hit_rate_after, + model, + ) + + cost_increased = ( + estimated_delta is not None + and estimated_delta > compaction_cost_usd + ) + + logger.info( + "compaction_telemetry_post: trigger=%s tokens_shed=%d pre=%d post=%d " + "compaction_cost=$%.6f hit_before=%.1f%% hit_after=%.1f%% delta=$%.6f increased=%s", + trigger, + tokens_shed, + pre_compact_token_count, + post_compact_token_count, + compaction_cost_usd, + cache_hit_rate_before if cache_hit_rate_before is not None else -1, + cache_hit_rate_after if cache_hit_rate_after is not None else -1, + estimated_delta if estimated_delta is not None else -1, + cost_increased, + ) + + if cost_increased: + logger.warning( + "CACHE-HOSTILE COMPACTION (confirmed): trigger=%s shed %d tokens " + "but increased effective cost (delta=$%.6f). Pre-hit=%.1f%%, " + "Post-hit=%.1f%%. Consider: smaller compaction window, different " + "model, or disable auto-compact.", + trigger, + tokens_shed, + estimated_delta or 0, + cache_hit_rate_before or 0, + cache_hit_rate_after or 0, + ) + elif cache_hit_rate_after is not None and cache_hit_rate_before is not None: + hit_rate_delta = cache_hit_rate_after - cache_hit_rate_before + if hit_rate_delta < -5: # significant drop + logger.warning( + "COMPACTION CACHE HIT RATE DROP: trigger=%s hit rate fell " + "%.1f%% -> %.1f%% (delta=%.1f%%). Cache prefix may have been " + "disturbed by compaction.", + trigger, + cache_hit_rate_before, + cache_hit_rate_after, + hit_rate_delta, + ) + # Maximum output tokens for the summary model COMPACT_MAX_OUTPUT_TOKENS = 8_192 @@ -524,6 +760,51 @@ async def compact_conversation( f"Pre-compact: {pre_compact_tokens:,} tokens." ) + # --- PR 3: Compaction Telemetry --- + compaction_cost_usd = 0.0 + if compaction_usage: + try: + from src.services.pricing import compute_cost + compaction_cost_usd = compute_cost(context.model, compaction_usage) + except Exception: + pass + + cache_hit_rate_before = _get_recent_cache_hit_rate(context.model) + + estimated_delta = _estimate_compaction_cost_delta( + pre_compact_tokens, + post_compact_tokens, + cache_hit_rate_before, + None, # post-compaction hit rate measured on next turn + context.model, + ) + + telemetry = CompactionTelemetry( + trigger=context.trigger, + tokens_shed=tokens_saved, + pre_compact_token_count=pre_compact_tokens, + post_compact_token_count=post_compact_tokens, + compaction_cost_usd=compaction_cost_usd, + cache_hit_rate_before=cache_hit_rate_before, + estimated_cost_delta_usd=estimated_delta, + cost_increased=(estimated_delta is not None and estimated_delta > compaction_cost_usd), + ) + + _log_compaction_telemetry(telemetry) + + # Store telemetry for post-compaction measurement + from src.bootstrap.state import set_compaction_telemetry_data, CompactionTelemetryData + set_compaction_telemetry_data(CompactionTelemetryData( + trigger=telemetry.trigger, + tokens_shed=telemetry.tokens_shed, + pre_compact_token_count=telemetry.pre_compact_token_count, + post_compact_token_count=telemetry.post_compact_token_count, + compaction_cost_usd=telemetry.compaction_cost_usd, + cache_hit_rate_before=telemetry.cache_hit_rate_before, + model=context.model, + )) + # --- End PR 3 Telemetry --- + return CompactionResult( boundary_marker=boundary_msg, summary_messages=[summary_msg], @@ -696,13 +977,53 @@ async def partial_compact_conversation( suppress_compact_warning() + # --- PR 3: Compaction Telemetry --- + compaction_cost_usd = 0.0 + if compaction_usage: + try: + from src.services.pricing import compute_cost + compaction_cost_usd = compute_cost(context.model, compaction_usage) + except Exception: + pass + + cache_hit_rate_before = _get_recent_cache_hit_rate(context.model) + + # For partial compaction, tokens_saved = pre_compact_tokens (entire summarized portion) + # post_compact_tokens is just the summary message + post_api_msgs = [{"role": "user", "content": formatted_summary}] + post_compact_tokens = count_messages_tokens(post_api_msgs) + tokens_saved = max(0, pre_compact_tokens - post_compact_tokens) + + estimated_delta = _estimate_compaction_cost_delta( + pre_compact_tokens, + post_compact_tokens, + cache_hit_rate_before, + None, + context.model, + ) + + telemetry = CompactionTelemetry( + trigger=f"{context.trigger}:{direction}", + tokens_shed=tokens_saved, + pre_compact_token_count=pre_compact_tokens, + post_compact_token_count=post_compact_tokens, + compaction_cost_usd=compaction_cost_usd, + cache_hit_rate_before=cache_hit_rate_before, + estimated_cost_delta_usd=estimated_delta, + cost_increased=(estimated_delta is not None and estimated_delta > compaction_cost_usd), + ) + + _log_compaction_telemetry(telemetry) + # --- End PR 3 Telemetry --- + return CompactionResult( boundary_marker=boundary_msg, summary_messages=[summary_msg], messages_to_keep=list(messages_to_keep), attachments=attachments, pre_compact_token_count=pre_compact_tokens, + post_compact_token_count=post_compact_tokens, compaction_usage=compaction_usage, trigger=context.trigger, - tokens_saved=pre_compact_tokens, + tokens_saved=tokens_saved, ) diff --git a/src/services/compact/pipeline.py b/src/services/compact/pipeline.py index 363f7f74f..e86559272 100644 --- a/src/services/compact/pipeline.py +++ b/src/services/compact/pipeline.py @@ -73,6 +73,9 @@ class PipelineConfig: max_output_tokens: int | None = None autocompact_threshold: float = 0.80 autocompact_tracking: AutoCompactTracking | None = None + # PR 2: Cost-aware compaction settings + compaction_mode: str = "token_threshold" # "token_threshold" | "cost_aware" + break_even_turns: int = 10 # Layer 5: post-compact attachment context # Forwarded into auto_compact_if_needed → CompactContext so post-compact @@ -129,6 +132,19 @@ def build_production_pipeline_config( ) except Exception: logger.debug("model context-window resolution failed", exc_info=True) + + # Load compaction settings from user config (PR 2) + compaction_mode = "token_threshold" + break_even_turns = 10 + try: + from src.settings.settings import get_settings + settings = get_settings() + if settings.compact: + compaction_mode = settings.compact.mode or "token_threshold" + break_even_turns = settings.compact.break_even_turns or 10 + except Exception: + logger.debug("Failed to load compaction settings from config, using defaults", exc_info=True) + return PipelineConfig( provider=provider, model=model, @@ -136,6 +152,8 @@ def build_production_pipeline_config( max_output_tokens=max_output_tokens, read_file_state=read_file_state or None, autocompact_tracking=autocompact_tracking, + compaction_mode=compaction_mode, + break_even_turns=break_even_turns, ) @@ -267,6 +285,8 @@ async def run( read_file_state=cfg.read_file_state, plan_file_path=cfg.plan_file_path, memory_paths=cfg.memory_paths, + compaction_mode=cfg.compaction_mode, + break_even_turns=cfg.break_even_turns, ) if result is not None: total_saved += result.tokens_saved diff --git a/src/settings/constants.py b/src/settings/constants.py index 4b4658fcd..1a9e3d116 100644 --- a/src/settings/constants.py +++ b/src/settings/constants.py @@ -28,6 +28,8 @@ auto_compact=True, threshold_tokens=100_000, max_compact_retries=3, + mode="token_threshold", + break_even_turns=10, ), hooks=HookSettings( enabled=True, diff --git a/src/settings/types.py b/src/settings/types.py index 41033155f..71e0cceef 100644 --- a/src/settings/types.py +++ b/src/settings/types.py @@ -80,6 +80,9 @@ class CompactSettings: auto_compact: bool = True threshold_tokens: int = 100_000 max_compact_retries: int = 3 + # PR 2: Cost-aware compaction trigger + mode: str = "token_threshold" # "token_threshold" | "cost_aware" + break_even_turns: int = 10 @dataclass diff --git a/tests/test_context_analyzer.py b/tests/test_context_analyzer.py index 20d545def..d86546573 100644 --- a/tests/test_context_analyzer.py +++ b/tests/test_context_analyzer.py @@ -181,26 +181,5 @@ def test_shows_memory_files(self): self.assertIn("### Memory Files", markdown) self.assertIn("CLAWCODEX.md", markdown) - def test_shows_api_usage(self): - """API usage section appears when usage data is provided.""" - from src.context_system.context_analyzer import analyze_context, format_context_as_markdown - result = analyze_context( - conversation_api_messages=[], - model="claude-sonnet-4-6", - system_prompt="", - tool_schemas=[], - clawcodex_md_content="", - api_usage={ - "input_tokens": 10000, - "output_tokens": 5000, - "cache_creation_input_tokens": 2000, - "cache_read_input_tokens": 500, - } - ) - markdown = format_context_as_markdown(result) - self.assertIn("### API Usage", markdown) - self.assertIn("10,000", markdown) - - -if __name__ == "__main__": + if __name__ == "__main__": unittest.main() diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index d938f41d9..aec083c12 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -93,7 +93,11 @@ setupGracefulExit({ const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err) recordParentLifecycle(`${scope}: ${message.split('\n')[0]?.slice(0, 400) ?? ''}`) - process.stderr.write(`clawcodex-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) + try { + process.stderr.write(`clawcodex-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) + } catch { + // Ignore write errors on closed stderr during process termination + } }, onSignal: signal => { // The next line in the crash log is the child's `=== SIGTERM received ===` diff --git a/ui-tui/src/lib/terminalModes.ts b/ui-tui/src/lib/terminalModes.ts index ff7e86fc3..f4d89ba50 100644 --- a/ui-tui/src/lib/terminalModes.ts +++ b/ui-tui/src/lib/terminalModes.ts @@ -53,16 +53,20 @@ export function resetTerminalModes(stream: ResettableStream = process.stdout, le if (fd !== undefined) { try { writeSync(fd, seq) - return true - } catch { + } catch (err: any) { + if (err?.code === 'EIO' || err?.code === 'EBADF') { + return false + } // Fall through to stream.write for mocked or unusual TTY streams. } } try { + if ((stream as any).destroyed) { + return false + } stream.write(seq) - return true } catch { return false