utim-cli 1.46.5__tar.gz → 1.46.7__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 (69) hide show
  1. {utim_cli-1.46.5/utim_cli.egg-info → utim_cli-1.46.7}/PKG-INFO +1 -1
  2. {utim_cli-1.46.5 → utim_cli-1.46.7}/pyproject.toml +1 -1
  3. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/auth.py +108 -46
  4. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/bootstrap.py +2 -2
  5. utim_cli-1.46.7/utim_cli/local_db.py +203 -0
  6. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/orchestrator.py +16 -3
  7. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/db.py +23 -0
  8. utim_cli-1.46.7/utim_cli/server/history.py +173 -0
  9. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/auth_routes.py +179 -1
  10. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/history_dialog.py +4 -4
  11. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/model_dialog.py +1 -1
  12. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/resume_dialog.py +5 -7
  13. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/utim.py +74 -22
  14. {utim_cli-1.46.5 → utim_cli-1.46.7/utim_cli.egg-info}/PKG-INFO +1 -1
  15. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/SOURCES.txt +1 -0
  16. utim_cli-1.46.5/utim_cli/server/history.py +0 -130
  17. {utim_cli-1.46.5 → utim_cli-1.46.7}/CHANGELOG.md +0 -0
  18. {utim_cli-1.46.5 → utim_cli-1.46.7}/LICENSE +0 -0
  19. {utim_cli-1.46.5 → utim_cli-1.46.7}/MANIFEST.in +0 -0
  20. {utim_cli-1.46.5 → utim_cli-1.46.7}/README.md +0 -0
  21. {utim_cli-1.46.5 → utim_cli-1.46.7}/setup.cfg +0 -0
  22. {utim_cli-1.46.5 → utim_cli-1.46.7}/setup.py +0 -0
  23. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/__init__.py +0 -0
  24. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/agent.py +0 -0
  25. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/backup.py +0 -0
  26. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/blender_agent.py +0 -0
  27. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/client_utils.py +0 -0
  28. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/config.py +0 -0
  29. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/constants.py +0 -0
  30. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/context_pruner.py +0 -0
  31. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/doctor.py +0 -0
  32. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/knowledge_graph.py +0 -0
  33. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/logger.py +0 -0
  34. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/mcp_clean_wrapper.py +0 -0
  35. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/mcp_client.py +0 -0
  36. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/mcp_registry.json +0 -0
  37. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/reflection.py +0 -0
  38. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/report.py +0 -0
  39. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/scrapy_search.py +0 -0
  40. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/__init__.py +0 -0
  41. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/auth.py +0 -0
  42. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/email_utils.py +0 -0
  43. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/firebase.py +0 -0
  44. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/logging_config.py +0 -0
  45. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/models.py +0 -0
  46. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/rate_limit.py +0 -0
  47. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/router.py +0 -0
  48. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/__init__.py +0 -0
  49. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/completion_routes.py +0 -0
  50. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/credit_routes.py +0 -0
  51. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/quota_routes.py +0 -0
  52. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/session_routes.py +0 -0
  53. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/share_routes.py +0 -0
  54. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/server.py +0 -0
  55. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/share.py +0 -0
  56. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/share_tui.py +0 -0
  57. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/situational_scoring.py +0 -0
  58. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/state.py +0 -0
  59. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tools.py +0 -0
  60. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/__init__.py +0 -0
  61. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/mcp_dialog.py +0 -0
  62. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/tools_dialog.py +0 -0
  63. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/update_dialog.py +0 -0
  64. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/vector_memory.py +0 -0
  65. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/workspace.py +0 -0
  66. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/dependency_links.txt +0 -0
  67. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/entry_points.txt +0 -0
  68. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/requires.txt +0 -0
  69. {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: utim-cli
3
- Version: 1.46.5
3
+ Version: 1.46.7
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.46.5"
7
+ version = "1.46.7"
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"
@@ -90,73 +90,135 @@ class _AuthCallbackHandler(http.server.BaseHTTPRequestHandler):
90
90
 
91
91
  def login() -> None:
92
92
  """
93
- Sign in by prompting the user to copy their UTIM API key from the web dashboard.
93
+ Sign in via the Device Authorization Flow.
94
+
95
+ 1. CLI requests a one-time code from the server.
96
+ 2. CLI displays a short URL + code and starts background polling.
97
+ 3. User visits the URL, signs in via Firebase, clicks Authorize.
98
+ 4. Server marks the code authorized — CLI poll receives the API key.
99
+ 5. Code is immediately burned server-side (single use, never reusable).
94
100
  """
95
- # Sleep briefly to ensure prompt_toolkit's terminal-suspension/clearing sequences
96
- # are fully processed by the terminal emulator before we print.
101
+ import sys
97
102
  import time
103
+ import threading
104
+
105
+ # Sleep briefly to ensure prompt_toolkit's terminal-suspension sequences are flushed
98
106
  time.sleep(0.3)
99
107
 
100
- # ANSI escape codes for formatting
108
+ # ANSI formatting
101
109
  PURPLE = "\033[1;35m"
102
- BLUE = "\033[1;34m"
110
+ BLUE = "\033[1;34m"
103
111
  YELLOW = "\033[1;33m"
104
- WHITE = "\033[1;37m"
105
- RESET = "\033[0m"
106
- DIM = "\033[2m"
107
-
108
- print(f"\n {PURPLE}UTIM CLI Sign-In{RESET}")
109
- print(" 1. Visit the UTIM Profile page in your browser:")
110
- print(f" {BLUE}{WEB_URL}/profile{RESET}")
111
- print(f" 2. Copy your {WHITE}UTIM API Key{RESET}.")
112
- print(" 3. Paste the key below to log in.\n")
112
+ GREEN = "\033[1;32m"
113
+ DIM = "\033[2m"
114
+ BOLD = "\033[1m"
115
+ RESET = "\033[0m"
113
116
 
114
- import sys
117
+ print(f"\n {PURPLE}UTIM CLI — Secure Device Sign-In{RESET}\n")
118
+ print(f" {DIM}Requesting authorization code from UTIM server…{RESET}")
115
119
  sys.stdout.flush()
116
120
 
117
- # Try to open the profile page in a background thread for convenience
118
- threading.Thread(target=webbrowser.open, args=(f"{WEB_URL}/profile",), daemon=True).start()
121
+ # Step 1 request a device code from the server
122
+ try:
123
+ resp = requests.post(
124
+ f"{SERVER_URL}/auth/device/request",
125
+ timeout=15,
126
+ )
127
+ if resp.status_code != 200:
128
+ print(f"\n \033[1;31m✗ Server error (HTTP {resp.status_code}). Please try again.\033[0m\n")
129
+ sys.stdout.flush()
130
+ return
131
+ data = resp.json()
132
+ except Exception as exc:
133
+ print(f"\n \033[1;31m✗ Could not reach UTIM server: {exc}\033[0m\n")
134
+ sys.stdout.flush()
135
+ return
136
+
137
+ device_code = data["device_code"]
138
+ user_code = data["user_code"]
139
+ verify_url = data["verify_url"]
140
+ expires_in = data.get("expires_in", 600)
141
+ poll_interval = data.get("poll_interval", 3)
142
+
143
+ # Step 2 — show the user what to do
144
+ print(f"\n {BOLD}To sign in, visit:{RESET}")
145
+ print(f"\n {BLUE}{verify_url}{RESET}\n")
146
+ print(f" Or visit {BLUE}{WEB_URL}/activate{RESET} and enter code:")
147
+ print(f"\n {YELLOW}{BOLD}{user_code}{RESET}\n")
148
+ print(f" {DIM}Waiting for you to authorize in the browser… (expires in {expires_in // 60} minutes){RESET}\n")
149
+ sys.stdout.flush()
119
150
 
151
+ # Try to open the browser in background (convenience — not required)
152
+ threading.Thread(target=webbrowser.open, args=(verify_url,), daemon=True).start()
153
+
154
+ # Step 3 — poll in background thread, signal main thread via Event
155
+ result: dict = {}
156
+ done_event = threading.Event()
157
+
158
+ def _poll():
159
+ deadline = time.time() + expires_in
160
+ while time.time() < deadline:
161
+ time.sleep(poll_interval)
162
+ try:
163
+ pr = requests.get(
164
+ f"{SERVER_URL}/auth/device/poll",
165
+ params={"device_code": device_code},
166
+ timeout=10,
167
+ )
168
+ if pr.status_code != 200:
169
+ continue
170
+ body = pr.json()
171
+ status = body.get("status")
172
+ if status == "authorized":
173
+ result.update(body)
174
+ done_event.set()
175
+ return
176
+ elif status == "expired":
177
+ result["status"] = "expired"
178
+ done_event.set()
179
+ return
180
+ # status == "pending" → keep polling
181
+ except Exception:
182
+ pass # network hiccup — keep polling
183
+ result["status"] = "expired"
184
+ done_event.set()
185
+
186
+ poll_thread = threading.Thread(target=_poll, daemon=True)
187
+ poll_thread.start()
188
+
189
+ # Step 4 — block the main thread until authorized or expired
190
+ # NOTE: We do NOT call input() here — that would hold an OS-level lock on stdin,
191
+ # causing prompt_toolkit to deadlock after login() returns.
192
+ # Instead we use done_event.wait() which is pure threading with no stdin involvement.
193
+ # Ctrl+C still works to abort (KeyboardInterrupt propagates normally).
120
194
  try:
121
- api_key = input(" Enter UTIM API Key (or press Enter to cancel): ").strip()
122
- except (EOFError, KeyboardInterrupt):
123
- print("\n \033[1;33mCancelled.\033[0m\n")
195
+ done_event.wait(timeout=expires_in + 5)
196
+ except KeyboardInterrupt:
197
+ print(f"\n {YELLOW}Cancelled.{RESET}\n")
124
198
  sys.stdout.flush()
125
199
  return
126
200
 
127
- if not api_key:
128
- print("\n \033[1;33mCancelled.\033[0m\n")
201
+ # Step 5 — process result
202
+ if result.get("status") == "expired":
203
+ print(f"\n \033[1;31m✗ Authorization code expired. Please run /login again.\033[0m\n")
129
204
  sys.stdout.flush()
130
205
  return
131
206
 
132
- print(f" {DIM}Verifying with UTIM server…{RESET}")
133
- sys.stdout.flush()
134
- try:
135
- resp = requests.get(
136
- f"{SERVER_URL}/auth/me",
137
- headers={"X-API-Key": api_key},
138
- timeout=15,
139
- )
140
- if resp.status_code != 200:
141
- print(f" \033[1;31m✗ Invalid API Key or server error (HTTP {resp.status_code}).\033[0m\n")
142
- sys.stdout.flush()
143
- return
144
- payload = resp.json()
145
- except Exception as exc:
146
- print(f" \033[1;31m✗ Verification failed: {exc}\033[0m\n")
207
+ if result.get("status") != "authorized":
208
+ print(f"\n \033[1;31m✗ Authorization failed. Please try again.\033[0m\n")
147
209
  sys.stdout.flush()
148
210
  return
149
211
 
150
212
  # Persist credentials
151
- config.set("token", "direct_key") # sentinel
152
- config.set("email", payload["email"])
153
- config.set("uid", payload["user_id"])
154
- config.set("name", payload.get("display_name") or payload["email"].split("@")[0])
155
- config.set("api_key", api_key)
156
-
157
- print(f" {YELLOW}✓ Welcome back!{RESET}\n")
158
- print(f" Signed in as {BLUE}{payload['email']}{RESET}\n")
159
- print(f" Credits: {payload['credits']:,.0f} UTIM\n")
213
+ config.set("token", "device_flow") # sentinel
214
+ config.set("email", result.get("email", ""))
215
+ config.set("uid", result.get("user_id", ""))
216
+ config.set("name", result.get("display_name") or result.get("email", "").split("@")[0])
217
+ config.set("api_key", result["api_key"])
218
+
219
+ print(f"\n {GREEN}✓ Authorized! Welcome back.{RESET}")
220
+ print(f" Signed in as {BLUE}{result.get('email', '')}{RESET}")
221
+ print(f" Credits: {result.get('credits', 0):,.0f} UTIM\n")
160
222
  sys.stdout.flush()
161
223
 
162
224
 
@@ -126,8 +126,8 @@ def initialize_utim() -> str:
126
126
 
127
127
  # 1. Initialize local_utim database using SQLAlchemy init_db
128
128
  try:
129
- from utim_cli.server.db import init_db
130
- init_db(silent=True)
129
+ from utim_cli.local_db import init_local_db
130
+ init_local_db(silent=True)
131
131
  except Exception as e:
132
132
  from utim_cli.logger import log_error
133
133
  log_error("bootstrap", "Failed to initialize SQLite local database", e)
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import uuid
4
+ import json
5
+ import datetime
6
+ from pathlib import Path
7
+ from typing import List, Dict, Any, Optional
8
+
9
+ from sqlalchemy import (
10
+ Column, String, Text, Integer, Float, BigInteger, DateTime,
11
+ create_engine, event, Index, ForeignKey
12
+ )
13
+ from sqlalchemy.orm import DeclarativeBase, sessionmaker, relationship
14
+
15
+ # ─── Database Base & Models ───────────────────────────────────────────────────
16
+
17
+ class Base(DeclarativeBase):
18
+ pass
19
+
20
+ class User(Base):
21
+ __tablename__ = "users"
22
+ id = Column(Integer, primary_key=True, autoincrement=True)
23
+ email = Column(String(255), unique=True, nullable=False, index=True)
24
+ conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
25
+
26
+ class Conversation(Base):
27
+ __tablename__ = "conversations"
28
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
29
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
30
+ model_id = Column(String(128), nullable=True)
31
+ title = Column(String(255), nullable=True)
32
+ messages = Column(Text, nullable=False, default="[]")
33
+ turn_history = Column(Text, nullable=True, default="[]")
34
+ redo_history = Column(Text, nullable=True, default="[]")
35
+ token_usage_input = Column(BigInteger, default=0)
36
+ token_usage_output = Column(BigInteger, default=0)
37
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
38
+ updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
39
+
40
+ user = relationship("User", back_populates="conversations")
41
+
42
+ # ─── Connection Wiring ───────────────────────────────────────────────────────
43
+
44
+ def _get_local_engine():
45
+ """Return a SQLAlchemy engine connected to .utim/utim_local.db (cwd)."""
46
+ utim_dir = Path(os.getcwd()) / ".utim"
47
+ utim_dir.mkdir(parents=True, exist_ok=True)
48
+ db_path = utim_dir / "utim_local.db"
49
+ url = f"sqlite:///{db_path}"
50
+ engine = create_engine(url, connect_args={"check_same_thread": False})
51
+
52
+ # Ensure WAL mode for safe concurrent access from background threads
53
+ @event.listens_for(engine, "connect")
54
+ def _set_wal(conn, _):
55
+ conn.execute("PRAGMA journal_mode=WAL")
56
+ conn.execute("PRAGMA synchronous=NORMAL")
57
+
58
+ Base.metadata.create_all(bind=engine)
59
+ return engine
60
+
61
+ def _make_session():
62
+ engine = _get_local_engine()
63
+ return sessionmaker(autocommit=False, autoflush=False, bind=engine)()
64
+
65
+ def init_local_db(silent: bool = True):
66
+ """Idempotently initialize local SQLite tables."""
67
+ try:
68
+ _get_local_engine()
69
+ except Exception as e:
70
+ if not silent:
71
+ print(f"Error initializing local DB: {e}")
72
+
73
+ # Default local user for fully local / unauthenticated operation
74
+ LOCAL_EMAIL = os.getenv("UTIM_EMAIL", "local@utim.dev")
75
+
76
+ # ─── JSON Serialization Helpers ─────────────────────────────────────────────
77
+
78
+ def _parse_json_field(value, default=None):
79
+ if default is None:
80
+ default = []
81
+ if value is None:
82
+ return default
83
+ if isinstance(value, (list, dict)):
84
+ return value
85
+ if isinstance(value, str):
86
+ try:
87
+ return json.loads(value)
88
+ except (json.JSONDecodeError, ValueError):
89
+ return default
90
+ return default
91
+
92
+ def _to_json(value):
93
+ if value is None:
94
+ return "[]"
95
+ if isinstance(value, str):
96
+ return value
97
+ return json.dumps(value, ensure_ascii=False)
98
+
99
+ # ─── User Helper ────────────────────────────────────────────────────────────
100
+
101
+ def get_or_create_user(db, email: str) -> User:
102
+ user = db.query(User).filter(User.email == email).first()
103
+ if not user:
104
+ user = User(email=email)
105
+ db.add(user)
106
+ db.commit()
107
+ db.refresh(user)
108
+ return user
109
+
110
+ # ─── History Manager ────────────────────────────────────────────────────────
111
+
112
+ class HistoryManager:
113
+ def __init__(self):
114
+ pass
115
+
116
+ def get_session(self, session_id: str, email: str = None) -> Dict[str, Any] | None:
117
+ db = _make_session()
118
+ conv = db.query(Conversation).filter(Conversation.id == session_id).first()
119
+ if not conv:
120
+ db.close()
121
+ return None
122
+ res = {
123
+ "session_id": conv.id,
124
+ "model_id": conv.model_id,
125
+ "messages": _parse_json_field(conv.messages),
126
+ "turn_history": _parse_json_field(conv.turn_history),
127
+ "redo_history": _parse_json_field(conv.redo_history),
128
+ "usage": {
129
+ "input_tokens": conv.token_usage_input,
130
+ "output_tokens": conv.token_usage_output
131
+ }
132
+ }
133
+ db.close()
134
+ return res
135
+
136
+ def create_session(self, model_id: str, email: str = None) -> str:
137
+ email = email or LOCAL_EMAIL
138
+ db = _make_session()
139
+ user = get_or_create_user(db, email)
140
+ session_id = str(uuid.uuid4())
141
+ new_conv = Conversation(
142
+ id=session_id,
143
+ user_id=user.id,
144
+ model_id=model_id,
145
+ messages=_to_json([]),
146
+ turn_history=_to_json([]),
147
+ redo_history=_to_json([])
148
+ )
149
+ db.add(new_conv)
150
+ db.commit()
151
+ db.close()
152
+ return session_id
153
+
154
+ 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):
155
+ email = email or LOCAL_EMAIL
156
+ db = _make_session()
157
+ user = get_or_create_user(db, email)
158
+
159
+ conv = db.query(Conversation).filter(Conversation.id == session_id).first()
160
+ if conv:
161
+ conv.user_id = user.id
162
+ conv.messages = _to_json(messages)
163
+ if turn_history is not None:
164
+ conv.turn_history = _to_json(turn_history)
165
+ if redo_history is not None:
166
+ conv.redo_history = _to_json(redo_history)
167
+
168
+ if first_user_msg and not conv.title:
169
+ conv.title = (first_user_msg[:80] + "\u2026" if len(first_user_msg) > 80 else first_user_msg)
170
+
171
+ db.commit()
172
+ db.close()
173
+
174
+ def checkpoint(self, session_id: str, messages: List[Dict[str, Any]], email: str = None, description: str = "auto"):
175
+ pass
176
+
177
+ def list_sessions(self, email: str = None):
178
+ db = _make_session()
179
+ conversations = db.query(Conversation).all()
180
+ sessions = []
181
+ for c in conversations:
182
+ msgs = _parse_json_field(c.messages)
183
+ has_user_msg = any(isinstance(m, dict) and m.get("role") == "user" for m in msgs)
184
+ if not has_user_msg:
185
+ continue
186
+ first_user = next(
187
+ (m.get("content", "") for m in msgs if isinstance(m, dict) and m.get("role") == "user"),
188
+ ""
189
+ )
190
+ if isinstance(first_user, list):
191
+ first_user = " ".join(p.get("text", "") for p in first_user if isinstance(p, dict))
192
+ title = c.title or (first_user[:70] + "\u2026" if len(first_user) > 70 else first_user) or c.id[:12]
193
+ sessions.append({
194
+ "session_id": c.id,
195
+ "model_id": c.model_id,
196
+ "message_count": len(msgs),
197
+ "title": title,
198
+ "created_at": c.created_at.isoformat() if hasattr(c, "created_at") and c.created_at else "",
199
+ "updated_at": c.updated_at.isoformat() if c.updated_at else "",
200
+ })
201
+ db.close()
202
+ sessions.sort(key=lambda s: s["updated_at"], reverse=True)
203
+ return sessions
@@ -376,7 +376,7 @@ class Orchestrator:
376
376
  if not self.session_id:
