nc1709 1.15.4__py3-none-any.whl → 1.18.8__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.
@@ -0,0 +1,416 @@
1
+ """
2
+ Conversation Logger for NC1709
3
+ Logs all conversations per session with user tracking.
4
+ """
5
+ import json
6
+ import os
7
+ import uuid
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Optional, Dict, Any, List
11
+ from dataclasses import dataclass, field, asdict
12
+
13
+
14
+ @dataclass
15
+ class ConversationEntry:
16
+ """A single entry in a conversation"""
17
+ timestamp: str
18
+ role: str # 'user', 'assistant', 'tool', 'system', 'error'
19
+ content: str
20
+ metadata: Dict[str, Any] = field(default_factory=dict)
21
+
22
+ def to_dict(self) -> Dict:
23
+ return asdict(self)
24
+
25
+ @classmethod
26
+ def from_dict(cls, data: Dict) -> "ConversationEntry":
27
+ return cls(**data)
28
+
29
+
30
+ @dataclass
31
+ class SessionInfo:
32
+ """Information about a session"""
33
+ session_id: str
34
+ started_at: str
35
+ ip_address: Optional[str] = None
36
+ user_agent: Optional[str] = None
37
+ user_id: Optional[str] = None
38
+ working_directory: Optional[str] = None
39
+ mode: str = "remote" # 'remote', 'local', 'agent'
40
+
41
+ def to_dict(self) -> Dict:
42
+ return asdict(self)
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: Dict) -> "SessionInfo":
46
+ return cls(**data)
47
+
48
+
49
+ class ConversationLogger:
50
+ """
51
+ Logs conversations to files per session.
52
+
53
+ Features:
54
+ - Per-session log files
55
+ - IP address tracking (for remote mode)
56
+ - User agent tracking
57
+ - Tool call logging
58
+ - Error logging
59
+ - JSON format for easy parsing
60
+ """
61
+
62
+ # Default log directory
63
+ LOG_DIR = "logs"
64
+
65
+ def __init__(
66
+ self,
67
+ base_dir: Optional[Path] = None,
68
+ session_id: Optional[str] = None,
69
+ ip_address: Optional[str] = None,
70
+ user_agent: Optional[str] = None,
71
+ user_id: Optional[str] = None,
72
+ mode: str = "remote"
73
+ ):
74
+ """
75
+ Initialize the conversation logger.
76
+
77
+ Args:
78
+ base_dir: Base directory for logs (defaults to ~/.nc1709)
79
+ session_id: Unique session ID (auto-generated if not provided)
80
+ ip_address: Client IP address (for remote mode)
81
+ user_agent: Client user agent string
82
+ user_id: Optional user identifier
83
+ mode: Operation mode (remote, local, agent)
84
+ """
85
+ # Determine base directory
86
+ if base_dir:
87
+ self.base_dir = Path(base_dir)
88
+ else:
89
+ self.base_dir = Path.home() / ".nc1709"
90
+
91
+ self.log_dir = self.base_dir / self.LOG_DIR
92
+ self.log_dir.mkdir(parents=True, exist_ok=True)
93
+
94
+ # Session info
95
+ self.session_id = session_id or self._generate_session_id()
96
+ self.session_info = SessionInfo(
97
+ session_id=self.session_id,
98
+ started_at=datetime.now().isoformat(),
99
+ ip_address=ip_address,
100
+ user_agent=user_agent,
101
+ user_id=user_id,
102
+ working_directory=str(Path.cwd()),
103
+ mode=mode
104
+ )
105
+
106
+ # Conversation entries
107
+ self.entries: List[ConversationEntry] = []
108
+
109
+ # Log file path
110
+ self.log_file = self._get_log_file_path()
111
+
112
+ # Initialize log file with session info
113
+ self._init_log_file()
114
+
115
+ def _generate_session_id(self) -> str:
116
+ """Generate a unique session ID"""
117
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
118
+ unique = uuid.uuid4().hex[:8]
119
+ return f"{timestamp}_{unique}"
120
+
121
+ def _get_log_file_path(self) -> Path:
122
+ """Get the log file path for this session"""
123
+ # Organize by date
124
+ date_str = datetime.now().strftime("%Y-%m-%d")
125
+ date_dir = self.log_dir / date_str
126
+ date_dir.mkdir(exist_ok=True)
127
+
128
+ return date_dir / f"session_{self.session_id}.json"
129
+
130
+ def _init_log_file(self) -> None:
131
+ """Initialize the log file with session info"""
132
+ data = {
133
+ "session": self.session_info.to_dict(),
134
+ "entries": []
135
+ }
136
+ self._write_log(data)
137
+
138
+ def _write_log(self, data: Dict) -> None:
139
+ """Write data to log file"""
140
+ try:
141
+ self.log_file.write_text(json.dumps(data, indent=2, ensure_ascii=False))
142
+ except Exception as e:
143
+ # Silently fail - logging shouldn't break the app
144
+ pass
145
+
146
+ def _append_entry(self, entry: ConversationEntry) -> None:
147
+ """Append an entry to the log file"""
148
+ self.entries.append(entry)
149
+
150
+ try:
151
+ # Read current log
152
+ if self.log_file.exists():
153
+ data = json.loads(self.log_file.read_text())
154
+ else:
155
+ data = {"session": self.session_info.to_dict(), "entries": []}
156
+
157
+ # Append entry
158
+ data["entries"].append(entry.to_dict())
159
+
160
+ # Write back
161
+ self._write_log(data)
162
+ except Exception:
163
+ pass
164
+
165
+ def log_user_message(self, message: str, metadata: Optional[Dict] = None) -> None:
166
+ """Log a user message"""
167
+ entry = ConversationEntry(
168
+ timestamp=datetime.now().isoformat(),
169
+ role="user",
170
+ content=message,
171
+ metadata=metadata or {}
172
+ )
173
+ self._append_entry(entry)
174
+
175
+ def log_assistant_message(self, message: str, metadata: Optional[Dict] = None) -> None:
176
+ """Log an assistant response"""
177
+ entry = ConversationEntry(
178
+ timestamp=datetime.now().isoformat(),
179
+ role="assistant",
180
+ content=message,
181
+ metadata=metadata or {}
182
+ )
183
+ self._append_entry(entry)
184
+
185
+ def log_tool_call(
186
+ self,
187
+ tool_name: str,
188
+ parameters: Dict,
189
+ result: Optional[str] = None,
190
+ success: bool = True,
191
+ duration_ms: Optional[int] = None
192
+ ) -> None:
193
+ """Log a tool call"""
194
+ entry = ConversationEntry(
195
+ timestamp=datetime.now().isoformat(),
196
+ role="tool",
197
+ content=result or "",
198
+ metadata={
199
+ "tool_name": tool_name,
200
+ "parameters": parameters,
201
+ "success": success,
202
+ "duration_ms": duration_ms
203
+ }
204
+ )
205
+ self._append_entry(entry)
206
+
207
+ def log_error(self, error: str, context: Optional[Dict] = None) -> None:
208
+ """Log an error"""
209
+ entry = ConversationEntry(
210
+ timestamp=datetime.now().isoformat(),
211
+ role="error",
212
+ content=error,
213
+ metadata=context or {}
214
+ )
215
+ self._append_entry(entry)
216
+
217
+ def log_system(self, message: str, metadata: Optional[Dict] = None) -> None:
218
+ """Log a system message"""
219
+ entry = ConversationEntry(
220
+ timestamp=datetime.now().isoformat(),
221
+ role="system",
222
+ content=message,
223
+ metadata=metadata or {}
224
+ )
225
+ self._append_entry(entry)
226
+
227
+ def update_session_info(self, **kwargs) -> None:
228
+ """Update session info (e.g., when IP becomes available)"""
229
+ for key, value in kwargs.items():
230
+ if hasattr(self.session_info, key):
231
+ setattr(self.session_info, key, value)
232
+
233
+ # Update log file
234
+ try:
235
+ if self.log_file.exists():
236
+ data = json.loads(self.log_file.read_text())
237
+ data["session"] = self.session_info.to_dict()
238
+ self._write_log(data)
239
+ except Exception:
240
+ pass
241
+
242
+ def get_session_summary(self) -> Dict:
243
+ """Get a summary of the current session"""
244
+ user_messages = sum(1 for e in self.entries if e.role == "user")
245
+ assistant_messages = sum(1 for e in self.entries if e.role == "assistant")
246
+ tool_calls = sum(1 for e in self.entries if e.role == "tool")
247
+ errors = sum(1 for e in self.entries if e.role == "error")
248
+
249
+ return {
250
+ "session_id": self.session_id,
251
+ "started_at": self.session_info.started_at,
252
+ "ip_address": self.session_info.ip_address,
253
+ "user_messages": user_messages,
254
+ "assistant_messages": assistant_messages,
255
+ "tool_calls": tool_calls,
256
+ "errors": errors,
257
+ "total_entries": len(self.entries)
258
+ }
259
+
260
+ @classmethod
261
+ def list_sessions(
262
+ cls,
263
+ base_dir: Optional[Path] = None,
264
+ date: Optional[str] = None,
265
+ limit: int = 20
266
+ ) -> List[Dict]:
267
+ """
268
+ List recent sessions.
269
+
270
+ Args:
271
+ base_dir: Base directory for logs
272
+ date: Filter by date (YYYY-MM-DD format)
273
+ limit: Maximum number of sessions to return
274
+
275
+ Returns:
276
+ List of session summaries
277
+ """
278
+ if base_dir:
279
+ log_dir = Path(base_dir) / cls.LOG_DIR
280
+ else:
281
+ log_dir = Path.home() / ".nc1709" / cls.LOG_DIR
282
+
283
+ if not log_dir.exists():
284
+ return []
285
+
286
+ sessions = []
287
+
288
+ # Get date directories
289
+ if date:
290
+ date_dirs = [log_dir / date] if (log_dir / date).exists() else []
291
+ else:
292
+ date_dirs = sorted(log_dir.iterdir(), reverse=True)
293
+
294
+ for date_dir in date_dirs:
295
+ if not date_dir.is_dir():
296
+ continue
297
+
298
+ for log_file in sorted(date_dir.glob("session_*.json"), reverse=True):
299
+ try:
300
+ data = json.loads(log_file.read_text())
301
+ session = data.get("session", {})
302
+ entries = data.get("entries", [])
303
+
304
+ sessions.append({
305
+ "session_id": session.get("session_id"),
306
+ "started_at": session.get("started_at"),
307
+ "ip_address": session.get("ip_address"),
308
+ "mode": session.get("mode"),
309
+ "entry_count": len(entries),
310
+ "file": str(log_file)
311
+ })
312
+
313
+ if len(sessions) >= limit:
314
+ return sessions
315
+ except Exception:
316
+ continue
317
+
318
+ return sessions
319
+
320
+ @classmethod
321
+ def load_session(cls, session_id: str, base_dir: Optional[Path] = None) -> Optional[Dict]:
322
+ """
323
+ Load a specific session by ID.
324
+
325
+ Args:
326
+ session_id: The session ID to load
327
+ base_dir: Base directory for logs
328
+
329
+ Returns:
330
+ Full session data or None if not found
331
+ """
332
+ if base_dir:
333
+ log_dir = Path(base_dir) / cls.LOG_DIR
334
+ else:
335
+ log_dir = Path.home() / ".nc1709" / cls.LOG_DIR
336
+
337
+ if not log_dir.exists():
338
+ return None
339
+
340
+ # Search all date directories
341
+ for date_dir in log_dir.iterdir():
342
+ if not date_dir.is_dir():
343
+ continue
344
+
345
+ log_file = date_dir / f"session_{session_id}.json"
346
+ if log_file.exists():
347
+ try:
348
+ return json.loads(log_file.read_text())
349
+ except Exception:
350
+ return None
351
+
352
+ return None
353
+
354
+
355
+ # Global logger instance (initialized per session)
356
+ _current_logger: Optional[ConversationLogger] = None
357
+
358
+
359
+ def get_logger() -> Optional[ConversationLogger]:
360
+ """Get the current conversation logger"""
361
+ return _current_logger
362
+
363
+
364
+ def init_logger(
365
+ session_id: Optional[str] = None,
366
+ ip_address: Optional[str] = None,
367
+ user_agent: Optional[str] = None,
368
+ user_id: Optional[str] = None,
369
+ mode: str = "remote"
370
+ ) -> ConversationLogger:
371
+ """Initialize a new conversation logger for this session"""
372
+ global _current_logger
373
+ _current_logger = ConversationLogger(
374
+ session_id=session_id,
375
+ ip_address=ip_address,
376
+ user_agent=user_agent,
377
+ user_id=user_id,
378
+ mode=mode
379
+ )
380
+ return _current_logger
381
+
382
+
383
+ def log_user(message: str, metadata: Optional[Dict] = None) -> None:
384
+ """Convenience function to log user message"""
385
+ if _current_logger:
386
+ _current_logger.log_user_message(message, metadata)
387
+
388
+
389
+ def log_assistant(message: str, metadata: Optional[Dict] = None) -> None:
390
+ """Convenience function to log assistant message"""
391
+ if _current_logger:
392
+ _current_logger.log_assistant_message(message, metadata)
393
+
394
+
395
+ def log_tool(
396
+ tool_name: str,
397
+ parameters: Dict,
398
+ result: Optional[str] = None,
399
+ success: bool = True,
400
+ duration_ms: Optional[int] = None
401
+ ) -> None:
402
+ """Convenience function to log tool call"""
403
+ if _current_logger:
404
+ _current_logger.log_tool_call(tool_name, parameters, result, success, duration_ms)
405
+
406
+
407
+ def log_error(error: str, context: Optional[Dict] = None) -> None:
408
+ """Convenience function to log error"""
409
+ if _current_logger:
410
+ _current_logger.log_error(error, context)
411
+
412
+
413
+ def log_system(message: str, metadata: Optional[Dict] = None) -> None:
414
+ """Convenience function to log system message"""
415
+ if _current_logger:
416
+ _current_logger.log_system(message, metadata)
nc1709/llm_adapter.py CHANGED
@@ -273,13 +273,45 @@ class LLMAdapter:
273
273
 
