utim-cli 2.0.1__tar.gz → 2.0.3__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.
Files changed (107) hide show
  1. {utim_cli-2.0.1 → utim_cli-2.0.3}/CHANGELOG.md +10 -0
  2. {utim_cli-2.0.1/utim_cli.egg-info → utim_cli-2.0.3}/PKG-INFO +1 -1
  3. {utim_cli-2.0.1 → utim_cli-2.0.3}/pyproject.toml +1 -1
  4. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/__init__.py +1 -1
  5. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/_version.py +1 -1
  6. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/orchestrator.py +44 -187
  7. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tools.py +170 -28
  8. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/marketplace_dialog.py +4185 -4180
  9. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/model_dialog.py +40 -36
  10. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/utim.py +3 -3
  11. {utim_cli-2.0.1 → utim_cli-2.0.3/utim_cli.egg-info}/PKG-INFO +1 -1
  12. {utim_cli-2.0.1 → utim_cli-2.0.3}/LICENSE +0 -0
  13. {utim_cli-2.0.1 → utim_cli-2.0.3}/MANIFEST.in +0 -0
  14. {utim_cli-2.0.1 → utim_cli-2.0.3}/README.md +0 -0
  15. {utim_cli-2.0.1 → utim_cli-2.0.3}/setup.cfg +0 -0
  16. {utim_cli-2.0.1 → utim_cli-2.0.3}/setup.py +0 -0
  17. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/_check_obj.py +0 -0
  18. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/_find_mcp.py +0 -0
  19. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/_find_plan.py +0 -0
  20. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/_find_tool.py +0 -0
  21. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/_patch_multimodal.py +0 -0
  22. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/_test_regex.py +0 -0
  23. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/agent.py +0 -0
  24. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/auth.py +0 -0
  25. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/backup.py +0 -0
  26. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/billing.py +0 -0
  27. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/blender_agent.py +0 -0
  28. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/bootstrap.py +0 -0
  29. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/client_utils.py +0 -0
  30. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/config.py +0 -0
  31. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/constants.py +0 -0
  32. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/context_pruner.py +0 -0
  33. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/doctor.py +0 -0
  34. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/harbor.py +0 -0
  35. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/knowledge_graph.py +0 -0
  36. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/local_db.py +0 -0
  37. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/logger.py +0 -0
  38. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/mcp_clean_wrapper.py +0 -0
  39. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/mcp_client.py +0 -0
  40. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/mcp_registry.json +0 -0
  41. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/reflection.py +0 -0
  42. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/report.py +0 -0
  43. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/scrapy_search.py +0 -0
  44. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/__init__.py +0 -0
  45. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/auth.py +0 -0
  46. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/db.py +0 -0
  47. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/about.md +0 -0
  48. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/changelog.md +0 -0
  49. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/docs.md +0 -0
  50. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/features.md +0 -0
  51. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/license.md +0 -0
  52. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/pricing.md +0 -0
  53. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/privacy.md +0 -0
  54. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/refund.md +0 -0
  55. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/support.md +0 -0
  56. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/docs_md/terms.md +0 -0
  57. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/email_utils.py +0 -0
  58. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/exchange_rate.py +0 -0
  59. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/firebase.py +0 -0
  60. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/history.py +0 -0
  61. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/logging_config.py +0 -0
  62. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/models.py +0 -0
  63. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/pricing_updater.py +0 -0
  64. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/rate_limit.py +0 -0
  65. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/router.py +0 -0
  66. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/__init__.py +0 -0
  67. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/auth_routes.py +0 -0
  68. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/completion_routes.py +0 -0
  69. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/credit_routes.py +0 -0
  70. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/feedback_routes.py +0 -0
  71. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/marketplace_routes.py +0 -0
  72. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/quota_routes.py +0 -0
  73. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/quota_share_routes.py +0 -0
  74. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/referral_routes.py +0 -0
  75. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/security_routes.py +0 -0
  76. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/session_routes.py +0 -0
  77. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/routes/share_routes.py +0 -0
  78. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/server.py +0 -0
  79. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/server/storage_nodes.py +0 -0
  80. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/share.py +0 -0
  81. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/share_tui.py +0 -0
  82. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/situational_scoring.py +0 -0
  83. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/state.py +0 -0
  84. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/__init__.py +0 -0
  85. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/feedback_dialog.py +0 -0
  86. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/history_dialog.py +0 -0
  87. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/marketplace_app_state.py +0 -0
  88. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  89. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/mcp_dialog.py +0 -0
  90. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/miniagents_dialog.py +0 -0
  91. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/publish_dialog.py +0 -0
  92. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/quota_dialog.py +0 -0
  93. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  94. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/quota_share_dialog.py +0 -0
  95. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/resume_dialog.py +0 -0
  96. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/skills_dialog.py +0 -0
  97. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/subagents_dialog.py +0 -0
  98. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/tools_dialog.py +0 -0
  99. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/tui/update_dialog.py +0 -0
  100. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/utilities.py +0 -0
  101. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/vector_memory.py +0 -0
  102. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli/workspace.py +0 -0
  103. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli.egg-info/SOURCES.txt +0 -0
  104. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli.egg-info/dependency_links.txt +0 -0
  105. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli.egg-info/entry_points.txt +0 -0
  106. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli.egg-info/requires.txt +0 -0
  107. {utim_cli-2.0.1 → utim_cli-2.0.3}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.3] - 2026-08-04
