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.
- {utim_cli-2.0.2 → utim_cli-2.0.4}/CHANGELOG.md +10 -0
- {utim_cli-2.0.2/utim_cli.egg-info → utim_cli-2.0.4}/PKG-INFO +1 -1
- {utim_cli-2.0.2 → utim_cli-2.0.4}/pyproject.toml +1 -1
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/__init__.py +1 -1
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_version.py +1 -1
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/context_pruner.py +3 -2
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/orchestrator.py +7 -6
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/reflection.py +0 -1
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/history.py +187 -187
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tools.py +7 -4
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/marketplace_dialog.py +4185 -4180
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/publish_dialog.py +973 -973
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/utim.py +2 -2
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/vector_memory.py +0 -1
- {utim_cli-2.0.2 → utim_cli-2.0.4/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-2.0.2 → utim_cli-2.0.4}/LICENSE +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/MANIFEST.in +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/README.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/setup.cfg +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/setup.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_check_obj.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_find_mcp.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_find_plan.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_find_tool.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_patch_multimodal.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/_test_regex.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/agent.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/auth.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/backup.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/billing.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/blender_agent.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/bootstrap.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/client_utils.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/config.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/constants.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/doctor.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/harbor.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/local_db.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/logger.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/mcp_client.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/report.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/__init__.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/auth.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/db.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/about.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/changelog.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/docs.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/features.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/license.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/pricing.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/privacy.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/refund.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/support.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/docs_md/terms.md +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/exchange_rate.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/firebase.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/models.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/pricing_updater.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/router.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/auth_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/completion_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/credit_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/feedback_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/marketplace_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/quota_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/quota_share_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/referral_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/security_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/server.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/server/storage_nodes.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/share.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/share_tui.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/state.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/feedback_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/history_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/marketplace_app_state.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/marketplace_layout_engine.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/miniagents_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/model_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/quota_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/quota_redeem_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/quota_share_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/resume_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/skills_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/subagents_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/utilities.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli/workspace.py +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/SOURCES.txt +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-2.0.2 → utim_cli-2.0.4}/utim_cli.egg-info/requires.txt +0 -0
- {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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "utim-cli"
|
|
7
|
-
version = "2.0.
|
|
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"
|
|
@@ -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
|
|
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"
|
|
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
|
-
#
|
|
1186
|
-
|
|
1187
|
-
|
|
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(
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
from sqlalchemy import
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
4784
|
+
utim_dir = get_utim_dir()
|
|
4782
4785
|
exp_file = utim_dir / 'experiences.json'
|
|
4783
4786
|
|
|
4784
4787
|
# Search experiences from experiences.json
|