Skip to content

Commit e14b3a6

Browse files
committed
Add conversation persistence and enhanced machine-bound key encryption
1 parent 4757ab2 commit e14b3a6

File tree

6 files changed

+186
-16
lines changed

6 files changed

+186
-16
lines changed

hacxgpt/config.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import os
33
import sys
44
import json
5-
from dotenv import load_dotenv
5+
from dotenv import load_dotenv, set_key
6+
from .utils.security import Security
67

78
class Config:
89
"""System Configuration & Constants"""
@@ -20,8 +21,7 @@ class Config:
2021
DEFAULT_PROVIDER = "hacxgpt"
2122

2223
# System Paths
23-
ENV_FILE = ".hacx"
24-
API_KEY_NAME = "HacxGPT-API" # Legacy/Default
24+
ENV_FILE = os.path.join(os.path.expanduser("~"), ".hacx")
2525
CODE_OUTPUT_DIR = "hacxgpt_code_output"
2626

2727
# Visual Theme
@@ -81,6 +81,20 @@ def get_model(cls):
8181
def get_provider_config(cls, provider=None):
8282
p = provider or cls.get_provider()
8383
return cls.PROVIDERS.get(p, cls.PROVIDERS.get(cls.DEFAULT_PROVIDER, {}))
84+
85+
@classmethod
86+
def get_api_key(cls, provider: str = None) -> str:
87+
"""Retrieves and decrypts the API key for the specified provider."""
88+
p_cfg = cls.get_provider_config(provider)
89+
key_var = p_cfg.get("key_var")
90+
if not key_var: return ""
91+
92+
# Reload env to get latest values
93+
load_dotenv(cls.ENV_FILE, override=True)
94+
encrypted_key = os.getenv(key_var, "")
95+
if not encrypted_key: return ""
96+
97+
return Security.decrypt(encrypted_key)
8498

8599
@staticmethod
86100
def is_hacxgpt_model(model_name: str) -> bool:
@@ -105,14 +119,14 @@ def initialize():
105119
"""Initialize environment (load .env, load configuration, etc)"""
106120
Config.load_providers()
107121

108-
# Look for .hacx in current directory or user home
109-
env_path = Config.ENV_FILE
110-
if not os.path.exists(env_path):
111-
user_env = os.path.join(os.path.expanduser("~"), Config.ENV_FILE)
112-
if os.path.exists(user_env):
113-
env_path = user_env
122+
# Support migration: if .hacx exists locally but NOT in home, copy it to home
123+
local_env = ".hacx"
124+
if os.path.exists(local_env) and not os.path.exists(Config.ENV_FILE):
125+
import shutil
126+
shutil.copy(local_env, Config.ENV_FILE)
127+
# print(f"Migration: Copied {local_env} to {Config.ENV_FILE}")
114128

115-
load_dotenv(dotenv_path=env_path, override=True)
129+
load_dotenv(dotenv_path=Config.ENV_FILE, override=True)
116130

117131
# Load last used provider/model if saved in env
118132
saved_provider = os.getenv("HACX_ACTIVE_PROVIDER")

hacxgpt/core/brain.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import sys
2-
from typing import Generator
2+
import os
3+
import json
4+
import time
5+
from typing import Generator, List
36
from ..config import Config
47
from ..ui.interface import UI
58
from .api import Client
@@ -12,6 +15,10 @@ def __init__(self, api_key: str):
1215
self.api_key = api_key
1316
self.model = Config.get_model()
1417
self.system_prompt = Config.load_system_prompt()
18+
self.session_dir = os.path.join(os.path.expanduser("~"), ".hacxgpt_sessions")
19+
if not os.path.exists(self.session_dir):
20+
os.makedirs(self.session_dir)
21+
1522
self._init_client()
1623

