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.
Files changed (43) hide show
  1. utim_cli-1.44.0/CHANGELOG.md +51 -0
  2. {utim_cli-1.43.20/utim_cli.egg-info → utim_cli-1.44.0}/PKG-INFO +1 -1
  3. {utim_cli-1.43.20 → utim_cli-1.44.0}/pyproject.toml +1 -1
  4. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/backup.py +32 -8
  5. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/bootstrap.py +38 -14
  6. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/config.py +43 -2
  7. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/orchestrator.py +95 -14
  8. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/situational_scoring.py +24 -4
  9. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/tools.py +6 -4
  10. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/utim.py +311 -152
  11. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/vector_memory.py +29 -7
  12. {utim_cli-1.43.20 → utim_cli-1.44.0/utim_cli.egg-info}/PKG-INFO +1 -1
  13. utim_cli-1.43.20/CHANGELOG.md +0 -29
  14. {utim_cli-1.43.20 → utim_cli-1.44.0}/LICENSE +0 -0
  15. {utim_cli-1.43.20 → utim_cli-1.44.0}/MANIFEST.in +0 -0
  16. {utim_cli-1.43.20 → utim_cli-1.44.0}/README.md +0 -0
  17. {utim_cli-1.43.20 → utim_cli-1.44.0}/setup.cfg +0 -0
  18. {utim_cli-1.43.20 → utim_cli-1.44.0}/setup.py +0 -0
  19. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/__init__.py +0 -0
  20. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/agent.py +0 -0
  21. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/auth.py +0 -0
  22. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/billing.py +0 -0
  23. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/blender_agent.py +0 -0
  24. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/client_utils.py +0 -0
  25. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/context_pruner.py +0 -0
  26. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/doctor.py +0 -0
  27. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/knowledge_graph.py +0 -0
  28. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/logger.py +0 -0
  29. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/mcp_clean_wrapper.py +0 -0
  30. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/mcp_client.py +0 -0
  31. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/mcp_registry.json +0 -0
  32. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/reflection.py +0 -0
  33. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/report.py +0 -0
  34. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/scrapy_search.py +0 -0
  35. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/share.py +0 -0
  36. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/share_tui.py +0 -0
  37. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/state.py +0 -0
  38. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli/workspace.py +0 -0
  39. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/SOURCES.txt +0 -0
  40. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/dependency_links.txt +0 -0
  41. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/entry_points.txt +0 -0
  42. {utim_cli-1.43.20 → utim_cli-1.44.0}/utim_cli.egg-info/requires.txt +0 -0
  43. {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
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utim-cli
3
- Version: 1.43.20
3
+ Version: 1.44.0
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 = "1.43.20"
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
- if not os.path.exists(UTIM_DIR):
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(BACKUP_DIR, exist_ok=True)
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(UTIM_DIR, f)
57
- dst = os.path.join(BACKUP_DIR, f)
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
- if not os.path.exists(BACKUP_DIR):
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(UTIM_DIR, exist_ok=True)
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(BACKUP_DIR, f)
85
- dst = os.path.join(UTIM_DIR, f)
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, and auto-create custom skills/rules if they don't exist."""
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
- from utim_cli.config import config
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 = UTIM_DIR / 'utim_local.db'
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 = UTIM_DIR / 'AGENTS.md'
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 = UTIM_DIR / 'skills'
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 = UTIM_DIR / 'DESIGN.md'
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
- md_path = UTIM_DIR / 'analytical_rules.md'
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
- md_path = UTIM_DIR / 'UTIM.md'
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 = UTIM_DIR / 'AGENTS.md'
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 = UTIM_DIR / 'skills' / skill_name / 'SKILL.md'
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 = UTIM_DIR / 'skills' / skill_name / 'SKILL.md'
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
- self.local_dir = pathlib.Path(".utim").resolve()
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 all undone turns from index 0 up to redo_idx (inclusive)."""
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
- # Get the slice of turns to redo
1874
- turns_to_redo = self.redo_history[:redo_idx + 1]
1875
- # Keep the remaining undone turns
1876
- self.redo_history = self.redo_history[redo_idx + 1:]
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
- # Redo them in order
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 (e.g., && vs ; separation)
161
- is_command_prompt = any(w in user_prompt.lower() for w in ["run", "exec", "command", "shell", "powershell", "cmd", "terminal", "install", "test"])
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 task_type in ["setup", "testing"] or is_command_prompt:
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 based on neural conceptual pattern matching."""
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
- # Ensure .utim directory exists
3134
- utim_dir = Path('.utim')
3135
- utim_dir.mkdir(exist_ok=True)
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 = Path('.utim')
3204
+ utim_dir = _cfg.local_dir
3203
3205
  exp_file = utim_dir / 'experiences.json'
3204
3206
 
3205
3207
  # Search experiences from experiences.json