utim-cli 2.0.2__tar.gz → 2.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {utim_cli-2.0.2 → utim_cli-2.0.4}/CHANGELOG.md +10 -0
  2. {utim_cli-2.0.2/utim_cli.egg-info → utim_cli-2.0.4}/PKG-INFO +1 -1
  3. {utim_cli-2.0.2 → utim_cli-2.0.4}/pyproject.toml +1 -1
  4. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/__init__.py +1 -1
  5. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_version.py +1 -1
  6. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/context_pruner.py +3 -2
  7. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/orchestrator.py +7 -6
  8. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/reflection.py +0 -1
  9. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/history.py +187 -187
  10. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tools.py +7 -4
  11. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/marketplace_dialog.py +4185 -4180
  12. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/publish_dialog.py +973 -973
  13. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/utim.py +2 -2
  14. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/vector_memory.py +0 -1
  15. {utim_cli-2.0.2 → utim_cli-2.0.4/utim_cli.egg-info}/PKG-INFO +1 -1
  16. {utim_cli-2.0.2 → utim_cli-2.0.4}/LICENSE +0 -0
  17. {utim_cli-2.0.2 → utim_cli-2.0.4}/MANIFEST.in +0 -0
  18. {utim_cli-2.0.2 → utim_cli-2.0.4}/README.md +0 -0
  19. {utim_cli-2.0.2 → utim_cli-2.0.4}/setup.cfg +0 -0
  20. {utim_cli-2.0.2 → utim_cli-2.0.4}/setup.py +0 -0
  21. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_check_obj.py +0 -0
  22. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_find_mcp.py +0 -0
  23. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_find_plan.py +0 -0
  24. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_find_tool.py +0 -0
  25. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_patch_multimodal.py +0 -0
  26. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_test_regex.py +0 -0
  27. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/agent.py +0 -0
  28. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/auth.py +0 -0
  29. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/backup.py +0 -0
  30. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/billing.py +0 -0
  31. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/blender_agent.py +0 -0
  32. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/bootstrap.py +0 -0
  33. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/client_utils.py +0 -0
  34. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/config.py +0 -0
  35. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/constants.py +0 -0
  36. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/doctor.py +0 -0
  37. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/harbor.py +0 -0
  38. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/knowledge_graph.py +0 -0
  39. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/local_db.py +0 -0
  40. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/logger.py +0 -0
  41. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/mcp_clean_wrapper.py +0 -0
  42. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/mcp_client.py +0 -0
  43. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/mcp_registry.json +0 -0
  44. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/report.py +0 -0
  45. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/scrapy_search.py +0 -0
  46. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/__init__.py +0 -0
  47. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/auth.py +0 -0
  48. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/db.py +0 -0
  49. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/about.md +0 -0
  50. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/changelog.md +0 -0
  51. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/docs.md +0 -0
  52. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/features.md +0 -0
  53. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/license.md +0 -0
  54. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/pricing.md +0 -0
  55. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/privacy.md +0 -0
  56. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/refund.md +0 -0
  57. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/support.md +0 -0
  58. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/terms.md +0 -0
  59. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/email_utils.py +0 -0
  60. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/exchange_rate.py +0 -0
  61. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/firebase.py +0 -0
  62. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/logging_config.py +0 -0
  63. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/models.py +0 -0
  64. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/pricing_updater.py +0 -0
  65. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/rate_limit.py +0 -0
  66. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/router.py +0 -0
  67. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/__init__.py +0 -0
  68. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/auth_routes.py +0 -0
  69. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/completion_routes.py +0 -0
  70. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/credit_routes.py +0 -0
  71. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/feedback_routes.py +0 -0
  72. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/marketplace_routes.py +0 -0
  73. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/quota_routes.py +0 -0
  74. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/quota_share_routes.py +0 -0
  75. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/referral_routes.py +0 -0
  76. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/security_routes.py +0 -0
  77. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/session_routes.py +0 -0
  78. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/share_routes.py +0 -0
  79. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/server.py +0 -0
  80. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/storage_nodes.py +0 -0
  81. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/share.py +0 -0
  82. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/share_tui.py +0 -0
  83. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/situational_scoring.py +0 -0
  84. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/state.py +0 -0
  85. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/__init__.py +0 -0
  86. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/feedback_dialog.py +0 -0
  87. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/history_dialog.py +0 -0
  88. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/marketplace_app_state.py +0 -0
  89. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  90. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/mcp_dialog.py +0 -0
  91. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/miniagents_dialog.py +0 -0
  92. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/model_dialog.py +0 -0
  93. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/quota_dialog.py +0 -0
  94. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  95. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/quota_share_dialog.py +0 -0
  96. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/resume_dialog.py +0 -0
  97. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/skills_dialog.py +0 -0
  98. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/subagents_dialog.py +0 -0
  99. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/tools_dialog.py +0 -0
  100. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/update_dialog.py +0 -0
  101. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/utilities.py +0 -0
  102. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/workspace.py +0 -0
  103. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/SOURCES.txt +0 -0
  104. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/dependency_links.txt +0 -0
  105. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/entry_points.txt +0 -0
  106. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/requires.txt +0 -0
  107. {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.4] - 2026-08-05
