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.

@@ -0,0 +1,7 @@
1
+ """
2
+ This is a DISTRIBUTION build.
3
+ Local LLM calling code has been removed.
4
+ All queries must go through the centralized backend.
5
+ """
6
+ DISTRIBUTION_BUILD = True
7
+ BACKEND_ONLY = True
cite_agent/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ Nocturnal Archive - Beta Agent
3
+
4
+ A Groq-powered research and finance co-pilot with deterministic tooling and
5
+ prior stacks preserved only in Git history, kept out of the runtime footprint.
6
+ """
7
+
8
+ from .enhanced_ai_agent import EnhancedNocturnalAgent, ChatRequest, ChatResponse
9
+
10
+ __version__ = "0.9.0b1"
11
+ __author__ = "Nocturnal Archive Team"
12
+ __email__ = "contact@nocturnal.dev"
13
+
14
+ __all__ = [
15
+ "EnhancedNocturnalAgent",
16
+ "ChatRequest",
17
+ "ChatResponse"
18
+ ]
19
+
20
+ # Package metadata
21
+ PACKAGE_NAME = "nocturnal-archive"
22
+ PACKAGE_VERSION = __version__
23
+ PACKAGE_DESCRIPTION = "Beta CLI agent for finance + research workflows"
24
+ PACKAGE_URL = "https://github.com/Spectating101/nocturnal-archive"
25
+
26
+ def get_version():
27
+ """Get the package version"""
28
+ return __version__
29
+
30
+ def quick_start():
31
+ """Print quick start instructions"""
32
+ print("""
33
+ 🚀 Nocturnal Archive Quick Start
34
+ ================================
35
+
36
+ 1. Install the package and CLI:
37
+ pip install nocturnal-archive
38
+
39
+ 2. Configure your Groq key:
40
+ nocturnal --setup
41
+
42
+ 3. Ask a question:
43
+ nocturnal "Compare Apple and Microsoft net income this quarter"
44
+
45
+ 4. Prefer embedding in code? Minimal example:
46
+ ```python
47
+ import asyncio
48
+ from nocturnal_archive import EnhancedNocturnalAgent, ChatRequest
49
+
50
+ async def main():
51
+ agent = EnhancedNocturnalAgent()
52
+ await agent.initialize()
53
+
54
+ response = await agent.process_request(ChatRequest(question="List repo workspace files"))
55
+ print(response.response)
56
+
57
+ await agent.close()
58
+
59
+ asyncio.run(main())
60
+ ```
61
+
62
+ Full installation instructions live in docs/INSTALL.md.
63
+ """)
64
+
65
+ if __name__ == "__main__":
66
+ quick_start()
@@ -0,0 +1,130 @@
1
+ """Account provisioning utilities for the Nocturnal Archive CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ from dataclasses import dataclass
8
+ from typing import Any, Dict, Optional
9
+
10
+
11
+ class AccountProvisioningError(RuntimeError):
12
+ """Raised when account provisioning fails."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AccountCredentials:
17
+ """User account credentials for backend authentication.
18
+
19
+ Production mode: User gets JWT tokens, NOT API keys.
20
+ Backend has the API keys, user just authenticates with JWT.
21
+ """
22
+ account_id: str
23
+ email: str
24
+ auth_token: str # JWT for backend authentication
25
+ refresh_token: str
26
+ telemetry_token: str
27
+ issued_at: Optional[str] = None
28
+
29
+ @classmethod
30
+ def from_payload(cls, email: str, payload: Dict[str, Any]) -> "AccountCredentials":
31
+ try:
32
+ return cls(
33
+ account_id=str(payload["accountId"]),
34
+ email=email,
35
+ auth_token=str(payload["authToken"]),
36
+ refresh_token=str(payload.get("refreshToken", "")),
37
+ telemetry_token=str(payload.get("telemetryToken", "")),
38
+ issued_at=str(payload.get("issuedAt", "")) or None,
39
+ )
40
+ except KeyError as exc: # pragma: no cover - defensive guard
41
+ raise AccountProvisioningError(
42
+ f"Account provisioning payload missing field: {exc!s}" # noqa: TRY200
43
+ ) from exc
44
+
45
+
46
+ class AccountClient:
47
+ """Minimal client for authenticating against the control plane.
48
+
49
+ If ``NOCTURNAL_CONTROL_PLANE_URL`` is unset the client falls back to an
50
+ offline deterministic token generator so local development and CI remain
51
+ hermetic.
52
+ """
53
+
54
+ def __init__(self, base_url: Optional[str] = None, timeout: int = 10):
55
+ self.base_url = (
56
+ base_url
57
+ or os.getenv("NOCTURNAL_CONTROL_PLANE_URL")
58
+ or "https://cite-agent-api-720dfadd602c.herokuapp.com"
59
+ )
60
+ self.timeout = timeout
61
+
62
+ def provision(self, email: str, password: str) -> AccountCredentials:
63
+ if self.base_url:
64
+ payload = self._request_credentials(email, password)
65
+ return AccountCredentials.from_payload(email=email, payload=payload)
66
+ return self._generate_offline_credentials(email, password)
67
+
68
+ # -- internal helpers -------------------------------------------------
69
+ def _request_credentials(self, email: str, password: str) -> Dict[str, Any]:
70
+ try: # pragma: no cover - requires network
71
+ import requests # type: ignore
72
+ except Exception as exc: # pragma: no cover - executed when requests missing
73
+ raise AccountProvisioningError(
74
+ "The 'requests' package is required for control-plane authentication"
75
+ ) from exc
76
+
77
+ endpoint = self.base_url.rstrip("/") + "/api/beta/login"
78
+ body = {"email": email, "password": password, "client": "cli"}
79
+ try:
80
+ response = requests.post(endpoint, json=body, timeout=self.timeout)
81
+ except Exception as exc: # pragma: no cover - network failure
82
+ raise AccountProvisioningError("Failed to reach control plane") from exc
83
+
84
+ if response.status_code >= 400:
85
+ detail = self._extract_error_detail(response)
86
+ raise AccountProvisioningError(
87
+ f"Authentication failed (status {response.status_code}): {detail}"
88
+ )
89
+
90
+ try:
91
+ payload = response.json()
92
+ except ValueError as exc: # pragma: no cover - invalid JSON
93
+ raise AccountProvisioningError("Control plane returned invalid JSON") from exc
94
+
95
+ if not isinstance(payload, dict): # pragma: no cover - sanity guard
96
+ raise AccountProvisioningError("Control plane response must be an object")
97
+ return payload
98
+
99
+ @staticmethod
100
+ def _extract_error_detail(response: Any) -> str:
101
+ try: # pragma: no cover - best effort decoding
102
+ data = response.json()
103
+ if isinstance(data, dict) and data.get("detail"):
104
+ return str(data["detail"])
105
+ except Exception:
106
+ pass
107
+ return response.text.strip() or "unknown error"
108
+
109
+ @staticmethod
110
+ def _generate_offline_credentials(email: str, password: str) -> AccountCredentials:
111
+ seed = f"{email.lower()}::{password}"
112
+ digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()
113
+ auth_token = digest[12:44]
114
+ refresh_token = digest[44:]
115
+ telemetry_token = digest[24:56]
116
+ return AccountCredentials(
117
+ account_id=digest[:12],
118
+ email=email,
119
+ auth_token=auth_token,
120
+ refresh_token=refresh_token,
121
+ telemetry_token=telemetry_token,
122
+ issued_at=None,
123
+ )
124
+
125
+
126
+ __all__ = [
127
+ "AccountClient",
128
+ "AccountCredentials",
129
+ "AccountProvisioningError",
130
+ ]
@@ -0,0 +1,172 @@
1
+ """
2
+ Backend-Only Agent (Distribution Version)
3
+ All LLM queries go through centralized backend API.
4
+ Local API keys are not supported.
5
+ """
6
+
7
+ import os
8
+ import requests
9
+ from typing import Dict, Any, Optional
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+
13
+ @dataclass
14
+ class ChatRequest:
15
+ question: str
16
+ user_id: str = "default"
17
+ conversation_id: str = "default"
18
+ context: Dict[str, Any] = None
19
+
20
+ @dataclass
21
+ class ChatResponse:
22
+ response: str
23
+ citations: list = None
24
+ tools_used: list = None
25
+ model: str = "backend"
26
+ timestamp: str = None
27
+
28
+ def __post_init__(self):
29
+ if self.timestamp is None:
30
+ self.timestamp = datetime.now(timezone.utc).isoformat()
31
+ if self.citations is None:
32
+ self.citations = []
33
+ if self.tools_used is None:
34
+ self.tools_used = []
35
+
36
+ class EnhancedNocturnalAgent:
37
+ """
38
+ Backend-only agent for distribution.
39
+ Proxies all requests to centralized API.
40
+ """
41
+
42
+ def __init__(self):
43
+ self.backend_url = (
44
+ os.getenv("NOCTURNAL_CONTROL_PLANE_URL")
45
+ or "https://cite-agent-api-720dfadd602c.herokuapp.com"
46
+ )
47
+ self.auth_token = None
48
+ self._load_auth()
49
+
50
+ def _load_auth(self):
51
+ """Load authentication token from config"""
52
+ # Try environment first
53
+ self.auth_token = os.getenv("NOCTURNAL_AUTH_TOKEN")
54
+
55
+ # Try config file
56
+ if not self.auth_token:
57
+ from pathlib import Path
58
+ config_file = Path.home() / ".nocturnal_archive" / "config.env"
59
+ if config_file.exists():
60
+ with open(config_file) as f:
61
+ for line in f:
62
+ if line.startswith("NOCTURNAL_AUTH_TOKEN="):
63
+ self.auth_token = line.split("=", 1)[1].strip()
64
+ break
65
+
66
+ async def initialize(self):
67
+ """Initialize agent"""
68
+ if not self.auth_token:
69
+ raise RuntimeError(
70
+ "Not authenticated. Please run 'cite-agent --setup' first."
71
+ )
72
+ print(f"✅ Connected to backend: {self.backend_url}")
73
+
74
+ async def chat(self, request: ChatRequest) -> ChatResponse:
75
+ """
76
+ Send chat request to backend API.
77
+
78
+ Args:
79
+ request: Chat request with question and context
80
+
81
+ Returns:
82
+ Chat response with answer and citations
83
+
84
+ Raises:
85
+ RuntimeError: If authentication fails or backend unavailable
86
+ """
87
+ if not self.auth_token:
88
+ raise RuntimeError(
89
+ "Not authenticated. Run 'cite-agent --setup' first."
90
+ )
91
+
92
+ try:
93
+ response = requests.post(
94
+ f"{self.backend_url}/api/query",
95
+ headers={
96
+ "Authorization": f"Bearer {self.auth_token}",
97
+ "Content-Type": "application/json"
98
+ },
99
+ json={
100
+ "query": request.question,
101
+ "context": request.context or {},
102
+ "user_id": request.user_id,
103
+ "conversation_id": request.conversation_id,
104
+ },
105
+ timeout=60
106
+ )
107
+
108
+ if response.status_code == 401:
109
+ raise RuntimeError(
110
+ "Authentication expired. Run 'cite-agent --setup' to log in again."
111
+ )
112
+
113
+ if response.status_code == 429:
114
+ raise RuntimeError(
115
+ "Daily quota exceeded (25,000 tokens). Resets tomorrow."
116
+ )
117
+
118
+ if response.status_code >= 400:
119
+ error_detail = response.json().get("detail", response.text)
120
+ raise RuntimeError(f"Backend error: {error_detail}")
121
+
122
+ data = response.json()
123
+
124
+ return ChatResponse(
125
+ response=data.get("response", data.get("answer", "")),
126
+ citations=data.get("citations", []),
127
+ tools_used=data.get("tools_used", []),
128
+ model=data.get("model", "backend"),
129
+ )
130
+
131
+ except requests.RequestException as e:
132
+ raise RuntimeError(
133
+ f"Backend connection failed: {e}. Check your internet connection."
134
+ ) from e
135
+
136
+ async def close(self):
137
+ """Cleanup"""
138
+ pass
139
+
140
+ def get_health_status(self) -> Dict[str, Any]:
141
+ """Get backend health status"""
142
+ try:
143
+ response = requests.get(
144
+ f"{self.backend_url}/api/health/",
145
+ timeout=5
146
+ )
147
+ return response.json()
148
+ except:
149
+ return {"status": "unavailable"}
150
+
151
+ def check_quota(self) -> Dict[str, Any]:
152
+ """Check remaining daily quota"""
153
+ if not self.auth_token:
154
+ raise RuntimeError("Not authenticated")
155
+
156
+ response = requests.get(
157
+ f"{self.backend_url}/api/auth/me",
158
+ headers={"Authorization": f"Bearer {self.auth_token}"},
159
+ timeout=10
160
+ )
161
+
162
+ if response.status_code == 401:
163
+ raise RuntimeError("Authentication expired")
164
+
165
+ response.raise_for_status()
166
+ data = response.json()
167
+
168
+ return {
169
+ "tokens_used": data.get("tokens_used_today", 0),
170
+ "tokens_remaining": data.get("tokens_remaining", 0),
171
+ "daily_limit": 25000,
172
+ }
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ASCII Plotting Module for Terminal Visualization
4
+ Uses plotext for clean, readable terminal charts
5
+ """
6
+
7
+ import sys
8
+ from typing import List, Optional, Dict, Any, Tuple
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Try to import plotext
14
+ try:
15
+ import plotext as plt
16
+ PLOTEXT_AVAILABLE = True
17
+ except ImportError:
18
+ PLOTEXT_AVAILABLE = False
19
+ logger.warning("plotext not installed - ASCII plotting unavailable")
20
+
21
+
22
+ class ASCIIPlotter:
23
+ """
24
+ Terminal-based plotting using plotext
25
+ Generates clean ASCII charts for data visualization
26
+ """
27
+
28
+ def __init__(self, width: int = 70, height: int = 20):
29
+ """
30
+ Initialize plotter
31
+
32
+ Args:
33
+ width: Plot width in characters
34
+ height: Plot height in characters
35
+ """
36
+ self.width = width
37
+ self.height = height
38
+ self.available = PLOTEXT_AVAILABLE
39
+
40
+ def plot_line(
41
+ self,
42
+ x: List[float],
43
+ y: List[float],
44
+ title: str = "Line Plot",
45
+ xlabel: str = "X",
46
+ ylabel: str = "Y",
47
+ label: Optional[str] = None
48
+ ) -> str:
49
+ """
50
+ Create a line plot
51
+
52
+ Args:
53
+ x: X-axis data
54
+ y: Y-axis data
55
+ title: Plot title
56
+ xlabel: X-axis label
57
+ ylabel: Y-axis label
58
+ label: Data series label
59
+
60
+ Returns:
61
+ ASCII art string representation of the plot
62
+ """
63
+ if not self.available:
64
+ return self._fallback_plot(x, y, title)
65
+
66
+ try:
67
+ plt.clf() # Clear previous plot
68
+ plt.plot_size(self.width, self.height)
69
+ plt.plot(x, y, label=label)
70
+ plt.title(title)
71
+ plt.xlabel(xlabel)
72
+ plt.ylabel(ylabel)
73
+ if label:
74
+ plt.legend()
75
+
76
+ # Get the plot as string
77
+ return plt.build()
78
+
79
+ except Exception as e:
80
+ logger.error(f"Plotting failed: {e}")
81
+ return self._fallback_plot(x, y, title)
82
+
83
+ def plot_multiple_lines(
84
+ self,
85
+ data: List[Tuple[List[float], List[float], str]],
86
+ title: str = "Multi-Line Plot",
87
+ xlabel: str = "X",
88
+ ylabel: str = "Y"
89
+ ) -> str:
90
+ """
91
+ Create a multi-line plot
92
+
93
+ Args:
94
+ data: List of (x, y, label) tuples
95
+ title: Plot title
96
+ xlabel: X-axis label
97
+ ylabel: Y-axis label
98
+
99
+ Returns:
100
+ ASCII art string representation of the plot
101
+ """
102
+ if not self.available:
103
+ return f"[Plot unavailable: plotext not installed]\n{title}"
104
+
105
+ try:
106
+ plt.clf()
107
+ plt.plot_size(self.width, self.height)
108
+
109
+ for x, y, label in data:
110
+ plt.plot(x, y, label=label)
111
+
112
+ plt.title(title)
113
+ plt.xlabel(xlabel)
114
+ plt.ylabel(ylabel)
115
+ plt.legend()
116
+
117
+ return plt.build()
118
+
119
+ except Exception as e:
120
+ logger.error(f"Multi-line plotting failed: {e}")
121
+ return f"[Plot error: {str(e)}]\n{title}"
122
+
123
+ def plot_scatter(
124
+ self,
125
+ x: List[float],
126
+ y: List[float],
127
+ title: str = "Scatter Plot",
128
+ xlabel: str = "X",
129
+ ylabel: str = "Y",
130
+ label: Optional[str] = None
131
+ ) -> str:
132
+ """Create a scatter plot"""
133
+ if not self.available:
134
+ return self._fallback_plot(x, y, title)
135
+
136
+ try:
137
+ plt.clf()
138
+ plt.plot_size(self.width, self.height)
139
+ plt.scatter(x, y, label=label)
140
+ plt.title(title)
141
+ plt.xlabel(xlabel)
142
+ plt.ylabel(ylabel)
143
+ if label:
144
+ plt.legend()
145
+
146
+ return plt.build()
147
+
148
+ except Exception as e:
149
+ logger.error(f"Scatter plot failed: {e}")
150
+ return self._fallback_plot(x, y, title)
151
+
152
+ def plot_bar(
153
+ self,
154
+ categories: List[str],
155
+ values: List[float],
156
+ title: str = "Bar Chart",
157
+ xlabel: str = "Category",
158
+ ylabel: str = "Value"
159
+ ) -> str:
160
+ """Create a bar chart"""
161
+ if not self.available:
162
+ return f"[Plot unavailable: plotext not installed]\n{title}"
163
+
164
+ try:
165
+ plt.clf()
166
+ plt.plot_size(self.width, self.height)
167
+ plt.bar(categories, values)
168
+ plt.title(title)
169
+ plt.xlabel(xlabel)
170
+ plt.ylabel(ylabel)
171
+
172
+ return plt.build()
173
+
174
+ except Exception as e:
175
+ logger.error(f"Bar chart failed: {e}")
176
+ return f"[Plot error: {str(e)}]\n{title}"
177
+
178
+ def plot_histogram(
179
+ self,
180
+ data: List[float],
181
+ bins: int = 20,
182
+ title: str = "Histogram",
183
+ xlabel: str = "Value",
184
+ ylabel: str = "Frequency"
185
+ ) -> str:
186
+ """Create a histogram"""
187
+ if not self.available:
188
+ return f"[Plot unavailable: plotext not installed]\n{title}"
189
+
190
+ try:
191
+ plt.clf()
192
+ plt.plot_size(self.width, self.height)
193
+ plt.hist(data, bins=bins)
194
+ plt.title(title)
195
+ plt.xlabel(xlabel)
196
+ plt.ylabel(ylabel)
197
+
198
+ return plt.build()
199
+
200
+ except Exception as e:
201
+ logger.error(f"Histogram failed: {e}")
202
+ return f"[Plot error: {str(e)}]\n{title}"
203
+
204
+ def _fallback_plot(self, x: List[float], y: List[float], title: str) -> str:
205
+ """
206
+ Simple fallback visualization when plotext is unavailable
207
+ Creates a basic ASCII representation
208
+ """
209
+ if not x or not y:
210
+ return f"[No data to plot]\n{title}"
211
+
212
+ # Simple text representation
213
+ output = [f"\n{title}", "─" * 40]
214
+
215
+ # Show min, max, mean
216
+ try:
217
+ output.append(f"Data points: {len(y)}")
218
+ output.append(f"Min: {min(y):.2f}")
219
+ output.append(f"Max: {max(y):.2f}")
220
+ output.append(f"Mean: {sum(y)/len(y):.2f}")
221
+ except Exception:
222
+ output.append("Data statistics unavailable")
223
+
224
+ output.append("─" * 40)
225
+ output.append("[Install plotext for visual charts: pip install plotext]")
226
+
227
+ return "\n".join(output)
228
+
229
+ @staticmethod
230
+ def is_available() -> bool:
231
+ """Check if plotting is available"""
232
+ return PLOTEXT_AVAILABLE
233
+
234
+
235
+ # Convenience functions for quick plotting
236
+
237
+ def plot_quick_line(x: List[float], y: List[float], title: str = "Plot") -> str:
238
+ """Quick line plot"""
239
+ plotter = ASCIIPlotter()
240
+ return plotter.plot_line(x, y, title=title)
241
+
242
+
243
+ def plot_quick_bar(categories: List[str], values: List[float], title: str = "Chart") -> str:
244
+ """Quick bar chart"""
245
+ plotter = ASCIIPlotter()
246
+ return plotter.plot_bar(categories, values, title=title)
247
+
248
+
249
+ # Example usage and testing
250
+ def example_usage():
251
+ """Demonstrate ASCII plotting capabilities"""
252
+
253
+ print("ASCII Plotter Demo\n")
254
+ print("=" * 70)
255
+
256
+ # Check availability
257
+ if not ASCIIPlotter.is_available():
258
+ print("❌ plotext not installed")
259
+ print("Install with: pip install plotext")
260
+ return
261
+
262
+ print("✅ plotext available\n")
263
+
264
+ # Example 1: Simple line plot
265
+ x = list(range(2020, 2025))
266
+ y = [100, 120, 115, 140, 150]
267
+
268
+ plotter = ASCIIPlotter(width=60, height=15)
269
+
270
+ print("Example 1: GDP Growth Over Time")
271
+ print(plotter.plot_line(x, y, title="GDP Growth (2020-2024)",
272
+ xlabel="Year", ylabel="GDP ($B)"))
273
+
274
+ # Example 2: Multiple lines
275
+ print("\n\nExample 2: Multi-Country Comparison")
276
+ data = [
277
+ (x, [100, 110, 105, 115, 120], "USA"),
278
+ (x, [90, 95, 92, 100, 105], "UK"),
279
+ (x, [80, 85, 88, 95, 100], "Japan")
280
+ ]
281
+ print(plotter.plot_multiple_lines(data, title="GDP Growth Comparison",
282
+ xlabel="Year", ylabel="GDP Index"))
283
+
284
+ # Example 3: Bar chart
285
+ print("\n\nExample 3: Quarterly Revenue")
286
+ quarters = ["Q1", "Q2", "Q3", "Q4"]
287
+ revenue = [250, 280, 290, 310]
288
+ print(plotter.plot_bar(quarters, revenue, title="2024 Revenue by Quarter",
289
+ xlabel="Quarter", ylabel="Revenue ($M)"))
290
+
291
+ print("\n" + "=" * 70)
292
+ print("✅ ASCII plotting demo complete!")
293
+
294
+
295
+ if __name__ == "__main__":
296
+ example_usage()