utim-cli 2.3.22__tar.gz → 2.3.24__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {utim_cli-2.3.22 → utim_cli-2.3.24}/CHANGELOG.md +10 -0
- {utim_cli-2.3.22/utim_cli.egg-info → utim_cli-2.3.24}/PKG-INFO +1 -1
- {utim_cli-2.3.22 → utim_cli-2.3.24}/pyproject.toml +1 -1
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim/__init__.py +2 -2
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/__init__.py +1 -1
- utim_cli-2.3.24/utim_cli/_chip_test.py +22 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/_version.py +1 -1
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/config.py +65 -0
- utim_cli-2.3.24/utim_cli/constants.py +43 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/context_pruner.py +308 -19
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/orchestrator.py +11 -3
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/orchestrator_llm_transport.py +98 -30
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/orchestrator_turn_history.py +23 -3
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/key_rotator.py +83 -56
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/models.py +4 -13
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/completion_routes.py +93 -23
- utim_cli-2.3.24/utim_cli/tui/compression_dialog.py +230 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/utim.py +220 -36
- {utim_cli-2.3.22 → utim_cli-2.3.24/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli.egg-info/SOURCES.txt +2 -0
- utim_cli-2.3.22/utim_cli/constants.py +0 -99
- {utim_cli-2.3.22 → utim_cli-2.3.24}/LICENSE +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/MANIFEST.in +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/README.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/setup.cfg +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/setup.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/_verify_repro.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/_verify_syntax.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/_wizard_smoke_test.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/agent.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/ask_helper.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/auth.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/backup.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/billing.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/blender_agent.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/bootstrap.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/brain.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/client_utils.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/crawler/__init__.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/crawler/engine.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/crawler/markdown_distiller.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/dependency_manager.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/doctor.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/evidence_registry.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/harbor.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/local_db.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/logger.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/mcp_client.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/models.txt +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/orchestrator_helpers.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/orchestrator_tool_exec.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/personalities.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/platform_installer.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/plugins/__init__.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/plugins/hooks.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/plugins/loader.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/plugins/manager.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/plugins/registry.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/plugins/runtime.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/plugins/schema.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/plugins/tool_loader.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/reflection.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/report.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/sdk/__init__.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/sdk/config.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/sdk/context.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/sdk/events.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/sdk/handlers.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/sdk/session.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/sdk/sidecar.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/__init__.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/admin_auth.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/attribution.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/audit_log.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/auth.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/batch_processor.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/captcha.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/cli_auth.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/concurrency.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/db.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/pricing.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/firebase.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/fix_duplicate_users.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/history.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/local_llm.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/micro_batcher.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/model_agent.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/plan_integrity.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/pricing_updater.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/provision_build.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/rewards_engine.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/router.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/admin_db_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/auth_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/billing_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/credit_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/marketplace_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/quota_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/quota_share_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/rewards_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/security_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/routes/webhook_routes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/security_alerts.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/server.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/storage_nodes.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/server/utils.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/share.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/share_tui.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/state.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/subagent_manager.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/task_dispatcher.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/task_logger.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tools.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tools_grep.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/marketplace_app_state.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/marketplace_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/marketplace_layout_engine.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/miniagents_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/model_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/personality_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/plugins_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/publish_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/quota_redeem_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/quota_share_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/rewards_tui.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/skills_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/subagents_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/tasks_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/thinking_display.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/update_checker.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/utilities.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/utimmodel.txt +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/vector_memory.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/wheel.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli/workspace.py +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-2.3.22 → utim_cli-2.3.24}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.3.24] - 2026-09-07
|
|
4
|
+
|
|
5
|
+
### ⚡ Performance
|
|
6
|
+
- Fixed latency issues
|
|
7
|
+
|
|
8
|
+
## [2.3.23] - 2026-09-07
|
|
9
|
+
|
|
10
|
+
### 🛠️ Minor Bug Fixes and Enhancement
|
|
11
|
+
- Minor bug fixes and enhancement
|
|
12
|
+
|
|
3
13
|
## [2.3.22] - 2026-09-06
|
|
4
14
|
|
|
5
15
|
### 🛡️ Seamless Rate-Limit Failover to Paid Slugs
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: utim-cli
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.24
|
|
4
4
|
Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
|
|
5
5
|
License: Emend AI Proprietary EULA
|
|
6
6
|
Project-URL: Homepage, https://utim.dev
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
2
|
UTIM Developer SDK
|
|
3
3
|
------------------
|
|
4
4
|
Unified autonomous coding agent SDK.
|
|
@@ -14,7 +14,7 @@ Quickstart:
|
|
|
14
14
|
try:
|
|
15
15
|
from utim_cli._version import VERSION as __version__
|
|
16
16
|
except ImportError:
|
|
17
|
-
__version__ = "2.3.
|
|
17
|
+
__version__ = "2.3.24"
|
|
18
18
|
|
|
19
19
|
# Developer-friendly core classes (similar to `import genai` / `import openai`)
|
|
20
20
|
from utim_cli.sdk.session import UtimSession as Session
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
_PASTE_CHIP_THRESHOLD = 3000
|
|
2
|
+
_PASTE_CHIP_SEQ = [0]
|
|
3
|
+
_PASTE_CHIPS = {}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _make_paste_chip(data):
|
|
7
|
+
if len(data) >= _PASTE_CHIP_THRESHOLD:
|
|
8
|
+
_PASTE_CHIP_SEQ[0] += 1
|
|
9
|
+
n = _PASTE_CHIP_SEQ[0]
|
|
10
|
+
token = f"[Pasted text #{n} {len(data)} chars]"
|
|
11
|
+
_PASTE_CHIPS[token] = data
|
|
12
|
+
return token
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Test cases
|
|
17
|
+
print("2921 chars ->", repr(_make_paste_chip("x" * 2921))) # user example
|
|
18
|
+
print("3000 chars ->", repr(_make_paste_chip("x" * 3000))) # at threshold
|
|
19
|
+
print("3001 chars ->", repr(_make_paste_chip("x" * 3001))) # just over
|
|
20
|
+
print("100 chars ->", repr(_make_paste_chip("x" * 100))) # small
|
|
21
|
+
print()
|
|
22
|
+
print("Chips created so far:", list(_PASTE_CHIPS.keys()))
|
|
@@ -666,6 +666,71 @@ class Config:
|
|
|
666
666
|
# Fallback – raise informative error later when used
|
|
667
667
|
return ""
|
|
668
668
|
|
|
669
|
+
# ── API Key Exhaustion Tracking (24-hour cooldown) ────────────────────────
|
|
670
|
+
|
|
671
|
+
def mark_api_key_exhausted(self, api_key: str, duration_hours: int = 24) -> None:
|
|
672
|
+
"""Mark an API key as exhausted due to rate limiting/429 errors.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
api_key: The API key that got exhausted
|
|
676
|
+
duration_hours: How long to exclude this key (default 24 hours)
|
|
677
|
+
"""
|
|
678
|
+
if not api_key:
|
|
679
|
+
return
|
|
680
|
+
exhausted = self.get("_api_key_exhausted", {})
|
|
681
|
+
exhausted[api_key] = time.time() + (duration_hours * 3600)
|
|
682
|
+
self.set("_api_key_exhausted", exhausted)
|
|
683
|
+
logger.info(f"API key marked exhausted for {duration_hours}h: {api_key[:8]}...")
|
|
684
|
+
|
|
685
|
+
def is_api_key_exhausted(self, api_key: str) -> bool:
|
|
686
|
+
"""Check if an API key is currently exhausted (rate-limited).
|
|
687
|
+
|
|
688
|
+
Args:
|
|
689
|
+
api_key: The API key to check
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
True if exhausted and within cooldown period, False otherwise
|
|
693
|
+
"""
|
|
694
|
+
if not api_key:
|
|
695
|
+
return False
|
|
696
|
+
exhausted = self.get("_api_key_exhausted", {})
|
|
697
|
+
expiry = exhausted.get(api_key, 0)
|
|
698
|
+
if time.time() < expiry:
|
|
699
|
+
return True
|
|
700
|
+
# Clean up expired entry
|
|
701
|
+
if api_key in exhausted:
|
|
702
|
+
del exhausted[api_key]
|
|
703
|
+
self.set("_api_key_exhausted", exhausted)
|
|
704
|
+
return False
|
|
705
|
+
|
|
706
|
+
def get_available_api_keys(self, keys: List[str]) -> List[str]:
|
|
707
|
+
"""Filter a list of API keys, excluding exhausted ones.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
keys: List of API keys to filter
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
List of non-exhausted API keys
|
|
714
|
+
"""
|
|
715
|
+
return [k for k in keys if k and not self.is_api_key_exhausted(k)]
|
|
716
|
+
|
|
717
|
+
def get_exhaustion_countdown(self, api_key: str) -> Optional[int]:
|
|
718
|
+
"""Get remaining seconds until an API key can be used again.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
api_key: The API key to check
|
|
722
|
+
|
|
723
|
+
Returns:
|
|
724
|
+
Seconds remaining, or None if not exhausted
|
|
725
|
+
"""
|
|
726
|
+
if not api_key:
|
|
727
|
+
return None
|
|
728
|
+
exhausted = self.get("_api_key_exhausted", {})
|
|
729
|
+
expiry = exhausted.get(api_key, 0)
|
|
730
|
+
remaining = int(expiry - time.time())
|
|
731
|
+
return remaining if remaining > 0 else None
|
|
732
|
+
|
|
733
|
+
|
|
669
734
|
# Global config instance
|
|
670
735
|
config = Config()
|
|
671
736
|
# Convenience constant for direct access
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
DEFAULT_MODEL = "poolside/laguna-s-2.1:free"
|
|
4
|
+
SERVER_URL = os.getenv("UTIM_SERVER_URL", "https://api.utim.dev")
|
|
5
|
+
OPENROUTER_BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
|
|
6
|
+
|
|
7
|
+
SUBAGENT_DEFAULTS = {
|
|
8
|
+
"web_search": DEFAULT_MODEL,
|
|
9
|
+
"plan_project": DEFAULT_MODEL,
|
|
10
|
+
"generate_image": "qwen/qwen-image-3",
|
|
11
|
+
"analyze_image": "google/gemma-4-31b-it:free",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
APINEX_MODEL_IDS = {
|
|
15
|
+
"claude/opus-5",
|
|
16
|
+
"claude/sonnet-5",
|
|
17
|
+
"deepseek/v4-flash-0731",
|
|
18
|
+
"deepseek/v4-pro-0813",
|
|
19
|
+
"free/deepseek-v4-flash-0731",
|
|
20
|
+
"free/deepseek-v4-pro-0813",
|
|
21
|
+
"free/glm-5.3-flash",
|
|
22
|
+
"free/gemini-3.1-pro",
|
|
23
|
+
"free/gemini-3.7-flash",
|
|
24
|
+
"gemini/3.1-pro",
|
|
25
|
+
"gemini/3.7-flash",
|
|
26
|
+
"kimi/k3",
|
|
27
|
+
"gpt/5.6-luna",
|
|
28
|
+
"gpt/5.6-sol",
|
|
29
|
+
"gpt/5.6-terra",
|
|
30
|
+
"glm/5.3",
|
|
31
|
+
"glm/5.3-flash",
|
|
32
|
+
"grok/4.6",
|
|
33
|
+
"qwen/3.8-max",
|
|
34
|
+
"free/qwen-3.8-max",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
import sys
|
|
39
|
+
_IS_LEGACY_WIN = (sys.platform == "win32" and "WT_SESSION" not in os.environ)
|
|
40
|
+
PROMPT_SYMBOL = ">" if _IS_LEGACY_WIN else "❯"
|
|
41
|
+
ARROW_SYMBOL = "->" if _IS_LEGACY_WIN else "➔"
|
|
42
|
+
|
|
43
|
+
|
|
@@ -36,6 +36,56 @@ def get_compression_fallback_models() -> List[str]:
|
|
|
36
36
|
return COMPRESSION_FALLBACK_MODELS
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
# ── Compression level runtime state ──────────────────────────────────────────
|
|
40
|
+
# These are mutated by apply_compression_level() (called from /compression dialog)
|
|
41
|
+
# and read by _progressive_keep_ratio() and build_compacted_history_message().
|
|
42
|
+
#
|
|
43
|
+
# Defaults match the "medium" preset:
|
|
44
|
+
# oldest_ratio=0.07, newest_ratio=0.70, merge_count=5
|
|
45
|
+
_COMPRESSION_PARAMS: Dict[str, Any] = {
|
|
46
|
+
"oldest_ratio": 0.07,
|
|
47
|
+
"newest_ratio": 0.70,
|
|
48
|
+
"merge_count": 5,
|
|
49
|
+
"level_id": "medium",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def apply_compression_level(level_id: str) -> None:
|
|
54
|
+
"""Apply a named compression level by updating the runtime params dict.
|
|
55
|
+
|
|
56
|
+
Called immediately after the user picks a level in /compression dialog.
|
|
57
|
+
The change takes effect on the *next* call to build_compacted_history_message.
|
|
58
|
+
"""
|
|
59
|
+
# Map level_id → (oldest_ratio, newest_ratio, merge_count)
|
|
60
|
+
LEVEL_MAP = {
|
|
61
|
+
"very_low": (0.50, 0.95, 0),
|
|
62
|
+
"low": (0.25, 0.85, 2),
|
|
63
|
+
"medium": (0.07, 0.70, 5),
|
|
64
|
+
"high": (0.03, 0.50, 8),
|
|
65
|
+
"very_high": (0.01, 0.30, 12),
|
|
66
|
+
}
|
|
67
|
+
if level_id not in LEVEL_MAP:
|
|
68
|
+
return
|
|
69
|
+
oldest, newest, merge = LEVEL_MAP[level_id]
|
|
70
|
+
_COMPRESSION_PARAMS["oldest_ratio"] = oldest
|
|
71
|
+
_COMPRESSION_PARAMS["newest_ratio"] = newest
|
|
72
|
+
_COMPRESSION_PARAMS["merge_count"] = merge
|
|
73
|
+
_COMPRESSION_PARAMS["level_id"] = level_id
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_compression_level_from_config() -> None:
|
|
77
|
+
"""Seed _COMPRESSION_PARAMS from persisted config on first use."""
|
|
78
|
+
try:
|
|
79
|
+
from utim_cli.config import config
|
|
80
|
+
level_id = config.get("compression_level") or "medium"
|
|
81
|
+
apply_compression_level(level_id)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
39
89
|
|
|
40
90
|
|
|
41
91
|
|
|
@@ -1246,8 +1296,13 @@ def summarize_single_turn_outcome(turn_msgs: List[Dict], tier: str = "medium") -
|
|
|
1246
1296
|
if cl and cl not in tool_outputs_summary and len(cl) < 80:
|
|
1247
1297
|
tool_outputs_summary.append(cl)
|
|
1248
1298
|
|
|
1249
|
-
# 1. Extract clean sentences from assistant text
|
|
1250
|
-
|
|
1299
|
+
# 1. Extract clean sentences from assistant text (combine across continuations if present)
|
|
1300
|
+
if len(assistant_texts) >= 2:
|
|
1301
|
+
base_text = " ".join(assistant_texts[-2:])
|
|
1302
|
+
elif assistant_texts:
|
|
1303
|
+
base_text = assistant_texts[-1]
|
|
1304
|
+
else:
|
|
1305
|
+
base_text = ""
|
|
1251
1306
|
if not base_text and compression_notes:
|
|
1252
1307
|
note = compression_notes[-1]
|
|
1253
1308
|
m_goal = re.search(r'## Completed\s*\n(.*?)(?=\n##|\Z)', note, re.DOTALL)
|
|
@@ -1314,15 +1369,192 @@ def summarize_single_turn_outcome(turn_msgs: List[Dict], tier: str = "medium") -
|
|
|
1314
1369
|
return "\n " + "\n ".join(detail_lines)
|
|
1315
1370
|
|
|
1316
1371
|
|
|
1372
|
+
def _progressive_keep_ratio(idx_0based: int, total_turns: int) -> float:
|
|
1373
|
+
"""Return the fraction of assistant text to keep for a given turn.
|
|
1374
|
+
|
|
1375
|
+
Progressive compression curve driven by the user-chosen compression level:
|
|
1376
|
+
- idx=0 (oldest) → _COMPRESSION_PARAMS["oldest_ratio"]
|
|
1377
|
+
- idx=N-1 (newest) → _COMPRESSION_PARAMS["newest_ratio"]
|
|
1378
|
+
- Linear interpolation for all turns in between.
|
|
1379
|
+
|
|
1380
|
+
When there is only one turn the ratio is always newest_ratio (near-verbatim).
|
|
1381
|
+
"""
|
|
1382
|
+
oldest = _COMPRESSION_PARAMS["oldest_ratio"] # e.g. 0.07 for medium
|
|
1383
|
+
newest = _COMPRESSION_PARAMS["newest_ratio"] # e.g. 0.70 for medium
|
|
1384
|
+
if total_turns <= 1:
|
|
1385
|
+
return newest
|
|
1386
|
+
t = idx_0based / (total_turns - 1) # 0.0 → oldest, 1.0 → newest
|
|
1387
|
+
return oldest + (newest - oldest) * t # lerp
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _compress_turn_text(text: str, keep_ratio: float) -> str:
|
|
1391
|
+
"""Truncate assistant text to ``keep_ratio`` of its original length at a
|
|
1392
|
+
sentence/newline boundary so no sentence is split in half.
|
|
1393
|
+
|
|
1394
|
+
Continuity anchors (next step, todo, error, file paths) are always
|
|
1395
|
+
preserved in full as a closing addendum even if they fall outside the
|
|
1396
|
+
budget.
|
|
1397
|
+
"""
|
|
1398
|
+
if not text:
|
|
1399
|
+
return text
|
|
1400
|
+
budget = max(80, int(len(text) * keep_ratio))
|
|
1401
|
+
if len(text) <= budget:
|
|
1402
|
+
return text
|
|
1403
|
+
|
|
1404
|
+
# Find the last sentence/newline boundary within budget
|
|
1405
|
+
cut = budget
|
|
1406
|
+
for sep in ("\n\n", "\n", ". ", "! ", "? "):
|
|
1407
|
+
pos = text.rfind(sep, 0, budget)
|
|
1408
|
+
if pos > 0:
|
|
1409
|
+
cut = pos + len(sep)
|
|
1410
|
+
break
|
|
1411
|
+
|
|
1412
|
+
kept = text[:cut].rstrip()
|
|
1413
|
+
|
|
1414
|
+
# Always append continuity anchors even if they were trimmed off
|
|
1415
|
+
anchor_lines = []
|
|
1416
|
+
for line in text[cut:].splitlines():
|
|
1417
|
+
ls = line.strip()
|
|
1418
|
+
if ls and re.search(
|
|
1419
|
+
r"(?i)(next step|todo|to-do|pending|remaining|blocker|continue|error|"
|
|
1420
|
+
r"exception|failed|fixed|modified|created|file:|path:|def |class )",
|
|
1421
|
+
ls,
|
|
1422
|
+
):
|
|
1423
|
+
anchor_lines.append(ls)
|
|
1424
|
+
if anchor_lines:
|
|
1425
|
+
kept = kept + "\n[…] " + " | ".join(anchor_lines[:3])
|
|
1426
|
+
|
|
1427
|
+
omitted = len(text) - len(kept)
|
|
1428
|
+
if omitted > 30:
|
|
1429
|
+
kept += f"\n[{omitted:,} chars omitted — {int((1 - keep_ratio) * 100)}% compressed]"
|
|
1430
|
+
|
|
1431
|
+
return kept
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _merge_oldest_turns(turns: List, merge_count: int) -> str:
|
|
1435
|
+
"""Collapse the ``merge_count`` oldest turns into one dense merged entry.
|
|
1436
|
+
|
|
1437
|
+
Produces a single bullet block: what each user asked + one-line outcome.
|
|
1438
|
+
"""
|
|
1439
|
+
lines = ["[Merged earliest turns — oldest context, highest compression]"]
|
|
1440
|
+
for i, (prompt, msgs) in enumerate(turns[:merge_count], 1):
|
|
1441
|
+
# User prompt: one line, word-boundary capped at 120 chars
|
|
1442
|
+
prompt_lines = [l.strip() for l in prompt.splitlines() if l.strip()]
|
|
1443
|
+
clean_prompt = prompt_lines[0] if prompt_lines else prompt.strip()
|
|
1444
|
+
if len(clean_prompt) > 120:
|
|
1445
|
+
clean_prompt = clean_prompt[:120].rsplit(" ", 1)[0] + "…"
|
|
1446
|
+
|
|
1447
|
+
outcome = summarize_single_turn_outcome(msgs, tier="low")
|
|
1448
|
+
# Apply the lowest ratio (7%) to these merged entries
|
|
1449
|
+
outcome = _compress_turn_text(outcome, keep_ratio=0.07)
|
|
1450
|
+
lines.append(f" • Turn {i}: \"{clean_prompt}\" → {outcome}")
|
|
1451
|
+
return "\n".join(lines)
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def is_continuation_prompt(prompt_or_msg: Any) -> bool:
|
|
1456
|
+
"""Check if a prompt or user message is simply a continuation nudge or inquiry about
|
|
1457
|
+
an abrupt stop (e.g. 'continue', 'why are u stopping', 'keep going', 'proceed'),
|
|
1458
|
+
rather than a distinct new task.
|
|
1459
|
+
|
|
1460
|
+
When True, the compression pipeline folds this interaction into the parent task
|
|
1461
|
+
turn instead of incrementing the turn count and prematurely pushing real tasks
|
|
1462
|
+
into aggressive compression/merging.
|
|
1463
|
+
"""
|
|
1464
|
+
if not prompt_or_msg:
|
|
1465
|
+
return False
|
|
1466
|
+
|
|
1467
|
+
if isinstance(prompt_or_msg, dict):
|
|
1468
|
+
if prompt_or_msg.get("role") != "user":
|
|
1469
|
+
return False
|
|
1470
|
+
content = prompt_or_msg.get("content") or ""
|
|
1471
|
+
else:
|
|
1472
|
+
content = prompt_or_msg
|
|
1473
|
+
|
|
1474
|
+
if isinstance(content, list):
|
|
1475
|
+
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
|
|
1476
|
+
elif not isinstance(content, str):
|
|
1477
|
+
content = str(content)
|
|
1478
|
+
|
|
1479
|
+
s = content.strip()
|
|
1480
|
+
if not s:
|
|
1481
|
+
return False
|
|
1482
|
+
|
|
1483
|
+
# 1. Automated continuation nudges & wrappers
|
|
1484
|
+
if s.startswith(("CONTINUE:", "[CONTINUE", "[Continue", "<CONTINUATION_REQUEST", "CONTINUE ")):
|
|
1485
|
+
return True
|
|
1486
|
+
if "CONTINUE: Your last message" in s or "CONTINUE: Please continue" in s:
|
|
1487
|
+
return True
|
|
1488
|
+
|
|
1489
|
+
# Strip surrounding quotes, punctuation and trailing emojis/whitespace
|
|
1490
|
+
cleaned = re.sub(r'^[^\w]+|[^\w]+$', '', s).strip().lower()
|
|
1491
|
+
|
|
1492
|
+
# If the message is long (> 160 chars), it is likely a substantive new prompt with new instructions
|
|
1493
|
+
if len(cleaned) > 160:
|
|
1494
|
+
return False
|
|
1495
|
+
|
|
1496
|
+
# 2. Exact single-word or short continuation keywords
|
|
1497
|
+
EXACT_CONTINUE_PHRASES = {
|
|
1498
|
+
"c", "continue", "pls continue", "please continue", "continue please",
|
|
1499
|
+
"pls continue the task", "please continue the task", "continue the task",
|
|
1500
|
+
"continue task", "continue working", "continue execution", "continue generating",
|
|
1501
|
+
"keep going", "please keep going", "pls keep going", "keep going please",
|
|
1502
|
+
"go on", "please go on", "pls go on",
|
|
1503
|
+
"proceed", "please proceed", "pls proceed",
|
|
1504
|
+
"carry on", "please carry on", "pls carry on",
|
|
1505
|
+
"resume", "please resume", "pls resume", "resume task", "resume working",
|
|
1506
|
+
"keep at it", "keep working", "keep running",
|
|
1507
|
+
"finish", "finish it", "finish this", "finish the task", "complete it", "complete this", "complete the task",
|
|
1508
|
+
"dont stop", "don't stop", "do not stop", "stop stopping",
|
|
1509
|
+
"more", "next", "go ahead",
|
|
1510
|
+
"continue from where you left off", "continue from last step", "continue from before",
|
|
1511
|
+
"please continue from where you left off", "pls continue from where you left off",
|
|
1512
|
+
}
|
|
1513
|
+
if cleaned in EXACT_CONTINUE_PHRASES:
|
|
1514
|
+
return True
|
|
1515
|
+
|
|
1516
|
+
# 3. Questions asking why the model stopped / paused / cut off
|
|
1517
|
+
STOP_INQUIRY_PATTERNS = [
|
|
1518
|
+
r"^why\s+(?:did\s+(?:you|u|it)|are\s+(?:you|u)|have\s+you|is\s+it)\s+(?:stop(?:ped|ping)?|pause[d]?|halt(?:ed)?|cut\s*off)[\s\w?!.]*$",
|
|
1519
|
+
r"^why\s+(?:stop(?:ped|ping)?|paused?|halted?|cut\s*off)[\s\w?!.]*$",
|
|
1520
|
+
r"^(?:you|u|it)\s+(?:stopped|paused|halted|cut\s*off)[\s\w?!.]*$",
|
|
1521
|
+
r"^(?:did\s+(?:you|u)|why\s+did\s+(?:you|u))\s+(?:freeze|crash|quit|give\s*up)[\s\w?!.]*$",
|
|
1522
|
+
r"^(?:what\s+happened(?:\s+why\s+did\s+(?:you|u)\s+stop)?)[\s\w?!.]*$",
|
|
1523
|
+
r"^(?:why\s+didn'?t\s+(?:you|u)\s+(?:finish|complete|continue))[\s\w?!.]*$",
|
|
1524
|
+
r"^(?:why\s+did\s+(?:you|u)\s+not\s+(?:finish|complete|continue))[\s\w?!.]*$",
|
|
1525
|
+
]
|
|
1526
|
+
for pat in STOP_INQUIRY_PATTERNS:
|
|
1527
|
+
if re.search(pat, cleaned):
|
|
1528
|
+
return True
|
|
1529
|
+
|
|
1530
|
+
# 4. General continue regex pattern
|
|
1531
|
+
if re.match(r"^(?:please\s+|pls\s+)?(?:continue|keep\s+going|go\s+on|proceed|resume|carry\s+on)(?:\s+(?:the\s+task|working|generating|code|it|with\s+(?:this|it|the\s+task)|from\s+(?:where\s+you\s+left\s+off|here|before)))?[\s.!?]*$", cleaned):
|
|
1532
|
+
return True
|
|
1533
|
+
|
|
1534
|
+
return False
|
|
1535
|
+
|
|
1536
|
+
|
|
1317
1537
|
def build_compacted_history_message(past_messages: List[Dict]) -> Optional[Dict]:
|
|
1318
|
-
"""
|
|
1538
|
+
"""Consolidate all previous turns into a single milestone message using
|
|
1539
|
+
**progressive compression** so the model retains rich recent context while
|
|
1540
|
+
oldest turns are heavily summarised to save tokens.
|
|
1541
|
+
|
|
1542
|
+
Compression curve (per turn position 1..N):
|
|
1543
|
+
• Turns 1–5 (oldest block) → merged into **one** combined entry at ~7%
|
|
1544
|
+
• Turn 6 onwards → individual entries with keep-ratio interpolating
|
|
1545
|
+
linearly from ~7% (oldest remaining) up to ~70% (Turn N, most recent).
|
|
1546
|
+
• Turn N+1 is the live current turn and is NEVER touched here.
|
|
1547
|
+
|
|
1548
|
+
Continuity anchors (next-step hints, file paths, errors) are always
|
|
1549
|
+
appended even if they fall outside the compression budget.
|
|
1550
|
+
"""
|
|
1319
1551
|
if not past_messages:
|
|
1320
1552
|
return None
|
|
1321
1553
|
|
|
1322
|
-
# Partition
|
|
1323
|
-
turns = []
|
|
1324
|
-
current_user_prompt = None
|
|
1325
|
-
current_turn_msgs = []
|
|
1554
|
+
# ── 1. Partition messages into (prompt, [msgs]) turns ─────────────────────
|
|
1555
|
+
turns: List = []
|
|
1556
|
+
current_user_prompt: Optional[str] = None
|
|
1557
|
+
current_turn_msgs: List = []
|
|
1326
1558
|
|
|
1327
1559
|
for m in past_messages:
|
|
1328
1560
|
role = m.get("role", "")
|
|
@@ -1342,6 +1574,12 @@ def build_compacted_history_message(past_messages: List[Dict]) -> Optional[Dict]
|
|
|
1342
1574
|
current_turn_msgs.append(m)
|
|
1343
1575
|
continue
|
|
1344
1576
|
|
|
1577
|
+
# Check if this user message is just a continuation nudge of the previous task
|
|
1578
|
+
if current_user_prompt is not None and is_continuation_prompt(content):
|
|
1579
|
+
# Fold into current turn rather than starting an extra turn!
|
|
1580
|
+
current_turn_msgs.append(m)
|
|
1581
|
+
continue
|
|
1582
|
+
|
|
1345
1583
|
if current_user_prompt is not None:
|
|
1346
1584
|
turns.append((current_user_prompt, current_turn_msgs))
|
|
1347
1585
|
current_user_prompt = content.strip()
|
|
@@ -1350,42 +1588,90 @@ def build_compacted_history_message(past_messages: List[Dict]) -> Optional[Dict]
|
|
|
1350
1588
|
if current_user_prompt is not None:
|
|
1351
1589
|
current_turn_msgs.append(m)
|
|
1352
1590
|
|
|
1591
|
+
|
|
1353
1592
|
if current_user_prompt is not None:
|
|
1354
1593
|
turns.append((current_user_prompt, current_turn_msgs))
|
|
1355
1594
|
|
|
1356
1595
|
if not turns:
|
|
1357
1596
|
return None
|
|
1358
1597
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1598
|
+
# Seed _COMPRESSION_PARAMS from config on the very first call (lazy init)
|
|
1599
|
+
if _COMPRESSION_PARAMS.get("level_id") == "medium":
|
|
1600
|
+
_load_compression_level_from_config()
|
|
1361
1601
|
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1602
|
+
# ── 2. Decide merge boundary ───────────────────────────────────────────────
|
|
1603
|
+
# Use the user-chosen merge count from _COMPRESSION_PARAMS.
|
|
1604
|
+
# Clamped so we never merge all turns (always keep >= 1 individual entry).
|
|
1605
|
+
total_turns = len(turns)
|
|
1606
|
+
level_merge = _COMPRESSION_PARAMS.get("merge_count", 5) # 0, 2, 5, 8, or 12
|
|
1607
|
+
merge_count = min(level_merge, max(0, total_turns - 1))
|
|
1608
|
+
if total_turns <= 3:
|
|
1609
|
+
merge_count = 0 # too few turns — keep all individually
|
|
1610
|
+
|
|
1611
|
+
# ── 3. Build turn entries ──────────────────────────────────────────────────
|
|
1612
|
+
turn_entries: List[str] = []
|
|
1613
|
+
|
|
1614
|
+
if merge_count > 0:
|
|
1615
|
+
merged_block = _merge_oldest_turns(turns, merge_count)
|
|
1616
|
+
turn_entries.append(merged_block)
|
|
1617
|
+
|
|
1618
|
+
# Individual turns after the merged block — apply progressive compression
|
|
1619
|
+
individual_turns = turns[merge_count:]
|
|
1620
|
+
ind_total = len(individual_turns)
|
|
1621
|
+
|
|
1622
|
+
for local_idx, (prompt, msgs) in enumerate(individual_turns):
|
|
1623
|
+
# Global index within the full history (for labelling)
|
|
1624
|
+
global_idx = merge_count + local_idx + 1 # 1-based
|
|
1625
|
+
|
|
1626
|
+
# Keep ratio grows from near-low to 70 % as turns approach N
|
|
1627
|
+
keep_ratio = _progressive_keep_ratio(local_idx, ind_total)
|
|
1628
|
+
|
|
1629
|
+
# Summarisation tier mirrors the keep ratio
|
|
1630
|
+
if keep_ratio >= 0.55:
|
|
1631
|
+
tier = "high"
|
|
1632
|
+
elif keep_ratio >= 0.25:
|
|
1633
|
+
tier = "medium"
|
|
1634
|
+
else:
|
|
1635
|
+
tier = "low"
|
|
1365
1636
|
|
|
1366
|
-
#
|
|
1637
|
+
# User prompt: truncate cleanly at word boundary
|
|
1367
1638
|
prompt_lines = [l.strip() for l in prompt.splitlines() if l.strip()]
|
|
1368
1639
|
clean_prompt = prompt_lines[0] if prompt_lines else prompt.strip()
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1640
|
+
# Prompt char budget also scales: 60 chars oldest → 200 chars newest
|
|
1641
|
+
prompt_budget = max(60, int(60 + (200 - 60) * (local_idx / max(1, ind_total - 1))))
|
|
1642
|
+
if len(clean_prompt) > prompt_budget:
|
|
1643
|
+
clean_prompt = clean_prompt[:prompt_budget].rsplit(" ", 1)[0] + "…"
|
|
1372
1644
|
|
|
1373
1645
|
outcome = summarize_single_turn_outcome(msgs, tier=tier)
|
|
1646
|
+
outcome = _compress_turn_text(outcome, keep_ratio=keep_ratio)
|
|
1374
1647
|
|
|
1648
|
+
pct_label = f"{int(keep_ratio * 100)}% kept"
|
|
1375
1649
|
if tier == "high" and "\n" in outcome:
|
|
1376
|
-
turn_entries.append(
|
|
1650
|
+
turn_entries.append(
|
|
1651
|
+
f"- **Turn {global_idx} (User)** [{pct_label}]: \"{clean_prompt}\"\n"
|
|
1652
|
+
f" ↳ **Assistant**:{outcome}"
|
|
1653
|
+
)
|
|
1377
1654
|
else:
|
|
1378
|
-
turn_entries.append(
|
|
1655
|
+
turn_entries.append(
|
|
1656
|
+
f"- **Turn {global_idx} (User)** [{pct_label}]: \"{clean_prompt}\"\n"
|
|
1657
|
+
f" ↳ **Assistant**: {outcome}"
|
|
1658
|
+
)
|
|
1659
|
+
|
|
1660
|
+
cur_oldest_pct = int(_COMPRESSION_PARAMS.get("oldest_ratio", 0.07) * 100)
|
|
1661
|
+
cur_newest_pct = int(_COMPRESSION_PARAMS.get("newest_ratio", 0.70) * 100)
|
|
1662
|
+
cur_level_id = str(_COMPRESSION_PARAMS.get("level_id", "medium")).replace("_", " ").upper()
|
|
1379
1663
|
|
|
1380
1664
|
history_content = (
|
|
1381
1665
|
"### SESSION HISTORY & PREVIOUS MILESTONES (COMPACTED CONTEXT)\n"
|
|
1382
|
-
"
|
|
1666
|
+
f"Turns are compressed progressively ({cur_level_id}): oldest ≈{cur_oldest_pct}% kept, newest ≈{cur_newest_pct}% kept.\n\n"
|
|
1383
1667
|
+ "\n\n".join(turn_entries)
|
|
1384
1668
|
)
|
|
1385
1669
|
|
|
1386
1670
|
return {"role": "user", "content": history_content}
|
|
1387
1671
|
|
|
1388
1672
|
|
|
1673
|
+
|
|
1674
|
+
|
|
1389
1675
|
def is_compression_artifact_message(message: Dict) -> bool:
|
|
1390
1676
|
"""Check if message is a synthetic compression note/digest/summary rather than a genuine user instruction."""
|
|
1391
1677
|
if not isinstance(message, dict):
|
|
@@ -1512,6 +1798,9 @@ def score_message_importance(message: Dict, llm_key: str = None) -> float:
|
|
|
1512
1798
|
if is_compression_artifact_message(message):
|
|
1513
1799
|
min_floor = 0.2
|
|
1514
1800
|
score = 0.35 # Synthetic notes get low score to allow replacement on next pass
|
|
1801
|
+
elif is_continuation_prompt(message):
|
|
1802
|
+
min_floor = 0.3
|
|
1803
|
+
score = 0.40 # Continuation prompts ('continue', 'why are u stopping') can be compressed/digested
|
|
1515
1804
|
else:
|
|
1516
1805
|
# Genuine user instructions must NEVER be below the compression threshold
|
|
1517
1806
|
min_floor = 1.0 # Never compress genuine user instructions
|
|
@@ -847,7 +847,8 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
|
|
|
847
847
|
t_llm_start = time.time()
|
|
848
848
|
msg, was_streamed = self._call_llm(
|
|
849
849
|
send_msgs,
|
|
850
|
-
override_tools=override_tools or getattr(self, "_subagent_tool_override", None)
|
|
850
|
+
override_tools=override_tools or getattr(self, "_subagent_tool_override", None),
|
|
851
|
+
silent=False,
|
|
851
852
|
)
|
|
852
853
|
reasoning_duration = time.time() - t_llm_start
|
|
853
854
|
|
|
@@ -855,12 +856,19 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
|
|
|
855
856
|
except _ServerUnavailableError as exc:
|
|
856
857
|
_err_text = str(exc).lower()
|
|
857
858
|
if "rate limit" in _err_text or "rate-limit" in _err_text or "429" in _err_text or "too many requests" in _err_text:
|
|
858
|
-
|
|
859
|
+
# Mark the current API key as exhausted for 24 hours
|
|
860
|
+
from utim_cli.config import config
|
|
861
|
+
current_key = config.get("api_key", "")
|
|
862
|
+
if current_key:
|
|
863
|
+
config.mark_api_key_exhausted(current_key, duration_hours=24)
|
|
864
|
+
self.console.print(f"\n[bold yellow]⏳ Rate-limited on API key {current_key[:12]}... — excluded for 24 hours.[/bold yellow]")
|
|
865
|
+
else:
|
|
866
|
+
self.console.print(f"\n[bold yellow]⏳ Model is temporarily rate-limited by the provider.[/bold yellow]")
|
|
859
867
|
self.console.print(" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n")
|
|
860
868
|
if len(self.messages) <= turn_msg_start + 1:
|
|
861
869
|
self.messages.append({
|
|
862
870
|
"role": "assistant",
|
|
863
|
-
"content": "⏳ **
|
|
871
|
+
"content": "⏳ **Rate Limited**: The API key has been temporarily exhausted (24h cooldown). Please wait and retry, or use `/model` to switch models."
|
|
864
872
|
})
|
|
865
873
|
self._persist_messages()
|
|
866
874
|
return
|