377
377
  # Create a local session if we don't have one
378
378
  try:
379
- from utim_cli.server.history import HistoryManager
379
+ from utim_cli.local_db import HistoryManager
380
380
  hm = HistoryManager()
381
381
  # Use the orchestrator's email (which defaults to local@utim.dev)
382
382
  user_email = self.email or os.getenv("UTIM_EMAIL", "local@utim.dev")
@@ -430,10 +430,10 @@ class Orchestrator:
430
430
  ]
431
431
  clean_redo_history.append(clean_turn)
432
432
 
433
- # Save to local database
433
+ # Save to local database and session_state.json
434
434
  def _save_local():
435
435
  try:
436
- from utim_cli.server.history import HistoryManager
436
+ from utim_cli.local_db import HistoryManager
437
437
  hm = HistoryManager()
438
438
  hm.add_messages(
439
439
  self.session_id,
@@ -443,6 +443,19 @@ class Orchestrator:
443
443
  turn_history=clean_turn_history,
444
444
  redo_history=clean_redo_history
445
445
  )
446
+
447
+ # Write current active session state to session_state.json
448
+ state_data = {
449
+ "session_id": self.session_id,
450
+ "messages": clean_messages,
451
+ "turn_history": clean_turn_history,
452
+ "redo_history": clean_redo_history,
453
+ "model_id": self.model_id
454
+ }
455
+ os.makedirs(".utim", exist_ok=True)
456
+ with open(".utim/session_state.json", "w", encoding="utf-8") as f:
457
+ json.dump(state_data, f, ensure_ascii=False, indent=4)
458
+
446
459
  from utim_cli.backup import backup_state
447
460
  backup_state()
448
461
  except Exception:
@@ -204,6 +204,29 @@ class PaymentOrder(Base):
204
204
  user = relationship("User", back_populates="payment_orders")
205
205
 
206
206
 
207
+ class DeviceAuthCode(Base):
208
+ """Single-use login token for the Device Authorization Flow (RFC 8628 inspired).
209
+
210
+ Flow:
211
+ 1. CLI POSTs /auth/device/request → gets device_code + user_code + verify_url
212
+ 2. CLI polls /auth/device/poll?device_code=... every 3 seconds
213
+ 3. User visits verify_url, signs in via Firebase, clicks Authorize
214
+ 4. Server stores api_key in this row, sets status='authorized'
215
+ 5. CLI poll response returns api_key, row is immediately invalidated
216
+ """
217
+ __tablename__ = "device_auth_codes"
218
+
219
+ id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
220
+ device_code = Column(String(64), unique=True, nullable=False, index=True,
221
+ default=lambda: uuid.uuid4().hex) # secret, only CLI sees this
222
+ user_code = Column(String(9), unique=True, nullable=False, index=True) # e.g. "DFRG-TYHJ"
223
+ status = Column(String(16), default="pending", nullable=False) # pending|authorized|expired
224
+ api_key = Column(String(64), nullable=True) # populated on authorization
225
+ user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
226
+ expires_at = Column(DateTime, nullable=False)
227
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
228
+
229
+
207
230
  class EmotionVector(Base):