4
+
5
+ ### 🛠️ Fixes
6
+ - **Fixed `Wrong color format 'dim'` crash in publish dialog**: Corrected style strings in `utim_cli/tui/publish_dialog.py` to use `class:dim fg:#hex` format instead of `dim fg:#hex`, preventing the `ValueError: Wrong color format 'dim'` crash when using prompt_toolkit 3.0.40+.
7
+
8
+ ## [2.0.3] - 2026-08-04
9
+
10
+ ### 🛠️ Fixes
11
+ - **Fixed `Wrong color format 'dim'` crash in marketplace TUI**: `prompt_toolkit` does not accept the `dim` modifier as a free-floating token before a color, nor `class:dim` as a literal inline style. All `f"dim {_MUTED}"` / `f"dim {_FG}"` style strings in `utim_cli/tui/marketplace_dialog.py` have been rewritten to `f"fg:{_MUTED}"` / `f"fg:{_FG}"`, and the `[dim]…[/dim]` markup parser now drops the `dim` token instead of emitting the unsafe `class:dim` class string. `/marketplace` no longer crashes on Windows when rendering info/hint/status rows.
12
+
3
13
  ## [2.0.2] - 2026-08-04
4
14
 
5
15
  ### 🛠️ Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.0.2"
7
+ version = "2.0.4"
8
8
  description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "2.0.2"
8
+ __version__ = "2.0.4"
9
9
 
10
10
  import builtins, os, pathlib, urllib.parse, re
11
11
 
@@ -11,4 +11,4 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.0.2"
14
+ VERSION = "2.0.4"
@@ -13,11 +13,12 @@ import json
13
13
  import hashlib
14
14
  from utim_cli.constants import DEFAULT_MODEL
15
15
 
16
- # Fallback models for compression operations - ordered by reliability and quality
16
+ # Fallback models for compression operations - ordered by reliability and quality.
17
+ # "openrouter/free" is the first fallback tried after the primary model.
17
18
  COMPRESSION_FALLBACK_MODELS = [
19
+ "openrouter/free",
18
20
  "poolside/laguna-xs-2.1:free",
19
21
  "cohere/north-mini-code:free",
20
- "openrouter/free",
21
22
  ]
22
23
 
23
24
  # Track what content has already been summarized to prevent duplicate summarization
@@ -1172,9 +1172,9 @@ class Orchestrator:
1172
1172
  except Exception:
1173
1173
  pass
1174
1174
 
1175
- # Load user-saved API key from local .utim/.env (written by the setup wizard / /auth)
1175
+ # Load user-saved API key from global ~/.utim/.env (written by the setup wizard / /auth)
1176
1176
  import pathlib
1177
- _user_env = pathlib.Path(".utim").resolve() / ".env"
1177
+ _user_env = pathlib.Path.home() / ".utim" / ".env"
1178
1178
  if _user_env.exists():
1179
1179
  try:
1180
1180
  from dotenv import load_dotenv as _load_dotenv
@@ -1182,12 +1182,13 @@ class Orchestrator:
1182
1182
  except Exception:
1183
1183
  pass
1184
1184
 
1185
- # Load user-saved API key from global ~/.utim/.env (written by the setup wizard / /auth)
1186
- _global_env = pathlib.Path.home() / ".utim" / ".env"
1187
- if _global_env.exists():
1185
+ # Legacy fallback: also check cwd-relative .utim/.env (pre-global-install
1186
+ # folders) so old installs keep working, but global ~/.utim always wins.
1187
+ _legacy_env = pathlib.Path(".utim").resolve() / ".env"
1188
+ if _legacy_env.exists():
1188
1189
  try:
1189
1190
  from dotenv import load_dotenv as _load_dotenv