274
274
  except Exception as e:
275
275
  last_error = e
276
+ error_str = str(e).lower()
277
+
278
+ # Identify recoverable vs non-recoverable errors
279
+ is_network_error = any(x in error_str for x in [
280
+ "connection", "timeout", "refused", "reset", "network",
281
+ "503", "502", "504", "429", "rate limit", "overloaded"
282
+ ])
283
+
276
284
  if attempt < max_retries - 1:
277
285
  wait_time = 2 ** attempt # Exponential backoff: 1, 2, 4 seconds
278
- print(f"⚠️ LLM request failed (attempt {attempt + 1}/{max_retries}): {e}")
279
- print(f" Retrying in {wait_time} seconds...")
286
+
287
+ if is_network_error:
288
+ print(f"⚠️ Network error (attempt {attempt + 1}/{max_retries}): {e}")
289
+ print(f" This may be a temporary issue. Retrying in {wait_time} seconds...")
290
+ else:
291
+ print(f"⚠️ LLM request failed (attempt {attempt + 1}/{max_retries}): {e}")
292
+ print(f" Retrying in {wait_time} seconds...")
293
+
280
294
  time.sleep(wait_time)
281
295
  continue
282
296
 
297
+ # Provide helpful error message based on error type
298
+ error_str = str(last_error).lower()
299
+ if "connection refused" in error_str or "cannot connect" in error_str:
300
+ raise RuntimeError(
301
+ f"Cannot connect to Ollama at {api_base}. "
302
+ f"Please ensure Ollama is running: 'ollama serve'"
303
+ )
304
+ elif "timeout" in error_str:
305
+ raise RuntimeError(
306
+ f"Request timed out after {max_retries} attempts. "
307
+ f"The model may be loading or the server is overloaded."
308
+ )
309
+ elif "429" in error_str or "rate limit" in error_str:
310
+ raise RuntimeError(
311
+ f"Rate limited after {max_retries} attempts. "
312
+ f"Please wait a moment before trying again."
313
+ )
314
+
283
315
  raise RuntimeError(f"LLM completion failed after {max_retries} attempts: {last_error}")
