utim-cli 1.47.4__tar.gz → 1.47.7__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-1.47.4 → utim_cli-1.47.7}/CHANGELOG.md +21 -0
- {utim_cli-1.47.4/utim_cli.egg-info → utim_cli-1.47.7}/PKG-INFO +1 -1
- {utim_cli-1.47.4 → utim_cli-1.47.7}/pyproject.toml +2 -2
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/__init__.py +1 -1
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/_version.py +1 -1
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/bootstrap.py +1 -0
- utim_cli-1.47.7/utim_cli/mcp_client.py +343 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/orchestrator.py +71 -47
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/reflection.py +500 -202
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/utim.py +101 -9
- {utim_cli-1.47.4 → utim_cli-1.47.7/utim_cli.egg-info}/PKG-INFO +1 -1
- utim_cli-1.47.7/utim_cli.egg-info/entry_points.txt +2 -0
- utim_cli-1.47.4/utim_cli/mcp_client.py +0 -210
- utim_cli-1.47.4/utim_cli.egg-info/entry_points.txt +0 -2
- {utim_cli-1.47.4 → utim_cli-1.47.7}/LICENSE +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/MANIFEST.in +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/README.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/setup.cfg +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/setup.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/agent.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/auth.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/backup.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/blender_agent.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/client_utils.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/config.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/constants.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/context_pruner.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/doctor.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/local_db.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/logger.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/report.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/__init__.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/auth.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/db.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/pricing.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/firebase.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/history.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/models.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/pricing_updater.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/router.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/auth_routes.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/completion_routes.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/credit_routes.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/quota_routes.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/server/server.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/share.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/share_tui.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/state.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tools.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/model_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/skills_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/utilities.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/vector_memory.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli/workspace.py +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli.egg-info/SOURCES.txt +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-1.47.4 → utim_cli-1.47.7}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.47.7] - 2026-07-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Android Termux Pure-Python Architecture**: Implemented pure-Python line-delimited JSON-RPC stdio client fallback (`PurePythonMCPSession`) for Model Context Protocol (MCP) servers, enabling 100% feature parity on Android Termux without requiring Rust compilation or `pydantic-core`.
|
|
7
|
+
- **Web Support Assistant Disclaimer & Installation Flow**: Added glassmorphism notice bar and 4-step installation visual guide on landing website to distinguish web support chat from local CLI agent.
|
|
8
|
+
- **Enhanced Cognitive Reflection Pipeline**: Replaced single-turn random skill creation with a 5-request interaction buffering engine. Analyzes full interaction trajectories across turns (detecting silent tool calling and file format assumption complaints like image PDFs vs text PDFs), with strict usefulness filtering to prevent storing junk data.
|
|
9
|
+
- **RAG-Based Skill Creation & Intelligent Hold-Back Architecture**: Implemented RAG skill synthesis over ChromaDB Vector Memory. Enforces a minimum threshold ($\ge 3$ accumulated experiences across sessions), an LLM usability/cohesion evaluation (`is_sufficiently_usable`), and cooldown stride tracking to hold back skill creation until technical patterns are mature and genuinely usable.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **Device Sign-In Unauthenticated Flow**: Fixed device authorization flow on `https://utim.dev/activate?code=XXXX-XXXX`. Auto-redirects unauthenticated users to `/auth` with a countdown while preserving the device code, bouncing them back to `/activate` automatically after sign-in to complete terminal authorization without broken loops.
|
|
13
|
+
|
|
14
|
+
## [1.47.6] - 2026-07-19
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Asynchronous Local LLM Setup**: Moved the local Qwen model downloading and setup pipeline into a background daemon thread, allowing UTIM to run completions instantly via Cloud API during initial setup/installation without blocking user queries.
|
|
18
|
+
|
|
19
|
+
## [1.47.5] - 2026-07-19
|
|
20
|
+
|
|
21
|
+
### Optimized
|
|
22
|
+
- **Instant startup / rules summarization**: Prioritized Cloud API completion calls (via UTIM completions server or direct OpenRouter) over local Qwen LLM execution in `summarize_experiences_with_model`. This completely eliminates the 1m 49s latency caused by importing PyTorch/Transformers and loading the 0.5B model from scratch, falling back to local execution only in offline mode.
|
|
23
|
+
|
|
3
24
|
## [1.47.4] - 2026-07-19
|
|
4
25
|
|
|
5
26
|
### Fixed
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "utim-cli"
|
|
7
|
-
version = "1.47.
|
|
7
|
+
version = "1.47.7"
|
|
8
8
|
description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -79,7 +79,7 @@ legacy-agent = [
|
|
|
79
79
|
]
|
|
80
80
|
|
|
81
81
|
[project.scripts]
|
|
82
|
-
utim = "utim_cli.utim:
|
|
82
|
+
utim = "utim_cli.utim:main_cli_entry"
|
|
83
83
|
|
|
84
84
|
[tool.setuptools.packages.find]
|
|
85
85
|
include = ["utim_cli*"]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Dict, List, Any, Optional
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from mcp import ClientSession, StdioServerParameters
|
|
9
|
+
from mcp.client.stdio import stdio_client
|
|
10
|
+
HAS_MCP_LIB = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
HAS_MCP_LIB = False
|
|
13
|
+
|
|
14
|
+
# =========================================================================
|
|
15
|
+
# Pure-Python Stdio MCP Client Fallback (Zero C/Rust/pydantic-core dependency)
|
|
16
|
+
# =========================================================================
|
|
17
|
+
class PurePythonMCPTool:
|
|
18
|
+
def __init__(self, name: str, description: str, inputSchema: dict):
|
|
19
|
+
self.name = name
|
|
20
|
+
self.description = description
|
|
21
|
+
self.inputSchema = inputSchema
|
|
22
|
+
|
|
23
|
+
class PurePythonMCPContent:
|
|
24
|
+
def __init__(self, text: str):
|
|
25
|
+
self.text = text
|
|
26
|
+
|
|
27
|
+
class PurePythonMCPResult:
|
|
28
|
+
def __init__(self, content: list, isError: bool = False):
|
|
29
|
+
self.content = content
|
|
30
|
+
self.isError = isError
|
|
31
|
+
|
|
32
|
+
class PurePythonMCPSession:
|
|
33
|
+
"""Pure-Python line-delimited JSON-RPC stdio client for MCP servers."""
|
|
34
|
+
def __init__(self, command: str, args: list, env: dict):
|
|
35
|
+
self.command = command
|
|
36
|
+
self.args = args
|
|
37
|
+
self.env = env
|
|
38
|
+
self.proc = None
|
|
39
|
+
self._req_id = 0
|
|
40
|
+
|
|
41
|
+
def _next_id(self):
|
|
42
|
+
self._req_id += 1
|
|
43
|
+
return self._req_id
|
|
44
|
+
|
|
45
|
+
async def start(self):
|
|
46
|
+
import sys
|
|
47
|
+
import shutil
|
|
48
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
49
|
+
wrapper_path = os.path.join(current_dir, "mcp_clean_wrapper.py")
|
|
50
|
+
resolved_command = shutil.which(self.command) or self.command
|
|
51
|
+
|
|
52
|
+
cmd_list = [sys.executable, "-u", wrapper_path, resolved_command] + self.args
|
|
53
|
+
self.proc = await asyncio.create_subprocess_exec(
|
|
54
|
+
*cmd_list,
|
|
55
|
+
stdin=asyncio.subprocess.PIPE,
|
|
56
|
+
stdout=asyncio.subprocess.PIPE,
|
|
57
|
+
stderr=asyncio.subprocess.PIPE,
|
|
58
|
+
env=self.env
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def _send_request(self, method: str, params: dict) -> dict:
|
|
62
|
+
if not self.proc or not self.proc.stdin:
|
|
63
|
+
raise RuntimeError("MCP process stdin not open")
|
|
64
|
+
req_id = self._next_id()
|
|
65
|
+
payload = json.dumps({
|
|
66
|
+
"jsonrpc": "2.0",
|
|
67
|
+
"id": req_id,
|
|
68
|
+
"method": method,
|
|
69
|
+
"params": params
|
|
70
|
+
}) + "\n"
|
|
71
|
+
self.proc.stdin.write(payload.encode("utf-8"))
|
|
72
|
+
await self.proc.stdin.drain()
|
|
73
|
+
|
|
74
|
+
while True:
|
|
75
|
+
line = await self.proc.stdout.readline()
|
|
76
|
+
if not line:
|
|
77
|
+
raise RuntimeError("MCP server process output ended unexpectedly.")
|
|
78
|
+
line_str = line.decode("utf-8", errors="ignore").strip()
|
|
79
|
+
if not line_str:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
msg = json.loads(line_str)
|
|
83
|
+
if msg.get("id") == req_id:
|
|
84
|
+
if "error" in msg:
|
|
85
|
+
err_msg = msg["error"].get("message", "MCP RPC Error")
|
|
86
|
+
raise RuntimeError(err_msg)
|
|
87
|
+
return msg.get("result", {})
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
async def _send_notification(self, method: str, params: dict):
|
|
92
|
+
if not self.proc or not self.proc.stdin:
|
|
93
|
+
return
|
|
94
|
+
payload = json.dumps({
|
|
95
|
+
"jsonrpc": "2.0",
|
|
96
|
+
"method": method,
|
|
97
|
+
"params": params
|
|
98
|
+
}) + "\n"
|
|
99
|
+
self.proc.stdin.write(payload.encode("utf-8"))
|
|
100
|
+
await self.proc.stdin.drain()
|
|
101
|
+
|
|
102
|
+
async def initialize(self):
|
|
103
|
+
await self._send_request("initialize", {
|
|
104
|
+
"protocolVersion": "2024-11-05",
|
|
105
|
+
"capabilities": {},
|
|
106
|
+
"clientInfo": {"name": "utim", "version": "1.0"}
|
|
107
|
+
})
|
|
108
|
+
await self._send_notification("notifications/initialized", {})
|
|
109
|
+
|
|
110
|
+
async def list_tools(self):
|
|
111
|
+
res = await self._send_request("tools/list", {})
|
|
112
|
+
raw_tools = res.get("tools", [])
|
|
113
|
+
tools = []
|
|
114
|
+
for t in raw_tools:
|
|
115
|
+
tools.append(PurePythonMCPTool(
|
|
116
|
+
name=t.get("name", ""),
|
|
117
|
+
description=t.get("description", ""),
|
|
118
|
+
inputSchema=t.get("inputSchema", {})
|
|
119
|
+
))
|
|
120
|
+
class ToolListResult:
|
|
121
|
+
def __init__(self, t_list):
|
|
122
|
+
self.tools = t_list
|
|
123
|
+
return ToolListResult(tools)
|
|
124
|
+
|
|
125
|
+
async def call_tool(self, name: str, arguments: dict):
|
|
126
|
+
res = await self._send_request("tools/call", {
|
|
127
|
+
"name": name,
|
|
128
|
+
"arguments": arguments
|
|
129
|
+
})
|
|
130
|
+
is_error = res.get("isError", False)
|
|
131
|
+
raw_content = res.get("content", [])
|
|
132
|
+
contents = []
|
|
133
|
+
for c in raw_content:
|
|
134
|
+
if isinstance(c, dict) and "text" in c:
|
|
135
|
+
contents.append(PurePythonMCPContent(c["text"]))
|
|
136
|
+
elif isinstance(c, str):
|
|
137
|
+
contents.append(PurePythonMCPContent(c))
|
|
138
|
+
if not contents:
|
|
139
|
+
contents.append(PurePythonMCPContent(json.dumps(res)))
|
|
140
|
+
return PurePythonMCPResult(contents, isError=is_error)
|
|
141
|
+
|
|
142
|
+
async def close(self):
|
|
143
|
+
if self.proc:
|
|
144
|
+
try:
|
|
145
|
+
self.proc.terminate()
|
|
146
|
+
await self.proc.wait()
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class MCPManager:
|
|
152
|
+
def __init__(self):
|
|
153
|
+
self.sessions: Dict[str, Any] = {}
|
|
154
|
+
self._exit_stacks = {}
|
|
155
|
+
self._tool_to_server: Dict[str, str] = {}
|
|
156
|
+
self._started = False
|
|
157
|
+
self.cached_tools: List[Dict[str, Any]] = []
|
|
158
|
+
self.server_tools: Dict[str, List[str]] = {} # server_name -> list of tool names
|
|
159
|
+
self._loop = None
|
|
160
|
+
self._thread = None
|
|
161
|
+
|
|
162
|
+
def start_loop(self):
|
|
163
|
+
if self._loop is None:
|
|
164
|
+
self._loop = asyncio.new_event_loop()
|
|
165
|
+
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
|
166
|
+
self._thread.start()
|
|
167
|
+
|
|
168
|
+
def _run_loop(self):
|
|
169
|
+
asyncio.set_event_loop(self._loop)
|
|
170
|
+
self._loop.run_forever()
|
|
171
|
+
|
|
172
|
+
def run_coro(self, coro):
|
|
173
|
+
self.start_loop()
|
|
174
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
175
|
+
return future.result()
|
|
176
|
+
|
|
177
|
+
def start(self):
|
|
178
|
+
if self._started:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
from utim_cli.config import get_utim_dir
|
|
182
|
+
config_path = os.path.abspath(os.path.join(get_utim_dir(), "mcp.json"))
|
|
183
|
+
if not os.path.exists(config_path):
|
|
184
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
185
|
+
with open(config_path, "w") as f:
|
|
186
|
+
json.dump({"mcpServers": {}}, f, indent=2)
|
|
187
|
+
|
|
188
|
+
self.start_loop()
|
|
189
|
+
self.run_coro(self._start_async())
|
|
190
|
+
|
|
191
|
+
async def _start_async(self):
|
|
192
|
+
from utim_cli.config import get_utim_dir
|
|
193
|
+
config_path = os.path.abspath(os.path.join(get_utim_dir(), "mcp.json"))
|
|
194
|
+
if not os.path.exists(config_path):
|
|
195
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
196
|
+
with open(config_path, "w") as f:
|
|
197
|
+
json.dump({"mcpServers": {}}, f, indent=2)
|
|
198
|
+
self._started = True
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
with open(config_path, "r") as f:
|
|
203
|
+
config = json.load(f)
|
|
204
|
+
except Exception:
|
|
205
|
+
self._started = True
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
servers = config.get("mcpServers") or {}
|
|
209
|
+
if not isinstance(servers, dict):
|
|
210
|
+
servers = {}
|
|
211
|
+
|
|
212
|
+
for name, srv_config in servers.items():
|
|
213
|
+
command = srv_config.get("command")
|
|
214
|
+
args = srv_config.get("args", [])
|
|
215
|
+
env = srv_config.get("env", {})
|
|
216
|
+
|
|
217
|
+
if not command:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
cleaned_args = []
|
|
221
|
+
for arg in args:
|
|
222
|
+
if isinstance(arg, str):
|
|
223
|
+
if (arg.startswith('"') and arg.endswith('"')) or (arg.startswith("'") and arg.endswith("'")):
|
|
224
|
+
arg = arg[1:-1]
|
|
225
|
+
cleaned_args.append(arg)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
merged_env = os.environ.copy()
|
|
229
|
+
merged_env.update(env)
|
|
230
|
+
|
|
231
|
+
if HAS_MCP_LIB:
|
|
232
|
+
import sys
|
|
233
|
+
import shutil
|
|
234
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
235
|
+
wrapper_path = os.path.join(current_dir, "mcp_clean_wrapper.py")
|
|
236
|
+
resolved_command = shutil.which(command) or command
|
|
237
|
+
|
|
238
|
+
server_params = StdioServerParameters(
|
|
239
|
+
command=sys.executable,
|
|
240
|
+
args=["-u", wrapper_path, resolved_command] + cleaned_args,
|
|
241
|
+
env=merged_env
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
from contextlib import AsyncExitStack
|
|
245
|
+
stack = AsyncExitStack()
|
|
246
|
+
self._exit_stacks[name] = stack
|
|
247
|
+
|
|
248
|
+
read, write = await stack.enter_async_context(stdio_client(server_params))
|
|
249
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
250
|
+
await asyncio.wait_for(session.initialize(), timeout=60.0)
|
|
251
|
+
self.sessions[name] = session
|
|
252
|
+
else:
|
|
253
|
+
# Pure-Python stdio client fallback (Android Termux / minimal installs)
|
|
254
|
+
session = PurePythonMCPSession(command, cleaned_args, merged_env)
|
|
255
|
+
await session.start()
|
|
256
|
+
await asyncio.wait_for(session.initialize(), timeout=60.0)
|
|
257
|
+
self.sessions[name] = session
|
|
258
|
+
|
|
259
|
+
# Retrieve tools from the server and cache them
|
|
260
|
+
try:
|
|
261
|
+
res = await session.list_tools()
|
|
262
|
+
self.server_tools[name] = []
|
|
263
|
+
for t in res.tools:
|
|
264
|
+
full_name = f"{name}__{t.name}"
|
|
265
|
+
self._tool_to_server[full_name] = name
|
|
266
|
+
self.server_tools[name].append(t.name)
|
|
267
|
+
|
|
268
|
+
self.cached_tools.append({
|
|
269
|
+
"type": "function",
|
|
270
|
+
"function": {
|
|
271
|
+
"name": full_name,
|
|
272
|
+
"description": f"[{name}] {t.description}",
|
|
273
|
+
"parameters": t.inputSchema
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
except Exception as e:
|
|
277
|
+
print(f"Error fetching tools from MCP server {name}: {e}")
|
|
278
|
+
|
|
279
|
+
except Exception as e:
|
|
280
|
+
print(f"Error starting MCP server {name}: {e}")
|
|
281
|
+
|
|
282
|
+
self._started = True
|
|
283
|
+
|
|
284
|
+
def get_tools(self) -> List[Dict[str, Any]]:
|
|
285
|
+
"""Synchronously returns the cached list of MCP tools."""
|
|
286
|
+
return self.cached_tools
|
|
287
|
+
|
|
288
|
+
def get_notification_context(self) -> str:
|
|
289
|
+
"""Returns the context notification string for connected MCP servers."""
|
|
290
|
+
from utim_cli.config import config
|
|
291
|
+
disabled = config.get("disabled_tools") or []
|
|
292
|
+
if not isinstance(disabled, list):
|
|
293
|
+
disabled = []
|
|
294
|
+
notifications = []
|
|
295
|
+
for server_name, tool_names in self.server_tools.items():
|
|
296
|
+
enabled_tool_names = [t for t in tool_names if f"{server_name}__{t}" not in disabled]
|
|
297
|
+
if enabled_tool_names:
|
|
298
|
+
tools_str = ", ".join(enabled_tool_names)
|
|
299
|
+
notifications.append(f"this mcp server {server_name} is connected and a new set of tools are available for u: {tools_str}")
|
|
300
|
+
return "\n".join(notifications)
|
|
301
|
+
|
|
302
|
+
def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
303
|
+
"""Synchronously routes tool call execution to the background thread."""
|
|
304
|
+
return self.run_coro(self._call_tool_async(server_name, tool_name, arguments))
|
|
305
|
+
|
|
306
|
+
async def _call_tool_async(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> str:
|
|
307
|
+
if server_name not in self.sessions:
|
|
308
|
+
return f"Error: MCP server '{server_name}' not running."
|
|
309
|
+
|
|
310
|
+
session = self.sessions[server_name]
|
|
311
|
+
try:
|
|
312
|
+
res = await session.call_tool(tool_name, arguments)
|
|
313
|
+
if getattr(res, "isError", False):
|
|
314
|
+
return f"Error from {server_name}: " + "\n".join(c.text for c in res.content)
|
|
315
|
+
return "\n".join(c.text for c in res.content)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
return f"Error calling {tool_name} on {server_name}: {str(e)}"
|
|
318
|
+
|
|
319
|
+
def restart(self):
|
|
320
|
+
"""Synchronously restarts and reloads all MCP sessions."""
|
|
321
|
+
self.run_coro(self._restart_async())
|
|
322
|
+
|
|
323
|
+
async def _restart_async(self):
|
|
324
|
+
for name, stack in list(self._exit_stacks.items()):
|
|
325
|
+
try:
|
|
326
|
+
await stack.aclose()
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
for name, session in list(self.sessions.items()):
|
|
330
|
+
if hasattr(session, "close"):
|
|
331
|
+
try:
|
|
332
|
+
await session.close()
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
self.sessions.clear()
|
|
336
|
+
self._exit_stacks.clear()
|
|
337
|
+
self._tool_to_server.clear()
|
|
338
|
+
self.cached_tools.clear()
|
|
339
|
+
self.server_tools.clear()
|
|
340
|
+
self._started = False
|
|
341
|
+
await self._start_async()
|
|
342
|
+
|
|
343
|
+
mcp_manager = MCPManager()
|
|
@@ -350,6 +350,42 @@ def compress_experience(text: str) -> str:
|
|
|
350
350
|
|
|
351
351
|
_qwen_model = None
|
|
352
352
|
_qwen_tokenizer = None
|
|
353
|
+
_qwen_downloading = False
|
|
354
|
+
|
|
355
|
+
def _trigger_qwen_download_bg():
|
|
356
|
+
"""Trigger background download and caching of Qwen local model if not already loaded or downloading."""
|
|
357
|
+
global _qwen_model, _qwen_tokenizer, _qwen_downloading
|
|
358
|
+
if _qwen_model is None and not _qwen_downloading:
|
|
359
|
+
import sys
|
|
360
|
+
if "pytest" in sys.modules:
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
_qwen_downloading = True
|
|
364
|
+
import threading
|
|
365
|
+
|
|
366
|
+
def download_model_bg():
|
|
367
|
+
global _qwen_model, _qwen_tokenizer, _qwen_downloading
|
|
368
|
+
try:
|
|
369
|
+
import torch
|
|
370
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
371
|
+
model_id = "Qwen/Qwen2.5-0.5B-Instruct"
|
|
372
|
+
tok = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True, local_files_only=False)
|
|
373
|
+
mod = AutoModelForCausalLM.from_pretrained(
|
|
374
|
+
model_id,
|
|
375
|
+
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
|
376
|
+
device_map="auto",
|
|
377
|
+
trust_remote_code=True,
|
|
378
|
+
local_files_only=False
|
|
379
|
+
)
|
|
380
|
+
_qwen_tokenizer = tok
|
|
381
|
+
_qwen_model = mod
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
finally:
|
|
385
|
+
_qwen_downloading = False
|
|
386
|
+
|
|
387
|
+
t = threading.Thread(target=download_model_bg, daemon=True)
|
|
388
|
+
t.start()
|
|
353
389
|
|
|
354
390
|
def summarize_experiences_with_model(experiences: list[dict], existing_cache: str = "") -> str:
|
|
355
391
|
"""Summarize list of experiences into a clean list of rules under 2000 tokens using local Qwen model first, falling back to LLM API."""
|
|
@@ -409,25 +445,40 @@ def summarize_experiences_with_model(experiences: list[dict], existing_cache: st
|
|
|
409
445
|
"Output ONLY the clean markdown list of rules (do not include introductory or concluding text):"
|
|
410
446
|
)
|
|
411
447
|
|
|
412
|
-
#
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
448
|
+
# Proactively trigger Qwen local model download in a background thread if needed
|
|
449
|
+
_trigger_qwen_download_bg()
|
|
450
|
+
|
|
451
|
+
# 1. Attempt Cloud API completions (via UTIM completions server or direct OpenRouter)
|
|
452
|
+
import os
|
|
453
|
+
api_key = config.get("api_key")
|
|
454
|
+
llm_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("UTIM_API_KEY")
|
|
455
|
+
|
|
456
|
+
if api_key or llm_key:
|
|
457
|
+
from utim_cli.reflection import REFLECTION_MODELS
|
|
458
|
+
from utim_cli.client_utils import proxy_openrouter_request
|
|
459
|
+
|
|
460
|
+
for model in REFLECTION_MODELS:
|
|
461
|
+
try:
|
|
462
|
+
resp = proxy_openrouter_request({
|
|
463
|
+
"model": model,
|
|
464
|
+
"messages": [
|
|
465
|
+
{"role": "system", "content": "You are a context compressor. Output rules list directly."},
|
|
466
|
+
{"role": "user", "content": prompt}
|
|
467
|
+
],
|
|
468
|
+
"max_tokens": 1000
|
|
469
|
+
})
|
|
470
|
+
if resp.status_code == 200:
|
|
471
|
+
content = resp.json()["choices"][0]["message"]["content"].strip()
|
|
472
|
+
if content:
|
|
473
|
+
return content
|
|
474
|
+
except Exception:
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
# 2. Fallback to local execution using Qwen2.5-0.5B-Instruct if background loader has completed
|
|
478
|
+
global _qwen_model, _qwen_tokenizer
|
|
479
|
+
if _qwen_model is not None:
|
|
480
|
+
try:
|
|
417
481
|
import torch
|
|
418
|
-
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
419
|
-
|
|
420
|
-
model_id = "Qwen/Qwen2.5-0.5B-Instruct"
|
|
421
|
-
|
|
422
|
-
if _qwen_model is None:
|
|
423
|
-
_qwen_tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
|
|
424
|
-
_qwen_model = AutoModelForCausalLM.from_pretrained(
|
|
425
|
-
model_id,
|
|
426
|
-
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
|
427
|
-
device_map="auto",
|
|
428
|
-
trust_remote_code=True
|
|
429
|
-
)
|
|
430
|
-
|
|
431
482
|
messages = [
|
|
432
483
|
{"role": "system", "content": "You are a context compressor. Output rules list directly."},
|
|
433
484
|
{"role": "user", "content": prompt}
|
|
@@ -454,36 +505,9 @@ def summarize_experiences_with_model(experiences: list[dict], existing_cache: st
|
|
|
454
505
|
response = _qwen_tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
|
|
455
506
|
if response:
|
|
456
507
|
return response
|
|
457
|
-
|
|
458
|
-
except Exception:
|
|
459
|
-
pass
|
|
460
|
-
|
|
461
|
-
# 2. Fallback to OpenRouter Cloud API
|
|
462
|
-
import os
|
|
463
|
-
llm_key = os.environ.get("OPENROUTER_API_KEY") or os.environ.get("UTIM_API_KEY")
|
|
464
|
-
if not llm_key:
|
|
465
|
-
return existing_cache or "\n".join(raw_texts[:15])
|
|
466
|
-
|
|
467
|
-
from utim_cli.reflection import REFLECTION_MODELS
|
|
468
|
-
from utim_cli.client_utils import proxy_openrouter_request
|
|
469
|
-
|
|
470
|
-
for model in REFLECTION_MODELS:
|
|
471
|
-
try:
|
|
472
|
-
resp = proxy_openrouter_request({
|
|
473
|
-
"model": model,
|
|
474
|
-
"messages": [
|
|
475
|
-
{"role": "system", "content": "You are a context compressor. Output rules list directly."},
|
|
476
|
-
{"role": "user", "content": prompt}
|
|
477
|
-
],
|
|
478
|
-
"max_tokens": 1000
|
|
479
|
-
})
|
|
480
|
-
if resp.status_code == 200:
|
|
481
|
-
content = resp.json()["choices"][0]["message"]["content"].strip()
|
|
482
|
-
if content:
|
|
483
|
-
return content
|
|
484
508
|
except Exception:
|
|
485
|
-
|
|
486
|
-
|
|
509
|
+
pass
|
|
510
|
+
|
|
487
511
|
return existing_cache or "\n".join(raw_texts[:15])
|
|
488
512
|
|
|
489
513
|
def update_experience_summary_cache(user_prompt: str) -> str:
|