1190
- _load_dotenv(_global_env, override=False) # override=False: env vars win
1191
+ _load_dotenv(_legacy_env, override=False) # override=False: env vars win
1191
1192
  except Exception:
1192
1193
  pass
1193
1194
 
@@ -1300,7 +1300,6 @@ def apply_skill_modifications(skill_modifications: Dict[str, Any], **kwargs):
1300
1300
 
1301
1301
  paths_to_write = [
1302
1302
  utim_base / folder_name / skill_name / "SKILL.md",
1303
- Path(f".utim/{folder_name}") / skill_name / "SKILL.md",
1304
1303
  ]
1305
1304
 
1306
1305
  for skill_path in paths_to_write:
@@ -1,187 +1,187 @@
1
- from __future__ import annotations
2
- import json
3
- import os
4
- import uuid
5
- from pathlib import Path
6
- from typing import List, Dict, Any, Optional
7
-
8
- from sqlalchemy import create_engine, event
9
- from sqlalchemy.orm import sessionmaker
10
-
11
- from .db import Base, Conversation, User
12
-
13
- # ── Local DB wiring ───────────────────────────────────────────────────────────
14
- # Always use .utim/utim_local.db in the CURRENT WORKING DIRECTORY.
15
- # This is the file bootstrap.py creates and backup.py backs up.
16
- # We do NOT use the DATABASE_URL from server/db.py that points at the
17
- # production PostgreSQL server, which is unreachable from the local CLI.
18
-
19
- _local_engine = None
20
-
21
- def _get_local_engine():
22
- """Return a SQLAlchemy engine connected to .utim/utim_local.db (cwd)."""
23
- global _local_engine
24
- if _local_engine is None:
25
- utim_dir = Path(os.getcwd()) / ".utim"
26
- utim_dir.mkdir(parents=True, exist_ok=True)
27
- db_path = utim_dir / "utim_local.db"
28
- url = f"sqlite:///{db_path}"
29
- _local_engine = create_engine(url, connect_args={"check_same_thread": False})
30
- # Ensure WAL mode for safe concurrent access from background threads
31
- @event.listens_for(_local_engine, "connect")
32
- def _set_wal(conn, _):
33
- conn.execute("PRAGMA journal_mode=WAL")
34
- conn.execute("PRAGMA synchronous=NORMAL")
35
- # Create all tables if they don't exist yet (idempotent)
36
- Base.metadata.create_all(bind=_local_engine)
37
- return _local_engine
38
-
39
- def close_history_db():
40
- """Close and dispose of the local SQLite database engine."""
41
- global _local_engine
42
- print(f"[DEBUG] close_history_db: _local_engine is {_local_engine}", flush=True)
43
- if _local_engine is not None:
44
- _local_engine.dispose()
45
- _local_engine = None
46
-
47
-
48
-
49
- def _make_session():
50
- engine = _get_local_engine()
51
- return sessionmaker(autocommit=False, autoflush=False, bind=engine)()
52
-
53
- # Default local user for fully local / unauthenticated operation
54
- LOCAL_EMAIL = os.getenv("UTIM_EMAIL", "local@utim.dev")
55
-
56
-
57
- def _parse_json_field(value, default=None):
58
- """Safely parse a DB field that may be a JSON string (SQLite Text) or already
59
- a Python object (PostgreSQL JSONB). Returns `default` on any failure."""
60
- if default is None:
61
- default = []
62
- if value is None:
63
- return default
64
- if isinstance(value, (list, dict)):
65
- return value # PostgreSQL JSONB already parsed
66
- if isinstance(value, str):
67
- try:
68
- return json.loads(value)
69
- except (json.JSONDecodeError, ValueError):
70
- return default
71
- return default
72
-
73
-
74
- def _to_json(value):
75
- """Serialize a Python list/dict to a JSON string for SQLite Text storage.
76
- If the value is already a string, return it as-is."""
77
- if value is None:
78
- return "[]"
79
- if isinstance(value, str):
80
- return value
81
- return json.dumps(value, ensure_ascii=False)
82
-
83
-
84
- def get_or_create_user(db, email: str) -> User:
85
- user = db.query(User).filter(User.email == email).first()
86
- if not user:
87
- user = User(email=email)
88
- db.add(user)
89
- db.commit()
90
- db.refresh(user)
91
- return user
92
-
93
-
94
- class HistoryManager:
95
- def __init__(self):
96
- pass
97
-
98
- def get_session(self, session_id: str, email: str = None) -> Dict[str, Any] | None:
99
- db = _make_session()
100
- conv = db.query(Conversation).filter(Conversation.id == session_id).first()
101
- if not conv:
102
- db.close()
103
- return None
104
- res = {
105
- "session_id": conv.id,
106
- "model_id": conv.model_id,
107
- "messages": _parse_json_field(conv.messages),
108
- "turn_history": _parse_json_field(conv.turn_history),
109
- "redo_history": _parse_json_field(conv.redo_history),
110
- "usage": {
111
- "input_tokens": conv.token_usage_input,
112
- "output_tokens": conv.token_usage_output
113
- }
114
- }
115
- db.close()
116
- return res
117
-
118
- def create_session(self, model_id: str, email: str = None) -> str:
119
- email = email or LOCAL_EMAIL
120
- db = _make_session()
121
- user = get_or_create_user(db, email)
122
- session_id = str(uuid.uuid4())
123
- new_conv = Conversation(
124
- id=session_id,
125
- user_id=user.id,
126
- model_id=model_id,
127
- messages=_to_json([]),
128
- turn_history=_to_json([]),
129
- redo_history=_to_json([])
130
- )
131
- db.add(new_conv)
132
- db.commit()
133
- db.close()
134
- return session_id
135
-
136
- def add_messages(self, session_id: str, messages: List[Dict[str, Any]], email: str = None, first_user_msg: str = "", turn_history: List[Dict[str, Any]] = None, redo_history: List[Dict[str, Any]] = None):
137
- email = email or LOCAL_EMAIL
138
- db = _make_session()
139
- user = get_or_create_user(db, email)
140
-
141
- conv = db.query(Conversation).filter(Conversation.id == session_id).first()
142
- if conv:
143
- # Dynamically update user association when credentials change
144
- conv.user_id = user.id
145
- conv.messages = _to_json(messages)
146
- if turn_history is not None:
147
- conv.turn_history = _to_json(turn_history)
148
- if redo_history is not None:
149
- conv.redo_history = _to_json(redo_history)
150
-
151
- # Update title from first user message if we have one
152
- if first_user_msg and not conv.title:
153
- conv.title = (first_user_msg[:80] + "\u2026" if len(first_user_msg) > 80 else first_user_msg)
154
-
155
- db.commit()
156
- db.close()
157
-
158
- def checkpoint(self, session_id: str, messages: List[Dict[str, Any]], email: str = None, description: str = "auto"):
159
- pass
160
-
161
- def list_sessions(self, email: str = None):
162
- db = _make_session()
163
- conversations = db.query(Conversation).all()
164
- sessions = []
165
- for c in conversations:
166
- msgs = _parse_json_field(c.messages)
167
- has_user_msg = any(isinstance(m, dict) and m.get("role") == "user" for m in msgs)
168
- if not has_user_msg:
169
- continue
170
- first_user = next(
171
- (m.get("content", "") for m in msgs if isinstance(m, dict) and m.get("role") == "user"),
172
- ""
173
- )
174
- if isinstance(first_user, list):
175
- first_user = " ".join(p.get("text", "") for p in first_user if isinstance(p, dict))
176
- title = c.title or (first_user[:70] + "\u2026" if len(first_user) > 70 else first_user) or c.id[:12]
177
- sessions.append({
178
- "session_id": c.id,
179
- "model_id": c.model_id,
180
- "message_count": len(msgs),
181
- "title": title,
182
- "created_at": c.created_at.isoformat() if hasattr(c, "created_at") and c.created_at else "",
183
- "updated_at": c.updated_at.isoformat() if c.updated_at else "",
184
- })
185
- db.close()
186
- sessions.sort(key=lambda s: s["updated_at"], reverse=True)
187
- return sessions
1
+ from __future__ import annotations
2
+ import json
3
+ import os
4
+ import uuid
5
+ from typing import List, Dict, Any, Optional
6
+
7
+ from sqlalchemy import create_engine, event
8
+ from sqlalchemy.orm import sessionmaker
9
+
10
+ from .db import Base, Conversation, User
11
+
12
+ # ── Local DB wiring ───────────────────────────────────────────────────────────
13
+ # Always use .utim/utim_local.db in the CURRENT WORKING DIRECTORY.
14
+ # This is the file bootstrap.py creates and backup.py backs up.
15
+ # We do NOT use the DATABASE_URL from server/db.py that points at the
16
+ # production PostgreSQL server, which is unreachable from the local CLI.
17
+
18
+ _local_engine = None
19
+
20
+ def _get_local_engine():
21
+ """Return a SQLAlchemy engine connected to ~/.utim/utim_local.db (global home)."""
22
+ global _local_engine
23
+ if _local_engine is None:
24
+ from utim_cli.config import get_utim_dir
25
+ utim_dir = get_utim_dir()
26
+ utim_dir.mkdir(parents=True, exist_ok=True)
27
+ db_path = utim_dir / "utim_local.db"
28
+ url = f"sqlite:///{db_path}"
29
+ _local_engine = create_engine(url, connect_args={"check_same_thread": False})
30
+ # Ensure WAL mode for safe concurrent access from background threads
31
+ @event.listens_for(_local_engine, "connect")
32
+ def _set_wal(conn, _):
33
+ conn.execute("PRAGMA journal_mode=WAL")
34
+ conn.execute("PRAGMA synchronous=NORMAL")
35
+ # Create all tables if they don't exist yet (idempotent)
36
+ Base.metadata.create_all(bind=_local_engine)
37
+ return _local_engine
38
+
39
+ def close_history_db():
40
+ """Close and dispose of the local SQLite database engine."""
41
+ global _local_engine
42
+ print(f"[DEBUG] close_history_db: _local_engine is {_local_engine}", flush=True)
43
+ if _local_engine is not None:
44
+ _local_engine.dispose()
45
+ _local_engine = None
46
+
47
+
48
+
49
+ def _make_session():
50
+ engine = _get_local_engine()
51
+ return sessionmaker(autocommit=False, autoflush=False, bind=engine)()
52
+
53
+ # Default local user for fully local / unauthenticated operation
54
+ LOCAL_EMAIL = os.getenv("UTIM_EMAIL", "local@utim.dev")
55
+
56
+
57
+ def _parse_json_field(value, default=None):
58
+ """Safely parse a DB field that may be a JSON string (SQLite Text) or already
59
+ a Python object (PostgreSQL JSONB). Returns `default` on any failure."""
60
+ if default is None:
61
+ default = []
62
+ if value is None:
63
+ return default
64
+ if isinstance(value, (list, dict)):
65
+ return value # PostgreSQL JSONB already parsed
66
+ if isinstance(value, str):
67
+ try:
68
+ return json.loads(value)
69
+ except (json.JSONDecodeError, ValueError):
70
+ return default
71
+ return default
72
+
73
+
74
+ def _to_json(value):
75
+ """Serialize a Python list/dict to a JSON string for SQLite Text storage.
76
+ If the value is already a string, return it as-is."""
77
+ if value is None:
78
+ return "[]"
79
+ if isinstance(value, str):
80
+ return value
81
+ return json.dumps(value, ensure_ascii=False)
82
+
83
+
84
+ def get_or_create_user(db, email: str) -> User:
85
+ user = db.query(User).filter(User.email == email).first()
86
+ if not user:
87
+ user = User(email=email)
88
+ db.add(user)
89
+ db.commit()
90
+ db.refresh(user)
91
+ return user
92
+
93
+
94
+ class HistoryManager:
95
+ def __init__(self):
96
+ pass
97
+
98
+ def get_session(self, session_id: str, email: str = None) -> Dict[str, Any] | None:
99
+ db = _make_session()
100
+ conv = db.query(Conversation).filter(Conversation.id == session_id).first()
101
+ if not conv:
102
+ db.close()
103
+ return None
104
+ res = {
105
+ "session_id": conv.id,
106
+ "model_id": conv.model_id,
107
+ "messages": _parse_json_field(conv.messages),
108
+ "turn_history": _parse_json_field(conv.turn_history),
109
+ "redo_history": _parse_json_field(conv.redo_history),
110
+ "usage": {
111
+ "input_tokens": conv.token_usage_input,
112
+ "output_tokens": conv.token_usage_output
113
+ }
114
+ }
115
+ db.close()
116
+ return res
117
+
118
+ def create_session(self, model_id: str, email: str = None) -> str:
119
+ email = email or LOCAL_EMAIL
120
+ db = _make_session()
121
+ user = get_or_create_user(db, email)
122
+ session_id = str(uuid.uuid4())
123
+ new_conv = Conversation(
124
+ id=session_id,
125
+ user_id=user.id,
126
+ model_id=model_id,
127
+ messages=_to_json([]),
128
+ turn_history=_to_json([]),
129
+ redo_history=_to_json([])
130
+ )
131
+ db.add(new_conv)
132
+ db.commit()
133
+ db.close()
134
+ return session_id
135
+
136
+ def add_messages(self, session_id: str, messages: List[Dict[str, Any]], email: str = None, first_user_msg: str = "", turn_history: List[Dict[str, Any]] = None, redo_history: List[Dict[str, Any]] = None):
137
+ email = email or LOCAL_EMAIL
138
+ db = _make_session()
139
+ user = get_or_create_user(db, email)
140
+
141
+ conv = db.query(Conversation).filter(Conversation.id == session_id).first()
142
+ if conv:
143
+ # Dynamically update user association when credentials change
144
+ conv.user_id = user.id
145
+ conv.messages = _to_json(messages)
146
+ if turn_history is not None:
147
+ conv.turn_history = _to_json(turn_history)
148
+ if redo_history is not None:
149
+ conv.redo_history = _to_json(redo_history)
150
+
151
+ # Update title from first user message if we have one
152
+ if first_user_msg and not conv.title:
153
+ conv.title = (first_user_msg[:80] + "\u2026" if len(first_user_msg) > 80 else first_user_msg)
154
+
155
+ db.commit()
156
+ db.close()
157
+
158
+ def checkpoint(self, session_id: str, messages: List[Dict[str, Any]], email: str = None, description: str = "auto"):
159
+ pass
160
+
161
+ def list_sessions(self, email: str = None):
162
+ db = _make_session()
163
+ conversations = db.query(Conversation).all()
164
+ sessions = []
165
+ for c in conversations:
166
+ msgs = _parse_json_field(c.messages)
167
+ has_user_msg = any(isinstance(m, dict) and m.get("role") == "user" for m in msgs)
168
+ if not has_user_msg:
169
+ continue
170
+ first_user = next(
171
+ (m.get("content", "") for m in msgs if isinstance(m, dict) and m.get("role") == "user"),
172
+ ""
173
+ )
174
+ if isinstance(first_user, list):
175
+ first_user = " ".join(p.get("text", "") for p in first_user if isinstance(p, dict))
176
+ title = c.title or (first_user[:70] + "\u2026" if len(first_user) > 70 else first_user) or c.id[:12]
177
+ sessions.append({
178
+ "session_id": c.id,
179
+ "model_id": c.model_id,
180
+ "message_count": len(msgs),
181
+ "title": title,
182
+ "created_at": c.created_at.isoformat() if hasattr(c, "created_at") and c.created_at else "",
183
+ "updated_at": c.updated_at.isoformat() if c.updated_at else "",
184
+ })
185
+ db.close()
186
+ sessions.sort(key=lambda s: s["updated_at"], reverse=True)
187
+ return sessions
@@ -2699,7 +2699,8 @@ def manage_memory(action: str, key: str = "", content: str = "",
2699
2699
  """
2700
2700
  import os, json, pathlib, time as _time
2701
2701
  from utim_cli.state import STATE
2702
- mem_file = pathlib.Path(".utim").resolve() / "memory.json"
2702
+ from utim_cli.config import get_utim_dir
2703
+ mem_file = get_utim_dir() / "memory.json"
2703
2704
  os.makedirs(mem_file.parent, exist_ok=True)
2704
2705
 
2705
2706
  memories: dict = {}
@@ -4707,10 +4708,11 @@ def store_experience(category: str, content: str, priority: int = None, subagent
4707
4708
  import json
4708
4709
  from datetime import datetime
4709
4710
  from pathlib import Path
4711
+ from utim_cli.config import get_utim_dir
4710
4712
 
4711
4713
  try:
4712
- # Ensure .utim directory exists
4713
- utim_dir = Path('.utim')
4714
+ # Ensure .utim directory exists (global ~/.utim, resolves per-user profile)
4715
+ utim_dir = get_utim_dir()
4714
4716
  utim_dir.mkdir(exist_ok=True)
4715
4717
  exp_file = utim_dir / 'experiences.json'
4716
4718
 
@@ -4776,9 +4778,10 @@ def recall_experience(query: str, limit: int = 5, subagent: str = None) -> str:
4776
4778
  from pathlib import Path
4777
4779
  import json
4778
4780
  from utim_cli.state import STATE
4781
+ from utim_cli.config import get_utim_dir
4779
4782
 
4780
4783
  results_str = []
4781
- utim_dir = Path('.utim')
4784
+ utim_dir = get_utim_dir()
4782
4785
  exp_file = utim_dir / 'experiences.json'
4783
4786
 
4784
4787
  # Search experiences from experiences.json