4
+
5
+ ### 🛠️ Fixes
6
+ - **Fixed `Wrong color format 'dim'` crash in marketplace TUI**: `prompt_toolkit` does not accept the `dim` modifier as a free-floating token before a color, nor `class:dim` as a literal inline style. All `f"dim {_MUTED}"` / `f"dim {_FG}"` style strings in `utim_cli/tui/marketplace_dialog.py` have been rewritten to `f"fg:{_MUTED}"` / `f"fg:{_FG}"`, and the `[dim]…[/dim]` markup parser now drops the `dim` token instead of emitting the unsafe `class:dim` class string. `/marketplace` no longer crashes on Windows when rendering info/hint/status rows.
7
+
8
+ ## [2.0.2] - 2026-08-04
9
+
10
+ ### 🛠️ Fixes
11
+ - Minor bug fixes.
12
+
3
13
  ## [2.0.1] - 2026-08-04 - Bugfix & Adaptive Iteration Budget
4
14
 
5
15
  ### 🛠️ Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.0.1
3
+ Version: 2.0.3
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.0.1"
7
+ version = "2.0.3"
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"
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "2.0.1"
8
+ __version__ = "2.0.3"
9
9
 
10
10
  import builtins, os, pathlib, urllib.parse, re
11
11
 
@@ -11,4 +11,4 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.0.1"
14
+ VERSION = "2.0.3"
@@ -3429,6 +3429,7 @@ class Orchestrator:
3429
3429
  results[orig_idx] = (tc, "[Aborted by user]")
3430
3430
  continue
3431
3431
  _tools_module._cancel_event = self.cancel_event
3432
+ _tools_module._active_model_id = self.model_id
3432
3433
  result = self._execute_tool_timed(tc)
3433
3434
  self._current_line_len = 0
3434
3435
  results[orig_idx] = (tc, result)
@@ -3684,81 +3685,6 @@ class Orchestrator:
3684
3685
  self._persist_messages()
3685
3686
  return res
3686
3687
 