208
231
  __tablename__ = "emotion_vectors"
209
232
 
@@ -0,0 +1,173 @@
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
+ def _get_local_engine():
20
+ """Return a SQLAlchemy engine connected to .utim/utim_local.db (cwd)."""
21
+ utim_dir = Path(os.getcwd()) / ".utim"
22
+ utim_dir.mkdir(parents=True, exist_ok=True)
23
+ db_path = utim_dir / "utim_local.db"
24
+ url = f"sqlite:///{db_path}"
25
+ engine = create_engine(url, connect_args={"check_same_thread": False})
26
+ # Ensure WAL mode for safe concurrent access from background threads
27
+ @event.listens_for(engine, "connect")
28
+ def _set_wal(conn, _):
29
+ conn.execute("PRAGMA journal_mode=WAL")
30
+ conn.execute("PRAGMA synchronous=NORMAL")
31
+ # Create all tables if they don't exist yet (idempotent)
32
+ Base.metadata.create_all(bind=engine)
33
+ return engine
34
+
35
+ def _make_session():
36
+ engine = _get_local_engine()
37
+ return sessionmaker(autocommit=False, autoflush=False, bind=engine)()
38
+
39
+ # Default local user for fully local / unauthenticated operation
40
+ LOCAL_EMAIL = os.getenv("UTIM_EMAIL", "local@utim.dev")
41
+
42
+
43
+ def _parse_json_field(value, default=None):
44
+ """Safely parse a DB field that may be a JSON string (SQLite Text) or already
45
+ a Python object (PostgreSQL JSONB). Returns `default` on any failure."""
46
+ if default is None:
47
+ default = []
48
+ if value is None:
49
+ return default
50
+ if isinstance(value, (list, dict)):
51
+ return value # PostgreSQL JSONB already parsed
52
+ if isinstance(value, str):
53
+ try:
54
+ return json.loads(value)
55
+ except (json.JSONDecodeError, ValueError):
56
+ return default
57
+ return default
58
+
59
+
60
+ def _to_json(value):
61
+ """Serialize a Python list/dict to a JSON string for SQLite Text storage.
62
+ If the value is already a string, return it as-is."""
63
+ if value is None:
64
+ return "[]"
65
+ if isinstance(value, str):
66
+ return value
67
+ return json.dumps(value, ensure_ascii=False)
68
+
69
+
70
+ def get_or_create_user(db, email: str) -> User:
71
+ user = db.query(User).filter(User.email == email).first()
72
+ if not user:
73
+ user = User(email=email)
74
+ db.add(user)
75
+ db.commit()
76
+ db.refresh(user)
77
+ return user
78
+
79
+
80
+ class HistoryManager:
81
+ def __init__(self):
82
+ pass
83
+
84
+ def get_session(self, session_id: str, email: str = None) -> Dict[str, Any] | None:
85
+ db = _make_session()
86
+ conv = db.query(Conversation).filter(Conversation.id == session_id).first()
87
+ if not conv:
88
+ db.close()
89
+ return None
90
+ res = {
91
+ "session_id": conv.id,
92
+ "model_id": conv.model_id,
93
+ "messages": _parse_json_field(conv.messages),
94
+ "turn_history": _parse_json_field(conv.turn_history),
95
+ "redo_history": _parse_json_field(conv.redo_history),
96
+ "usage": {
97
+ "input_tokens": conv.token_usage_input,
98
+ "output_tokens": conv.token_usage_output
99
+ }
100
+ }
101
+ db.close()
102
+ return res
103
+
104
+ def create_session(self, model_id: str, email: str = None) -> str:
105
+ email = email or LOCAL_EMAIL
106
+ db = _make_session()
107
+ user = get_or_create_user(db, email)
108
+ session_id = str(uuid.uuid4())
109
+ new_conv = Conversation(
110
+ id=session_id,
111
+ user_id=user.id,
112
+ model_id=model_id,
113
+ messages=_to_json([]),
114
+ turn_history=_to_json([]),
115
+ redo_history=_to_json([])
116
+ )
117
+ db.add(new_conv)
118
+ db.commit()
119
+ db.close()
120
+ return session_id
121
+
122
+ 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):
123
+ email = email or LOCAL_EMAIL
124
+ db = _make_session()
125
+ user = get_or_create_user(db, email)
126
+
127
+ conv = db.query(Conversation).filter(Conversation.id == session_id).first()
128
+ if conv:
129
+ # Dynamically update user association when credentials change
130
+ conv.user_id = user.id
131
+ conv.messages = _to_json(messages)
132
+ if turn_history is not None:
133
+ conv.turn_history = _to_json(turn_history)
134
+ if redo_history is not None:
135
+ conv.redo_history = _to_json(redo_history)
136
+
137
+ # Update title from first user message if we have one
138
+ if first_user_msg and not conv.title:
139
+ conv.title = (first_user_msg[:80] + "\u2026" if len(first_user_msg) > 80 else first_user_msg)
140
+
141
+ db.commit()
142
+ db.close()
143
+
144
+ def checkpoint(self, session_id: str, messages: List[Dict[str, Any]], email: str = None, description: str = "auto"):
145
+ pass
146
+
147
+ def list_sessions(self, email: str = None):
148
+ db = _make_session()
149
+ conversations = db.query(Conversation).all()
150
+ sessions = []
151
+ for c in conversations:
152
+ msgs = _parse_json_field(c.messages)
153
+ has_user_msg = any(isinstance(m, dict) and m.get("role") == "user" for m in msgs)
154
+ if not has_user_msg:
155
+ continue
156
+ first_user = next(
157
+ (m.get("content", "") for m in msgs if isinstance(m, dict) and m.get("role") == "user"),
158
+ ""
159
+ )
160
+ if isinstance(first_user, list):
161
+ first_user = " ".join(p.get("text", "") for p in first_user if isinstance(p, dict))
162
+ title = c.title or (first_user[:70] + "\u2026" if len(first_user) > 70 else first_user) or c.id[:12]
163
+ sessions.append({
164
+ "session_id": c.id,
165
+ "model_id": c.model_id,
166
+ "message_count": len(msgs),
167
+ "title": title,
168
+ "created_at": c.created_at.isoformat() if hasattr(c, "created_at") and c.created_at else "",
169
+ "updated_at": c.updated_at.isoformat() if c.updated_at else "",
170
+ })
171
+ db.close()
172
+ sessions.sort(key=lambda s: s["updated_at"], reverse=True)
173
+ return sessions