1724
# HacxGPT models don't need the system prompt history
@@ -62,6 +69,42 @@ def reset(self):
6269
self.history = []
6370
else:
6471
self.history = [{"role": "system", "content": self.system_prompt}]
72+
73+
def save_session(self, name: str) -> str:
74+
"""Save current history to a file"""
75+
session_data = {
76+
"model": self.model,
77+
"provider": Config.ACTIVE_PROVIDER,
78+
"history": self.history,
79+
"timestamp": time.time()
80+
}
81+
filename = f"{name}.json"
82+
filepath = os.path.join(self.session_dir, filename)
83+
with open(filepath, 'w', encoding='utf-8') as f:
84+
json.dump(session_data, f, indent=4)
85+
return filepath
86+
87+
def load_session(self, name: str) -> bool:
88+
"""Load history from a file"""
89+
filename = f"{name}.json"
90+
filepath = os.path.join(self.session_dir, filename)
91+
if not os.path.exists(filepath):
92+
return False
93+
94+
with open(filepath, 'r', encoding='utf-8') as f:
95+
session_data = json.load(f)
96+
97+
self.history = session_data.get("history", [])
98+
self.model = session_data.get("model", self.model)
99+
Config.ACTIVE_PROVIDER = session_data.get("provider", Config.ACTIVE_PROVIDER)
100+
Config.ACTIVE_MODEL = self.model
101+
self._init_client()
102+
return True
103+
104+
def list_sessions(self) -> List[str]:
105+
"""List all saved session names"""
106+
sessions = [f.replace(".json", "") for f in os.listdir(self.session_dir) if f.endswith(".json")]
107+
return sorted(sessions)
65108

66109
def chat(self, user_input: str) -> Generator[str, None, None]:
67110
self.history.append({"role": "user", "content": user_input})

hacxgpt/main.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .core.brain import HacxBrain
1111
from .utils.system import check_dependencies
1212
from .utils.updater import Updater
13+
from .utils.security import Security
1314

1415
# Initialize Colorama
1516
colorama.init(autoreset=True)
@@ -30,9 +31,7 @@ def setup(self) -> bool:
3031
except:
3132
pass # Silent fail if offline
3233

33-
p_cfg = Config.get_provider_config()
34-
key_var = p_cfg.get("key_var")
35-
key = os.getenv(key_var)
34+
key = Config.get_api_key()
3635

3736
if not key:
3837
self.ui.banner()
@@ -96,7 +95,9 @@ def configure_key(self, provider: str = None) -> bool:
9695
if not os.path.exists(Config.ENV_FILE):
9796
with open(Config.ENV_FILE, 'w') as f: f.write("")
9897

99-
set_key(Config.ENV_FILE, key_var, key.strip())
98+
# Encrypt the key before saving
99+
encrypted_key = Security.encrypt(key.strip())
100+
set_key(Config.ENV_FILE, key_var, encrypted_key)
100101
set_key(Config.ENV_FILE, "HACX_ACTIVE_PROVIDER", provider)
101102
set_key(Config.ENV_FILE, "HACX_ACTIVE_MODEL", selected_model)
102103

@@ -139,12 +140,45 @@ def run_chat(self):
139140
"/model <name> - Switch Model\n"
140141
"/setup - Configure API Keys\n"
141142
"/status - Show current config\n"
143+
"/save <name> - Save current session\n"
144+
"/load <name> - Load a saved session\n"
145+
"/sessions - List saved sessions\n"
142146
"/new - Wipe Memory\n"
143147
"/exit - Disconnect"
144148
)
145149
self.ui.show_msg("Help", help_text, "magenta")
146150
continue
147151

152+
if prompt.lower().startswith('/save'):
153+
parts = prompt.split()
154+
if len(parts) < 2:
155+
self.ui.show_msg("Error", "Usage: /save <name>", "red")
156+
continue
157+
name = parts[1]
158+
path = self.brain.save_session(name)
159+
self.ui.show_msg("Session Saved", f"Neural state backed up to: {name}\n[dim]{path}[/]", "green")
160+
continue
161+
162+
if prompt.lower().startswith('/load'):
163+
parts = prompt.split()
164+
if len(parts) < 2:
165+
self.ui.show_msg("Error", "Usage: /load <name>", "red")
166+
continue
167+
name = parts[1]
168+
if self.brain.load_session(name):
169+
self.ui.show_msg("Session Restored", f"Neural state '{name}' re-established.", "green")
170+
else:
171+
self.ui.show_msg("Error", f"Session '{name}' not found.", "red")
172+
continue
173+
174+
if prompt.lower() == '/sessions':
175+
sessions = self.brain.list_sessions()
176+
if not sessions:
177+
self.ui.show_msg("Sessions", "No saved neural states found.", "yellow")
178+
else:
179+
self.ui.show_msg("Saved Sessions", "\n".join([f"- {s}" for s in sessions]), "cyan")
180+
continue
181+
148182
if prompt.lower() == '/update':
149183
self.ui.show_msg("System Update", "Initiating update process...", "cyan")
150184
success, msg = Updater.update()

