cite-agent 1.0.0__py3-none-any.whl
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.
Potentially problematic release.
This version of cite-agent might be problematic. Click here for more details.
- cite_agent/__distribution__.py +7 -0
- cite_agent/__init__.py +66 -0
- cite_agent/account_client.py +130 -0
- cite_agent/agent_backend_only.py +172 -0
- cite_agent/ascii_plotting.py +296 -0
- cite_agent/auth.py +281 -0
- cite_agent/backend_only_client.py +83 -0
- cite_agent/cli.py +512 -0
- cite_agent/cli_enhanced.py +207 -0
- cite_agent/dashboard.py +339 -0
- cite_agent/enhanced_ai_agent.py +172 -0
- cite_agent/rate_limiter.py +298 -0
- cite_agent/setup_config.py +417 -0
- cite_agent/telemetry.py +85 -0
- cite_agent/ui.py +175 -0
- cite_agent/updater.py +187 -0
- cite_agent/web_search.py +203 -0
- cite_agent-1.0.0.dist-info/METADATA +234 -0
- cite_agent-1.0.0.dist-info/RECORD +23 -0
- cite_agent-1.0.0.dist-info/WHEEL +5 -0
- cite_agent-1.0.0.dist-info/entry_points.txt +3 -0
- cite_agent-1.0.0.dist-info/licenses/LICENSE +21 -0
- cite_agent-1.0.0.dist-info/top_level.txt +1 -0
cite_agent/auth.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication System for Nocturnal Archive
|
|
3
|
+
Handles user login, registration, and session management
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Dict
|
|
13
|
+
import requests
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
|
|
16
|
+
class AuthenticationError(Exception):
|
|
17
|
+
"""Authentication failed"""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
class AuthManager:
|
|
21
|
+
"""Manages user authentication and sessions"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
24
|
+
self.config_dir = config_dir or Path.home() / ".nocturnal_archive"
|
|
25
|
+
self.config_dir.mkdir(exist_ok=True)
|
|
26
|
+
|
|
27
|
+
self.session_file = self.config_dir / "session.json"
|
|
28
|
+
self.api_base = os.getenv("NOCTURNAL_AUTH_API", "https://api.nocturnal.dev")
|
|
29
|
+
|
|
30
|
+
def login(self, email: str, password: str) -> Dict:
|
|
31
|
+
"""
|
|
32
|
+
Authenticate user with email and password
|
|
33
|
+
Returns user session data
|
|
34
|
+
"""
|
|
35
|
+
# Hash password
|
|
36
|
+
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
# Call auth API
|
|
40
|
+
response = requests.post(
|
|
41
|
+
f"{self.api_base}/auth/login",
|
|
42
|
+
json={
|
|
43
|
+
"email": email,
|
|
44
|
+
"password_hash": password_hash
|
|
45
|
+
},
|
|
46
|
+
timeout=10
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if response.status_code == 200:
|
|
50
|
+
session_data = response.json()
|
|
51
|
+
self._save_session(session_data)
|
|
52
|
+
return session_data
|
|
53
|
+
elif response.status_code == 401:
|
|
54
|
+
raise AuthenticationError("Invalid email or password")
|
|
55
|
+
else:
|
|
56
|
+
raise AuthenticationError(f"Login failed: {response.status_code}")
|
|
57
|
+
|
|
58
|
+
except requests.RequestException as e:
|
|
59
|
+
# Fallback: offline mode with local validation
|
|
60
|
+
return self._offline_login(email, password_hash)
|
|
61
|
+
|
|
62
|
+
def register(self, email: str, password: str) -> Dict:
|
|
63
|
+
"""
|
|
64
|
+
Register new user with email and password only (no license key for beta)
|
|
65
|
+
Returns user session data
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
response = requests.post(
|
|
69
|
+
f"{self.api_base}/auth/register",
|
|
70
|
+
json={
|
|
71
|
+
"email": email,
|
|
72
|
+
"password": password # Send plain password over HTTPS (backend will hash)
|
|
73
|
+
},
|
|
74
|
+
timeout=10
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if response.status_code == 201:
|
|
78
|
+
session_data = response.json()
|
|
79
|
+
self._save_session(session_data)
|
|
80
|
+
return session_data
|
|
81
|
+
elif response.status_code == 409:
|
|
82
|
+
raise AuthenticationError("Email already registered")
|
|
83
|
+
else:
|
|
84
|
+
raise AuthenticationError(f"Registration failed: {response.status_code}")
|
|
85
|
+
|
|
86
|
+
except requests.RequestException as e:
|
|
87
|
+
# Fallback: create local session (beta mode)
|
|
88
|
+
return self._offline_register(email, password)
|
|
89
|
+
|
|
90
|
+
def get_session(self) -> Optional[Dict]:
|
|
91
|
+
"""Get current session if valid"""
|
|
92
|
+
if not self.session_file.exists():
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
with open(self.session_file, 'r') as f:
|
|
97
|
+
session = json.load(f)
|
|
98
|
+
|
|
99
|
+
# Check if session expired
|
|
100
|
+
expires_at = datetime.fromisoformat(session.get('expires_at', '2000-01-01'))
|
|
101
|
+
if datetime.now() > expires_at:
|
|
102
|
+
self.logout()
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
return session
|
|
106
|
+
|
|
107
|
+
except (json.JSONDecodeError, KeyError):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def logout(self):
|
|
111
|
+
"""Clear current session"""
|
|
112
|
+
if self.session_file.exists():
|
|
113
|
+
self.session_file.unlink()
|
|
114
|
+
|
|
115
|
+
def refresh_session(self) -> bool:
|
|
116
|
+
"""Refresh session token"""
|
|
117
|
+
session = self.get_session()
|
|
118
|
+
if not session:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
response = requests.post(
|
|
123
|
+
f"{self.api_base}/auth/refresh",
|
|
124
|
+
headers={"Authorization": f"Bearer {session['token']}"},
|
|
125
|
+
timeout=10
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if response.status_code == 200:
|
|
129
|
+
new_session = response.json()
|
|
130
|
+
self._save_session(new_session)
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
except requests.RequestException:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def _save_session(self, session_data: Dict):
|
|
139
|
+
"""Save session to file"""
|
|
140
|
+
# Add expiration (30 days from now)
|
|
141
|
+
if 'expires_at' not in session_data:
|
|
142
|
+
expires_at = datetime.now() + timedelta(days=30)
|
|
143
|
+
session_data['expires_at'] = expires_at.isoformat()
|
|
144
|
+
|
|
145
|
+
with open(self.session_file, 'w') as f:
|
|
146
|
+
json.dump(session_data, f, indent=2)
|
|
147
|
+
|
|
148
|
+
# Secure permissions (owner only)
|
|
149
|
+
os.chmod(self.session_file, 0o600)
|
|
150
|
+
|
|
151
|
+
def _offline_login(self, email: str, password_hash: str) -> Dict:
|
|
152
|
+
"""Offline login fallback (beta mode)"""
|
|
153
|
+
# Check if user exists in local cache
|
|
154
|
+
users_file = self.config_dir / "users.json"
|
|
155
|
+
|
|
156
|
+
if not users_file.exists():
|
|
157
|
+
raise AuthenticationError("No internet connection and no local user found")
|
|
158
|
+
|
|
159
|
+
with open(users_file, 'r') as f:
|
|
160
|
+
users = json.load(f)
|
|
161
|
+
|
|
162
|
+
user = users.get(email)
|
|
163
|
+
if not user or user['password_hash'] != password_hash:
|
|
164
|
+
raise AuthenticationError("Invalid credentials")
|
|
165
|
+
|
|
166
|
+
# Create offline session
|
|
167
|
+
session = {
|
|
168
|
+
"email": email,
|
|
169
|
+
"user_id": user['user_id'],
|
|
170
|
+
"access_token": "offline-" + hashlib.sha256(email.encode()).hexdigest()[:16],
|
|
171
|
+
"daily_token_limit": user.get('daily_token_limit', 25000),
|
|
172
|
+
"expires_at": (datetime.now() + timedelta(days=30)).isoformat(),
|
|
173
|
+
"offline_mode": True
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
self._save_session(session)
|
|
177
|
+
return session
|
|
178
|
+
|
|
179
|
+
def _offline_register(self, email: str, password: str) -> Dict:
|
|
180
|
+
"""Offline registration fallback (beta mode) - no license key needed"""
|
|
181
|
+
# Hash password
|
|
182
|
+
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
|
183
|
+
|
|
184
|
+
# Save user locally
|
|
185
|
+
users_file = self.config_dir / "users.json"
|
|
186
|
+
users = {}
|
|
187
|
+
|
|
188
|
+
if users_file.exists():
|
|
189
|
+
with open(users_file, 'r') as f:
|
|
190
|
+
users = json.load(f)
|
|
191
|
+
|
|
192
|
+
if email in users:
|
|
193
|
+
raise AuthenticationError("Email already registered")
|
|
194
|
+
|
|
195
|
+
user_id = hashlib.sha256(email.encode()).hexdigest()[:12]
|
|
196
|
+
users[email] = {
|
|
197
|
+
"user_id": user_id,
|
|
198
|
+
"password_hash": password_hash,
|
|
199
|
+
"license_key": license_key,
|
|
200
|
+
"daily_limit": 25,
|
|
201
|
+
"created_at": datetime.now().isoformat()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
with open(users_file, 'w') as f:
|
|
205
|
+
json.dump(users, f, indent=2)
|
|
206
|
+
|
|
207
|
+
os.chmod(users_file, 0o600)
|
|
208
|
+
|
|
209
|
+
# Create session
|
|
210
|
+
session = {
|
|
211
|
+
"email": email,
|
|
212
|
+
"user_id": user_id,
|
|
213
|
+
"access_token": "offline-" + hashlib.sha256(email.encode()).hexdigest()[:16],
|
|
214
|
+
"daily_token_limit": 25000,
|
|
215
|
+
"expires_at": (datetime.now() + timedelta(days=30)).isoformat(),
|
|
216
|
+
"offline_mode": True
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
self._save_session(session)
|
|
220
|
+
return session
|
|
221
|
+
|
|
222
|
+
def _validate_license_format(self, license_key: str) -> bool:
|
|
223
|
+
"""Validate license key format: NA-BETA-{uid}-{expiry}-{checksum}"""
|
|
224
|
+
parts = license_key.split("-")
|
|
225
|
+
return (
|
|
226
|
+
len(parts) == 5 and
|
|
227
|
+
parts[0] == "NA" and
|
|
228
|
+
parts[1] == "BETA" and
|
|
229
|
+
len(parts[2]) == 8 and # user_id
|
|
230
|
+
len(parts[3]) == 8 and # expiry date
|
|
231
|
+
len(parts[4]) == 8 # checksum
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def generate_license_key(self, email: str, days: int = 30) -> str:
|
|
235
|
+
"""
|
|
236
|
+
Generate a license key for a user
|
|
237
|
+
Format: NA-BETA-{user_id}-{expiry}-{checksum}
|
|
238
|
+
"""
|
|
239
|
+
# Generate user ID from email
|
|
240
|
+
user_id = hashlib.sha256(email.encode()).hexdigest()[:8]
|
|
241
|
+
|
|
242
|
+
# Generate expiry date
|
|
243
|
+
expiry_date = datetime.now() + timedelta(days=days)
|
|
244
|
+
expiry_str = expiry_date.strftime("%Y%m%d")
|
|
245
|
+
|
|
246
|
+
# Generate checksum
|
|
247
|
+
data = f"{email}{user_id}{expiry_str}"
|
|
248
|
+
checksum = hashlib.sha256(data.encode()).hexdigest()[:8]
|
|
249
|
+
|
|
250
|
+
# Construct license key
|
|
251
|
+
license_key = f"NA-BETA-{user_id}-{expiry_str}-{checksum}"
|
|
252
|
+
|
|
253
|
+
return license_key
|
|
254
|
+
|
|
255
|
+
def _verify_license_offline(self, license_key: str, email: str) -> bool:
|
|
256
|
+
"""Verify license key offline"""
|
|
257
|
+
try:
|
|
258
|
+
parts = license_key.split("-")
|
|
259
|
+
if len(parts) != 5:
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
user_id_provided, expiry_str, checksum_provided = parts[2], parts[3], parts[4]
|
|
263
|
+
|
|
264
|
+
# Verify user ID matches email
|
|
265
|
+
user_id_expected = hashlib.sha256(email.encode()).hexdigest()[:8]
|
|
266
|
+
if user_id_provided != user_id_expected:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
# Check expiry
|
|
270
|
+
expiry_date = datetime.strptime(expiry_str, "%Y%m%d")
|
|
271
|
+
if datetime.now() > expiry_date:
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
# Verify checksum
|
|
275
|
+
data = f"{email}{user_id_provided}{expiry_str}"
|
|
276
|
+
checksum_expected = hashlib.sha256(data.encode()).hexdigest()[:8]
|
|
277
|
+
|
|
278
|
+
return checksum_provided == checksum_expected
|
|
279
|
+
|
|
280
|
+
except Exception:
|
|
281
|
+
return False
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend-only client for distribution.
|
|
3
|
+
All queries are proxied through the centralized backend.
|
|
4
|
+
Local LLM calls are not supported.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import requests
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
|
|
11
|
+
class BackendOnlyClient:
|
|
12
|
+
"""
|
|
13
|
+
Minimal client that only talks to backend API.
|
|
14
|
+
Used in distribution to prevent local API key usage.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.backend_url = (
|
|
19
|
+
os.getenv("NOCTURNAL_CONTROL_PLANE_URL")
|
|
20
|
+
or "https://cite-agent-api-720dfadd602c.herokuapp.com"
|
|
21
|
+
)
|
|
22
|
+
self.auth_token = os.getenv("NOCTURNAL_AUTH_TOKEN")
|
|
23
|
+
|
|
24
|
+
def query(self, message: str, style: str = "academic") -> Dict[str, Any]:
|
|
25
|
+
"""
|
|
26
|
+
Send query to backend API.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
message: User query
|
|
30
|
+
style: Response style
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Backend response with citations
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
RuntimeError: If not authenticated or backend unavailable
|
|
37
|
+
"""
|
|
38
|
+
if not self.auth_token:
|
|
39
|
+
raise RuntimeError(
|
|
40
|
+
"Not authenticated. Run 'cite-agent --setup' first."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
response = requests.post(
|
|
45
|
+
f"{self.backend_url}/api/query",
|
|
46
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
47
|
+
json={"query": message, "style": style},
|
|
48
|
+
timeout=30
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if response.status_code == 401:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
"Authentication failed. Your token may have expired. "
|
|
54
|
+
"Run 'cite-agent --setup' to log in again."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if response.status_code == 429:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"Daily quota exceeded (25,000 tokens/day). "
|
|
60
|
+
"Your quota will reset tomorrow."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
response.raise_for_status()
|
|
64
|
+
return response.json()
|
|
65
|
+
|
|
66
|
+
except requests.RequestException as e:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
f"Backend unavailable: {e}. "
|
|
69
|
+
f"Please check your internet connection or try again later."
|
|
70
|
+
) from e
|
|
71
|
+
|
|
72
|
+
def check_quota(self) -> Dict[str, Any]:
|
|
73
|
+
"""Check remaining daily quota"""
|
|
74
|
+
if not self.auth_token:
|
|
75
|
+
raise RuntimeError("Not authenticated")
|
|
76
|
+
|
|
77
|
+
response = requests.get(
|
|
78
|
+
f"{self.backend_url}/api/auth/me",
|
|
79
|
+
headers={"Authorization": f"Bearer {self.auth_token}"},
|
|
80
|
+
timeout=10
|
|
81
|
+
)
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
return response.json()
|