3687
- def _filter_active_tools(self, all_tools: List[Dict], messages: List[Dict]) -> List[Dict]:
3688
- """Semantic Intent Tool Masking with Self-Healing Task Re-hydration.
3689
-
3690
- Core coding tools and MCP tools are ALWAYS active. Specialized tools
3691
- (analyze_image, generate_image, plan_project, create_skill, background_tasks)
3692
- are activated via broad semantic intent detection (synonyms, implicit requests,
3693
- file paths) and persistently retained once activated during the task session.
3694
- """
3695
- if not all_tools:
3696
- return all_tools
3697
-
3698
- if not hasattr(self, "_activated_specialized_tools"):
3699
- self._activated_specialized_tools = set()
3700
-
3701
- CORE_TOOLS = {
3702
- "read_file", "write_file", "edit_file", "move_file", "delete_file",
3703
- "run_command", "list_directory", "grep_search", "search", "search_codebase",
3704
- "recall_experience", "store_experience", "manage_memory"
3705
- }
3706
-
3707
- # Broad semantic intent patterns (handles slang, implicit desires, multi-language phrasing)
3708
- SEMANTIC_INTENT_PATTERNS = {
3709
- "analyze_image": [
3710
- r"\.(png|jpg|jpeg|webp|gif|bmp|svg|tiff|ico)\b",
3711
- r"(?i)\b(image|img|screenshot|photo|picture|diagram|fig|figure|ui|mockup|wireframe|visual|inspect|look at|see|show|crop|icon|logo|asset)\b"
3712
- ],
3713
- "generate_image": [
3714
- r"(?i)\b(generate|draw|create|paint|render|make|design|build)\b.*\b(image|picture|photo|illustration|graphic|logo|banner|art|ui|mockup|icon|asset|cover|poster|avatar)\b",
3715
- r"(?i)\b(text to image|image gen|sd|flux|dall-e|midjourney|prompt expander)\b"
3716
- ],
3717
- "plan_project": [
3718
- r"(?i)\b(plan|architect|roadmap|milestone|phases|breakdown|strategy|design system|structure repo|project structure|todo list|task list|multi-step|workflow|steps)\b",
3719
- r"(?i)\b(how should (i|we)|what is the best (way|architecture|structure)|organize this)\b"
3720
- ],
3721
- "create_skill": [
3722
- r"(?i)\b(skill|custom tool|reusable prompt|workflow template|save routine|workspace rule|automation|agent skill)\b"
3723
- ],
3724
- "background_tasks": [
3725
- r"(?i)\b(background|task|bg|daemon|long running|process|job|async|monitor|poll|kill task)\b"
3726
- ]
3727
- }
3728
-
3729
- # Collect text across recent messages (user + assistant reasoning + tool outputs)
3730
- recent_text_parts = []
3731
- for m in (messages[-8:] if messages else []):
3732
- content = m.get("content")
3733
- if isinstance(content, str):
3734
- recent_text_parts.append(content)
3735
- elif isinstance(content, list):
3736
- for part in content:
3737
- if isinstance(part, dict) and part.get("type") == "text":
3738
- recent_text_parts.append(part.get("text", ""))
3739
-
3740
- combined_text = "\n".join(recent_text_parts)
3741
-
3742
- # 1. Semantic intent detection
3743
- for tool_name, patterns in SEMANTIC_INTENT_PATTERNS.items():
3744
- for pat in patterns:
3745
- if re.search(pat, combined_text):
3746
- self._activated_specialized_tools.add(tool_name)
3747
- break
3748
-
3749
- # 2. Re-hydrate if iteration count >= 3 (complex multi-step task automatically gets plan_project)
3750
- if getattr(self, "current_iteration", 0) >= 3:
3751
- self._activated_specialized_tools.add("plan_project")
3752
-
3753
- # 3. Filter all_tools
3754
- filtered = []
3755
- for t in all_tools:
3756
- name = t.get("function", {}).get("name", "")
3757
- if name in CORE_TOOLS or name not in SEMANTIC_INTENT_PATTERNS or name in self._activated_specialized_tools:
3758
- filtered.append(t)
3759
-
3760
- return filtered
3761
-
3762
3688
  # ── Context compression ──────────────────────────────────────────────────
3763
3689
 
3764
3690
  @staticmethod
@@ -4909,30 +4835,6 @@ class Orchestrator:
4909
4835
  # No extra newline here - let the next block handle it
4910
4836
  pass
4911
4837
 
