utim-cli 1.43.20__tar.gz → 1.44.0__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.44.0/CHANGELOG.md +51 -0
- {utim_cli-1.43.20/utim_cli.egg-info → utim_cli-1.44.0}/PKG-INFO +1 -1
- {utim_cli-1.43.20 → utim_cli-1.44.0}/pyproject.toml +1 -1
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/backup.py +32 -8
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/bootstrap.py +38 -14
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/config.py +43 -2
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/orchestrator.py +95 -14
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/situational_scoring.py +24 -4
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/tools.py +6 -4
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/utim.py +311 -152
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/vector_memory.py +29 -7
- {utim_cli-1.43.20 → utim_cli-1.44.0/utim_cli.egg-info}/PKG-INFO +1 -1
- utim_cli-1.43.20/CHANGELOG.md +0 -29
- {utim_cli-1.43.20 → utim_cli-1.44.0}/LICENSE +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/MANIFEST.in +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/README.md +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/setup.cfg +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/setup.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/__init__.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/agent.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/auth.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/billing.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/blender_agent.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/client_utils.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/context_pruner.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/doctor.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/logger.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/mcp_client.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/reflection.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/report.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/share.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/share_tui.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/state.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/workspace.py +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/SOURCES.txt +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the UTIM CLI Agent will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.44.00] - 2026-07-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **New Frontier Model `anthropic/claude-sonnet-5`**: Registered the new flagship Claude 3.5 Sonnet next-gen model across the main agent and subagent registries. Priced at $2.00/1M input and $10.00/1M output base with a 2% UTIM markup, and whitelisted for Hobby/Pro/Team tiers.
|
|
9
|
+
- **Full File Diff Preview in TUI**: Pressing `Space` inside the interactive Undo or Redo list dialog now loads a full-screen, syntax-colored (green/red/cyan) paginated unified diff preview panel displaying exactly what code changes will be reverted or restored.
|
|
10
|
+
- **Local Project-Based Storage Architecture**: Separated local workspace folders into persistent `.utim/` (containing `utim_local.db`, local configurations, and learning experiences) and transient `.utim_tmp/` (containing plans, vector database, web searches, and generated images). Added auto-generation of a root `.gitignore` inside `.utim_tmp` to exclude it from version control.
|
|
11
|
+
- **Workspace Startup UX Notices**: Friendly CLI notification banner at startup informing the user when a project workspace is initialized, detailing what the `.utim/` and `.utim_tmp/` directories store and confirming the exact path where they were created.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **Local SQLite Connection Path**: Configured the SQLite Database URL to dynamically point directly to the project-relative `.utim/utim_local.db` path (resolving lazy CWD relative paths on start) instead of a hardcoded path in the working directory.
|
|
15
|
+
- **ORM JSON Column Serialization**: Swapped raw `Text` columns to native SQLAlchemy `JSON` types for conversation `messages`, `turn_history`, and `redo_history` columns, ensuring transparent SQLite/PostgreSQL serialization and eliminating raw JSON string/list parsing issues on resume.
|
|
16
|
+
- **Divergent History Guard / Warn-on-Dirty-Redo**: Redoing an action after typing new prompts now prompts the user with a confirmation warning that their new post-undo trajectory will be discarded and rewritten, preventing accidental loss of the original redo stack.
|
|
17
|
+
- **Scrollback Buffer Cleared on Undo/Redo**: Swapped `console.clear()` with ANSI escape sequences (`\033[3J\033[H\033[2J`) to completely wipe the scrollback history buffer so undone turns are permanently removed from terminal scrollback.
|
|
18
|
+
- **Faithful Conversation Replay**: Replaced simplified text-only message printing after undo/redo with full `_print_session_history` rendering, faithfully replaying all tool calls and interactive agent actions in their original visual layout.
|
|
19
|
+
- **Chronological Redo Ordering**: Fixed a list slicing bug where redoing multiple steps applied changes backward (newest first). Redoing now chronologically restores turns from the oldest undone prompt up to the selected target.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Resume Dialog Crash**: Fixed a crash in `/resume` dialog causing unhandled exceptions in the event loop by introducing the missing `FileChange` model and the `get_or_create_user` helper in `db.py`.
|
|
23
|
+
- **Deserialization Failure on Session Resume**: Resolved a bug in `_dialog_resume` and `HistoryManager` where the conversation and turn/redo histories were read back as raw strings instead of deserialized JSON lists.
|
|
24
|
+
- **Situational RAG Injection Noise**: Tightened semantic distance threshold (`1.2` to `0.7`) and introduced a `0.55` similarity floor in the Experience DB retrieval engine. Suppressed command-execution boosts on conversational prompts (like "hello"), keeping the context window clean.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## [1.43.20] - 2026-07-01
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **Local-Global Experience Vector DB Sync**: UTIM now carries the ChromaDB experience database across project folders automatically. On startup, the previous project's `experiences.json`, `vector_db`, and vector metadata files are copied to the current folder — deleted from the old folder only after a successful paste. Last active folder is persisted server-side so the sync survives PC restarts.
|
|
31
|
+
- **BYOK Provider Disconnection (key `x`)**: Press `x` in the model selector to view a list of connected custom providers and disconnect them. All associated models are removed from the model list instantly.
|
|
32
|
+
- **Bring Your Own Key (BYOK) for Free Users**: Free-tier users can now connect any OpenAI-compatible provider using their own API key. BYOK models bypass UTIM quota limits and plan gating entirely.
|
|
33
|
+
- **Dynamic Changelog & Version API**: `/api/changelog` parses `CHANGELOG.md` at request time and returns structured JSON. `/health` version field is also derived from `CHANGELOG.md` dynamically — deploying a new changelog auto-updates both the website changelog page and version badge.
|
|
34
|
+
- **Changelog Tab on Website**: The terminal website now includes a `↻ Changelog` tab that fetches live release notes from the Railway server on load.
|
|
35
|
+
- **Features Page Redesign**: The Features tab now displays a 9-card animated grid with per-feature glow icons, numbered labels, colored tags, and spring entrance animations — matching the full terminal aesthetic of the site.
|
|
36
|
+
- **Last Project Folder Persistence**: Server stores each user's last active project directory in the database. Survives PC restarts and is used to drive the cross-folder experience DB sync.
|
|
37
|
+
- **`GET /api/auth/last-folder` and `POST /api/auth/last-folder` Endpoints**: New authenticated REST endpoints to read/write a user's last active project folder path.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- **Plan Upgrade Credit Transition**: Free plan credit allowance ($1.00) is zeroed out instead of rolling over to the new paid plan's quota bank on upgrade.
|
|
41
|
+
- **Hobby Plan Model Access**: Hobby plan `allowed_models` corrected to `"all"` so users can access the full model registry (not just free-tier models).
|
|
42
|
+
- **BYOK Model Gating Bypass**: Orchestrator pre-flight checks now skip plan gating and UTIM credit checks for custom BYOK models since they use user-owned keys.
|
|
43
|
+
- **Pricing Page Claims Cleaned**: Removed unsubstantiated marketing claims (`Dedicated elite endpoints`, `Autonomous team agents access`, `High-speed dedicated endpoints`, `All premium models (GPT-4o, Claude)`, `Synthetic Eye & VSIX tools`, `Claude Sonnet 4.6 (1 RPM)`, `10-session history storage`) from all pricing tier feature lists.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- **BYOK Flow Display Bug**: Rewrote Add/Delete/BYOK-import dialogs to run on `sys.__stdout__` with `prompt_toolkit.prompt()` so they are never swallowed by the alt-screen buffer.
|
|
47
|
+
- **Double-Exit Crash (TUI)**: Wrapped all keyboard exit callbacks (`ENTER`/`ESC`/`q`/`CTRL+C`) in `try...except` blocks to prevent double-fire tracebacks.
|
|
48
|
+
- **`/changelog` Route No Longer Shows Legacy Page**: All website routes (including `/changelog`) now correctly map to the `<PowershellUI />` terminal component. Legacy standalone page removed.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "utim-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.44.00"
|
|
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"
|
|
@@ -4,9 +4,27 @@ import json
|
|
|
4
4
|
import sqlite3
|
|
5
5
|
from utim_cli.logger import log_info, log_error, log_warning
|
|
6
6
|
|
|
7
|
+
def _utim_dir() -> str:
|
|
8
|
+
"""Return the absolute path to the project-local .utim directory."""
|
|
9
|
+
try:
|
|
10
|
+
from utim_cli.config import config
|
|
11
|
+
return str(config.local_dir)
|
|
12
|
+
except Exception:
|
|
13
|
+
return os.path.join(os.getcwd(), ".utim")
|
|
14
|
+
|
|
15
|
+
def _backup_dir() -> str:
|
|
16
|
+
"""Return the absolute path to the project-local .utim_backup directory."""
|
|
17
|
+
try:
|
|
18
|
+
from utim_cli.config import config
|
|
19
|
+
return str(config.local_dir.parent / ".utim_backup")
|
|
20
|
+
except Exception:
|
|
21
|
+
return os.path.join(os.getcwd(), ".utim_backup")
|
|
22
|
+
|
|
23
|
+
# Legacy constants kept for any external code that might import them
|
|
7
24
|
BACKUP_DIR = ".utim_backup"
|
|
8
25
|
UTIM_DIR = ".utim"
|
|
9
26
|
|
|
27
|
+
|
|
10
28
|
def is_json_valid(path: str) -> bool:
|
|
11
29
|
"""Check if JSON file exists and is parseable."""
|
|
12
30
|
if not os.path.exists(path):
|
|
@@ -43,18 +61,20 @@ def is_file_valid(path: str) -> bool:
|
|
|
43
61
|
def backup_state():
|
|
44
62
|
"""Backup active configuration and database files to .utim_backup/."""
|
|
45
63
|
try:
|
|
46
|
-
|
|
64
|
+
utim_dir = _utim_dir()
|
|
65
|
+
backup_dir = _backup_dir()
|
|
66
|
+
if not os.path.exists(utim_dir):
|
|
47
67
|
return
|
|
48
68
|
|
|
49
|
-
os.makedirs(
|
|
69
|
+
os.makedirs(backup_dir, exist_ok=True)
|
|
50
70
|
|
|
51
71
|
files_to_backup = [
|
|
52
72
|
"config.json", "mcp.json", "utim_local.db",
|
|
53
73
|
"memory.json", "session_state.json"
|
|
54
74
|
]
|
|
55
75
|
for f in files_to_backup:
|
|
56
|
-
src = os.path.join(
|
|
57
|
-
dst = os.path.join(
|
|
76
|
+
src = os.path.join(utim_dir, f)
|
|
77
|
+
dst = os.path.join(backup_dir, f)
|
|
58
78
|
if os.path.exists(src):
|
|
59
79
|
# Verify file is not corrupted before backing it up
|
|
60
80
|
if not is_file_valid(src):
|
|
@@ -67,13 +87,16 @@ def backup_state():
|
|
|
67
87
|
except Exception as e:
|
|
68
88
|
log_error("backup", f"State backup failed: {e}", e)
|
|
69
89
|
|
|
90
|
+
|
|
70
91
|
def restore_state() -> bool:
|
|
71
92
|
"""Restore configuration and database files from backup if corrupt or missing."""
|
|
72
93
|
try:
|
|
73
|
-
|
|
94
|
+
utim_dir = _utim_dir()
|
|
95
|
+
backup_dir = _backup_dir()
|
|
96
|
+
if not os.path.exists(backup_dir):
|
|
74
97
|
return False
|
|
75
98
|
|
|
76
|
-
os.makedirs(
|
|
99
|
+
os.makedirs(utim_dir, exist_ok=True)
|
|
77
100
|
restored = False
|
|
78
101
|
|
|
79
102
|
files_to_restore = [
|
|
@@ -81,8 +104,8 @@ def restore_state() -> bool:
|
|
|
81
104
|
"memory.json", "session_state.json"
|
|
82
105
|
]
|
|
83
106
|
for f in files_to_restore:
|
|
84
|
-
src = os.path.join(
|
|
85
|
-
dst = os.path.join(
|
|
107
|
+
src = os.path.join(backup_dir, f)
|
|
108
|
+
dst = os.path.join(utim_dir, f)
|
|
86
109
|
|
|
87
110
|
# Check if destination is missing or corrupted
|
|
88
111
|
needs_restore = not os.path.exists(dst) or not is_file_valid(dst)
|
|
@@ -99,3 +122,4 @@ def restore_state() -> bool:
|
|
|
99
122
|
log_error("backup", f"State restoration failed: {e}", e)
|
|
100
123
|
return False
|
|
101
124
|
|
|
125
|
+
|
|
@@ -114,7 +114,22 @@ description: Guidelines and patterns for asynchronous Python programming using a
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
def initialize_utim() -> str:
|
|
117
|
-
"""Initialize .utim directory, local SQLite database,
|
|
117
|
+
"""Initialize .utim and .utim_tmp directory structure, local SQLite database,
|
|
118
|
+
and auto-create custom skills/rules if they don't exist.
|
|
119
|
+
|
|
120
|
+
Returns the path to the local DB file.
|
|
121
|
+
"""
|
|
122
|
+
from utim_cli.config import config as _cfg
|
|
123
|
+
|
|
124
|
+
# Ensure BOTH project-local directories exist (and capture first-run state)
|
|
125
|
+
utim_first_run, utim_tmp_first_run = _cfg.ensure_project_dirs()
|
|
126
|
+
UTIM_DIR_resolved = _cfg.local_dir
|
|
127
|
+
UTIM_TMP_DIR_resolved = _cfg.local_tmp_dir
|
|
128
|
+
|
|
129
|
+
# Store first-run flag so utim.py can show a welcome hint
|
|
130
|
+
initialize_utim._utim_first_run = utim_first_run # type: ignore[attr-defined]
|
|
131
|
+
initialize_utim._utim_tmp_first_run = utim_tmp_first_run # type: ignore[attr-defined]
|
|
132
|
+
|
|
118
133
|
try:
|
|
119
134
|
from utim_cli.backup import restore_state
|
|
120
135
|
restore_state()
|
|
@@ -122,8 +137,6 @@ def initialize_utim() -> str:
|
|
|
122
137
|
from utim_cli.logger import log_error
|
|
123
138
|
log_error("bootstrap", "Failed to restore state during initialization", e)
|
|
124
139
|
|
|
125
|
-
UTIM_DIR.mkdir(exist_ok=True)
|
|
126
|
-
|
|
127
140
|
# 1. Initialize local_utim database using SQLAlchemy init_db
|
|
128
141
|
try:
|
|
129
142
|
from utim_cli.server.db import init_db
|
|
@@ -143,14 +156,13 @@ def initialize_utim() -> str:
|
|
|
143
156
|
|
|
144
157
|
# 5. Sync global/local experience database across project folders
|
|
145
158
|
try:
|
|
146
|
-
|
|
147
|
-
api_key = config.get("api_key")
|
|
159
|
+
api_key = _cfg.get("api_key")
|
|
148
160
|
if api_key:
|
|
149
161
|
_sync_global_experience(api_key, os.getcwd())
|
|
150
162
|
except Exception:
|
|
151
163
|
pass
|
|
152
164
|
|
|
153
|
-
db_path =
|
|
165
|
+
db_path = UTIM_DIR_resolved / "utim_local.db"
|
|
154
166
|
return str(db_path)
|
|
155
167
|
|
|
156
168
|
|
|
@@ -274,14 +286,16 @@ def _sync_global_experience(api_key: str, current_folder: str):
|
|
|
274
286
|
def _write_skills_and_agents():
|
|
275
287
|
"""Auto-create skills/ directory, AGENTS.md, and all default SKILL.md files inside .utim/."""
|
|
276
288
|
try:
|
|
289
|
+
from utim_cli.config import config
|
|
290
|
+
utim_dir = config.local_dir
|
|
277
291
|
# Write AGENTS.md
|
|
278
|
-
agents_path =
|
|
292
|
+
agents_path = utim_dir / 'AGENTS.md'
|
|
279
293
|
if not agents_path.exists():
|
|
280
294
|
with open(agents_path, 'w', encoding='utf-8') as f:
|
|
281
295
|
f.write(DEFAULT_AGENTS_MD)
|
|
282
296
|
|
|
283
297
|
# Write skills
|
|
284
|
-
skills_dir =
|
|
298
|
+
skills_dir = utim_dir / 'skills'
|
|
285
299
|
for skill_name, content in DEFAULT_SKILLS.items():
|
|
286
300
|
skill_dir = skills_dir / skill_name
|
|
287
301
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -291,13 +305,14 @@ def _write_skills_and_agents():
|
|
|
291
305
|
f.write(content)
|
|
292
306
|
|
|
293
307
|
# Write DESIGN.md
|
|
294
|
-
design_path =
|
|
308
|
+
design_path = utim_dir / 'DESIGN.md'
|
|
295
309
|
if not design_path.exists():
|
|
296
310
|
_write_default_design_md(design_path)
|
|
297
311
|
except Exception as e:
|
|
298
312
|
from utim_cli.logger import log_error
|
|
299
313
|
log_error("bootstrap", "Failed to write skills and agents configuration to .utim/", e)
|
|
300
314
|
|
|
315
|
+
|
|
301
316
|
def _write_default_design_md(md_path: Path):
|
|
302
317
|
content = """# Premium Web Design System (Aesthetics Cheat-Sheet)
|
|
303
318
|
Use this guide to build modern, beautiful, and "award-winning" web interfaces. Apply these principles, variables, and recipes to wow the user.
|
|
@@ -319,7 +334,8 @@ Use this guide to build modern, beautiful, and "award-winning" web interfaces. A
|
|
|
319
334
|
|
|
320
335
|
def _write_analytical_rules_md():
|
|
321
336
|
"""Write the analytical_rules.md file to .utim folder if not exists."""
|
|
322
|
-
|
|
337
|
+
from utim_cli.config import config
|
|
338
|
+
md_path = config.local_dir / 'analytical_rules.md'
|
|
323
339
|
if md_path.exists():
|
|
324
340
|
return
|
|
325
341
|
|
|
@@ -335,9 +351,11 @@ Always start by identifying the TRUE objective, then work backwards to feasible
|
|
|
335
351
|
from utim_cli.logger import log_error
|
|
336
352
|
log_error("bootstrap", f"Failed to write analytical rules markdown to {md_path}", e)
|
|
337
353
|
|
|
354
|
+
|
|
338
355
|
def _write_utim_md():
|
|
339
356
|
"""Write the UTIM.md file to .utim folder if not exists."""
|
|
340
|
-
|
|
357
|
+
from utim_cli.config import config
|
|
358
|
+
md_path = config.local_dir / 'UTIM.md'
|
|
341
359
|
if md_path.exists():
|
|
342
360
|
return
|
|
343
361
|
|
|
@@ -351,10 +369,13 @@ def _write_utim_md():
|
|
|
351
369
|
from utim_cli.logger import log_error
|
|
352
370
|
log_error("bootstrap", f"Failed to write UTIM.md markdown to {md_path}", e)
|
|
353
371
|
|
|
372
|
+
|
|
354
373
|
def get_rag_context(user_prompt: str = "") -> str:
|
|
355
374
|
"""Get RAG context string for system prompt injection by matching user_prompt keywords to skills."""
|
|
356
375
|
try:
|
|
376
|
+
from utim_cli.config import config
|
|
357
377
|
from utim_cli.state import STATE
|
|
378
|
+
utim_dir = config.local_dir
|
|
358
379
|
STATE["injected_contexts"] = []
|
|
359
380
|
|
|
360
381
|
# Simple keyword matching to select relevant skills
|
|
@@ -377,7 +398,7 @@ def get_rag_context(user_prompt: str = "") -> str:
|
|
|
377
398
|
|
|
378
399
|
# If no keywords matched, default to AGENTS.md
|
|
379
400
|
if not matched_skills:
|
|
380
|
-
agents_path =
|
|
401
|
+
agents_path = utim_dir / 'AGENTS.md'
|
|
381
402
|
if agents_path.exists():
|
|
382
403
|
with open(agents_path, 'r', encoding='utf-8') as f:
|
|
383
404
|
content = f.read()
|
|
@@ -387,7 +408,7 @@ def get_rag_context(user_prompt: str = "") -> str:
|
|
|
387
408
|
|
|
388
409
|
context = ""
|
|
389
410
|
for skill_name in matched_skills[:2]: # Load top 2 matched skills
|
|
390
|
-
skill_path =
|
|
411
|
+
skill_path = utim_dir / 'skills' / skill_name / 'SKILL.md'
|
|
391
412
|
if skill_path.exists():
|
|
392
413
|
with open(skill_path, 'r', encoding='utf-8') as f:
|
|
393
414
|
content = f.read()
|
|
@@ -404,9 +425,12 @@ def get_rag_context(user_prompt: str = "") -> str:
|
|
|
404
425
|
log_error("bootstrap", "Failed to get RAG context from skills", e)
|
|
405
426
|
return ""
|
|
406
427
|
|
|
428
|
+
|
|
407
429
|
def get_subagent_rag_context(subagent_name: str, query: str = "") -> str:
|
|
408
430
|
"""Get RAG context string for the specified sub-agent prompt injection by reading from markdown skill files directly."""
|
|
409
431
|
try:
|
|
432
|
+
from utim_cli.config import config
|
|
433
|
+
utim_dir = config.local_dir
|
|
410
434
|
# Map subagent name to the most relevant skill file
|
|
411
435
|
mapping = {
|
|
412
436
|
"project_res": "software-architecture",
|
|
@@ -418,7 +442,7 @@ def get_subagent_rag_context(subagent_name: str, query: str = "") -> str:
|
|
|
418
442
|
if not skill_name:
|
|
419
443
|
return ""
|
|
420
444
|
|
|
421
|
-
skill_path =
|
|
445
|
+
skill_path = utim_dir / 'skills' / skill_name / 'SKILL.md'
|
|
422
446
|
if not skill_path.exists():
|
|
423
447
|
return ""
|
|
424
448
|
|
|
@@ -2,14 +2,55 @@ import json
|
|
|
2
2
|
import shutil
|
|
3
3
|
import os
|
|
4
4
|
import pathlib
|
|
5
|
-
from typing import Any, Dict, Optional
|
|
5
|
+
from typing import Any, Dict, Optional, Tuple
|
|
6
6
|
|
|
7
7
|
class Config:
|
|
8
8
|
def __init__(self):
|
|
9
9
|
self.global_dir = pathlib.Path.home() / ".utim"
|
|
10
|
-
|
|
10
|
+
# local_dir and local_tmp_dir are always relative to the current working
|
|
11
|
+
# directory (the user's project root). They are resolved lazily via
|
|
12
|
+
# properties so that tests that change os.getcwd() see the correct path.
|
|
11
13
|
self._data: Dict[str, Any] = self._load()
|
|
12
14
|
|
|
15
|
+
# ── Project-local storage paths ──────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def local_dir(self) -> pathlib.Path:
|
|
19
|
+
"""Path to the project-local .utim directory (persistent data)."""
|
|
20
|
+
return pathlib.Path(os.getcwd()) / ".utim"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def local_tmp_dir(self) -> pathlib.Path:
|
|
24
|
+
"""Path to the project-local .utim_tmp directory (transient data)."""
|
|
25
|
+
return pathlib.Path(os.getcwd()) / ".utim_tmp"
|
|
26
|
+
|
|
27
|
+
def ensure_project_dirs(self) -> Tuple[bool, bool]:
|
|
28
|
+
"""Create .utim and .utim_tmp if they do not yet exist.
|
|
29
|
+
|
|
30
|
+
Returns (utim_created, utim_tmp_created) — True if the directory was
|
|
31
|
+
just created for the first time (useful for showing first-run messages).
|
|
32
|
+
"""
|
|
33
|
+
utim_created = not self.local_dir.exists()
|
|
34
|
+
utim_tmp_created = not self.local_tmp_dir.exists()
|
|
35
|
+
|
|
36
|
+
self.local_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.local_tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
# Write a .gitignore inside .utim_tmp so transient files are never
|
|
40
|
+
# accidentally committed to version control.
|
|
41
|
+
gitignore = self.local_tmp_dir / ".gitignore"
|
|
42
|
+
if not gitignore.exists():
|
|
43
|
+
try:
|
|
44
|
+
gitignore.write_text(
|
|
45
|
+
"# Auto-generated by UTIM. These files are transient and safe to delete.\n"
|
|
46
|
+
"*\n",
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
return utim_created, utim_tmp_created
|
|
53
|
+
|
|
13
54
|
def _load(self) -> Dict[str, Any]:
|
|
14
55
|
data = {}
|
|
15
56
|
# Load global config
|
|
@@ -361,6 +361,7 @@ class Orchestrator:
|
|
|
361
361
|
# Turn-level file-change tracking
|
|
362
362
|
self.turn_history: List[Dict[str, Any]] = []
|
|
363
363
|
self.redo_history: List[Dict[str, Any]] = []
|
|
364
|
+
self._post_undo_dirty: bool = False # True if user typed a new prompt while redo_history was non-empty
|
|
364
365
|
self._turn_changes: List[Dict[str, Any]] = []
|
|
365
366
|
self._current_turn_start: int = 1
|
|
366
367
|
|
|
@@ -1682,7 +1683,6 @@ class Orchestrator:
|
|
|
1682
1683
|
after = ch.get("after") or ""
|
|
1683
1684
|
b_lines = before.splitlines()
|
|
1684
1685
|
a_lines = after.splitlines()
|
|
1685
|
-
# Simple heuristic: added = lines only in after, removed = lines only in before
|
|
1686
1686
|
b_set = set(b_lines); a_set = set(a_lines)
|
|
1687
1687
|
add_total += len(a_lines) - len([l for l in a_lines if l in b_set])
|
|
1688
1688
|
del_total += len(b_lines) - len([l for l in b_lines if l in a_set])
|
|
@@ -1696,6 +1696,69 @@ class Orchestrator:
|
|
|
1696
1696
|
parts.append(f"[bold red]-{del_total}[/bold red]")
|
|
1697
1697
|
return " ".join(parts) if parts else "No files changed"
|
|
1698
1698
|
|
|
1699
|
+
def get_undo_file_diff(self, from_turn_idx: int, mode: str = "undo") -> List[Dict]:
|
|
1700
|
+
"""Build a per-file diff preview for all turns from from_turn_idx onward (undo)
|
|
1701
|
+
or for all redo_history turns (redo).
|
|
1702
|
+
Returns a list of dicts: {path, action, before_lines, after_lines, added, removed}
|
|
1703
|
+
"""
|
|
1704
|
+
import difflib
|
|
1705
|
+
source_turns: List[Dict] = []
|
|
1706
|
+
if mode == "undo":
|
|
1707
|
+
source_turns = self.turn_history[from_turn_idx:]
|
|
1708
|
+
else: # redo
|
|
1709
|
+
source_turns = list(reversed(self.redo_history))
|
|
1710
|
+
|
|
1711
|
+
# Collapse per-file: keep the first before and last after across all turns
|
|
1712
|
+
file_first_before: Dict[str, Any] = {}
|
|
1713
|
+
file_last_after: Dict[str, Any] = {}
|
|
1714
|
+
file_action: Dict[str, str] = {}
|
|
1715
|
+
for turn in source_turns:
|
|
1716
|
+
for ch in turn.get("changes", []):
|
|
1717
|
+
path = ch["path"]
|
|
1718
|
+
act = ch.get("action", "write")
|
|
1719
|
+
if path not in file_first_before:
|
|
1720
|
+
file_first_before[path] = ch.get("before")
|
|
1721
|
+
file_action[path] = act
|
|
1722
|
+
file_last_after[path] = ch.get("after")
|
|
1723
|
+
|
|
1724
|
+
results: List[Dict] = []
|
|
1725
|
+
for path in file_first_before:
|
|
1726
|
+
before_str = file_first_before[path] or ""
|
|
1727
|
+
after_str = file_last_after.get(path) or ""
|
|
1728
|
+
b_lines = before_str.splitlines(keepends=True)
|
|
1729
|
+
a_lines = after_str.splitlines(keepends=True)
|
|
1730
|
+
|
|
1731
|
+
if mode == "undo":
|
|
1732
|
+
# Undo: show revert direction (after→before)
|
|
1733
|
+
diff_lines = list(difflib.unified_diff(
|
|
1734
|
+
a_lines, b_lines,
|
|
1735
|
+
fromfile=f"{path} (current)",
|
|
1736
|
+
tofile=f"{path} (after undo)",
|
|
1737
|
+
n=3
|
|
1738
|
+
))
|
|
1739
|
+
added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
|
|
1740
|
+
removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
|
|
1741
|
+
else:
|
|
1742
|
+
# Redo: show forward direction (before→after)
|
|
1743
|
+
diff_lines = list(difflib.unified_diff(
|
|
1744
|
+
b_lines, a_lines,
|
|
1745
|
+
fromfile=f"{path} (current)",
|
|
1746
|
+
tofile=f"{path} (after redo)",
|
|
1747
|
+
n=3
|
|
1748
|
+
))
|
|
1749
|
+
added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
|
|
1750
|
+
removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
|
|
1751
|
+
|
|
1752
|
+
results.append({
|
|
1753
|
+
"path": path,
|
|
1754
|
+
"action": file_action[path],
|
|
1755
|
+
"diff_lines": diff_lines,
|
|
1756
|
+
"added": added,
|
|
1757
|
+
"removed": removed,
|
|
1758
|
+
})
|
|
1759
|
+
return results
|
|
1760
|
+
|
|
1761
|
+
|
|
1699
1762
|
def rewind_single_turn(self, turn_idx: int, revert_code: bool = True,
|
|
1700
1763
|
revert_msgs: bool = True) -> Dict[str, Any]:
|
|
1701
1764
|
"""Rewind only a single turn (not all subsequent turns)."""
|
|
@@ -1860,24 +1923,37 @@ class Orchestrator:
|
|
|
1860
1923
|
turn["msg_start"] = msg_start
|
|
1861
1924
|
turn["msg_end"] = msg_end
|
|
1862
1925
|
self.turn_history.append(turn)
|
|
1926
|
+
self._post_undo_dirty = False # Redo completed — dirty flag cleared
|
|
1863
1927
|
|
|
1864
1928
|
# Persist messages and redo history to DB
|
|
1865
1929
|
self._persist_messages()
|
|
1866
1930
|
return res
|
|
1867
1931
|
|
|
1868
1932
|
def redo_up_to_turn(self, redo_idx: int) -> Dict[str, Any]:
|
|
1869
|
-
"""Redo
|
|
1933
|
+
"""Redo undone turns chronologically up to redo_idx turns from the oldest undone.
|
|
1934
|
+
|
|
1935
|
+
redo_history stores turns in reverse-chronological order: [C, B, A] where A
|
|
1936
|
+
is at the end (most recently undone = earliest chronologically = first to redo).
|
|
1937
|
+
|
|
1938
|
+
The dialog shows them in chronological order [A, B, C] so:
|
|
1939
|
+
redo_idx=0 → redo just A (the last item in redo_history)
|
|
1940
|
+
redo_idx=1 → redo A + B (the last 2 items, applied A first)
|
|
1941
|
+
redo_idx=2 → redo A+B+C (all, applied A→B→C)
|
|
1942
|
+
"""
|
|
1870
1943
|
if not hasattr(self, "redo_history") or not self.redo_history or redo_idx >= len(self.redo_history):
|
|
1871
1944
|
return {"reverted": [], "errors": ["No undone turns to redo."]}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
#
|
|
1876
|
-
|
|
1877
|
-
|
|
1945
|
+
|
|
1946
|
+
n = redo_idx + 1 # number of turns to redo
|
|
1947
|
+
|
|
1948
|
+
# Take the n chronologically-oldest turns from the END of redo_history
|
|
1949
|
+
# redo_history = [C, B, A] → last n=2 = [B, A] → reversed = [A, B] ✓
|
|
1950
|
+
turns_to_redo = list(reversed(self.redo_history[-n:]))
|
|
1951
|
+
# Remove those n items from the end
|
|
1952
|
+
self.redo_history = self.redo_history[:-n] if n < len(self.redo_history) else []
|
|
1953
|
+
|
|
1878
1954
|
res: Dict[str, Any] = {"redone_code": [], "errors": []}
|
|
1879
|
-
|
|
1880
|
-
#
|
|
1955
|
+
|
|
1956
|
+
# Apply in chronological order (A → B → C)
|
|
1881
1957
|
for turn in turns_to_redo:
|
|
1882
1958
|
# Redo code changes
|
|
1883
1959
|
for ch in turn.get("changes", []):
|
|
@@ -1901,18 +1977,19 @@ class Orchestrator:
|
|
|
1901
1977
|
res["redone_code"].append(path)
|
|
1902
1978
|
except Exception as e:
|
|
1903
1979
|
res["errors"].append(f"{path}: {e}")
|
|
1904
|
-
|
|
1905
|
-
# Redo messages: append them back
|
|
1980
|
+
|
|
1981
|
+
# Redo messages: append them back in order
|
|
1906
1982
|
msg_start = len(self.messages)
|
|
1907
1983
|
messages_to_add = turn.get("messages", [])
|
|
1908
1984
|
self.messages.extend(messages_to_add)
|
|
1909
1985
|
msg_end = len(self.messages)
|
|
1910
|
-
|
|
1986
|
+
|
|
1911
1987
|
# Reconstruct the turn entry and append back to turn_history
|
|
1912
1988
|
turn["msg_start"] = msg_start
|
|
1913
1989
|
turn["msg_end"] = msg_end
|
|
1914
1990
|
self.turn_history.append(turn)
|
|
1915
|
-
|
|
1991
|
+
|
|
1992
|
+
self._post_undo_dirty = False # Redo completed — dirty flag cleared
|
|
1916
1993
|
self._persist_messages()
|
|
1917
1994
|
return res
|
|
1918
1995
|
|
|
@@ -2620,6 +2697,10 @@ class Orchestrator:
|
|
|
2620
2697
|
pass
|
|
2621
2698
|
|
|
2622
2699
|
self.messages.append({"role": "user", "content": user_message})
|
|
2700
|
+
# Track dirty redo: if there was redo history when the user typed this new prompt,
|
|
2701
|
+
# flag it so /redo can warn them instead of silently losing the redo stack
|
|
2702
|
+
if self.redo_history:
|
|
2703
|
+
self._post_undo_dirty = True
|
|
2623
2704
|
self.redo_history = [] # Clear redo history on new user action
|
|
2624
2705
|
self._persist_messages(in_progress_turn={
|
|
2625
2706
|
"user_msg": user_message,
|
|
@@ -157,11 +157,17 @@ def get_situational_multiplier(content: str, task_type: str, user_prompt: str =
|
|
|
157
157
|
except Exception:
|
|
158
158
|
pass
|
|
159
159
|
|
|
160
|
-
# 2. Command Chaining & Operator Pattern Boosting
|
|
161
|
-
|
|
160
|
+
# 2. Command Chaining & Operator Pattern Boosting — ONLY for explicit command-execution tasks
|
|
161
|
+
# Must match BOTH the task type AND have specific command-execution words (not just "run")
|
|
162
|
+
COMMAND_EXECUTION_WORDS = ["exec", "execute", "command", "shell", "powershell", "cmd", "terminal", "install", "npm run", "pip install", "run tests", "run script"]
|
|
163
|
+
is_command_prompt = any(w in user_prompt.lower() for w in COMMAND_EXECUTION_WORDS)
|
|
162
164
|
if any(k in c_lower for k in ["&&", "command", "shell", "powershell", "cmd", "operator", "separate"]):
|
|
163
|
-
if
|
|
165
|
+
# Only boost if the task is explicitly about running commands AND the prompt is command-related
|
|
166
|
+
if task_type in ["setup", "testing"] and is_command_prompt:
|
|
164
167
|
multiplier *= 2.2
|
|
168
|
+
elif task_type == "general" or not is_command_prompt:
|
|
169
|
+
# Suppress command-operator hints completely for general/conversational prompts
|
|
170
|
+
multiplier *= 0.1
|
|
165
171
|
|
|
166
172
|
# 3. Operating System and Platform Pattern Boosting
|
|
167
173
|
if os.name == "nt": # Windows
|
|
@@ -205,9 +211,19 @@ def get_situational_multiplier(content: str, task_type: str, user_prompt: str =
|
|
|
205
211
|
|
|
206
212
|
|
|
207
213
|
def score_and_filter_context(items: List[Dict], user_prompt: str, limit: int = 5) -> List[Dict]:
|
|
208
|
-
"""Scores a list of retrieved context items
|
|
214
|
+
"""Scores a list of retrieved context items and drops any below a minimum relevance threshold.
|
|
215
|
+
|
|
216
|
+
Items with a base_score below MIN_BASE_SCORE are discarded before situational scoring,
|
|
217
|
+
preventing irrelevant experiences (e.g. PowerShell tips) from being injected on
|
|
218
|
+
unrelated conversational prompts like 'hello'.
|
|
219
|
+
"""
|
|
209
220
|
if not items:
|
|
210
221
|
return []
|
|
222
|
+
|
|
223
|
+
# Minimum cosine-similarity-derived base score to be eligible for injection.
|
|
224
|
+
# base_score = 1/(1+distance), so distance=0.7 → base_score≈0.59.
|
|
225
|
+
# Items with lower scores are semantically too far from the query.
|
|
226
|
+
MIN_BASE_SCORE = 0.55
|
|
211
227
|
|
|
212
228
|
task_type = classify_task_type(user_prompt)
|
|
213
229
|
scored_items = []
|
|
@@ -216,6 +232,10 @@ def score_and_filter_context(items: List[Dict], user_prompt: str, limit: int = 5
|
|
|
216
232
|
content = item.get("content", "")
|
|
217
233
|
base_score = item.get("base_score", 1.0)
|
|
218
234
|
|
|
235
|
+
# Hard gate: drop items that are too semantically distant regardless of boosts
|
|
236
|
+
if base_score < MIN_BASE_SCORE:
|
|
237
|
+
continue
|
|
238
|
+
|
|
219
239
|
# Calculate neural pattern multiplier
|
|
220
240
|
multiplier = get_situational_multiplier(content, task_type, user_prompt=user_prompt)
|
|
221
241
|
situational_score = base_score * multiplier
|
|
@@ -3130,9 +3130,10 @@ def store_experience(category: str, content: str, priority: int = None, subagent
|
|
|
3130
3130
|
from pathlib import Path
|
|
3131
3131
|
|
|
3132
3132
|
try:
|
|
3133
|
-
#
|
|
3134
|
-
|
|
3135
|
-
utim_dir.
|
|
3133
|
+
# Use config.local_dir for project-local path resolution
|
|
3134
|
+
from utim_cli.config import config as _cfg
|
|
3135
|
+
utim_dir = _cfg.local_dir
|
|
3136
|
+
utim_dir.mkdir(parents=True, exist_ok=True)
|
|
3136
3137
|
exp_file = utim_dir / 'experiences.json'
|
|
3137
3138
|
|
|
3138
3139
|
# Load existing experiences
|
|
@@ -3197,9 +3198,10 @@ def recall_experience(query: str, limit: int = 5, subagent: str = None) -> str:
|
|
|
3197
3198
|
from pathlib import Path
|
|
3198
3199
|
import json
|
|
3199
3200
|
from utim_cli.state import STATE
|
|
3201
|
+
from utim_cli.config import config as _cfg
|
|
3200
3202
|
|
|
3201
3203
|
results_str = []
|
|
3202
|
-
utim_dir =
|
|
3204
|
+
utim_dir = _cfg.local_dir
|
|
3203
3205
|
exp_file = utim_dir / 'experiences.json'
|
|
3204
3206
|
|
|
3205
3207
|
# Search experiences from experiences.json
|