hacxgpt/utils/security.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import os
2+
import sys
3+
import base64
4+
import hashlib
5+
import subprocess
6+
from cryptography.fernet import Fernet
7+
8+
class Security:
9+
"""Handles machine-specific key encryption and decryption."""
10+
11+
_cached_key = None
12+
13+
@classmethod
14+
def get_machine_id(cls) -> str:
15+
"""Generates a reasonably unique hardware-linked ID for this device."""
16+
try:
17+
if sys.platform == "win32":
18+
# Get Windows UUID from registry/command
19+
cmd = 'wmic csproduct get uuid'
20+
uuid = subprocess.check_output(cmd, shell=True).decode().split('
21+
')[1].strip()
22+
if uuid: return uuid
23+
elif sys.platform == "darwin":
24+
# MacOS hardware UUID
25+
cmd = "ioreg -rd1 -c IOPlatformExpertDevice | grep -E '(IOPlatformUUID)'"
26+
uuid = subprocess.check_output(cmd, shell=True).decode().split('"')[-2]
27+
if uuid: return uuid
28+
else:
29+
# Linux machine-id
30+
if os.path.exists("/etc/machine-id"):
31+
with open("/etc/machine-id", "r") as f:
32+
return f.read().strip()
33+
except:
34+
pass
35+
36+
# Fallback to a hash of the node (MAC address)
37+
import uuid as _uuid
38+
return str(_uuid.getnode())
39+
40+
@classmethod
41+
def _get_fernet_key(cls) -> bytes:
42+
"""Derives a Fernet-compatible key from the machine ID."""
43+
if cls._cached_key:
44+
return cls._cached_key
45+
46+
machine_id = cls.get_machine_id()
47+
# Derive a 32-byte key using SHA256
48+
key_hash = hashlib.sha256(machine_id.encode()).digest()
49+
# Fernet keys must be base64-encoded 32-byte keys
50+
cls._cached_key = base64.urlsafe_b64encode(key_hash)
51+
return cls._cached_key
52+
53+
@classmethod
54+
def encrypt(cls, data: str) -> str:
55+
"""Encrypts a string using the machine-bound key."""
56+
if not data: return ""
57+
try:
58+
f = Fernet(cls._get_fernet_key())
59+
return f.encrypt(data.encode()).decode()
60+
except Exception as e:
61+
# Fallback if cryptography fails (unlikely)
62+
return data
63+
64+
@classmethod
65+
def decrypt(cls, encrypted_data: str) -> str:
66+
"""Decrypts a string using the machine-bound key."""
67+
if not encrypted_data: return ""
68+
try:
69+
# If it doesn't look like Fernet, return as is (for migration)
70+
if not encrypted_data.startswith("gAAAA"):
71+
return encrypted_data
72+
73+
f = Fernet(cls._get_fernet_key())
74+
return f.decrypt(encrypted_data.encode()).decode()
75+
except:
76+
# If decryption fails, it might be raw data
77+
return encrypted_data

hacxgpt/utils/system.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ def check_dependencies():
1212
("dotenv", "python-dotenv"),
1313
("rich", "rich"),
1414
("pyperclip", "pyperclip"),
15-
("requests", "requests")
15+
("requests", "requests"),
16+
("cryptography", "cryptography")
1617
]
1718

1819
missing_pip_names = []

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ pyperclip>=1.8.0
55
colorama>=0.4.6
66
prompt_toolkit>=3.0.0
77
requests>=2.31.0
8+
cryptography>=42.0.0

0 commit comments

Comments
 (0)