4912
- # ── Self-Healing Re-Activation Gate ──────────────────────────────
4913
- # If native tool_calls are empty and the model expresses inability to find a tool,
4914
- # or mentions a specialized tool by name, immediately unmask ALL specialized tools
4915
- # and re-prompt the model in 0ms so it NEVER gets stuck or stops.
4916
- SPECIALIZED_TOOLS_ALL = {"analyze_image", "generate_image", "plan_project", "create_skill", "background_tasks"}
4917
- if not tool_calls and content and not _attempted_tool_call:
4918
- content_lower = content.lower()
4919
- mentioned_specialized = {t for t in SPECIALIZED_TOOLS_ALL if t in content_lower}
4920
- lacks_tool_signal = any(phrase in content_lower for phrase in [
4921
- "don't have", "do not have", "cannot access", "no tool", "tool is missing",
4922
- "unable to generate", "unable to analyze", "not available", "don't see"
4923
- ])
4924
- if mentioned_specialized or lacks_tool_signal:
4925
- if not hasattr(self, "_activated_specialized_tools"):
4926
- self._activated_specialized_tools = set()
4927
- self._activated_specialized_tools.update(SPECIALIZED_TOOLS_ALL)
4928
-
4929
- self.messages.append({"role": "assistant", "content": content})
4930
- self.messages.append({
4931
- "role": "user",
4932
- "content": "[SYSTEM DIRECTIVE] All specialized tools (analyze_image, generate_image, plan_project, create_skill, background_tasks) have been fully unmasked and loaded into your active tool schema. Please re-run immediately using the required tool."
4933
- })
4934
- continue
4935
-
4936
4838
  # Parse text-based tool calls fallback if native tool calls are empty
4937
4839
  if not tool_calls and content:
4938
4840
  parsed_calls = []
@@ -5520,6 +5422,11 @@ class Orchestrator:
5520
5422
  + result[-tail_chars:]
5521
5423
  + (f"\n\n{_line_hint}" if _line_hint else "")
5522
5424
  )
5425
+
5426
+ # Clear any stale vision images from a previous iteration before
5427
+ # this batch of tools runs (defensive — shouldn't normally be set).
5428
+ _tools_module._pending_vision_images.clear()
5429
+
5523
5430
  # Execute tools in parallel when beneficial
5524
5431
  if len(tool_calls) > 1:
5525
5432
  # Use parallel execution for multiple tools
@@ -5536,50 +5443,14 @@ class Orchestrator:
5536
5443
  func_name = tc.get("function", {}).get("name", "")
5537
5444
  result = _cap_tool_result(func_name, result)
5538
5445
 
5539
- # If result is an [Image: path] marker and model is vision-capable,
5540
- # inject the actual image as multimodal content
5541
- _img_match = __import__("re").match(r"^\[Image:\s+(.+)\]$", result.strip())
5542
- if _img_match and func_name in ("read_file",):
5543
- try:
5544
- from utim_cli.tools import is_model_vision_capable
5545
- if is_model_vision_capable():
5546
- _img_path = _img_match.group(1)
5547
- _multimodal = _tool_result_to_multimodal(result, _img_path)
5548
- self.messages.append(
5549
- {
5550
- "role": "tool",
5551
- "tool_call_id": tc_id,
5552
- "name": func_name,
5553
- "content": _multimodal,
5554
- }
5555
- )
5556
- else:
5557
- self.messages.append(
5558
- {
5559
- "role": "tool",
5560
- "tool_call_id": tc_id,
5561
- "name": func_name,
5562
- "content": result,
5563
- }
5564
- )
5565
- except Exception:
5566
- self.messages.append(
5567
- {
5568
- "role": "tool",
5569
- "tool_call_id": tc_id,
5570
- "name": func_name,
5571
- "content": result,
5572
- }
5573
- )
5574
- else:
5575
- self.messages.append(
5576
- {
5577
- "role": "tool",
5578
- "tool_call_id": tc_id,
5579
- "name": func_name,
5580
- "content": result,
5581
- }
5582
- )
5446
+ self.messages.append(
5447
+ {
5448
+ "role": "tool",
5449
+ "tool_call_id": tc_id,
5450
+ "name": func_name,
5451
+ "content": result,
5452
+ }
5453
+ )
5583
5454
  else:
5584
5455
  # Single tool - execute directly
5585
5456
  for tc in tool_calls:
@@ -5588,55 +5459,41 @@ class Orchestrator:
5588
5459
 
5589
5460
  func_name = tc.get("function", {}).get("name", "")
5590
5461
  _tools_module._cancel_event = self.cancel_event
5462
+ _tools_module._active_model_id = self.model_id
5591
5463
  result = self._execute_tool_timed(tc)
5592
5464
  self._current_line_len = 0
5593
5465
  tc_id = tc.get("id") or str(tc.get("index", "0"))
5594
5466
  result = _cap_tool_result(func_name, result)
5595
5467
 
5596
- # If result is an [Image: path] marker and model is vision-capable,
5597
- # inject the actual image as multimodal content
5598
- _img_match = __import__("re").match(r"^\[Image:\s+(.+)\]$", result.strip())
5599
- if _img_match and func_name in ("read_file",):
5600
- try:
5601
- from utim_cli.tools import is_model_vision_capable
5602
- if is_model_vision_capable():
5603
- _img_path = _img_match.group(1)
5604
- _multimodal = _tool_result_to_multimodal(result, _img_path)
5605
- self.messages.append(
5606
- {
5607
- "role": "tool",
5608
- "tool_call_id": tc_id,
5609
- "name": func_name,
5610
- "content": _multimodal,
5611
- }
5612
- )
5613
- else:
5614
- self.messages.append(
5615
- {
5616
- "role": "tool",
5617
- "tool_call_id": tc_id,
5618
- "name": func_name,
5619
- "content": result,
5620
- }
5621
- )
5622
- except Exception:
5623
- self.messages.append(
5624
- {
5625
- "role": "tool",
5626
- "tool_call_id": tc_id,
5627
- "name": func_name,
5628
- "content": result,
5629
- }
5630
- )
5631
- else:
5632
- self.messages.append(
5633
- {
5634
- "role": "tool",
5635
- "tool_call_id": tc_id,
5636
- "name": func_name,
5637
- "content": result,
5638
- }
5639
- )
5468
+ self.messages.append(
5469
+ {
5470
+ "role": "tool",
5471
+ "tool_call_id": tc_id,
5472
+ "name": func_name,
5473
+ "content": result,
5474
+ }
5475
+ )
5476
+
5477
+ # ── Vision image injection ────────────────────────────────────────
5478
+ # read_file encodes image bytes and pushes them to
5479
+ # _tools_module._pending_vision_images so they bypass the tool-result
5480
+ # string pipeline entirely. Drain the queue here (after ALL tools in
5481
+ # this iteration have run) and inject each image as a "user" message
5482
+ # — the only role that providers universally accept image_url content in.
5483
+ _vision_queue = getattr(_tools_module, "_pending_vision_images", [])
5484
+ if _vision_queue:
5485
+ for _vimg in list(_vision_queue):
5486
+ self.messages.append(
5487
+ {
5488
+ "role": "user",
5489
+ "content": [
5490
+ {"type": "text", "text": f"[Image from read_file: {_vimg['meta']}]"},
5491
+ {"type": "image_url", "image_url": {"url": f"data:{_vimg['mime']};base64,{_vimg['b64']}"}},
5492
+ ],
5493
+ }
5494
+ )
5495
+ _vision_queue.clear()
5496
+
5640
5497
  # --- Repeated Tool Failure Loop Detection ---
5641
5498
  if not hasattr(self, "_tool_failure_counts"):
5642
5499
  self._tool_failure_counts = {}
@@ -159,27 +159,36 @@ def resolve_project_path(filepath: str) -> str:
159
159
  _VISION_MODELS_CACHE = None
160
160
 
161
161
  def is_model_vision_capable(model_id: str = None) -> bool:
162
- """Determine if a model is vision-capable based on models.txt architecture input_modalities."""
162
+ """Determine if a model is vision-capable (Vision: True/False) based on MODEL_REGISTRY and models.txt input_modalities."""
163
163
  global _VISION_MODELS_CACHE
164
164
  if not model_id:
165
- try:
166
- from utim_cli.config import config
167
- model_id = config.get("model") or config.get("active_model") or "google/gemini-3.5-flash-lite"
168
- except Exception:
169
- model_id = "google/gemini-3.5-flash-lite"
165
+ # Prefer the slot injected by the orchestrator; fall back to config as last resort.
166
+ model_id = _active_model_id or ""
167
+ if not model_id:
168
+ try:
169
+ from utim_cli.config import config
170
+ model_id = config.get("main_model") or config.get("model") or ""
171
+ except Exception:
172
+ model_id = ""
170
173
 
171
- model_id_str = str(model_id).lower().strip()
174
+ model_id_str = str(model_id).strip()
175
+ if not model_id_str:
176
+ return False # unknown model → treat as non-vision (safe default)
172
177
 
173
- # Quick keyword match for known vision families
174
- vision_keywords = [
175
- "gemini", "claude", "gpt-5", "gpt-4", "grok", "qwen3.7", "qwen3.6",
176
- "inkling", "kimi", "muse-spark", "krea", "vision", "-vl", "llava", "pixtral"
177
- ]
178
- if any(kw in model_id_str for kw in vision_keywords):
179
- if not any(non_v in model_id_str for non_v in ["text-only", "coder-air", "kat-coder"]):
180
- return True
178
+ model_id_lower = model_id_str.lower()
179
+
180
+ # 1. Check server MODEL_REGISTRY first
181
+ try:
182
+ from utim_cli.server.models import MODEL_REGISTRY
183
+ if model_id_str in MODEL_REGISTRY:
184
+ return MODEL_REGISTRY[model_id_str].vision
185
+ for k, entry in MODEL_REGISTRY.items():
186
+ if k.lower() == model_id_lower:
187
+ return entry.vision
188
+ except Exception:
189
+ pass
181
190
 
182
- # Lazy-load models.txt mapping
191
+ # 2. Build / query models.txt cache (authoritative list from OpenRouter metadata)
183
192
  if _VISION_MODELS_CACHE is None:
184
193
  _VISION_MODELS_CACHE = {}
185
194
  try:
@@ -190,15 +199,27 @@ def is_model_vision_capable(model_id: str = None) -> bool:
190
199
  models_txt_path = "models.txt"
191
200
  if os.path.exists(models_txt_path):
192
201
  with open(models_txt_path, "r", encoding="utf-8") as f:
193
- data = json.load(f).get("data", [])
194
- for m in data:
195
- mid = m.get("id", "").lower()
196
- in_mods = m.get("architecture", {}).get("input_modalities", [])
197
- _VISION_MODELS_CACHE[mid] = ("image" in in_mods)
202
+ data = json.load(f)
203
+ items = data.get("data", []) if isinstance(data, dict) else data
204
+ for m in items:
205
+ mid = m.get("id", "")
206
+ if not mid:
207
+ continue
208
+ in_mods = m.get("architecture", {}).get("input_modalities", []) or []
209
+ _VISION_MODELS_CACHE[mid.lower()] = ("image" in in_mods)
198
210
  except Exception:
199
211
  _VISION_MODELS_CACHE = {}
200
212
 
201
- return _VISION_MODELS_CACHE.get(model_id_str, False)
213
+ if model_id_lower in _VISION_MODELS_CACHE:
214
+ return _VISION_MODELS_CACHE[model_id_lower]
215
+
216
+ # 3. Fallback for custom / unlisted model names
217
+ vision_keywords = ["vision", "-vl", "llava", "pixtral", "image", "multimodal", "gpt-4o", "gemini", "claude-3", "claude-4", "claude-5", "qwen3"]
218
+ if any(kw in model_id_lower for kw in vision_keywords):
219
+ if not any(non_v in model_id_lower for non_v in ["text-only", "coder-air", "kat-coder"]):
220
+ return True
221
+
222
+ return False
202
223
 
203
224
 
204
225
  def _extract_file_symbols(filepath: str, file_content: str) -> list:
@@ -305,15 +326,125 @@ def read_file(filepath: str, start_line: int = None, end_line: int = None, symbo
305
326
  ext = _os.path.splitext(filepath)[1].lower()
306
327
  image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'}
307
328
  if ext in image_extensions:
308
- # Vision-capable models can see the image directly — no need for a text
309
- # description that clutters the tool indicator. Non-vision models still
310
- # need the description as their only way to "see" the image.
329
+ # Collect image metadata (size, dimensions) for the text portion of the response.
311
330
  try:
312
- if is_model_vision_capable():
313
- return f"[Image: {filepath}]"
331
+ file_size = _os.path.getsize(filepath)
332
+ size_kb = file_size / 1024.0
333
+ size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb/1024:.2f} MB"
334
+ except Exception:
335
+ size_str = "unknown size"
336
+
337
+ meta = f"[Image: {_os.path.basename(filepath)} | {ext.lstrip('.').upper()} | {size_str} | URI: {_make_file_uri(filepath)}]"
338
+
339
+ # Try to also read image dimensions (pure stdlib where possible, else Pillow)
340
+ dim_str = ""
341
+ try:
342
+ with open(filepath, "rb") as _f:
343
+ _head = _f.read(64)
344
+ # PNG: width@16, height@20 (big-endian uint32)
345
+ if _head.startswith(b"\x89PNG\r\n\x1a\n") and len(_head) >= 24:
346
+ import struct
347
+ w, h = struct.unpack(">II", _head[16:24])
348
+ dim_str = f" | {w}x{h}px"
349
+ # GIF: width@6, height@8 (little-endian uint16)
350
+ elif _head.startswith(b"GIF8") and len(_head) >= 10:
351
+ import struct
352
+ w, h = struct.unpack("<HH", _head[6:10])
353
+ dim_str = f" | {w}x{h}px"
354
+ # JPEG: parse SOF marker to find dimensions
355
+ elif _head.startswith(b"\xff\xd8\xff"):
356
+ import struct
357
+ _f2 = open(filepath, "rb")
358
+ try:
359
+ soi = _f2.read(2)
360
+ while True:
361
+ marker = _f2.read(2)
362
+ if len(marker) < 2:
363
+ break
364
+ if marker[0] != 0xFF:
365
+ break
366
+ # Skip SOI / EOI / RST markers
367
+ if marker[1] in (0xD8, 0xD9) or 0xD0 <= marker[1] <= 0xD7:
368
+ continue
369
+ seg_len_bytes = _f2.read(2)
370
+ if len(seg_len_bytes) < 2:
371
+ break
372
+ seg_len = struct.unpack(">H", seg_len_bytes)[0]
373
+ # SOFn markers (Start of Frame): 0xC0-0xCF except 0xC4,0xC8,0xCC
374
+ if 0xC0 <= marker[1] <= 0xCF and marker[1] not in (0xC4, 0xC8, 0xCC):
375
+ _f2.read(1) # precision
376
+ h, w = struct.unpack(">HH", _f2.read(4))
377
+ dim_str = f" | {w}x{h}px"
378
+ break
379
+ else:
380
+ _f2.read(seg_len - 2)
381
+ finally:
382
+ _f2.close()
383
+ # WebP: 'RIFF....WEBP' then VP8 / VP8L / VP8X chunk
384
+ elif _head.startswith(b"RIFF") and _head[8:12] == b"WEBP":
385
+ with open(filepath, "rb") as _f3:
386
+ _f3.read(12)
387
+ chunk_hdr = _f3.read(4)
388
+ if chunk_hdr == b"VP8 ":
389
+ _f3.read(10) # frame tag + start code
390
+ w = int.from_bytes(_f3.read(2), "little") & 0x3FFF
391
+ h = int.from_bytes(_f3.read(2), "little") & 0x3FFF
392
+ dim_str = f" | {w}x{h}px"
393
+ elif chunk_hdr == b"VP8L":
394
+ _f3.read(1) # signature byte
395
+ b1, b2, b3, b4 = _f3.read(4)
396
+ w = ((b2 & 0x3F) << 8 | b1) + 1
397
+ h = (((b4 & 0x0F) << 10) | (b3 << 2) | ((b2 & 0xC0) >> 6)) + 1
398
+ dim_str = f" | {w}x{h}px"
399
+ elif chunk_hdr == b"VP8X":
400
+ _f3.read(8)
401
+ w = int.from_bytes(_f3.read(3), "little") + 1
402
+ h = int.from_bytes(_f3.read(3), "little") + 1
403
+ dim_str = f" | {w}x{h}px"
314
404
  except Exception:
315
405
  pass
316
- return analyze_image(filepath, "Identify and describe this image in detail, listing any people, actors, characters, text, UI elements, colors, and visual layout.")
406
+
407
+ meta += dim_str
408
+
409
+ # Route image to model based on vision capability:
410
+ #
411
+ # Vision-capable model → encode bytes here, push to _pending_vision_images.
412
+ # The orchestrator drains this queue after all tools complete and injects a
413
+ # "user" message with the raw base64 image so the model truly sees it.
414
+ # (image_url blocks are NOT supported inside "tool" role messages by most
415
+ # providers — they must live in a "user" message.)
416
+ #
417
+ # Non-vision model → call analyze_image() in the background and return its
418
+ # text description so the model has useful context.
419
+ if is_model_vision_capable():
420
+ import base64 as _b64mod, mimetypes as _mtmod
421
+ _mime_v, _ = _mtmod.guess_type(filepath)
422
+ _ext_v = _os.path.splitext(filepath)[1].lower()
423
+ if not _mime_v or not _mime_v.startswith("image/"):
424
+ _mime_v = f"image/{_ext_v[1:]}"
425
+ if _ext_v == ".jpg":
426
+ _mime_v = "image/jpeg"
427
+ try:
428
+ with open(filepath, "rb") as _fv:
429
+ _b64_v = _b64mod.b64encode(_fv.read()).decode("utf-8")
430
+ # Push to queue — orchestrator injects the user message after tools complete
431
+ _pending_vision_images.append({"meta": meta, "b64": _b64_v, "mime": _mime_v})
432
+ return meta # return clean metadata text only; no sentinel needed
433
+ except Exception as _venc_err:
434
+ # Encoding failed — fall through to analyze_image text fallback below
435
+ pass
436
+
437
+ # Non-vision model (or encoding failed): describe image via external vision sub-model.
438
+ try:
439
+ description = analyze_image(
440
+ filepath,
441
+ "Identify and describe this image in detail, listing any people, actors, characters, "
442
+ "text, UI elements, colors, visual layout, code or terminal output shown, and any "
443
+ "visible error messages or stack traces verbatim."
444
+ )
445
+ return f"{meta}\n{description}"
446
+ except Exception as _e2:
447
+ return f"{meta}\n(Vision description unavailable: {_e2})"
317
448
 
318
449
  try:
319
450
  with open(filepath, "rb") as f:
@@ -967,6 +1098,17 @@ def edit_file(filepath: str, old_str: str = None, new_str: str = None, replaceme
967
1098
  # threading.Event here before each tool call so run_command can be aborted.
968
1099
  _cancel_event = None # type: threading.Event | None
969
1100
 
1101
+ # Active model ID slot — injected by the orchestrator before each tool call
1102
+ # (same pattern as _cancel_event). read_file reads this to decide whether to
1103
+ # push image bytes to _pending_vision_images or call analyze_image instead.
1104
+ _active_model_id: str = ""
1105
+
1106
+ # Queue for vision images: read_file pushes here when the active model is
1107
+ # vision-capable; the orchestrator drains it after each tool-call batch and
1108
+ # injects a "user" message containing the raw image bytes so the model can
1109
+ # actually see the image (most providers reject image_url in "tool" messages).
1110
+ _pending_vision_images: list = [] # list of {"meta": str, "b64": str, "mime": str}
1111
+
970
1112
  # ─── Intelligent Sandbox mode ──────────────────────────────────────────────────
971
1113
  # Set to True via --sandbox CLI flag. When active, run_command uses static analysis
972
1114
  # to check commands for safety. Risky commands are blocked unless approved.