284
316
 
285
317
  def _stream_completion(
@@ -416,13 +448,39 @@ class LLMAdapter:
416
448
 
417
449
  except Exception as e:
418
450
  last_error = e
451
+ error_str = str(e).lower()
452
+
453
+ # Identify recoverable errors
454
+ is_recoverable = any(x in error_str for x in [
455
+ "connection", "timeout", "refused", "reset", "network",
456
+ "503", "502", "504", "429", "rate limit", "overloaded"
457
+ ])
458
+
419
459
  if attempt < max_retries - 1:
420
460
  wait_time = 2 ** attempt
421
- print(f"⚠️ LLM request failed (attempt {attempt + 1}/{max_retries}): {e}")
422
- print(f" Retrying in {wait_time} seconds...")
461
+
462
+ if is_recoverable:
463
+ print(f"⚠️ Network error (attempt {attempt + 1}/{max_retries}): {e}")
464
+ print(f" Retrying in {wait_time} seconds...")
465
+ else:
466
+ print(f"⚠️ LLM request failed (attempt {attempt + 1}/{max_retries}): {e}")
467
+ print(f" Retrying in {wait_time} seconds...")
468
+
423
469
  time.sleep(wait_time)
424
470
  continue
425
471
 
472
+ # Provide helpful error message
473
+ error_str = str(last_error).lower()
474
+ if "connection refused" in error_str or "cannot connect" in error_str:
475
+ raise RuntimeError(
476
+ f"Cannot connect to Ollama at {api_base}. "
477
+ f"Please ensure Ollama is running: 'ollama serve'"
478
+ )
479
+ elif "timeout" in error_str:
480
+ raise RuntimeError(
481
+ f"Request timed out. The model may be loading or overloaded."
482
+ )
483
+
426
484
  raise RuntimeError(f"LLM chat failed after {max_retries} attempts: {last_error}")
427
485
 
428
486
  def clear_history(self) -> None: