h2ogpte 1.6.41rc5__py3-none-any.whl → 1.6.43rc1__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.
Files changed (98) hide show
  1. h2ogpte/__init__.py +1 -1
  2. h2ogpte/cli/__init__.py +0 -0
  3. h2ogpte/cli/commands/__init__.py +0 -0
  4. h2ogpte/cli/commands/command_handlers/__init__.py +0 -0
  5. h2ogpte/cli/commands/command_handlers/agent.py +41 -0
  6. h2ogpte/cli/commands/command_handlers/chat.py +37 -0
  7. h2ogpte/cli/commands/command_handlers/clear.py +8 -0
  8. h2ogpte/cli/commands/command_handlers/collection.py +67 -0
  9. h2ogpte/cli/commands/command_handlers/config.py +113 -0
  10. h2ogpte/cli/commands/command_handlers/disconnect.py +36 -0
  11. h2ogpte/cli/commands/command_handlers/exit.py +37 -0
  12. h2ogpte/cli/commands/command_handlers/help.py +8 -0
  13. h2ogpte/cli/commands/command_handlers/history.py +29 -0
  14. h2ogpte/cli/commands/command_handlers/rag.py +146 -0
  15. h2ogpte/cli/commands/command_handlers/research_agent.py +45 -0
  16. h2ogpte/cli/commands/command_handlers/session.py +77 -0
  17. h2ogpte/cli/commands/command_handlers/status.py +33 -0
  18. h2ogpte/cli/commands/dispatcher.py +79 -0
  19. h2ogpte/cli/core/__init__.py +0 -0
  20. h2ogpte/cli/core/app.py +105 -0
  21. h2ogpte/cli/core/config.py +199 -0
  22. h2ogpte/cli/core/encryption.py +104 -0
  23. h2ogpte/cli/core/session.py +171 -0
  24. h2ogpte/cli/integrations/__init__.py +0 -0
  25. h2ogpte/cli/integrations/agent.py +338 -0
  26. h2ogpte/cli/integrations/rag.py +442 -0
  27. h2ogpte/cli/main.py +90 -0
  28. h2ogpte/cli/ui/__init__.py +0 -0
  29. h2ogpte/cli/ui/hbot_prompt.py +435 -0
  30. h2ogpte/cli/ui/prompts.py +129 -0
  31. h2ogpte/cli/ui/status_bar.py +133 -0
  32. h2ogpte/cli/utils/__init__.py +0 -0
  33. h2ogpte/cli/utils/file_manager.py +411 -0
  34. h2ogpte/h2ogpte.py +471 -67
  35. h2ogpte/h2ogpte_async.py +482 -68
  36. h2ogpte/h2ogpte_sync_base.py +8 -1
  37. h2ogpte/rest_async/__init__.py +6 -3
  38. h2ogpte/rest_async/api/chat_api.py +29 -0
  39. h2ogpte/rest_async/api/collections_api.py +293 -0
  40. h2ogpte/rest_async/api/extractors_api.py +2874 -70
  41. h2ogpte/rest_async/api/prompt_templates_api.py +32 -32
  42. h2ogpte/rest_async/api_client.py +1 -1
  43. h2ogpte/rest_async/configuration.py +1 -1
  44. h2ogpte/rest_async/models/__init__.py +5 -2
  45. h2ogpte/rest_async/models/chat_completion.py +4 -2
  46. h2ogpte/rest_async/models/chat_completion_delta.py +5 -3
  47. h2ogpte/rest_async/models/chat_completion_request.py +1 -1
  48. h2ogpte/rest_async/models/chat_session.py +4 -2
  49. h2ogpte/rest_async/models/chat_settings.py +1 -1
  50. h2ogpte/rest_async/models/collection.py +4 -2
  51. h2ogpte/rest_async/models/collection_create_request.py +4 -2
  52. h2ogpte/rest_async/models/create_chat_session_request.py +87 -0
  53. h2ogpte/rest_async/models/extraction_request.py +1 -1
  54. h2ogpte/rest_async/models/extractor.py +4 -2
  55. h2ogpte/rest_async/models/guardrails_settings.py +8 -4
  56. h2ogpte/rest_async/models/guardrails_settings_create_request.py +1 -1
  57. h2ogpte/rest_async/models/process_document_job_request.py +1 -1
  58. h2ogpte/rest_async/models/question_request.py +1 -1
  59. h2ogpte/rest_async/models/{reset_and_share_prompt_template_request.py → reset_and_share_request.py} +6 -6
  60. h2ogpte/{rest_sync/models/reset_and_share_prompt_template_with_groups_request.py → rest_async/models/reset_and_share_with_groups_request.py} +6 -6
  61. h2ogpte/rest_async/models/summarize_request.py +1 -1
  62. h2ogpte/rest_async/models/update_collection_workspace_request.py +87 -0
  63. h2ogpte/rest_async/models/update_extractor_privacy_request.py +87 -0
  64. h2ogpte/rest_sync/__init__.py +6 -3
  65. h2ogpte/rest_sync/api/chat_api.py +29 -0
  66. h2ogpte/rest_sync/api/collections_api.py +293 -0
  67. h2ogpte/rest_sync/api/extractors_api.py +2874 -70
  68. h2ogpte/rest_sync/api/prompt_templates_api.py +32 -32
  69. h2ogpte/rest_sync/api_client.py +1 -1
  70. h2ogpte/rest_sync/configuration.py +1 -1
  71. h2ogpte/rest_sync/models/__init__.py +5 -2
  72. h2ogpte/rest_sync/models/chat_completion.py +4 -2
  73. h2ogpte/rest_sync/models/chat_completion_delta.py +5 -3
  74. h2ogpte/rest_sync/models/chat_completion_request.py +1 -1
  75. h2ogpte/rest_sync/models/chat_session.py +4 -2
  76. h2ogpte/rest_sync/models/chat_settings.py +1 -1
  77. h2ogpte/rest_sync/models/collection.py +4 -2
  78. h2ogpte/rest_sync/models/collection_create_request.py +4 -2
  79. h2ogpte/rest_sync/models/create_chat_session_request.py +87 -0
  80. h2ogpte/rest_sync/models/extraction_request.py +1 -1
  81. h2ogpte/rest_sync/models/extractor.py +4 -2
  82. h2ogpte/rest_sync/models/guardrails_settings.py +8 -4
  83. h2ogpte/rest_sync/models/guardrails_settings_create_request.py +1 -1
  84. h2ogpte/rest_sync/models/process_document_job_request.py +1 -1
  85. h2ogpte/rest_sync/models/question_request.py +1 -1
  86. h2ogpte/rest_sync/models/{reset_and_share_prompt_template_request.py → reset_and_share_request.py} +6 -6
  87. h2ogpte/{rest_async/models/reset_and_share_prompt_template_with_groups_request.py → rest_sync/models/reset_and_share_with_groups_request.py} +6 -6
  88. h2ogpte/rest_sync/models/summarize_request.py +1 -1
  89. h2ogpte/rest_sync/models/update_collection_workspace_request.py +87 -0
  90. h2ogpte/rest_sync/models/update_extractor_privacy_request.py +87 -0
  91. h2ogpte/session.py +3 -2
  92. h2ogpte/session_async.py +22 -6
  93. h2ogpte/types.py +6 -0
  94. {h2ogpte-1.6.41rc5.dist-info → h2ogpte-1.6.43rc1.dist-info}/METADATA +5 -1
  95. {h2ogpte-1.6.41rc5.dist-info → h2ogpte-1.6.43rc1.dist-info}/RECORD +98 -59
  96. h2ogpte-1.6.43rc1.dist-info/entry_points.txt +2 -0
  97. {h2ogpte-1.6.41rc5.dist-info → h2ogpte-1.6.43rc1.dist-info}/WHEEL +0 -0
  98. {h2ogpte-1.6.41rc5.dist-info → h2ogpte-1.6.43rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,33 @@
1
+ from rich.table import Table
2
+
3
+ from ...core.app import get_app_state
4
+
5
+
6
+ async def handle_status(args: str) -> bool:
7
+ """Show session status."""
8
+ app = get_app_state()
9
+
10
+ # Show session status
11
+ app.session.display_status()
12
+
13
+ # Show connection status
14
+ status_table = Table(title="Connection Status", show_header=False)
15
+ status_table.add_column("Service", style="cyan")
16
+ status_table.add_column("Status", style="white")
17
+
18
+ rag_status = (
19
+ "[green]Connected[/green]"
20
+ if app.rag_manager.connected
21
+ else "[red]Disconnected[/red]"
22
+ )
23
+ agent_status = (
24
+ "[green]Connected[/green]"
25
+ if app.agent_manager.connected
26
+ else "[red]Disconnected[/red]"
27
+ )
28
+
29
+ status_table.add_row("RAG System", rag_status)
30
+ status_table.add_row("Agent System", agent_status)
31
+
32
+ app.console.print(status_table)
33
+ return True
@@ -0,0 +1,79 @@
1
+ from typing import Dict, Callable, Awaitable
2
+
3
+ from .command_handlers.help import handle_help
4
+ from .command_handlers.status import handle_status
5
+ from .command_handlers.clear import handle_clear
6
+ from .command_handlers.history import handle_history
7
+ from .command_handlers.exit import handle_exit
8
+ from .command_handlers.config import handle_config
9
+ from .command_handlers.rag import handle_register, handle_upload, handle_analyze
10
+ from .command_handlers.agent import handle_agent
11
+ from .command_handlers.chat import handle_chat
12
+ from .command_handlers.research_agent import handle_research_agent
13
+ from .command_handlers.session import handle_save, handle_load, handle_session
14
+ from .command_handlers.collection import handle_create_collection
15
+ from .command_handlers.disconnect import handle_disconnect
16
+
17
+ from ..core.app import get_app_state
18
+
19
+
20
+ def _sanitize_command_for_history(command: str, cmd: str) -> str:
21
+ if cmd == "/register":
22
+ return "/register [hidden credentials]"
23
+ elif cmd in ["/config"] and "api" in command.lower():
24
+ return f"{cmd} [hidden sensitive data]"
25
+ else:
26
+ return command
27
+
28
+
29
+ COMMAND_HANDLERS: Dict[str, Callable[[str], Awaitable[bool]]] = {
30
+ "/help": handle_help,
31
+ "/status": handle_status,
32
+ "/clear": handle_clear,
33
+ "/history": handle_history,
34
+ "/exit": handle_exit,
35
+ "/quit": handle_exit,
36
+ "/config": handle_config,
37
+ "/register": handle_register,
38
+ "/upload": handle_upload,
39
+ "/analyze": handle_analyze,
40
+ "/agent": handle_agent,
41
+ "/research": handle_research_agent,
42
+ "/save": handle_save,
43
+ "/load": handle_load,
44
+ "/session": handle_session,
45
+ "/collection": handle_create_collection,
46
+ "/disconnect": handle_disconnect,
47
+ }
48
+
49
+
50
+ async def dispatch_command(command: str) -> bool:
51
+ if not command:
52
+ return True
53
+
54
+ app = get_app_state()
55
+ parts = command.split(maxsplit=1)
56
+ cmd = parts[0].lower()
57
+ args = parts[1] if len(parts) > 1 else ""
58
+
59
+ history_command = _sanitize_command_for_history(command, cmd)
60
+ app.session.add_to_history(history_command, None)
61
+
62
+ if cmd in COMMAND_HANDLERS:
63
+ try:
64
+ result = await COMMAND_HANDLERS[cmd](args)
65
+ return result if result is not None else True
66
+ except Exception as e:
67
+ app.ui.show_error(f"Command failed: {e}")
68
+ app.console.print(f"[dim]Debug: {type(e).__name__}: {e}[/dim]")
69
+ return True
70
+ else:
71
+ if command.startswith("/"):
72
+ app.ui.show_error(f"Unknown command: {cmd}")
73
+ app.ui.show_info("Type /help for available commands")
74
+ else:
75
+ try:
76
+ await handle_chat(command)
77
+ except Exception as e:
78
+ app.ui.show_error(f"Chat failed: {e}")
79
+ return True
File without changes
@@ -0,0 +1,105 @@
1
+ from typing import Optional
2
+ from rich.console import Console
3
+
4
+ from .config import settings
5
+ from .session import Session
6
+ from ..integrations.rag import RAGManager
7
+ from ..integrations.agent import AgentManager
8
+ from ..utils.file_manager import FileManager, FileUploader, DirectoryAnalyzer
9
+ from ..ui.prompts import UIManager
10
+
11
+
12
+ class AppState:
13
+ """Global application state container."""
14
+
15
+ def __init__(self):
16
+ self.console = Console()
17
+ self.session = Session()
18
+ self.ui = UIManager()
19
+ self.settings = settings
20
+ self.rag_manager = RAGManager()
21
+ self.agent_manager = AgentManager()
22
+ self.file_manager = FileManager()
23
+ self.file_uploader = FileUploader(self.file_manager)
24
+ self.dir_analyzer = DirectoryAnalyzer(self.file_manager)
25
+ self._cleanup_done = False
26
+
27
+ # Register interrupt handler
28
+ self.session.register_interrupt_handler(self._handle_interrupt)
29
+
30
+ async def try_auto_reconnect(self):
31
+ """Try to automatically reconnect using saved credentials."""
32
+ try:
33
+ if await self.rag_manager.auto_reconnect(self.settings):
34
+ await self.update_status_bar()
35
+ self.ui.show_success(
36
+ f"Reconnected as: {await self.rag_manager.get_username()}"
37
+ )
38
+ return True
39
+ except Exception as e:
40
+ # Silent failure - just continue without connection
41
+ pass
42
+ return False
43
+
44
+ def _handle_interrupt(self):
45
+ """Handle interrupt signal."""
46
+ if self.agent_manager.session:
47
+ self.agent_manager.session.interrupt()
48
+
49
+ async def update_status_bar(self):
50
+ """Update the status bar with current connection info."""
51
+ # Check if we have connection info
52
+ connected = self.rag_manager.connected or self.agent_manager.connected
53
+
54
+ # Get actual username from RAG manager
55
+ username = None
56
+ collection = None
57
+ chat_session = None
58
+ if self.rag_manager.connected:
59
+ username = await self.rag_manager.get_username()
60
+ collection = await self.rag_manager.get_collection_name()
61
+ chat_session = await self.rag_manager.get_chat_session_name()
62
+
63
+ # Use RAG chat session if available, otherwise use manual session
64
+ session = chat_session or getattr(self, "_current_session", None)
65
+
66
+ self.ui.update_status(
67
+ connected=connected,
68
+ username=username,
69
+ collection=collection,
70
+ session=session,
71
+ )
72
+
73
+ async def set_session(self, session_name: str):
74
+ """Set the current session name."""
75
+ self._current_session = session_name
76
+ await self.update_status_bar()
77
+
78
+ async def cleanup(self):
79
+ """Clean up all resources."""
80
+ if self._cleanup_done:
81
+ return
82
+
83
+ await self.rag_manager.close()
84
+ await self.agent_manager.close()
85
+ self.session.save_session()
86
+ self._cleanup_done = True
87
+
88
+
89
+ # Global app instance - initialized once
90
+ app_state: Optional[AppState] = None
91
+
92
+
93
+ def get_app_state() -> AppState:
94
+ """Get the global app state instance."""
95
+ global app_state
96
+ if app_state is None:
97
+ app_state = AppState()
98
+ return app_state
99
+
100
+
101
+ def initialize_app() -> AppState:
102
+ """Initialize the global app state."""
103
+ global app_state
104
+ app_state = AppState()
105
+ return app_state
@@ -0,0 +1,199 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ import toml
5
+ from pydantic import BaseModel, Field
6
+ from pydantic_settings import BaseSettings
7
+ from pydantic_settings import SettingsConfigDict
8
+ from rich.console import Console
9
+
10
+ from .encryption import SecureStorage
11
+
12
+ console = Console()
13
+
14
+
15
+ class RAGConfig(BaseModel):
16
+ """RAG system configuration."""
17
+
18
+ endpoint: str = Field(default="", description="RAG system endpoint URL")
19
+ api_key: str = Field(default="", description="API key for RAG system")
20
+ collection_name: str = Field(
21
+ default="default", description="Collection name in RAG"
22
+ )
23
+ chunk_size: int = Field(
24
+ default=1000, description="Chunk size for document processing"
25
+ )
26
+ chunk_overlap: int = Field(default=200, description="Overlap between chunks")
27
+
28
+
29
+ class AgentConfig(BaseModel):
30
+ """Agent system configuration."""
31
+
32
+ endpoint: str = Field(default="", description="Agent system endpoint URL")
33
+ api_key: str = Field(default="", description="API key for agent system")
34
+ model: str = Field(default="gpt-4", description="Model to use for agent")
35
+ temperature: float = Field(default=0.7, description="Temperature for generation")
36
+ max_tokens: int = Field(default=2000, description="Maximum tokens for response")
37
+ timeout: int = Field(default=300, description="Timeout in seconds")
38
+
39
+
40
+ class UIConfig(BaseModel):
41
+ """UI configuration."""
42
+
43
+ theme: str = Field(default="monokai", description="Syntax highlighting theme")
44
+ show_progress: bool = Field(default=True, description="Show progress bars")
45
+ auto_complete: bool = Field(default=True, description="Enable autocomplete")
46
+ history_size: int = Field(default=1000, description="Command history size")
47
+ animation: bool = Field(default=True, description="Enable animations")
48
+
49
+
50
+ class Settings(BaseSettings):
51
+ """Main application settings."""
52
+
53
+ app_name: str = "h2oGPTe-CLI"
54
+ debug: bool = False
55
+
56
+ config_dir: Path = Field(default_factory=lambda: Path.home() / ".h2ogpte-cli")
57
+ data_dir: Path = Field(
58
+ default_factory=lambda: Path.home() / ".h2ogpte-cli" / "data"
59
+ )
60
+ cache_dir: Path = Field(
61
+ default_factory=lambda: Path.home() / ".h2ogpte-cli" / "cache"
62
+ )
63
+ logs_dir: Path = Field(
64
+ default_factory=lambda: Path.home() / ".h2ogpte-cli" / "logs"
65
+ )
66
+
67
+ rag: RAGConfig = Field(default_factory=RAGConfig)
68
+ agent: AgentConfig = Field(default_factory=AgentConfig)
69
+ ui: UIConfig = Field(default_factory=UIConfig)
70
+
71
+ _secure_storage: Optional[SecureStorage] = None
72
+
73
+ model_config = SettingsConfigDict(
74
+ env_file=(
75
+ ".env",
76
+ ".env.production",
77
+ ".env.test",
78
+ ".env.development",
79
+ ".env.local",
80
+ ),
81
+ env_prefix="H2OGPTE_CLI_",
82
+ env_file_encoding="utf-8",
83
+ env_nested_delimiter="__",
84
+ extra="ignore",
85
+ )
86
+
87
+ def _get_secure_storage(self) -> SecureStorage:
88
+ """Get or create secure storage instance."""
89
+ if self._secure_storage is None:
90
+ self._secure_storage = SecureStorage(self.config_dir)
91
+ return self._secure_storage
92
+
93
+ def get_rag_api_key(self) -> str:
94
+ """Get decrypted RAG API key."""
95
+ storage = self._get_secure_storage()
96
+ if storage.is_encrypted(self.rag.api_key):
97
+ return storage.decrypt(self.rag.api_key)
98
+ return self.rag.api_key
99
+
100
+ def get_agent_api_key(self) -> str:
101
+ """Get decrypted Agent API key."""
102
+ storage = self._get_secure_storage()
103
+ if storage.is_encrypted(self.agent.api_key):
104
+ return storage.decrypt(self.agent.api_key)
105
+ return self.agent.api_key
106
+
107
+ def set_rag_api_key(self, api_key: str):
108
+ """Set encrypted RAG API key."""
109
+ storage = self._get_secure_storage()
110
+ self.rag.api_key = storage.encrypt(api_key)
111
+
112
+ def set_agent_api_key(self, api_key: str):
113
+ """Set encrypted Agent API key."""
114
+ storage = self._get_secure_storage()
115
+ self.agent.api_key = storage.encrypt(api_key)
116
+
117
+ def save(self, path: Optional[Path] = None):
118
+ """Save configuration to file."""
119
+ if path is None:
120
+ path = self.config_dir / "config.toml"
121
+
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+
124
+ config_dict = {
125
+ "app": {
126
+ "name": self.app_name,
127
+ "debug": self.debug,
128
+ },
129
+ "directories": {
130
+ "config": str(self.config_dir),
131
+ "data": str(self.data_dir),
132
+ "cache": str(self.cache_dir),
133
+ "logs": str(self.logs_dir),
134
+ },
135
+ "rag": self.rag.model_dump(),
136
+ "agent": self.agent.model_dump(),
137
+ "ui": self.ui.model_dump(),
138
+ }
139
+
140
+ with open(path, "w") as f:
141
+ toml.dump(config_dict, f)
142
+
143
+ console.print(f"[green]✓[/green] Configuration saved to {path}")
144
+
145
+ @classmethod
146
+ def load(cls, path: Optional[Path] = None) -> "Settings":
147
+ """Load configuration from file."""
148
+ if path is None:
149
+ path = Path.home() / ".h2ogpte-cli" / "config.toml"
150
+
151
+ if not path.exists():
152
+ return cls()
153
+
154
+ try:
155
+ with open(path, "r") as f:
156
+ config_dict = toml.load(f)
157
+
158
+ # Flatten the configuration
159
+ flat_config = {}
160
+
161
+ if "app" in config_dict:
162
+ app_config = config_dict["app"]
163
+ # Map config fields to Settings fields
164
+ if "name" in app_config:
165
+ flat_config["app_name"] = app_config["name"]
166
+ if "version" in app_config:
167
+ flat_config["version"] = app_config["version"]
168
+ if "debug" in app_config:
169
+ flat_config["debug"] = app_config["debug"]
170
+
171
+ if "directories" in config_dict:
172
+ dirs = config_dict["directories"]
173
+ for key in ["config_dir", "data_dir", "cache_dir", "logs_dir"]:
174
+ if key.replace("_dir", "") in dirs:
175
+ flat_config[key] = Path(dirs[key.replace("_dir", "")])
176
+
177
+ if "rag" in config_dict:
178
+ flat_config["rag"] = RAGConfig(**config_dict["rag"])
179
+
180
+ if "agent" in config_dict:
181
+ flat_config["agent"] = AgentConfig(**config_dict["agent"])
182
+
183
+ if "ui" in config_dict:
184
+ flat_config["ui"] = UIConfig(**config_dict["ui"])
185
+
186
+ return cls(**flat_config)
187
+
188
+ except Exception as e:
189
+ console.print(f"[yellow]⚠[/yellow] Error loading config: {e}")
190
+ return cls()
191
+
192
+ def ensure_directories(self):
193
+ """Ensure all required directories exist."""
194
+ for dir_path in [self.config_dir, self.data_dir, self.cache_dir, self.logs_dir]:
195
+ dir_path.mkdir(parents=True, exist_ok=True)
196
+
197
+
198
+ # Global settings instance
199
+ settings = Settings.load()
@@ -0,0 +1,104 @@
1
+ import os
2
+ import base64
3
+ import hashlib
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from cryptography.fernet import Fernet
7
+ from cryptography.hazmat.primitives import hashes
8
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
9
+
10
+
11
+ class SecureStorage:
12
+ """Handles encryption/decryption of sensitive data."""
13
+
14
+ def __init__(self, config_dir: Path):
15
+ self.config_dir = config_dir
16
+ self.key_file = config_dir / ".keyfile"
17
+ self._cipher_suite = None
18
+
19
+ def _get_machine_id(self) -> bytes:
20
+ """Generate a machine-specific identifier."""
21
+ # Use machine-specific data for key generation
22
+ import platform
23
+ import socket
24
+
25
+ machine_data = (
26
+ platform.node()
27
+ + platform.machine()
28
+ + platform.processor()
29
+ + str(os.getuid() if hasattr(os, "getuid") else "windows")
30
+ ).encode()
31
+
32
+ return hashlib.sha256(machine_data).digest()
33
+
34
+ def _get_or_create_key(self) -> bytes:
35
+ """Get or create encryption key."""
36
+ if self.key_file.exists():
37
+ with open(self.key_file, "rb") as f:
38
+ return f.read()
39
+
40
+ # Create new key based on machine ID
41
+ machine_id = self._get_machine_id()
42
+
43
+ # Use PBKDF2 to derive a key from machine ID
44
+ kdf = PBKDF2HMAC(
45
+ algorithm=hashes.SHA256(),
46
+ length=32,
47
+ salt=b"hbot_salt_2024", # Static salt for consistency
48
+ iterations=100000,
49
+ )
50
+ key = base64.urlsafe_b64encode(kdf.derive(machine_id))
51
+
52
+ # Save key to file
53
+ self.config_dir.mkdir(parents=True, exist_ok=True)
54
+ with open(self.key_file, "wb") as f:
55
+ f.write(key)
56
+
57
+ # Set restrictive permissions
58
+ os.chmod(self.key_file, 0o600)
59
+
60
+ return key
61
+
62
+ def _get_cipher_suite(self) -> Fernet:
63
+ """Get cipher suite for encryption/decryption."""
64
+ if self._cipher_suite is None:
65
+ key = self._get_or_create_key()
66
+ self._cipher_suite = Fernet(key)
67
+ return self._cipher_suite
68
+
69
+ def encrypt(self, data: str) -> str:
70
+ """Encrypt sensitive data."""
71
+ if not data:
72
+ return ""
73
+
74
+ cipher_suite = self._get_cipher_suite()
75
+ encrypted_data = cipher_suite.encrypt(data.encode())
76
+ return base64.urlsafe_b64encode(encrypted_data).decode()
77
+
78
+ def decrypt(self, encrypted_data: str) -> str:
79
+ """Decrypt sensitive data."""
80
+ if not encrypted_data:
81
+ return ""
82
+
83
+ try:
84
+ cipher_suite = self._get_cipher_suite()
85
+ decoded_data = base64.urlsafe_b64decode(encrypted_data.encode())
86
+ decrypted_data = cipher_suite.decrypt(decoded_data)
87
+ return decrypted_data.decode()
88
+ except Exception:
89
+ # Return empty string if decryption fails (corrupted or wrong key)
90
+ return ""
91
+
92
+ def is_encrypted(self, data: str) -> bool:
93
+ """Check if data appears to be encrypted."""
94
+ if not data:
95
+ return False
96
+
97
+ # Simple heuristic: encrypted data should be base64-like and long
98
+ try:
99
+ # Check if it's valid base64
100
+ base64.urlsafe_b64decode(data.encode())
101
+ # Encrypted data should be long (>50 chars) and not look like a normal API key
102
+ return len(data) > 50 and not data.startswith("sk-")
103
+ except:
104
+ return False
@@ -0,0 +1,171 @@
1
+ import asyncio
2
+ import signal
3
+ import sys
4
+ from typing import Optional, Dict, Any, List, Callable
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ import json
8
+ from rich.console import Console
9
+ from rich.live import Live
10
+ from rich.table import Table
11
+ from rich.panel import Panel
12
+ from rich.layout import Layout
13
+ from rich.text import Text
14
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
15
+
16
+ console = Console()
17
+
18
+
19
+ class Session:
20
+ """Manages a CLI session with state, history, and interruption handling."""
21
+
22
+ def __init__(self):
23
+ self.start_time = datetime.now()
24
+ self.history: List[Dict[str, Any]] = []
25
+ self.context: Dict[str, Any] = {}
26
+ self.is_running = False
27
+ self.interrupted = False
28
+ self.current_task: Optional[asyncio.Task] = None
29
+ self.interrupt_handlers: List[Callable] = []
30
+ self.progress = Progress(
31
+ SpinnerColumn(),
32
+ TextColumn("[progress.description]{task.description}"),
33
+ BarColumn(),
34
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
35
+ )
36
+
37
+ # Setup interrupt handling
38
+ signal.signal(signal.SIGINT, self._handle_interrupt)
39
+
40
+ def _handle_interrupt(self, signum, frame):
41
+ """Handle interrupt signal (Ctrl+C)."""
42
+ self.interrupted = True
43
+ console.print("\n[yellow]⚠[/yellow] Interrupt received. Processing...")
44
+
45
+ # Call registered interrupt handlers
46
+ for handler in self.interrupt_handlers:
47
+ try:
48
+ handler()
49
+ except Exception as e:
50
+ console.print(f"[red]Error in interrupt handler: {e}[/red]")
51
+
52
+ # Cancel current task if running
53
+ if self.current_task and not self.current_task.done():
54
+ self.current_task.cancel()
55
+
56
+ def register_interrupt_handler(self, handler: Callable):
57
+ """Register a handler to be called on interrupt."""
58
+ self.interrupt_handlers.append(handler)
59
+
60
+ def add_to_history(self, command: str, result: Any, success: bool = True):
61
+ """Add a command and its result to history."""
62
+ entry = {
63
+ "timestamp": datetime.now().isoformat(),
64
+ "command": command,
65
+ "result": str(result) if result else None,
66
+ "success": success,
67
+ }
68
+ self.history.append(entry)
69
+
70
+ def get_context(self, key: str, default: Any = None) -> Any:
71
+ """Get a value from session context."""
72
+ return self.context.get(key, default)
73
+
74
+ def set_context(self, key: str, value: Any):
75
+ """Set a value in session context."""
76
+ self.context[key] = value
77
+
78
+ def clear_context(self):
79
+ """Clear session context."""
80
+ self.context.clear()
81
+
82
+ async def run_with_interrupt(self, coro, description: str = "Processing..."):
83
+ """Run a coroutine with interrupt handling."""
84
+ self.is_running = True
85
+ self.interrupted = False
86
+
87
+ task_id = self.progress.add_task(description, total=None)
88
+
89
+ try:
90
+ with self.progress:
91
+ self.current_task = asyncio.create_task(coro)
92
+ result = await self.current_task
93
+ self.progress.update(task_id, completed=100)
94
+ return result
95
+
96
+ except asyncio.CancelledError:
97
+ console.print("[yellow]✗[/yellow] Task cancelled")
98
+ return None
99
+
100
+ except Exception as e:
101
+ console.print(f"[red]✗[/red] Error: {e}")
102
+ return None
103
+
104
+ finally:
105
+ self.is_running = False
106
+ self.current_task = None
107
+ self.progress.remove_task(task_id)
108
+
109
+ def save_session(self, path: Optional[Path] = None):
110
+ """Save session to file."""
111
+ if path is None:
112
+ from .config import settings
113
+
114
+ path = (
115
+ settings.data_dir
116
+ / f"session_{self.start_time.strftime('%Y%m%d_%H%M%S')}.json"
117
+ )
118
+
119
+ path.parent.mkdir(parents=True, exist_ok=True)
120
+
121
+ session_data = {
122
+ "start_time": self.start_time.isoformat(),
123
+ "history": self.history,
124
+ "context": self.context,
125
+ }
126
+
127
+ with open(path, "w") as f:
128
+ json.dump(session_data, f, indent=2, default=str)
129
+
130
+ # console.print(f"[green]✓[/green] Session saved to {path}")
131
+
132
+ @classmethod
133
+ def load_session(cls, path: Path) -> "Session":
134
+ """Load session from file."""
135
+ with open(path, "r") as f:
136
+ session_data = json.load(f)
137
+
138
+ session = cls()
139
+ session.start_time = datetime.fromisoformat(session_data["start_time"])
140
+ session.history = session_data["history"]
141
+ session.context = session_data["context"]
142
+
143
+ return session
144
+
145
+ def display_status(self):
146
+ """Display current session status."""
147
+ layout = Layout()
148
+
149
+ # Session info
150
+ info_table = Table(title="Session Information", show_header=False)
151
+ info_table.add_row("Started", self.start_time.strftime("%Y-%m-%d %H:%M:%S"))
152
+ info_table.add_row(
153
+ "Duration", str(datetime.now() - self.start_time).split(".")[0]
154
+ )
155
+ info_table.add_row("Commands", str(len(self.history)))
156
+ info_table.add_row("Context Items", str(len(self.context)))
157
+
158
+ # Recent history
159
+ history_table = Table(title="Recent History")
160
+ history_table.add_column("Time", style="dim")
161
+ history_table.add_column("Command")
162
+ history_table.add_column("Status")
163
+
164
+ for entry in self.history[-5:]:
165
+ time_str = datetime.fromisoformat(entry["timestamp"]).strftime("%H:%M:%S")
166
+ status = "[green]✓[/green]" if entry["success"] else "[red]✗[/red]"
167
+ history_table.add_row(time_str, entry["command"][:50], status)
168
+
169
+ layout.split_column(Layout(Panel(info_table)), Layout(Panel(history_table)))
170
+
171
+ console.print(layout)
File without changes