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
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Automatic setup and configuration for Nocturnal Archive
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from getpass import getpass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional, Dict, Any, List, Tuple
|
|
10
|
+
|
|
11
|
+
from .account_client import AccountClient, AccountCredentials, AccountProvisioningError
|
|
12
|
+
|
|
13
|
+
KEY_PLACEHOLDER = "__KEYRING__"
|
|
14
|
+
KEYRING_SERVICE = "Nocturnal Archive"
|
|
15
|
+
DEFAULT_QUERY_LIMIT = 25
|
|
16
|
+
|
|
17
|
+
# Production: Users don't need API keys - backend has them
|
|
18
|
+
# Optional research API keys for advanced features (not required)
|
|
19
|
+
MANAGED_SECRETS: Dict[str, Dict[str, Any]] = {
|
|
20
|
+
"OPENALEX_API_KEY": {
|
|
21
|
+
"label": "OpenAlex",
|
|
22
|
+
"prompt": "OpenAlex API key (optional)",
|
|
23
|
+
"optional": True,
|
|
24
|
+
},
|
|
25
|
+
"PUBMED_API_KEY": {
|
|
26
|
+
"label": "PubMed",
|
|
27
|
+
"prompt": "PubMed API key (optional)",
|
|
28
|
+
"optional": True,
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class NocturnalConfig:
|
|
33
|
+
"""Handles automatic configuration and setup"""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.config_dir = Path.home() / ".nocturnal_archive"
|
|
37
|
+
self.config_file = self.config_dir / "config.env"
|
|
38
|
+
self.ensure_config_dir()
|
|
39
|
+
self._keyring = None
|
|
40
|
+
try:
|
|
41
|
+
import keyring # type: ignore
|
|
42
|
+
|
|
43
|
+
self._keyring = keyring
|
|
44
|
+
except Exception:
|
|
45
|
+
self._keyring = None
|
|
46
|
+
|
|
47
|
+
def ensure_config_dir(self):
|
|
48
|
+
"""Create config directory if it doesn't exist"""
|
|
49
|
+
self.config_dir.mkdir(exist_ok=True)
|
|
50
|
+
|
|
51
|
+
def interactive_setup(self) -> bool:
|
|
52
|
+
"""Interactive setup for account authentication and configuration."""
|
|
53
|
+
print("š Nocturnal Archive Beta Setup")
|
|
54
|
+
print("=" * 40)
|
|
55
|
+
print()
|
|
56
|
+
|
|
57
|
+
if self.config_file.exists():
|
|
58
|
+
print("ā
Configuration already exists!")
|
|
59
|
+
response = input("Do you want to reconfigure? (y/N): ").strip().lower()
|
|
60
|
+
if response not in ["y", "yes"]:
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
print("You'll use your institution-issued account to sign in. No invite codes or manual API keys required.")
|
|
64
|
+
print()
|
|
65
|
+
|
|
66
|
+
email = self._prompt_academic_email()
|
|
67
|
+
if not email:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
password = self._prompt_password()
|
|
71
|
+
if not password:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
if not self._confirm_beta_terms():
|
|
75
|
+
print("ā Terms must be accepted to continue")
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
credentials = self._provision_account(email, password)
|
|
80
|
+
except AccountProvisioningError as exc:
|
|
81
|
+
print(f"ā Could not verify your account: {exc}")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
print("\nš”ļø Recap of beta limitations:")
|
|
85
|
+
for item in self._beta_limitations():
|
|
86
|
+
print(f" ⢠{item}")
|
|
87
|
+
print()
|
|
88
|
+
|
|
89
|
+
if not self._confirm("I understand the beta limitations above (Y/n): "):
|
|
90
|
+
print("ā Please acknowledge the beta limitations to continue")
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
config: Dict[str, Any] = {
|
|
94
|
+
"NOCTURNAL_ACCOUNT_EMAIL": credentials.email,
|
|
95
|
+
"NOCTURNAL_ACCOUNT_ID": credentials.account_id,
|
|
96
|
+
"NOCTURNAL_AUTH_TOKEN": credentials.auth_token,
|
|
97
|
+
"NOCTURNAL_REFRESH_TOKEN": credentials.refresh_token,
|
|
98
|
+
"NOCTURNAL_TELEMETRY_TOKEN": credentials.telemetry_token,
|
|
99
|
+
"NOCTURNAL_ACCOUNT_ISSUED_AT": credentials.issued_at or "",
|
|
100
|
+
"NOCTURNAL_TELEMETRY": "1",
|
|
101
|
+
"NOCTURNAL_TERMS_ACCEPTED": "1",
|
|
102
|
+
"NOCTURNAL_LIMITATIONS_ACK": "1",
|
|
103
|
+
"NOCTURNAL_CONFIG_VERSION": "2.0.0",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
optional_updates = self._configure_optional_secrets(existing_config=config)
|
|
107
|
+
config.update(optional_updates)
|
|
108
|
+
|
|
109
|
+
self.save_config(config)
|
|
110
|
+
|
|
111
|
+
print("\nā
Configuration saved successfully!")
|
|
112
|
+
print(f"š Config location: {self.config_file}")
|
|
113
|
+
print("\nš You're ready to use Nocturnal Archive!")
|
|
114
|
+
print("\nQuick start:")
|
|
115
|
+
print("```python")
|
|
116
|
+
print("from nocturnal_archive import EnhancedNocturnalAgent, ChatRequest")
|
|
117
|
+
print("import asyncio")
|
|
118
|
+
print()
|
|
119
|
+
print("async def main():")
|
|
120
|
+
print(" agent = EnhancedNocturnalAgent()")
|
|
121
|
+
print(" await agent.initialize()")
|
|
122
|
+
print(" # Your code here...")
|
|
123
|
+
print(" await agent.close()")
|
|
124
|
+
print()
|
|
125
|
+
print("asyncio.run(main())")
|
|
126
|
+
print("```")
|
|
127
|
+
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
def _confirm(self, prompt: str) -> bool:
|
|
131
|
+
response = input(prompt).strip().lower()
|
|
132
|
+
return response in ["", "y", "yes"]
|
|
133
|
+
|
|
134
|
+
def _prompt_academic_email(self) -> Optional[str]:
|
|
135
|
+
for attempt in range(5):
|
|
136
|
+
email = input("Academic email address: ").strip()
|
|
137
|
+
if not email:
|
|
138
|
+
print("ā Email cannot be empty")
|
|
139
|
+
continue
|
|
140
|
+
if not self._is_academic_email(email):
|
|
141
|
+
print("ā Email address must use an academic domain (e.g. .edu, .ac.uk)")
|
|
142
|
+
continue
|
|
143
|
+
return email.lower()
|
|
144
|
+
print("ā Could not capture a valid academic email after multiple attempts")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def _prompt_password(self) -> Optional[str]:
|
|
148
|
+
for attempt in range(5):
|
|
149
|
+
password = getpass("Account password: ")
|
|
150
|
+
if not password:
|
|
151
|
+
print("ā Password cannot be empty")
|
|
152
|
+
continue
|
|
153
|
+
if len(password) < 8:
|
|
154
|
+
print("ā ļø Passwords should be at least 8 characters long.")
|
|
155
|
+
confirm = input("Continue with this password? (y/N): ").strip().lower()
|
|
156
|
+
if confirm not in ["y", "yes"]:
|
|
157
|
+
continue
|
|
158
|
+
confirm_password = getpass("Confirm password: ")
|
|
159
|
+
if password != confirm_password:
|
|
160
|
+
print("ā Passwords do not match")
|
|
161
|
+
continue
|
|
162
|
+
return password
|
|
163
|
+
print("ā Could not confirm password after multiple attempts")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def _provision_account(self, email: str, password: str) -> AccountCredentials:
|
|
167
|
+
client = AccountClient()
|
|
168
|
+
return client.provision(email=email, password=password)
|
|
169
|
+
|
|
170
|
+
def _is_academic_email(self, email: str) -> bool:
|
|
171
|
+
if "@" not in email:
|
|
172
|
+
return False
|
|
173
|
+
local, domain = email.split("@", 1)
|
|
174
|
+
if not local or not domain:
|
|
175
|
+
return False
|
|
176
|
+
domain = domain.lower()
|
|
177
|
+
# Accept domains containing edu/ac anywhere except the top-most TLD (to allow edu.mx, ac.uk, etc.)
|
|
178
|
+
parts = domain.split(".")
|
|
179
|
+
if len(parts) < 2:
|
|
180
|
+
return False
|
|
181
|
+
academic_markers = {"edu", "ac"}
|
|
182
|
+
return any(part in academic_markers for part in parts)
|
|
183
|
+
|
|
184
|
+
def _store_secret(self, name: str, value: str) -> bool:
|
|
185
|
+
if not value or not self._keyring:
|
|
186
|
+
return False
|
|
187
|
+
try:
|
|
188
|
+
self._keyring.set_password(KEYRING_SERVICE, name, value)
|
|
189
|
+
return True
|
|
190
|
+
except Exception:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
def _persist_secret(self, name: str, value: Optional[str], persist_config: bool = True) -> bool:
|
|
194
|
+
if not value:
|
|
195
|
+
return False
|
|
196
|
+
stored = self._store_secret(name, value)
|
|
197
|
+
if stored and persist_config:
|
|
198
|
+
config = self.load_config()
|
|
199
|
+
config[name] = KEY_PLACEHOLDER
|
|
200
|
+
config["NOCTURNAL_SECRET_BACKEND"] = "keyring"
|
|
201
|
+
self.save_config(config)
|
|
202
|
+
return stored
|
|
203
|
+
|
|
204
|
+
def _configure_optional_secrets(self, existing_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
205
|
+
config_updates: Dict[str, Any] = {}
|
|
206
|
+
current_config = existing_config or {}
|
|
207
|
+
optional_keys = [key for key, meta in MANAGED_SECRETS.items() if meta.get("optional") and key != "GROQ_API_KEY"]
|
|
208
|
+
if not optional_keys:
|
|
209
|
+
return config_updates
|
|
210
|
+
|
|
211
|
+
print("š Optional integrations")
|
|
212
|
+
print("Provide any additional API keys you already have. Press Enter to skip a provider.")
|
|
213
|
+
for secret_name in optional_keys:
|
|
214
|
+
meta = MANAGED_SECRETS[secret_name]
|
|
215
|
+
if current_config.get(secret_name):
|
|
216
|
+
continue
|
|
217
|
+
prompt_text = f"{meta.get('prompt', secret_name)} (optional): "
|
|
218
|
+
try:
|
|
219
|
+
value = getpass(prompt_text)
|
|
220
|
+
except Exception:
|
|
221
|
+
value = input(prompt_text)
|
|
222
|
+
if not value:
|
|
223
|
+
continue
|
|
224
|
+
stored = self._persist_secret(secret_name, value, persist_config=False)
|
|
225
|
+
config_updates[secret_name] = KEY_PLACEHOLDER if stored else value
|
|
226
|
+
if stored:
|
|
227
|
+
print(f" ⢠Stored {meta.get('label', secret_name)} key securely in your keychain.")
|
|
228
|
+
else:
|
|
229
|
+
print(f" ⢠Saved {meta.get('label', secret_name)} key to config.env (plaintext). Consider configuring a keychain backend.")
|
|
230
|
+
return config_updates
|
|
231
|
+
|
|
232
|
+
def import_secrets(self, secrets: Dict[str, str], allow_plaintext: bool = True) -> Dict[str, Tuple[bool, str]]:
|
|
233
|
+
results: Dict[str, Tuple[bool, str]] = {}
|
|
234
|
+
config = self.load_config()
|
|
235
|
+
for name, value in secrets.items():
|
|
236
|
+
if name not in MANAGED_SECRETS:
|
|
237
|
+
continue
|
|
238
|
+
if not value:
|
|
239
|
+
results[name] = (False, "empty value")
|
|
240
|
+
continue
|
|
241
|
+
stored = self._persist_secret(name, value, persist_config=False)
|
|
242
|
+
if stored:
|
|
243
|
+
config[name] = KEY_PLACEHOLDER
|
|
244
|
+
results[name] = (True, "stored in keyring")
|
|
245
|
+
elif allow_plaintext:
|
|
246
|
+
config[name] = value
|
|
247
|
+
results[name] = (True, "stored in config file")
|
|
248
|
+
else:
|
|
249
|
+
results[name] = (False, "keyring unavailable and plaintext disabled")
|
|
250
|
+
if results:
|
|
251
|
+
self.save_config(config)
|
|
252
|
+
return results
|
|
253
|
+
|
|
254
|
+
def import_from_env_file(self, path: str, allow_plaintext: bool = True) -> Dict[str, Tuple[bool, str]]:
|
|
255
|
+
env_path = Path(path).expanduser()
|
|
256
|
+
if not env_path.exists():
|
|
257
|
+
raise FileNotFoundError(f"Secrets file not found: {env_path}")
|
|
258
|
+
secrets: Dict[str, str] = {}
|
|
259
|
+
with open(env_path, "r", encoding="utf-8") as handle:
|
|
260
|
+
for line in handle:
|
|
261
|
+
line = line.strip()
|
|
262
|
+
if not line or line.startswith("#"):
|
|
263
|
+
continue
|
|
264
|
+
if "=" not in line:
|
|
265
|
+
continue
|
|
266
|
+
key, value = line.split("=", 1)
|
|
267
|
+
secrets[key.strip()] = value.strip().strip('\"')
|
|
268
|
+
return self.import_secrets(secrets, allow_plaintext=allow_plaintext)
|
|
269
|
+
|
|
270
|
+
def _retrieve_secret(self, name: str) -> Optional[str]:
|
|
271
|
+
if not self._keyring:
|
|
272
|
+
return None
|
|
273
|
+
try:
|
|
274
|
+
return self._keyring.get_password(KEYRING_SERVICE, name)
|
|
275
|
+
except Exception:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
def _notify_keyring_success(self):
|
|
279
|
+
print("š Stored Groq API key securely in your system keychain.")
|
|
280
|
+
|
|
281
|
+
def _warn_keyring_fallback(self):
|
|
282
|
+
if self._keyring is None:
|
|
283
|
+
print("ā ļø Could not access the system keychain. Storing the key in config.env instead.")
|
|
284
|
+
else:
|
|
285
|
+
print("ā ļø Keychain write failed; falling back to plain-text storage in config.env.")
|
|
286
|
+
|
|
287
|
+
def _ensure_query_limit(self, config: Dict[str, str]) -> bool:
|
|
288
|
+
if config.get("NOCTURNAL_QUERY_LIMIT") != str(DEFAULT_QUERY_LIMIT):
|
|
289
|
+
config["NOCTURNAL_QUERY_LIMIT"] = str(DEFAULT_QUERY_LIMIT)
|
|
290
|
+
config.pop("NOCTURNAL_QUERY_LIMIT_SIG", None)
|
|
291
|
+
return True
|
|
292
|
+
if "NOCTURNAL_QUERY_LIMIT_SIG" in config:
|
|
293
|
+
config.pop("NOCTURNAL_QUERY_LIMIT_SIG", None)
|
|
294
|
+
return True
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
def _beta_limitations(self) -> List[str]:
|
|
298
|
+
return [
|
|
299
|
+
"Daily usage capped at 25 queries per tester",
|
|
300
|
+
"Complex shell / filesystem commands remain sandboxed",
|
|
301
|
+
"Research API may rate-limit during heavy usage",
|
|
302
|
+
"Telemetry is always on and streamed to the control plane",
|
|
303
|
+
"Beta builds auto-update on launch"
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
def _confirm_beta_terms(self) -> bool:
|
|
307
|
+
print("š Beta Participation Terms")
|
|
308
|
+
print("You are agreeing to: confidential use, providing feedback, and abiding by the usage limits.")
|
|
309
|
+
print("For full details see the Beta Agreement included with your invite.")
|
|
310
|
+
return self._confirm("Do you accept the beta terms? (Y/n): ")
|
|
311
|
+
|
|
312
|
+
def save_config(self, config: Dict[str, Any]):
|
|
313
|
+
"""Save configuration to file"""
|
|
314
|
+
with open(self.config_file, 'w') as f:
|
|
315
|
+
f.write("# Nocturnal Archive Configuration\n")
|
|
316
|
+
f.write("# Generated automatically - do not edit manually\n\n")
|
|
317
|
+
|
|
318
|
+
for key, value in config.items():
|
|
319
|
+
if value: # Only save non-empty values
|
|
320
|
+
f.write(f"{key}={value}\n")
|
|
321
|
+
|
|
322
|
+
def load_config(self) -> Dict[str, str]:
|
|
323
|
+
"""Load configuration from file"""
|
|
324
|
+
config = {}
|
|
325
|
+
if self.config_file.exists():
|
|
326
|
+
with open(self.config_file, 'r') as f:
|
|
327
|
+
for line in f:
|
|
328
|
+
line = line.strip()
|
|
329
|
+
if line and not line.startswith('#'):
|
|
330
|
+
if '=' in line:
|
|
331
|
+
key, value = line.split('=', 1)
|
|
332
|
+
config[key.strip()] = value.strip()
|
|
333
|
+
return config
|
|
334
|
+
|
|
335
|
+
def setup_environment(self):
|
|
336
|
+
"""Set up environment variables from config"""
|
|
337
|
+
config = self.load_config()
|
|
338
|
+
dirty = False
|
|
339
|
+
if self._ensure_query_limit(config):
|
|
340
|
+
dirty = True
|
|
341
|
+
limit_value = int(config.get("NOCTURNAL_QUERY_LIMIT", str(DEFAULT_QUERY_LIMIT)))
|
|
342
|
+
os.environ["NOCTURNAL_QUERY_LIMIT"] = str(limit_value)
|
|
343
|
+
os.environ.pop("NOCTURNAL_QUERY_LIMIT_SIG", None)
|
|
344
|
+
for key, value in config.items():
|
|
345
|
+
if key not in MANAGED_SECRETS:
|
|
346
|
+
if not os.getenv(key) and value:
|
|
347
|
+
os.environ[key] = value
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
if value == KEY_PLACEHOLDER:
|
|
351
|
+
secret = self._retrieve_secret(key)
|
|
352
|
+
if secret and not os.getenv(key):
|
|
353
|
+
os.environ[key] = secret
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
if value and value != KEY_PLACEHOLDER and self._store_secret(key, value):
|
|
357
|
+
config[key] = KEY_PLACEHOLDER
|
|
358
|
+
config["NOCTURNAL_SECRET_BACKEND"] = "keyring"
|
|
359
|
+
if not os.getenv(key):
|
|
360
|
+
os.environ[key] = value
|
|
361
|
+
dirty = True
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
if not os.getenv(key) and value:
|
|
365
|
+
os.environ[key] = value
|
|
366
|
+
if dirty:
|
|
367
|
+
self.save_config(config)
|
|
368
|
+
return len(config) > 0
|
|
369
|
+
|
|
370
|
+
def check_setup(self) -> bool:
|
|
371
|
+
"""Check if setup is complete"""
|
|
372
|
+
config = self.load_config()
|
|
373
|
+
return (
|
|
374
|
+
self.config_file.exists()
|
|
375
|
+
and bool(config.get('NOCTURNAL_ACCOUNT_EMAIL'))
|
|
376
|
+
and bool(config.get('NOCTURNAL_AUTH_TOKEN'))
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def get_setup_status(self) -> Dict[str, Any]:
|
|
380
|
+
"""Get detailed setup status"""
|
|
381
|
+
config = self.load_config()
|
|
382
|
+
secret_status: Dict[str, bool] = {}
|
|
383
|
+
for key in MANAGED_SECRETS:
|
|
384
|
+
in_config = config.get(key)
|
|
385
|
+
if in_config == KEY_PLACEHOLDER:
|
|
386
|
+
secret_status[key] = bool(self._retrieve_secret(key)) or bool(os.getenv(key))
|
|
387
|
+
else:
|
|
388
|
+
secret_status[key] = bool(in_config) or bool(os.getenv(key))
|
|
389
|
+
return {
|
|
390
|
+
"configured": self.check_setup(),
|
|
391
|
+
"config_file": str(self.config_file),
|
|
392
|
+
"openalex_configured": secret_status.get('OPENALEX_API_KEY', False),
|
|
393
|
+
"pubmed_configured": secret_status.get('PUBMED_API_KEY', False),
|
|
394
|
+
"account_email": config.get('NOCTURNAL_ACCOUNT_EMAIL'),
|
|
395
|
+
"account_id": config.get('NOCTURNAL_ACCOUNT_ID'),
|
|
396
|
+
"terms_accepted": config.get('NOCTURNAL_TERMS_ACCEPTED') == '1',
|
|
397
|
+
"config_keys": list(config.keys())
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
def auto_setup():
|
|
401
|
+
"""Automatic setup function"""
|
|
402
|
+
config = NocturnalConfig()
|
|
403
|
+
|
|
404
|
+
# Try to setup environment from existing config
|
|
405
|
+
if config.setup_environment():
|
|
406
|
+
return True
|
|
407
|
+
|
|
408
|
+
# If no config exists, run interactive setup
|
|
409
|
+
print("š§ Nocturnal Archive needs initial setup")
|
|
410
|
+
return config.interactive_setup()
|
|
411
|
+
|
|
412
|
+
def get_config():
|
|
413
|
+
"""Get configuration instance"""
|
|
414
|
+
return NocturnalConfig()
|
|
415
|
+
|
|
416
|
+
if __name__ == "__main__":
|
|
417
|
+
auto_setup()
|
cite_agent/telemetry.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Telemetry pipeline for the Nocturnal Archive beta."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
_LOCK = threading.Lock()
|
|
13
|
+
_MANAGER: Optional["TelemetryManager"] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TelemetryManager:
|
|
17
|
+
"""JSONL telemetry writer with optional control-plane streaming."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, log_path: Path, telemetry_token: Optional[str]):
|
|
20
|
+
self.log_path = log_path
|
|
21
|
+
self.telemetry_token = telemetry_token
|
|
22
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def _from_environment(cls) -> "TelemetryManager":
|
|
26
|
+
root = Path(os.getenv("NOCTURNAL_HOME", str(Path.home() / ".nocturnal_archive")))
|
|
27
|
+
log_dir = root / "logs"
|
|
28
|
+
log_path = log_dir / "beta-telemetry.jsonl"
|
|
29
|
+
token = os.getenv("NOCTURNAL_TELEMETRY_TOKEN") or None
|
|
30
|
+
return cls(log_path=log_path, telemetry_token=token)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def get(cls) -> "TelemetryManager":
|
|
34
|
+
global _MANAGER
|
|
35
|
+
if _MANAGER is None:
|
|
36
|
+
_MANAGER = cls._from_environment()
|
|
37
|
+
return _MANAGER
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def refresh(cls) -> None:
|
|
41
|
+
"""Force re-reading environment configuration."""
|
|
42
|
+
global _MANAGER
|
|
43
|
+
_MANAGER = cls._from_environment()
|
|
44
|
+
|
|
45
|
+
def record(self, event_type: str, payload: Dict[str, Any]) -> None:
|
|
46
|
+
record = {
|
|
47
|
+
"event": event_type,
|
|
48
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
49
|
+
**payload,
|
|
50
|
+
}
|
|
51
|
+
try:
|
|
52
|
+
with _LOCK:
|
|
53
|
+
with self.log_path.open("a", encoding="utf-8") as fh:
|
|
54
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
55
|
+
except Exception:
|
|
56
|
+
# Telemetry must never break the agent; swallow errors silently.
|
|
57
|
+
pass
|
|
58
|
+
self._send_remote(record)
|
|
59
|
+
|
|
60
|
+
def _send_remote(self, record: Dict[str, Any]) -> None:
|
|
61
|
+
endpoint = os.getenv("NOCTURNAL_TELEMETRY_ENDPOINT")
|
|
62
|
+
if not endpoint:
|
|
63
|
+
return
|
|
64
|
+
try: # pragma: no cover - network integration best effort
|
|
65
|
+
import requests # type: ignore
|
|
66
|
+
except Exception:
|
|
67
|
+
return
|
|
68
|
+
headers = {}
|
|
69
|
+
if self.telemetry_token:
|
|
70
|
+
headers["Authorization"] = f"Bearer {self.telemetry_token}"
|
|
71
|
+
try: # pragma: no cover - network integration best effort
|
|
72
|
+
requests.post(
|
|
73
|
+
endpoint.rstrip("/") + "/ingest",
|
|
74
|
+
json=record,
|
|
75
|
+
headers=headers,
|
|
76
|
+
timeout=float(os.getenv("NOCTURNAL_TELEMETRY_TIMEOUT", "5")),
|
|
77
|
+
)
|
|
78
|
+
except Exception:
|
|
79
|
+
# Remote telemetry failures are non-fatal; we rely on local logs for replay.
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def disable_telemetry() -> None:
|
|
84
|
+
"""Backward-compatible shim; telemetry is now always-on."""
|
|
85
|
+
TelemetryManager.refresh()
|
cite_agent/ui.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Polished Terminal UI - Inspired by Cursor/Claude
|
|
3
|
+
Clean, professional, with subtle visual hierarchy
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.prompt import Prompt
|
|
9
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich import box
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
class NocturnalUI:
|
|
18
|
+
"""Polished terminal UI - clean with subtle hierarchy"""
|
|
19
|
+
|
|
20
|
+
# Color palette (subtle, professional)
|
|
21
|
+
ACCENT = "cyan"
|
|
22
|
+
DIM = "dim"
|
|
23
|
+
SUCCESS = "green"
|
|
24
|
+
ERROR = "red"
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def show_welcome():
|
|
28
|
+
"""Welcome screen - quick and polished"""
|
|
29
|
+
console.clear()
|
|
30
|
+
|
|
31
|
+
# Quick loading with style
|
|
32
|
+
with Progress(
|
|
33
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
34
|
+
TextColumn("[cyan]Initializing Nocturnal Archive...[/cyan]"),
|
|
35
|
+
console=console,
|
|
36
|
+
transient=True
|
|
37
|
+
) as progress:
|
|
38
|
+
progress.add_task("", total=None)
|
|
39
|
+
time.sleep(1.0)
|
|
40
|
+
|
|
41
|
+
# Clean header with subtle styling
|
|
42
|
+
console.print()
|
|
43
|
+
console.print("ā" + "ā" * 58 + "ā", style="dim")
|
|
44
|
+
console.print("ā [bold cyan]Nocturnal Archive[/bold cyan]" + " " * 35 + "ā", style="dim")
|
|
45
|
+
console.print("ā [dim]Research & Finance Intelligence Terminal[/dim]" + " " * 10 + "ā", style="dim")
|
|
46
|
+
console.print("ā" + "ā" * 58 + "ā", style="dim")
|
|
47
|
+
console.print()
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def prompt_login() -> tuple[str, str]:
|
|
51
|
+
"""Login prompt with subtle panel"""
|
|
52
|
+
console.print()
|
|
53
|
+
console.print("[dim]āāā Authentication āāā[/dim]")
|
|
54
|
+
console.print()
|
|
55
|
+
|
|
56
|
+
email = Prompt.ask("[cyan]Email[/cyan]", console=console)
|
|
57
|
+
password = Prompt.ask("[cyan]Password[/cyan]", password=True, console=console)
|
|
58
|
+
|
|
59
|
+
return email, password
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def prompt_register() -> tuple[str, str, str]:
|
|
63
|
+
"""Registration prompt with subtle panel"""
|
|
64
|
+
console.print()
|
|
65
|
+
console.print("[dim]āāā Create Account āāā[/dim]")
|
|
66
|
+
console.print()
|
|
67
|
+
|
|
68
|
+
email = Prompt.ask("[cyan]Email[/cyan]", console=console)
|
|
69
|
+
password = Prompt.ask("[cyan]Password[/cyan]", password=True, console=console)
|
|
70
|
+
license_key = Prompt.ask("[cyan]License Key[/cyan]", console=console)
|
|
71
|
+
|
|
72
|
+
return email, password, license_key
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def show_status(email: str, queries_today: int, daily_limit: int):
|
|
76
|
+
"""Status bar - subtle and informative"""
|
|
77
|
+
remaining = daily_limit - queries_today
|
|
78
|
+
status_color = "green" if remaining > 10 else "yellow" if remaining > 5 else "red"
|
|
79
|
+
|
|
80
|
+
console.print()
|
|
81
|
+
console.print(
|
|
82
|
+
f"[dim]{email}[/dim] ā [{status_color}]{queries_today}/{daily_limit}[/{status_color}] [dim]queries today[/dim]"
|
|
83
|
+
)
|
|
84
|
+
console.print("[dim]" + "ā" * 60 + "[/dim]")
|
|
85
|
+
console.print()
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def prompt_query() -> str:
|
|
89
|
+
"""Get user query with styled prompt"""
|
|
90
|
+
return Prompt.ask("[bold cyan]āÆ[/bold cyan]", console=console)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
@contextmanager
|
|
94
|
+
def show_thinking():
|
|
95
|
+
"""Thinking spinner - polished"""
|
|
96
|
+
with Progress(
|
|
97
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
98
|
+
TextColumn("[dim]Processing your query...[/dim]"),
|
|
99
|
+
console=console,
|
|
100
|
+
transient=True
|
|
101
|
+
) as progress:
|
|
102
|
+
progress.add_task("", total=None)
|
|
103
|
+
yield
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def show_response(response: str, metadata: dict = None):
|
|
107
|
+
"""Show agent response with subtle styling"""
|
|
108
|
+
console.print()
|
|
109
|
+
|
|
110
|
+
# Response content
|
|
111
|
+
console.print(response)
|
|
112
|
+
|
|
113
|
+
# Optional metadata footer (minimal)
|
|
114
|
+
if metadata:
|
|
115
|
+
console.print()
|
|
116
|
+
meta_parts = []
|
|
117
|
+
if metadata.get("tools_used"):
|
|
118
|
+
meta_parts.append(f"[dim]Tools: {', '.join(metadata['tools_used'])}[/dim]")
|
|
119
|
+
if metadata.get("sources"):
|
|
120
|
+
meta_parts.append(f"[dim]Sources: {metadata['sources']}[/dim]")
|
|
121
|
+
if meta_parts:
|
|
122
|
+
console.print(" ⢠".join(meta_parts))
|
|
123
|
+
|
|
124
|
+
console.print()
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def show_error(message: str):
|
|
128
|
+
"""Error message - clear but not aggressive"""
|
|
129
|
+
console.print()
|
|
130
|
+
console.print(f"[red]ā[/red] {message}")
|
|
131
|
+
console.print()
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def show_success(message: str):
|
|
135
|
+
"""Success message - subtle confirmation"""
|
|
136
|
+
console.print()
|
|
137
|
+
console.print(f"[green]ā[/green] {message}")
|
|
138
|
+
console.print()
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def show_info(message: str):
|
|
142
|
+
"""Info message"""
|
|
143
|
+
console.print()
|
|
144
|
+
console.print(f"[dim]ā¹[/dim] {message}")
|
|
145
|
+
console.print()
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def show_help():
|
|
149
|
+
"""Show help message - clean list"""
|
|
150
|
+
console.print()
|
|
151
|
+
console.print("[bold cyan]Commands[/bold cyan]")
|
|
152
|
+
console.print("[dim]āāāāāāāāā[/dim]")
|
|
153
|
+
console.print(" [cyan]help[/cyan] Show this help message")
|
|
154
|
+
console.print(" [cyan]clear[/cyan] Clear the screen")
|
|
155
|
+
console.print(" [cyan]logout[/cyan] Log out of your account")
|
|
156
|
+
console.print(" [cyan]exit[/cyan] Exit the application")
|
|
157
|
+
console.print()
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def show_tips(tips: list = None):
|
|
161
|
+
"""Show helpful tips - clean list"""
|
|
162
|
+
if tips is None:
|
|
163
|
+
tips = [
|
|
164
|
+
"Try: 'Find papers about transformers'",
|
|
165
|
+
"Ask: 'What's Apple's latest revenue?'",
|
|
166
|
+
"Query: 'Analyze sentiment in tech stocks'",
|
|
167
|
+
"Search: 'Compare Google vs Microsoft earnings'",
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
console.print()
|
|
171
|
+
console.print("[bold cyan]Tips[/bold cyan]")
|
|
172
|
+
console.print("[dim]āāāā[/dim]")
|
|
173
|
+
for tip in tips:
|
|
174
|
+
console.print(f" [dim]ā¢[/dim] {tip}")
|
|
175
|
+
console.print()
|