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.
- {utim_cli-1.46.5/utim_cli.egg-info → utim_cli-1.46.7}/PKG-INFO +1 -1
- {utim_cli-1.46.5 → utim_cli-1.46.7}/pyproject.toml +1 -1
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/auth.py +108 -46
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/bootstrap.py +2 -2
- utim_cli-1.46.7/utim_cli/local_db.py +203 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/orchestrator.py +16 -3
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/db.py +23 -0
- utim_cli-1.46.7/utim_cli/server/history.py +173 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/auth_routes.py +179 -1
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/history_dialog.py +4 -4
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/model_dialog.py +1 -1
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/resume_dialog.py +5 -7
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/utim.py +74 -22
- {utim_cli-1.46.5 → utim_cli-1.46.7/utim_cli.egg-info}/PKG-INFO +1 -1
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/SOURCES.txt +1 -0
- utim_cli-1.46.5/utim_cli/server/history.py +0 -130
- {utim_cli-1.46.5 → utim_cli-1.46.7}/CHANGELOG.md +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/LICENSE +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/MANIFEST.in +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/README.md +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/setup.cfg +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/setup.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/__init__.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/agent.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/backup.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/blender_agent.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/client_utils.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/config.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/constants.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/context_pruner.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/doctor.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/knowledge_graph.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/logger.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/mcp_clean_wrapper.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/mcp_client.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/mcp_registry.json +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/reflection.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/report.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/scrapy_search.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/__init__.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/auth.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/email_utils.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/firebase.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/logging_config.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/models.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/rate_limit.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/router.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/__init__.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/completion_routes.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/credit_routes.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/quota_routes.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/session_routes.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/routes/share_routes.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/server/server.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/share.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/share_tui.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/situational_scoring.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/state.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tools.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/__init__.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/mcp_dialog.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/tools_dialog.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/tui/update_dialog.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/vector_memory.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli/workspace.py +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/dependency_links.txt +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/entry_points.txt +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/requires.txt +0 -0
- {utim_cli-1.46.5 → utim_cli-1.46.7}/utim_cli.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "utim-cli"
|
|
7
|
-
version = "1.46.
|
|
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
|
|
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
|
-
|
|
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
|
|
108
|
+
# ANSI formatting
|
|
101
109
|
PURPLE = "\033[1;35m"
|
|
102
|
-
BLUE
|
|
110
|
+
BLUE = "\033[1;34m"
|
|
103
111
|
YELLOW = "\033[1;33m"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
except
|
|
123
|
-
print("\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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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",
|
|
152
|
-
config.set("email",
|
|
153
|
-
config.set("uid",
|
|
154
|
-
config.set("name",
|
|
155
|
-
config.set("api_key",
|
|
156
|
-
|
|
157
|
-
print(f" {
|
|
158
|
-
print(f" Signed in as {BLUE}{
|
|
159
|
-
print(f" Credits: {
|
|
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.
|
|
130
|
-
|
|
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.
|
|
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.
|
|
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
|