utim-cli 1.0.0__py3-none-any.whl
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/__init__.py +40 -0
- utim_cli/agent.py +359 -0
- utim_cli/auth.py +208 -0
- utim_cli/backup.py +101 -0
- utim_cli/billing.py +40 -0
- utim_cli/blender_agent.py +1018 -0
- utim_cli/bootstrap.py +324 -0
- utim_cli/client_utils.py +135 -0
- utim_cli/config.py +194 -0
- utim_cli/context_pruner.py +504 -0
- utim_cli/doctor.py +118 -0
- utim_cli/knowledge_graph.py +462 -0
- utim_cli/logger.py +121 -0
- utim_cli/mcp_clean_wrapper.py +55 -0
- utim_cli/mcp_client.py +198 -0
- utim_cli/mcp_registry.json +1102 -0
- utim_cli/orchestrator.py +3209 -0
- utim_cli/reflection.py +200 -0
- utim_cli/report.py +100 -0
- utim_cli/scrapy_search.py +229 -0
- utim_cli/share.py +320 -0
- utim_cli/share_tui.py +554 -0
- utim_cli/situational_scoring.py +269 -0
- utim_cli/state.py +15 -0
- utim_cli/tools.py +3381 -0
- utim_cli/utim.py +4051 -0
- utim_cli/vector_memory.py +629 -0
- utim_cli/workspace.py +33 -0
- utim_cli-1.0.0.dist-info/METADATA +134 -0
- utim_cli-1.0.0.dist-info/RECORD +34 -0
- utim_cli-1.0.0.dist-info/WHEEL +5 -0
- utim_cli-1.0.0.dist-info/entry_points.txt +2 -0
- utim_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- utim_cli-1.0.0.dist-info/top_level.txt +1 -0
utim_cli/bootstrap.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UTIM Bootstrap Module
|
|
3
|
+
Auto-creates the .utim folder structure, custom skills, and local database on first run.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
UTIM_DIR = Path('.utim')
|
|
10
|
+
|
|
11
|
+
DEFAULT_AGENTS_MD = """# Project-Scoped Rules and Guidelines for UTIM CLI
|
|
12
|
+
|
|
13
|
+
Welcome to the UTIM CLI development workspace. Follow these rules and activate the corresponding workspace-scoped skills based on your current task.
|
|
14
|
+
|
|
15
|
+
## Available Workspace Skills
|
|
16
|
+
|
|
17
|
+
You MUST read the corresponding `SKILL.md` file using the file viewer tool before implementing features in these domains:
|
|
18
|
+
|
|
19
|
+
1. **Terminal UI (TUI) Design**: [.utim/skills/terminal-ui-design/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/terminal-ui-design/SKILL.md)
|
|
20
|
+
- *Use when*: Modifying interactive menus, Rich console logs, layouts, and `prompt_toolkit` inputs.
|
|
21
|
+
2. **Software Architecture**: [.utim/skills/software-architecture/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/software-architecture/SKILL.md)
|
|
22
|
+
- *Use when*: Designing new Python modules, modifying state/orchestrator files, or refactoring codebase structures.
|
|
23
|
+
3. **MCP Server Development**: [.utim/skills/mcp-server-development/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/mcp-server-development/SKILL.md)
|
|
24
|
+
- *Use when*: Writing new MCP servers, modifying connection pools, or handling stdio protocol wrappers.
|
|
25
|
+
4. **CLI UX Patterns**: [.utim/skills/cli-ux-patterns/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/cli-ux-patterns/SKILL.md)
|
|
26
|
+
- *Use when*: Refining CLI prompts, progress indicators, non-interactive (TTY) handling, and user confirmations.
|
|
27
|
+
5. **Premium Web Design**: [.utim/skills/web-design-premium/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/web-design-premium/SKILL.md)
|
|
28
|
+
- *Use when*: Coding web interfaces, using HSL colors, glassmorphism UI, transitions, or applying SEO tags.
|
|
29
|
+
6. **LLM Orchestration**: [.utim/skills/llm-orchestration/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/llm-orchestration/SKILL.md)
|
|
30
|
+
- *Use when*: Editing LLM prompting context, parser loops, tools injection, token pruner, or semantic vector DB memory retrieval.
|
|
31
|
+
7. **Asynchronous Python (asyncio)**: [.utim/skills/async-python/SKILL.md](file:///C:/Users/user/Desktop/New%20folder/New%20folder/.utim/skills/async-python/SKILL.md)
|
|
32
|
+
- *Use when*: Implementing asynchronous background tasks, reading subprocess streams, or thread safety locks.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
DEFAULT_SKILLS = {
|
|
36
|
+
"terminal-ui-design": """---
|
|
37
|
+
name: terminal-ui-design
|
|
38
|
+
description: Guidelines and best practices for creating beautiful, responsive terminal UIs (TUIs) in Python using Rich and prompt_toolkit.
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
# Terminal UI (TUI) Design Guide
|
|
42
|
+
|
|
43
|
+
Terminal UIs must combine aesthetic appeal with technical reliability. Use Rich for output panels/tables and prompt_toolkit for interactive prompts, validations, and completion suggestions.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 1. Color Palette & Theming
|
|
48
|
+
- Primary/Accent: Cyan or magenta.
|
|
49
|
+
- Success: Green.
|
|
50
|
+
- Warning: Yellow.
|
|
51
|
+
- Error: Red.
|
|
52
|
+
- Dim/Muted: Gray.
|
|
53
|
+
|
|
54
|
+
Always use a centralized theme rather than hardcoding colors.
|
|
55
|
+
""",
|
|
56
|
+
"software-architecture": """---
|
|
57
|
+
name: software-architecture
|
|
58
|
+
description: Guidelines and design patterns for Python application architecture, focus on modularity, decoupling, state management, and reliable abstractions.
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# Software Architecture Guidelines
|
|
62
|
+
|
|
63
|
+
Adhere to modular separation of concerns. Keep UI scripts, business logic, state models, and tool execution independent to prevent tight coupling.
|
|
64
|
+
""",
|
|
65
|
+
"mcp-server-development": """---
|
|
66
|
+
name: mcp-server-development
|
|
67
|
+
description: Guidelines for developing and integrating Model Context Protocol (MCP) servers, troubleshooting stdio stream corruption, and implementing robust tool/resource handlers.
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
# MCP Server Development Guide
|
|
71
|
+
|
|
72
|
+
- Redirect all logs and debug printing to stderr, never stdout, to prevent stdio transport corruption.
|
|
73
|
+
- Process stdin EOF gracefully to prevent hanging subprocesses.
|
|
74
|
+
""",
|
|
75
|
+
"cli-ux-patterns": """---
|
|
76
|
+
name: cli-ux-patterns
|
|
77
|
+
description: Core guidelines for designing highly polished Command Line Interface (CLI) user experiences.
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
# CLI UX Patterns
|
|
81
|
+
|
|
82
|
+
- Always clean up temporary or interactive question prompts using screen clearing codes.
|
|
83
|
+
- Ensure all mutating commands support non-destructive dry-run modes.
|
|
84
|
+
""",
|
|
85
|
+
"web-design-premium": """---
|
|
86
|
+
name: web-design-premium
|
|
87
|
+
description: Design principles and code implementation guidelines for premium, modern, and visually striking web interfaces.
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
# Premium Web Design Guide
|
|
91
|
+
|
|
92
|
+
Apply glassmorphism (backdrop-filter blur), modern sans-serif fonts (e.g. Outfit, Inter), clean HSL-tailored colors, smooth transitions, and standard SEO best practices.
|
|
93
|
+
""",
|
|
94
|
+
"llm-orchestration": """---
|
|
95
|
+
name: llm-orchestration
|
|
96
|
+
description: Guidelines and patterns for LLM agent loops, tool-calling, context management, semantic token pruning, and reflection strategies.
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
# LLM Orchestration & Agent Loops
|
|
100
|
+
|
|
101
|
+
- Ensure iteration limits are set to prevent infinite LLM reasoning loops.
|
|
102
|
+
- Perform pre-commit validation checks before executing tool code modifications.
|
|
103
|
+
""",
|
|
104
|
+
"async-python": """---
|
|
105
|
+
name: async-python
|
|
106
|
+
description: Guidelines and patterns for asynchronous Python programming using asyncio.
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
# Asynchronous Python (asyncio)
|
|
110
|
+
|
|
111
|
+
- Use asyncio.create_subprocess_exec to execute subprocesses concurrently.
|
|
112
|
+
- Protect asynchronous actions with timeouts using asyncio.timeout.
|
|
113
|
+
"""
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def initialize_utim() -> str:
|
|
117
|
+
"""Initialize .utim directory, local SQLite database, and auto-create custom skills/rules if they don't exist."""
|
|
118
|
+
try:
|
|
119
|
+
from utim_cli.backup import restore_state
|
|
120
|
+
restore_state()
|
|
121
|
+
except Exception as e:
|
|
122
|
+
from utim_cli.logger import log_error
|
|
123
|
+
log_error("bootstrap", "Failed to restore state during initialization", e)
|
|
124
|
+
|
|
125
|
+
UTIM_DIR.mkdir(exist_ok=True)
|
|
126
|
+
|
|
127
|
+
# 1. Initialize local_utim database using SQLAlchemy init_db
|
|
128
|
+
try:
|
|
129
|
+
from utim_cli.server.db import init_db
|
|
130
|
+
init_db()
|
|
131
|
+
except Exception as e:
|
|
132
|
+
from utim_cli.logger import log_error
|
|
133
|
+
log_error("bootstrap", "Failed to initialize SQLite local database", e)
|
|
134
|
+
|
|
135
|
+
# 2. Write analytical_rules.md if not exists
|
|
136
|
+
_write_analytical_rules_md()
|
|
137
|
+
|
|
138
|
+
# 3. Write UTIM.md if not exists
|
|
139
|
+
_write_utim_md()
|
|
140
|
+
|
|
141
|
+
# 4. Auto-create skills directory and default skills/rules
|
|
142
|
+
_write_skills_and_agents()
|
|
143
|
+
|
|
144
|
+
db_path = UTIM_DIR / 'utim_local.db'
|
|
145
|
+
return str(db_path)
|
|
146
|
+
|
|
147
|
+
def _write_skills_and_agents():
|
|
148
|
+
"""Auto-create skills/ directory, AGENTS.md, and all default SKILL.md files inside .utim/."""
|
|
149
|
+
try:
|
|
150
|
+
# Write AGENTS.md
|
|
151
|
+
agents_path = UTIM_DIR / 'AGENTS.md'
|
|
152
|
+
if not agents_path.exists():
|
|
153
|
+
with open(agents_path, 'w', encoding='utf-8') as f:
|
|
154
|
+
f.write(DEFAULT_AGENTS_MD)
|
|
155
|
+
|
|
156
|
+
# Write skills
|
|
157
|
+
skills_dir = UTIM_DIR / 'skills'
|
|
158
|
+
for skill_name, content in DEFAULT_SKILLS.items():
|
|
159
|
+
skill_dir = skills_dir / skill_name
|
|
160
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
skill_file = skill_dir / 'SKILL.md'
|
|
162
|
+
if not skill_file.exists():
|
|
163
|
+
with open(skill_file, 'w', encoding='utf-8') as f:
|
|
164
|
+
f.write(content)
|
|
165
|
+
|
|
166
|
+
# Write DESIGN.md
|
|
167
|
+
design_path = UTIM_DIR / 'DESIGN.md'
|
|
168
|
+
if not design_path.exists():
|
|
169
|
+
_write_default_design_md(design_path)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
from utim_cli.logger import log_error
|
|
172
|
+
log_error("bootstrap", "Failed to write skills and agents configuration to .utim/", e)
|
|
173
|
+
|
|
174
|
+
def _write_default_design_md(md_path: Path):
|
|
175
|
+
content = """# Premium Web Design System (Aesthetics Cheat-Sheet)
|
|
176
|
+
Use this guide to build modern, beautiful, and "award-winning" web interfaces. Apply these principles, variables, and recipes to wow the user.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 1. Typography
|
|
181
|
+
* **Body & UI**: 'Outfit', sans-serif (clean, rounded, premium feel).
|
|
182
|
+
* **Headings**: 'Bricolage Grotesque', sans-serif (vibrant, high-agency design).
|
|
183
|
+
|
|
184
|
+
## 2. Color Palette (Clean Dark/Light HSL)
|
|
185
|
+
- Primary HSL colors, gradients, and custom themes to prevent generic looks.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
with open(md_path, 'w', encoding='utf-8') as f:
|
|
189
|
+
f.write(content)
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
def _write_analytical_rules_md():
|
|
194
|
+
"""Write the analytical_rules.md file to .utim folder if not exists."""
|
|
195
|
+
md_path = UTIM_DIR / 'analytical_rules.md'
|
|
196
|
+
if md_path.exists():
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
content = """# Enhanced Analytical Framework for UTIM AI
|
|
200
|
+
|
|
201
|
+
## Core Principle: GOAL-FIRST ANALYSIS
|
|
202
|
+
Always start by identifying the TRUE objective, then work backwards to feasible actions.
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
with open(md_path, 'w', encoding='utf-8') as f:
|
|
206
|
+
f.write(content)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
from utim_cli.logger import log_error
|
|
209
|
+
log_error("bootstrap", f"Failed to write analytical rules markdown to {md_path}", e)
|
|
210
|
+
|
|
211
|
+
def _write_utim_md():
|
|
212
|
+
"""Write the UTIM.md file to .utim folder if not exists."""
|
|
213
|
+
md_path = UTIM_DIR / 'UTIM.md'
|
|
214
|
+
if md_path.exists():
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
content = """## 1. Identity & Operating Mindset
|
|
218
|
+
* **Role**: You are UTIM AI, a high-agency senior software engineering intelligence operating inside a developer CLI.
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
with open(md_path, 'w', encoding='utf-8') as f:
|
|
222
|
+
f.write(content)
|
|
223
|
+
except Exception as e:
|
|
224
|
+
from utim_cli.logger import log_error
|
|
225
|
+
log_error("bootstrap", f"Failed to write UTIM.md markdown to {md_path}", e)
|
|
226
|
+
|
|
227
|
+
def get_rag_context(user_prompt: str = "") -> str:
|
|
228
|
+
"""Get RAG context string for system prompt injection by matching user_prompt keywords to skills."""
|
|
229
|
+
try:
|
|
230
|
+
from utim_cli.state import STATE
|
|
231
|
+
STATE["injected_contexts"] = []
|
|
232
|
+
|
|
233
|
+
# Simple keyword matching to select relevant skills
|
|
234
|
+
matched_skills = []
|
|
235
|
+
user_prompt_lower = user_prompt.lower()
|
|
236
|
+
|
|
237
|
+
keywords = {
|
|
238
|
+
"terminal-ui-design": ["ui", "tui", "console", "rich", "prompt", "menu", "prompt_toolkit", "layout"],
|
|
239
|
+
"software-architecture": ["architecture", "design", "module", "structure", "refactor", "coupling"],
|
|
240
|
+
"mcp-server-development": ["mcp", "server", "figma", "github", "stdio", "json-rpc"],
|
|
241
|
+
"cli-ux-patterns": ["cli", "ux", "undo", "redo", "prompt", "interactive", "confirm"],
|
|
242
|
+
"web-design-premium": ["web", "css", "html", "react", "premium", "glassmorphism", "style"],
|
|
243
|
+
"llm-orchestration": ["llm", "agent", "orchestration", "token", "prune", "prompt", "thinking"],
|
|
244
|
+
"async-python": ["async", "sync", "await", "loop", "concurrency", "thread", "process"]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for skill_name, keys in keywords.items():
|
|
248
|
+
if any(k in user_prompt_lower for k in keys):
|
|
249
|
+
matched_skills.append(skill_name)
|
|
250
|
+
|
|
251
|
+
# If no keywords matched, default to AGENTS.md
|
|
252
|
+
if not matched_skills:
|
|
253
|
+
agents_path = UTIM_DIR / 'AGENTS.md'
|
|
254
|
+
if agents_path.exists():
|
|
255
|
+
with open(agents_path, 'r', encoding='utf-8') as f:
|
|
256
|
+
content = f.read()
|
|
257
|
+
STATE["injected_contexts"].append(content)
|
|
258
|
+
return f"\n### PROJECT RULES & GUIDELINES ###\n{content}\n"
|
|
259
|
+
return ""
|
|
260
|
+
|
|
261
|
+
context = ""
|
|
262
|
+
for skill_name in matched_skills[:2]: # Load top 2 matched skills
|
|
263
|
+
skill_path = UTIM_DIR / 'skills' / skill_name / 'SKILL.md'
|
|
264
|
+
if skill_path.exists():
|
|
265
|
+
with open(skill_path, 'r', encoding='utf-8') as f:
|
|
266
|
+
content = f.read()
|
|
267
|
+
if content.startswith('---'):
|
|
268
|
+
parts = content.split('---', 2)
|
|
269
|
+
if len(parts) >= 3:
|
|
270
|
+
content = parts[2].strip()
|
|
271
|
+
STATE["injected_contexts"].append(content)
|
|
272
|
+
context += f"\n### RELEVANT CORE SKILL: {skill_name.upper()} ###\n{content[:1500]}\n"
|
|
273
|
+
|
|
274
|
+
return context
|
|
275
|
+
except Exception as e:
|
|
276
|
+
from utim_cli.logger import log_error
|
|
277
|
+
log_error("bootstrap", "Failed to get RAG context from skills", e)
|
|
278
|
+
return ""
|
|
279
|
+
|
|
280
|
+
def get_subagent_rag_context(subagent_name: str, query: str = "") -> str:
|
|
281
|
+
"""Get RAG context string for the specified sub-agent prompt injection by reading from markdown skill files directly."""
|
|
282
|
+
try:
|
|
283
|
+
# Map subagent name to the most relevant skill file
|
|
284
|
+
mapping = {
|
|
285
|
+
"project_res": "software-architecture",
|
|
286
|
+
"plan_project": "llm-orchestration",
|
|
287
|
+
"web_search": "cli-ux-patterns",
|
|
288
|
+
"generate_image": "web-design-premium"
|
|
289
|
+
}
|
|
290
|
+
skill_name = mapping.get(subagent_name)
|
|
291
|
+
if not skill_name:
|
|
292
|
+
return ""
|
|
293
|
+
|
|
294
|
+
skill_path = UTIM_DIR / 'skills' / skill_name / 'SKILL.md'
|
|
295
|
+
if not skill_path.exists():
|
|
296
|
+
return ""
|
|
297
|
+
|
|
298
|
+
with open(skill_path, 'r', encoding='utf-8') as f:
|
|
299
|
+
content = f.read()
|
|
300
|
+
|
|
301
|
+
# Strip YAML frontmatter if present
|
|
302
|
+
if content.startswith('---'):
|
|
303
|
+
parts = content.split('---', 2)
|
|
304
|
+
if len(parts) >= 3:
|
|
305
|
+
content = parts[2].strip()
|
|
306
|
+
|
|
307
|
+
# Return a formatted summary context
|
|
308
|
+
context = f"\n\n### SUB-AGENT {subagent_name.upper()} SKILLS & FRAMEWORK (from {skill_name}) ###\n"
|
|
309
|
+
if len(content) > 1500:
|
|
310
|
+
context += content[:1500] + "\n... [Truncated for Context Limit]"
|
|
311
|
+
else:
|
|
312
|
+
context += content
|
|
313
|
+
return context
|
|
314
|
+
except Exception as e:
|
|
315
|
+
from utim_cli.logger import log_error
|
|
316
|
+
log_error("bootstrap", f"Failed to load subagent markdown context for {subagent_name}", e)
|
|
317
|
+
return ""
|
|
318
|
+
|
|
319
|
+
def get_time_rag_context(user_prompt: str) -> str:
|
|
320
|
+
"""Gets relevant past task execution time experiences (mock/empty as databases are simplified)."""
|
|
321
|
+
return ""
|
|
322
|
+
|
|
323
|
+
if __name__ == '__main__':
|
|
324
|
+
print(f"Initializing UTIM... Database: {initialize_utim()}")
|
utim_cli/client_utils.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import requests
|
|
4
|
+
from utim_cli.config import config
|
|
5
|
+
|
|
6
|
+
class WrappedResponse:
|
|
7
|
+
"""A compatibility wrapper that mimics requests.Response for client callers."""
|
|
8
|
+
def __init__(self, content_str=None, generator=None, status_code=200):
|
|
9
|
+
self.content_str = content_str
|
|
10
|
+
self.generator = generator
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
self.encoding = "utf-8"
|
|
13
|
+
|
|
14
|
+
def raise_for_status(self):
|
|
15
|
+
if self.status_code != 200:
|
|
16
|
+
raise requests.HTTPError(f"HTTP {self.status_code}")
|
|
17
|
+
|
|
18
|
+
def json(self):
|
|
19
|
+
if self.content_str:
|
|
20
|
+
return json.loads(self.content_str)
|
|
21
|
+
raise ValueError("No JSON content available")
|
|
22
|
+
|
|
23
|
+
def __enter__(self):
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def iter_lines(self, decode_unicode=False):
|
|
30
|
+
if self.generator:
|
|
31
|
+
return self.generator
|
|
32
|
+
if self.content_str:
|
|
33
|
+
return [self.content_str]
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
def get_server_url() -> str:
|
|
37
|
+
return "https://utim-cli-production.up.railway.app"
|
|
38
|
+
|
|
39
|
+
def proxy_openrouter_request(json_data: dict, stream: bool = False, timeout=None) -> WrappedResponse:
|
|
40
|
+
"""Routes LLM request to UTIM server /completions, or falls back to direct OpenRouter."""
|
|
41
|
+
# Ensure environment variables are loaded (orchestrator handles this, but tools.py is independent)
|
|
42
|
+
_cwd_env = os.path.join(os.getcwd(), ".env")
|
|
43
|
+
if os.path.isfile(_cwd_env):
|
|
44
|
+
try:
|
|
45
|
+
from dotenv import load_dotenv
|
|
46
|
+
load_dotenv(_cwd_env, override=True)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
api_key = config.get("api_key")
|
|
51
|
+
server_url = get_server_url()
|
|
52
|
+
|
|
53
|
+
if api_key:
|
|
54
|
+
headers = {
|
|
55
|
+
"X-API-Key": api_key,
|
|
56
|
+
"Content-Type": "application/json"
|
|
57
|
+
}
|
|
58
|
+
payload = {
|
|
59
|
+
"messages": json_data.get("messages"),
|
|
60
|
+
"model_id": json_data.get("model"),
|
|
61
|
+
"tools": json_data.get("tools"),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# UTIM server completions is a streaming endpoint
|
|
65
|
+
resp = requests.post(f"{server_url}/completions", json=payload, headers=headers, stream=True, timeout=timeout)
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
|
|
68
|
+
if stream or json_data.get("stream"):
|
|
69
|
+
def line_generator():
|
|
70
|
+
for line in resp.iter_lines(decode_unicode=True):
|
|
71
|
+
if line:
|
|
72
|
+
try:
|
|
73
|
+
data = json.loads(line)
|
|
74
|
+
if data.get("type") == "content_delta" and data.get("text"):
|
|
75
|
+
chunk = {
|
|
76
|
+
"choices": [{
|
|
77
|
+
"delta": {"content": data["text"]}
|
|
78
|
+
}]
|
|
79
|
+
}
|
|
80
|
+
yield "data: " + json.dumps(chunk)
|
|
81
|
+
elif data.get("type") == "done":
|
|
82
|
+
if data.get("error"):
|
|
83
|
+
yield "data: " + json.dumps({"error": {"message": data["error"]}})
|
|
84
|
+
break
|
|
85
|
+
tcs = data.get("tool_calls")
|
|
86
|
+
if tcs:
|
|
87
|
+
chunk = {
|
|
88
|
+
"choices": [{
|
|
89
|
+
"delta": {"tool_calls": tcs}
|
|
90
|
+
}]
|
|
91
|
+
}
|
|
92
|
+
yield "data: " + json.dumps(chunk)
|
|
93
|
+
break
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
yield "data: [DONE]"
|
|
97
|
+
return WrappedResponse(generator=line_generator())
|
|
98
|
+
else:
|
|
99
|
+
# Consume stream to construct final non-streaming response dict
|
|
100
|
+
content = ""
|
|
101
|
+
tool_calls = None
|
|
102
|
+
for line in resp.iter_lines(decode_unicode=True):
|
|
103
|
+
if line:
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(line)
|
|
106
|
+
if data.get("type") == "content_delta" and data.get("text"):
|
|
107
|
+
content += data["text"]
|
|
108
|
+
elif data.get("type") == "done":
|
|
109
|
+
if data.get("error"):
|
|
110
|
+
raise RuntimeError(data["error"])
|
|
111
|
+
content = data.get("content") or content
|
|
112
|
+
tool_calls = data.get("tool_calls")
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
res_dict = {
|
|
116
|
+
"choices": [{
|
|
117
|
+
"message": {
|
|
118
|
+
"role": "assistant",
|
|
119
|
+
"content": content,
|
|
120
|
+
"tool_calls": tool_calls
|
|
121
|
+
}
|
|
122
|
+
}]
|
|
123
|
+
}
|
|
124
|
+
return WrappedResponse(content_str=json.dumps(res_dict))
|
|
125
|
+
else:
|
|
126
|
+
llm_key = os.getenv("OPENROUTER_API_KEY")
|
|
127
|
+
if not llm_key:
|
|
128
|
+
raise RuntimeError("Neither UTIM API key nor local OPENROUTER_API_KEY is configured.")
|
|
129
|
+
|
|
130
|
+
headers = {
|
|
131
|
+
"Authorization": f"Bearer {llm_key}",
|
|
132
|
+
"Content-Type": "application/json"
|
|
133
|
+
}
|
|
134
|
+
# Direct forwarding to OpenRouter
|
|
135
|
+
return requests.post("https://openrouter.ai/api/v1/chat/completions", json=json_data, headers=headers, stream=stream or json_data.get("stream"), timeout=timeout)
|
utim_cli/config.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
class Config:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.global_dir = pathlib.Path.home() / ".utim"
|
|
10
|
+
self.local_dir = pathlib.Path(".utim").resolve()
|
|
11
|
+
self._data: Dict[str, Any] = self._load()
|
|
12
|
+
|
|
13
|
+
def _load(self) -> Dict[str, Any]:
|
|
14
|
+
data = {}
|
|
15
|
+
# Load global config
|
|
16
|
+
global_config = self.global_dir / "config.json"
|
|
17
|
+
if global_config.exists():
|
|
18
|
+
try:
|
|
19
|
+
with open(global_config, "r", encoding="utf-8") as f:
|
|
20
|
+
data.update(json.load(f))
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
# Load local config if exists
|
|
25
|
+
local_config = self.local_dir / "config.json"
|
|
26
|
+
if local_config.exists():
|
|
27
|
+
try:
|
|
28
|
+
with open(local_config, "r", encoding="utf-8") as f:
|
|
29
|
+
data.update(json.load(f))
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
def save(self):
|
|
35
|
+
# Save to global config directory
|
|
36
|
+
self.global_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
global_config = self.global_dir / "config.json"
|
|
38
|
+
try:
|
|
39
|
+
with open(global_config, "w", encoding="utf-8") as f:
|
|
40
|
+
json.dump(self._data, f, indent=4)
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
# Save to local config directory if it exists
|
|
45
|
+
if self.local_dir.exists():
|
|
46
|
+
local_config = self.local_dir / "config.json"
|
|
47
|
+
try:
|
|
48
|
+
with open(local_config, "w", encoding="utf-8") as f:
|
|
49
|
+
json.dump(self._data, f, indent=4)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
54
|
+
return self._data.get(key, default)
|
|
55
|
+
|
|
56
|
+
def set(self, key: str, value: Any):
|
|
57
|
+
self._data[key] = value
|
|
58
|
+
self.save()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def token(self) -> Optional[str]:
|
|
62
|
+
return self.get("token")
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def email(self) -> str:
|
|
66
|
+
return self.get("email", os.getenv("UTIM_EMAIL", "local@utim.dev"))
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def name(self) -> Optional[str]:
|
|
70
|
+
return self.get("name")
|
|
71
|
+
|
|
72
|
+
def clear(self):
|
|
73
|
+
self._data = {}
|
|
74
|
+
self.save()
|
|
75
|
+
|
|
76
|
+
# ── Custom / Bring-Your-Own-Model support ─────────────────────────────────
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def custom_models(self) -> list:
|
|
80
|
+
"""Return the list of user-defined models.
|
|
81
|
+
|
|
82
|
+
Each entry is a dict with the following keys:
|
|
83
|
+
model_id – unique identifier shown in the picker (e.g. "gpt-4o")
|
|
84
|
+
provider_name – human-readable label (e.g. "OpenAI")
|
|
85
|
+
base_url – OpenAI-compatible chat completions base URL
|
|
86
|
+
(e.g. "https://api.openai.com/v1")
|
|
87
|
+
api_key – API key for that provider (stored in plain text
|
|
88
|
+
in .utim/config.json; the user is warned)
|
|
89
|
+
context_window – integer token budget (default 128 000)
|
|
90
|
+
"""
|
|
91
|
+
return self._data.get("custom_models", [])
|
|
92
|
+
|
|
93
|
+
@custom_models.setter
|
|
94
|
+
def custom_models(self, value: list):
|
|
95
|
+
self._data["custom_models"] = value
|
|
96
|
+
self.save()
|
|
97
|
+
|
|
98
|
+
def add_custom_model(self, entry: dict) -> None:
|
|
99
|
+
"""Append or replace a custom model entry keyed on model_id."""
|
|
100
|
+
models = self.custom_models
|
|
101
|
+
models = [m for m in models if m.get("model_id") != entry["model_id"]]
|
|
102
|
+
models.append(entry)
|
|
103
|
+
self.custom_models = models
|
|
104
|
+
|
|
105
|
+
def remove_custom_model(self, model_id: str) -> bool:
|
|
106
|
+
"""Remove a custom model by model_id. Returns True if it existed."""
|
|
107
|
+
models = self.custom_models
|
|
108
|
+
new = [m for m in models if m.get("model_id") != model_id]
|
|
109
|
+
if len(new) == len(models):
|
|
110
|
+
return False
|
|
111
|
+
self.custom_models = new
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def get_custom_model(self, model_id: str) -> Optional[dict]:
|
|
115
|
+
"""Return the custom model entry for *model_id*, or None."""
|
|
116
|
+
for m in self.custom_models:
|
|
117
|
+
if m.get("model_id") == model_id:
|
|
118
|
+
return m
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def debug_mode(self) -> bool:
|
|
123
|
+
return os.environ.get("UTIM_DEBUG", "0").lower() in ("1", "true", "yes")
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def dry_run(self) -> bool:
|
|
127
|
+
return os.environ.get("UTIM_DRY_RUN", "0").lower() in ("1", "true", "yes") or self.get("dry_run", False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def fallback_models(self) -> list:
|
|
132
|
+
"""Return fallback LLM models list."""
|
|
133
|
+
models = os.environ.get("UTIM_FALLBACK_MODELS")
|
|
134
|
+
if models:
|
|
135
|
+
return [m.strip() for m in models.split(",")]
|
|
136
|
+
return [
|
|
137
|
+
"poolside/laguna-m.1:free",
|
|
138
|
+
"qwen/qwen3-coder:free",
|
|
139
|
+
"nvidia/nemotron-3-ultra-550b-a55b:free",
|
|
140
|
+
"openrouter/free"
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def keep_full_turns(self) -> int:
|
|
145
|
+
# Number of recent turns to retain in memory
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
return int(os.environ.get("UTIM_KEEP_TURNS", "10"))
|
|
149
|
+
except ValueError:
|
|
150
|
+
return 10
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def compression_enabled(self) -> bool:
|
|
154
|
+
# Enable adaptive context compression
|
|
155
|
+
return os.environ.get("UTIM_COMPRESSION", "true").lower() in ("1", "true", "yes")
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def blender_path(self) -> str:
|
|
159
|
+
"""Return the absolute path to the Blender executable.
|
|
160
|
+
|
|
161
|
+
Detection order:
|
|
162
|
+
1. Environment variable ``UTIM_BLENDER_PATH``
|
|
163
|
+
2. Stored user config key ``blender_path``
|
|
164
|
+
3. Common Windows install locations
|
|
165
|
+
4. ``shutil.which('blender')`` fallback
|
|
166
|
+
"""
|
|
167
|
+
# 1. explicit env var
|
|
168
|
+
env_path = os.getenv("UTIM_BLENDER_PATH")
|
|
169
|
+
if env_path and os.path.isfile(env_path):
|
|
170
|
+
return env_path
|
|
171
|
+
# 2. stored in config file
|
|
172
|
+
stored = self.get("blender_path")
|
|
173
|
+
if stored and os.path.isfile(stored):
|
|
174
|
+
return stored
|
|
175
|
+
# 3. common install locations (Windows default)
|
|
176
|
+
common_paths = [
|
|
177
|
+
r"C:\\Program Files\\Blender Foundation\\Blender\\blender.exe",
|
|
178
|
+
r"C:\\Program Files (x86)\\Blender Foundation\\Blender\\blender.exe",
|
|
179
|
+
r"E:\\Blender\\blender.exe",
|
|
180
|
+
]
|
|
181
|
+
for p in common_paths:
|
|
182
|
+
if os.path.isfile(p):
|
|
183
|
+
return p
|
|
184
|
+
# 4. which search (covers custom PATH entries)
|
|
185
|
+
which_path = shutil.which("blender")
|
|
186
|
+
if which_path:
|
|
187
|
+
return which_path
|
|
188
|
+
# Fallback – raise informative error later when used
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
# Global config instance
|
|
192
|
+
config = Config()
|
|
193
|
+
# Convenience constant for direct access
|
|
194
|
+
BLENDER_PATH = config.blender_path
|