amd-gaia 0.14.3__py3-none-any.whl → 0.15.1__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.
- {amd_gaia-0.14.3.dist-info → amd_gaia-0.15.1.dist-info}/METADATA +223 -223
- amd_gaia-0.15.1.dist-info/RECORD +178 -0
- {amd_gaia-0.14.3.dist-info → amd_gaia-0.15.1.dist-info}/entry_points.txt +1 -0
- {amd_gaia-0.14.3.dist-info → amd_gaia-0.15.1.dist-info}/licenses/LICENSE.md +20 -20
- gaia/__init__.py +29 -29
- gaia/agents/__init__.py +19 -19
- gaia/agents/base/__init__.py +9 -9
- gaia/agents/base/agent.py +2177 -2177
- gaia/agents/base/api_agent.py +120 -120
- gaia/agents/base/console.py +1841 -1841
- gaia/agents/base/errors.py +237 -237
- gaia/agents/base/mcp_agent.py +86 -86
- gaia/agents/base/tools.py +83 -83
- gaia/agents/blender/agent.py +556 -556
- gaia/agents/blender/agent_simple.py +133 -135
- gaia/agents/blender/app.py +211 -211
- gaia/agents/blender/app_simple.py +41 -41
- gaia/agents/blender/core/__init__.py +16 -16
- gaia/agents/blender/core/materials.py +506 -506
- gaia/agents/blender/core/objects.py +316 -316
- gaia/agents/blender/core/rendering.py +225 -225
- gaia/agents/blender/core/scene.py +220 -220
- gaia/agents/blender/core/view.py +146 -146
- gaia/agents/chat/__init__.py +9 -9
- gaia/agents/chat/agent.py +835 -835
- gaia/agents/chat/app.py +1058 -1058
- gaia/agents/chat/session.py +508 -508
- gaia/agents/chat/tools/__init__.py +15 -15
- gaia/agents/chat/tools/file_tools.py +96 -96
- gaia/agents/chat/tools/rag_tools.py +1729 -1729
- gaia/agents/chat/tools/shell_tools.py +436 -436
- gaia/agents/code/__init__.py +7 -7
- gaia/agents/code/agent.py +549 -549
- gaia/agents/code/cli.py +377 -0
- gaia/agents/code/models.py +135 -135
- gaia/agents/code/orchestration/__init__.py +24 -24
- gaia/agents/code/orchestration/checklist_executor.py +1763 -1763
- gaia/agents/code/orchestration/checklist_generator.py +713 -713
- gaia/agents/code/orchestration/factories/__init__.py +9 -9
- gaia/agents/code/orchestration/factories/base.py +63 -63
- gaia/agents/code/orchestration/factories/nextjs_factory.py +118 -118
- gaia/agents/code/orchestration/factories/python_factory.py +106 -106
- gaia/agents/code/orchestration/orchestrator.py +841 -841
- gaia/agents/code/orchestration/project_analyzer.py +391 -391
- gaia/agents/code/orchestration/steps/__init__.py +67 -67
- gaia/agents/code/orchestration/steps/base.py +188 -188
- gaia/agents/code/orchestration/steps/error_handler.py +314 -314
- gaia/agents/code/orchestration/steps/nextjs.py +828 -828
- gaia/agents/code/orchestration/steps/python.py +307 -307
- gaia/agents/code/orchestration/template_catalog.py +469 -469
- gaia/agents/code/orchestration/workflows/__init__.py +14 -14
- gaia/agents/code/orchestration/workflows/base.py +80 -80
- gaia/agents/code/orchestration/workflows/nextjs.py +186 -186
- gaia/agents/code/orchestration/workflows/python.py +94 -94
- gaia/agents/code/prompts/__init__.py +11 -11
- gaia/agents/code/prompts/base_prompt.py +77 -77
- gaia/agents/code/prompts/code_patterns.py +2036 -2036
- gaia/agents/code/prompts/nextjs_prompt.py +40 -40
- gaia/agents/code/prompts/python_prompt.py +109 -109
- gaia/agents/code/schema_inference.py +365 -365
- gaia/agents/code/system_prompt.py +41 -41
- gaia/agents/code/tools/__init__.py +42 -42
- gaia/agents/code/tools/cli_tools.py +1138 -1138
- gaia/agents/code/tools/code_formatting.py +319 -319
- gaia/agents/code/tools/code_tools.py +769 -769
- gaia/agents/code/tools/error_fixing.py +1347 -1347
- gaia/agents/code/tools/external_tools.py +180 -180
- gaia/agents/code/tools/file_io.py +845 -845
- gaia/agents/code/tools/prisma_tools.py +190 -190
- gaia/agents/code/tools/project_management.py +1016 -1016
- gaia/agents/code/tools/testing.py +321 -321
- gaia/agents/code/tools/typescript_tools.py +122 -122
- gaia/agents/code/tools/validation_parsing.py +461 -461
- gaia/agents/code/tools/validation_tools.py +806 -806
- gaia/agents/code/tools/web_dev_tools.py +1758 -1758
- gaia/agents/code/validators/__init__.py +16 -16
- gaia/agents/code/validators/antipattern_checker.py +241 -241
- gaia/agents/code/validators/ast_analyzer.py +197 -197
- gaia/agents/code/validators/requirements_validator.py +145 -145
- gaia/agents/code/validators/syntax_validator.py +171 -171
- gaia/agents/docker/__init__.py +7 -7
- gaia/agents/docker/agent.py +642 -642
- gaia/agents/emr/__init__.py +8 -8
- gaia/agents/emr/agent.py +1506 -1506
- gaia/agents/emr/cli.py +1322 -1322
- gaia/agents/emr/constants.py +475 -475
- gaia/agents/emr/dashboard/__init__.py +4 -4
- gaia/agents/emr/dashboard/server.py +1974 -1974
- gaia/agents/jira/__init__.py +11 -11
- gaia/agents/jira/agent.py +894 -894
- gaia/agents/jira/jql_templates.py +299 -299
- gaia/agents/routing/__init__.py +7 -7
- gaia/agents/routing/agent.py +567 -570
- gaia/agents/routing/system_prompt.py +75 -75
- gaia/agents/summarize/__init__.py +11 -0
- gaia/agents/summarize/agent.py +885 -0
- gaia/agents/summarize/prompts.py +129 -0
- gaia/api/__init__.py +23 -23
- gaia/api/agent_registry.py +238 -238
- gaia/api/app.py +305 -305
- gaia/api/openai_server.py +575 -575
- gaia/api/schemas.py +186 -186
- gaia/api/sse_handler.py +373 -373
- gaia/apps/__init__.py +4 -4
- gaia/apps/llm/__init__.py +6 -6
- gaia/apps/llm/app.py +173 -169
- gaia/apps/summarize/app.py +116 -633
- gaia/apps/summarize/html_viewer.py +133 -133
- gaia/apps/summarize/pdf_formatter.py +284 -284
- gaia/audio/__init__.py +2 -2
- gaia/audio/audio_client.py +439 -439
- gaia/audio/audio_recorder.py +269 -269
- gaia/audio/kokoro_tts.py +599 -599
- gaia/audio/whisper_asr.py +432 -432
- gaia/chat/__init__.py +16 -16
- gaia/chat/app.py +430 -430
- gaia/chat/prompts.py +522 -522
- gaia/chat/sdk.py +1228 -1225
- gaia/cli.py +5481 -5621
- gaia/database/__init__.py +10 -10
- gaia/database/agent.py +176 -176
- gaia/database/mixin.py +290 -290
- gaia/database/testing.py +64 -64
- gaia/eval/batch_experiment.py +2332 -2332
- gaia/eval/claude.py +542 -542
- gaia/eval/config.py +37 -37
- gaia/eval/email_generator.py +512 -512
- gaia/eval/eval.py +3179 -3179
- gaia/eval/groundtruth.py +1130 -1130
- gaia/eval/transcript_generator.py +582 -582
- gaia/eval/webapp/README.md +167 -167
- gaia/eval/webapp/package-lock.json +875 -875
- gaia/eval/webapp/package.json +20 -20
- gaia/eval/webapp/public/app.js +3402 -3402
- gaia/eval/webapp/public/index.html +87 -87
- gaia/eval/webapp/public/styles.css +3661 -3661
- gaia/eval/webapp/server.js +415 -415
- gaia/eval/webapp/test-setup.js +72 -72
- gaia/llm/__init__.py +9 -2
- gaia/llm/base_client.py +60 -0
- gaia/llm/exceptions.py +12 -0
- gaia/llm/factory.py +70 -0
- gaia/llm/lemonade_client.py +3236 -3221
- gaia/llm/lemonade_manager.py +294 -294
- gaia/llm/providers/__init__.py +9 -0
- gaia/llm/providers/claude.py +108 -0
- gaia/llm/providers/lemonade.py +120 -0
- gaia/llm/providers/openai_provider.py +79 -0
- gaia/llm/vlm_client.py +382 -382
- gaia/logger.py +189 -189
- gaia/mcp/agent_mcp_server.py +245 -245
- gaia/mcp/blender_mcp_client.py +138 -138
- gaia/mcp/blender_mcp_server.py +648 -648
- gaia/mcp/context7_cache.py +332 -332
- gaia/mcp/external_services.py +518 -518
- gaia/mcp/mcp_bridge.py +811 -550
- gaia/mcp/servers/__init__.py +6 -6
- gaia/mcp/servers/docker_mcp.py +83 -83
- gaia/perf_analysis.py +361 -0
- gaia/rag/__init__.py +10 -10
- gaia/rag/app.py +293 -293
- gaia/rag/demo.py +304 -304
- gaia/rag/pdf_utils.py +235 -235
- gaia/rag/sdk.py +2194 -2194
- gaia/security.py +163 -163
- gaia/talk/app.py +289 -289
- gaia/talk/sdk.py +538 -538
- gaia/testing/__init__.py +87 -87
- gaia/testing/assertions.py +330 -330
- gaia/testing/fixtures.py +333 -333
- gaia/testing/mocks.py +493 -493
- gaia/util.py +46 -46
- gaia/utils/__init__.py +33 -33
- gaia/utils/file_watcher.py +675 -675
- gaia/utils/parsing.py +223 -223
- gaia/version.py +100 -100
- amd_gaia-0.14.3.dist-info/RECORD +0 -168
- gaia/agents/code/app.py +0 -266
- gaia/llm/llm_client.py +0 -729
- {amd_gaia-0.14.3.dist-info → amd_gaia-0.15.1.dist-info}/WHEEL +0 -0
- {amd_gaia-0.14.3.dist-info → amd_gaia-0.15.1.dist-info}/top_level.txt +0 -0
gaia/agents/chat/session.py
CHANGED
|
@@ -1,508 +1,508 @@
|
|
|
1
|
-
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
-
# SPDX-License-Identifier: MIT
|
|
3
|
-
"""
|
|
4
|
-
Session management for Chat Agent with path validation and history.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import logging
|
|
9
|
-
from dataclasses import asdict, dataclass
|
|
10
|
-
from datetime import datetime, timedelta
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Dict, List, Optional
|
|
13
|
-
|
|
14
|
-
logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@dataclass
|
|
18
|
-
class PathPermission:
|
|
19
|
-
"""Track permission for a path."""
|
|
20
|
-
|
|
21
|
-
path: str
|
|
22
|
-
allowed: bool
|
|
23
|
-
timestamp: str
|
|
24
|
-
recursive: bool = False
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@dataclass
|
|
28
|
-
class ChatSession:
|
|
29
|
-
"""Chat session with history and indexed documents."""
|
|
30
|
-
|
|
31
|
-
session_id: str
|
|
32
|
-
created_at: str
|
|
33
|
-
updated_at: str
|
|
34
|
-
indexed_documents: List[str]
|
|
35
|
-
watched_directories: List[str]
|
|
36
|
-
chat_history: List[Dict[str, str]]
|
|
37
|
-
path_permissions: Dict[str, PathPermission]
|
|
38
|
-
metadata: Dict[str, any]
|
|
39
|
-
|
|
40
|
-
def to_dict(self) -> Dict:
|
|
41
|
-
"""Convert session to dictionary."""
|
|
42
|
-
data = asdict(self)
|
|
43
|
-
# Convert PathPermission objects to dicts
|
|
44
|
-
data["path_permissions"] = {
|
|
45
|
-
path: asdict(perm) for path, perm in self.path_permissions.items()
|
|
46
|
-
}
|
|
47
|
-
return data
|
|
48
|
-
|
|
49
|
-
@classmethod
|
|
50
|
-
def from_dict(cls, data: Dict) -> "ChatSession":
|
|
51
|
-
"""Create session from dictionary."""
|
|
52
|
-
# Convert path_permissions dicts back to PathPermission objects
|
|
53
|
-
if "path_permissions" in data:
|
|
54
|
-
data["path_permissions"] = {
|
|
55
|
-
path: PathPermission(**perm_dict)
|
|
56
|
-
for path, perm_dict in data["path_permissions"].items()
|
|
57
|
-
}
|
|
58
|
-
return cls(**data)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class SessionManager:
|
|
62
|
-
"""Manage chat sessions with path validation and persistence."""
|
|
63
|
-
|
|
64
|
-
def __init__(self, session_dir: str = ".gaia/sessions", auto_cleanup: bool = True):
|
|
65
|
-
"""
|
|
66
|
-
Initialize session manager with optional automatic cleanup.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
session_dir: Directory to store session files
|
|
70
|
-
auto_cleanup: Automatically clean up old sessions on init (default: True)
|
|
71
|
-
"""
|
|
72
|
-
self.session_dir = Path(session_dir)
|
|
73
|
-
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
-
|
|
75
|
-
# Cache directory for path permissions
|
|
76
|
-
self.permissions_file = self.session_dir / "path_permissions.json"
|
|
77
|
-
self.path_permissions: Dict[str, PathPermission] = {}
|
|
78
|
-
self._load_permissions()
|
|
79
|
-
|
|
80
|
-
# Automatic session cleanup to prevent accumulation
|
|
81
|
-
if auto_cleanup:
|
|
82
|
-
try:
|
|
83
|
-
cleanup_stats = self.cleanup_old_sessions(
|
|
84
|
-
max_age_days=30, max_sessions=50
|
|
85
|
-
)
|
|
86
|
-
if cleanup_stats.get("total_deleted", 0) > 0:
|
|
87
|
-
logger.info(
|
|
88
|
-
f"Auto-cleanup: Removed {cleanup_stats['total_deleted']} old sessions"
|
|
89
|
-
)
|
|
90
|
-
except Exception as e:
|
|
91
|
-
logger.warning(f"Auto-cleanup failed: {e}")
|
|
92
|
-
|
|
93
|
-
def _load_permissions(self):
|
|
94
|
-
"""Load cached path permissions."""
|
|
95
|
-
if self.permissions_file.exists():
|
|
96
|
-
try:
|
|
97
|
-
with open(self.permissions_file, "r", encoding="utf-8") as f:
|
|
98
|
-
data = json.load(f)
|
|
99
|
-
self.path_permissions = {
|
|
100
|
-
path: PathPermission(**perm_dict)
|
|
101
|
-
for path, perm_dict in data.items()
|
|
102
|
-
}
|
|
103
|
-
logger.info(
|
|
104
|
-
f"Loaded {len(self.path_permissions)} cached path permissions"
|
|
105
|
-
)
|
|
106
|
-
except Exception as e:
|
|
107
|
-
logger.error(f"Error loading path permissions: {e}")
|
|
108
|
-
self.path_permissions = {}
|
|
109
|
-
|
|
110
|
-
def _save_permissions(self):
|
|
111
|
-
"""Save path permissions to cache."""
|
|
112
|
-
try:
|
|
113
|
-
data = {path: asdict(perm) for path, perm in self.path_permissions.items()}
|
|
114
|
-
with open(self.permissions_file, "w", encoding="utf-8") as f:
|
|
115
|
-
json.dump(data, f, indent=2)
|
|
116
|
-
logger.debug(f"Saved {len(self.path_permissions)} path permissions")
|
|
117
|
-
except Exception as e:
|
|
118
|
-
logger.error(f"Error saving path permissions: {e}")
|
|
119
|
-
|
|
120
|
-
def validate_path(
|
|
121
|
-
self, path: str, operation: str = "access", prompt_user: bool = True
|
|
122
|
-
) -> bool:
|
|
123
|
-
"""
|
|
124
|
-
Validate if path access is allowed with user confirmation.
|
|
125
|
-
|
|
126
|
-
Args:
|
|
127
|
-
path: Path to validate
|
|
128
|
-
operation: Operation type ('read', 'write', 'index', 'watch')
|
|
129
|
-
prompt_user: If True, prompt user for confirmation if not cached
|
|
130
|
-
|
|
131
|
-
Returns:
|
|
132
|
-
True if access is allowed, False otherwise
|
|
133
|
-
"""
|
|
134
|
-
try:
|
|
135
|
-
# Resolve to absolute path
|
|
136
|
-
resolved_path = str(Path(path).resolve())
|
|
137
|
-
|
|
138
|
-
# Check cache first
|
|
139
|
-
if resolved_path in self.path_permissions:
|
|
140
|
-
perm = self.path_permissions[resolved_path]
|
|
141
|
-
logger.debug(
|
|
142
|
-
f"Using cached permission for {resolved_path}: {perm.allowed}"
|
|
143
|
-
)
|
|
144
|
-
return perm.allowed
|
|
145
|
-
|
|
146
|
-
# If not cached and prompting disabled, deny by default
|
|
147
|
-
if not prompt_user:
|
|
148
|
-
logger.warning(
|
|
149
|
-
f"Path not in cache and prompting disabled: {resolved_path}"
|
|
150
|
-
)
|
|
151
|
-
return False
|
|
152
|
-
|
|
153
|
-
# Prompt user for confirmation
|
|
154
|
-
print(f"\n{'='*60}")
|
|
155
|
-
print("Path Access Request")
|
|
156
|
-
print(f"{'='*60}")
|
|
157
|
-
print(f"Path: {resolved_path}")
|
|
158
|
-
print(f"Operation: {operation}")
|
|
159
|
-
print(f"{'='*60}")
|
|
160
|
-
|
|
161
|
-
response = (
|
|
162
|
-
input("Allow access to this path? (yes/no/always): ").strip().lower()
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
if response in ["yes", "y", "always", "a"]:
|
|
166
|
-
allowed = True
|
|
167
|
-
# Cache the decision
|
|
168
|
-
self.path_permissions[resolved_path] = PathPermission(
|
|
169
|
-
path=resolved_path,
|
|
170
|
-
allowed=True,
|
|
171
|
-
timestamp=datetime.now().isoformat(),
|
|
172
|
-
recursive=False,
|
|
173
|
-
)
|
|
174
|
-
self._save_permissions()
|
|
175
|
-
print("✅ Access granted and cached for future use")
|
|
176
|
-
else:
|
|
177
|
-
allowed = False
|
|
178
|
-
# Cache denial as well
|
|
179
|
-
self.path_permissions[resolved_path] = PathPermission(
|
|
180
|
-
path=resolved_path,
|
|
181
|
-
allowed=False,
|
|
182
|
-
timestamp=datetime.now().isoformat(),
|
|
183
|
-
recursive=False,
|
|
184
|
-
)
|
|
185
|
-
self._save_permissions()
|
|
186
|
-
print("❌ Access denied and cached")
|
|
187
|
-
|
|
188
|
-
return allowed
|
|
189
|
-
|
|
190
|
-
except Exception as e:
|
|
191
|
-
logger.error(f"Error validating path {path}: {e}")
|
|
192
|
-
return False
|
|
193
|
-
|
|
194
|
-
def validate_directory(
|
|
195
|
-
self, directory: str, operation: str = "watch", prompt_user: bool = True
|
|
196
|
-
) -> bool:
|
|
197
|
-
"""
|
|
198
|
-
Validate directory access with recursive option.
|
|
199
|
-
|
|
200
|
-
Args:
|
|
201
|
-
directory: Directory path to validate
|
|
202
|
-
operation: Operation type
|
|
203
|
-
prompt_user: If True, prompt user for confirmation
|
|
204
|
-
|
|
205
|
-
Returns:
|
|
206
|
-
True if access is allowed
|
|
207
|
-
"""
|
|
208
|
-
try:
|
|
209
|
-
resolved_dir = str(Path(directory).resolve())
|
|
210
|
-
|
|
211
|
-
# Check if this directory or any parent has cached permission
|
|
212
|
-
for cached_path, perm in self.path_permissions.items():
|
|
213
|
-
try:
|
|
214
|
-
if perm.recursive and Path(resolved_dir).is_relative_to(
|
|
215
|
-
cached_path
|
|
216
|
-
):
|
|
217
|
-
logger.debug(f"Using recursive permission from {cached_path}")
|
|
218
|
-
return perm.allowed
|
|
219
|
-
except (ValueError, TypeError):
|
|
220
|
-
continue
|
|
221
|
-
|
|
222
|
-
# Check exact match
|
|
223
|
-
if resolved_dir in self.path_permissions:
|
|
224
|
-
return self.path_permissions[resolved_dir].allowed
|
|
225
|
-
|
|
226
|
-
# Prompt user
|
|
227
|
-
if not prompt_user:
|
|
228
|
-
return False
|
|
229
|
-
|
|
230
|
-
print(f"\n{'='*60}")
|
|
231
|
-
print("Directory Access Request")
|
|
232
|
-
print(f"{'='*60}")
|
|
233
|
-
print(f"Directory: {resolved_dir}")
|
|
234
|
-
print(f"Operation: {operation}")
|
|
235
|
-
print(f"{'='*60}")
|
|
236
|
-
|
|
237
|
-
response = (
|
|
238
|
-
input("Allow access? (yes/no/always/recursive): ").strip().lower()
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
if response in ["yes", "y", "always", "a", "recursive", "r"]:
|
|
242
|
-
recursive = response in ["recursive", "r"]
|
|
243
|
-
self.path_permissions[resolved_dir] = PathPermission(
|
|
244
|
-
path=resolved_dir,
|
|
245
|
-
allowed=True,
|
|
246
|
-
timestamp=datetime.now().isoformat(),
|
|
247
|
-
recursive=recursive,
|
|
248
|
-
)
|
|
249
|
-
self._save_permissions()
|
|
250
|
-
print(
|
|
251
|
-
f"✅ Access granted ({'recursive' if recursive else 'single directory'})"
|
|
252
|
-
)
|
|
253
|
-
return True
|
|
254
|
-
else:
|
|
255
|
-
self.path_permissions[resolved_dir] = PathPermission(
|
|
256
|
-
path=resolved_dir,
|
|
257
|
-
allowed=False,
|
|
258
|
-
timestamp=datetime.now().isoformat(),
|
|
259
|
-
recursive=False,
|
|
260
|
-
)
|
|
261
|
-
self._save_permissions()
|
|
262
|
-
print("❌ Access denied")
|
|
263
|
-
return False
|
|
264
|
-
|
|
265
|
-
except Exception as e:
|
|
266
|
-
logger.error(f"Error validating directory {directory}: {e}")
|
|
267
|
-
return False
|
|
268
|
-
|
|
269
|
-
def create_session(self, session_id: Optional[str] = None) -> ChatSession:
|
|
270
|
-
"""
|
|
271
|
-
Create a new chat session.
|
|
272
|
-
|
|
273
|
-
Args:
|
|
274
|
-
session_id: Optional session ID, generated if not provided
|
|
275
|
-
|
|
276
|
-
Returns:
|
|
277
|
-
New ChatSession instance
|
|
278
|
-
"""
|
|
279
|
-
if session_id is None:
|
|
280
|
-
session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
281
|
-
|
|
282
|
-
now = datetime.now().isoformat()
|
|
283
|
-
session = ChatSession(
|
|
284
|
-
session_id=session_id,
|
|
285
|
-
created_at=now,
|
|
286
|
-
updated_at=now,
|
|
287
|
-
indexed_documents=[],
|
|
288
|
-
watched_directories=[],
|
|
289
|
-
chat_history=[],
|
|
290
|
-
path_permissions=dict(self.path_permissions), # Copy current permissions
|
|
291
|
-
metadata={},
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
logger.info(f"Created new session: {session_id}")
|
|
295
|
-
return session
|
|
296
|
-
|
|
297
|
-
def save_session(self, session: ChatSession) -> bool:
|
|
298
|
-
"""
|
|
299
|
-
Save session to disk.
|
|
300
|
-
|
|
301
|
-
Args:
|
|
302
|
-
session: ChatSession to save
|
|
303
|
-
|
|
304
|
-
Returns:
|
|
305
|
-
True if successful
|
|
306
|
-
"""
|
|
307
|
-
try:
|
|
308
|
-
session.updated_at = datetime.now().isoformat()
|
|
309
|
-
session_file = self.session_dir / f"{session.session_id}.json"
|
|
310
|
-
|
|
311
|
-
with open(session_file, "w", encoding="utf-8") as f:
|
|
312
|
-
json.dump(session.to_dict(), f, indent=2)
|
|
313
|
-
|
|
314
|
-
logger.info(f"Saved session: {session.session_id}")
|
|
315
|
-
return True
|
|
316
|
-
|
|
317
|
-
except Exception as e:
|
|
318
|
-
logger.error(f"Error saving session {session.session_id}: {e}")
|
|
319
|
-
return False
|
|
320
|
-
|
|
321
|
-
def load_session(self, session_id: str) -> Optional[ChatSession]:
|
|
322
|
-
"""
|
|
323
|
-
Load session from disk.
|
|
324
|
-
|
|
325
|
-
Args:
|
|
326
|
-
session_id: Session ID to load
|
|
327
|
-
|
|
328
|
-
Returns:
|
|
329
|
-
ChatSession if found, None otherwise
|
|
330
|
-
"""
|
|
331
|
-
try:
|
|
332
|
-
session_file = self.session_dir / f"{session_id}.json"
|
|
333
|
-
|
|
334
|
-
if not session_file.exists():
|
|
335
|
-
logger.warning(f"Session not found: {session_id}")
|
|
336
|
-
return None
|
|
337
|
-
|
|
338
|
-
with open(session_file, "r", encoding="utf-8") as f:
|
|
339
|
-
data = json.load(f)
|
|
340
|
-
|
|
341
|
-
session = ChatSession.from_dict(data)
|
|
342
|
-
logger.info(f"Loaded session: {session_id}")
|
|
343
|
-
return session
|
|
344
|
-
|
|
345
|
-
except Exception as e:
|
|
346
|
-
logger.error(f"Error loading session {session_id}: {e}")
|
|
347
|
-
return None
|
|
348
|
-
|
|
349
|
-
def list_sessions(self) -> List[Dict[str, str]]:
|
|
350
|
-
"""
|
|
351
|
-
List all available sessions.
|
|
352
|
-
|
|
353
|
-
Returns:
|
|
354
|
-
List of session metadata (id, created_at, updated_at)
|
|
355
|
-
"""
|
|
356
|
-
sessions = []
|
|
357
|
-
|
|
358
|
-
try:
|
|
359
|
-
for session_file in self.session_dir.glob("session_*.json"):
|
|
360
|
-
try:
|
|
361
|
-
with open(session_file, "r", encoding="utf-8") as f:
|
|
362
|
-
data = json.load(f)
|
|
363
|
-
|
|
364
|
-
sessions.append(
|
|
365
|
-
{
|
|
366
|
-
"session_id": data["session_id"],
|
|
367
|
-
"created_at": data["created_at"],
|
|
368
|
-
"updated_at": data["updated_at"],
|
|
369
|
-
"num_documents": len(data.get("indexed_documents", [])),
|
|
370
|
-
"num_messages": len(data.get("chat_history", [])),
|
|
371
|
-
}
|
|
372
|
-
)
|
|
373
|
-
except Exception as e:
|
|
374
|
-
logger.error(f"Error reading session file {session_file}: {e}")
|
|
375
|
-
continue
|
|
376
|
-
|
|
377
|
-
except Exception as e:
|
|
378
|
-
logger.error(f"Error listing sessions: {e}")
|
|
379
|
-
|
|
380
|
-
return sorted(sessions, key=lambda x: x["updated_at"], reverse=True)
|
|
381
|
-
|
|
382
|
-
def delete_session(self, session_id: str) -> bool:
|
|
383
|
-
"""
|
|
384
|
-
Delete a session.
|
|
385
|
-
|
|
386
|
-
Args:
|
|
387
|
-
session_id: Session ID to delete
|
|
388
|
-
|
|
389
|
-
Returns:
|
|
390
|
-
True if successful
|
|
391
|
-
"""
|
|
392
|
-
try:
|
|
393
|
-
session_file = self.session_dir / f"{session_id}.json"
|
|
394
|
-
|
|
395
|
-
if session_file.exists():
|
|
396
|
-
session_file.unlink()
|
|
397
|
-
logger.info(f"Deleted session: {session_id}")
|
|
398
|
-
return True
|
|
399
|
-
else:
|
|
400
|
-
logger.warning(f"Session not found: {session_id}")
|
|
401
|
-
return False
|
|
402
|
-
|
|
403
|
-
except Exception as e:
|
|
404
|
-
logger.error(f"Error deleting session {session_id}: {e}")
|
|
405
|
-
return False
|
|
406
|
-
|
|
407
|
-
def cleanup_old_sessions(
|
|
408
|
-
self, max_age_days: int = 30, max_sessions: int = 50
|
|
409
|
-
) -> Dict[str, int]:
|
|
410
|
-
"""
|
|
411
|
-
Clean up old sessions to prevent unbounded growth.
|
|
412
|
-
|
|
413
|
-
Removes sessions that are:
|
|
414
|
-
1. Older than max_age_days (TTL-based cleanup)
|
|
415
|
-
2. Beyond max_sessions limit (keep only most recent)
|
|
416
|
-
|
|
417
|
-
Args:
|
|
418
|
-
max_age_days: Maximum age in days before deletion (default: 30)
|
|
419
|
-
max_sessions: Maximum number of sessions to keep (default: 50)
|
|
420
|
-
|
|
421
|
-
Returns:
|
|
422
|
-
Dictionary with cleanup statistics
|
|
423
|
-
"""
|
|
424
|
-
try:
|
|
425
|
-
cutoff_time = datetime.now() - timedelta(days=max_age_days)
|
|
426
|
-
sessions = []
|
|
427
|
-
|
|
428
|
-
# Collect all sessions with metadata
|
|
429
|
-
for session_file in self.session_dir.glob("session_*.json"):
|
|
430
|
-
try:
|
|
431
|
-
with open(session_file, "r", encoding="utf-8") as f:
|
|
432
|
-
data = json.load(f)
|
|
433
|
-
|
|
434
|
-
updated_at = datetime.fromisoformat(data["updated_at"])
|
|
435
|
-
sessions.append(
|
|
436
|
-
{
|
|
437
|
-
"file": session_file,
|
|
438
|
-
"session_id": data["session_id"],
|
|
439
|
-
"updated_at": updated_at,
|
|
440
|
-
}
|
|
441
|
-
)
|
|
442
|
-
except Exception as e:
|
|
443
|
-
logger.error(f"Error reading session {session_file}: {e}")
|
|
444
|
-
continue
|
|
445
|
-
|
|
446
|
-
# Sort by update time (newest first)
|
|
447
|
-
sessions.sort(key=lambda x: x["updated_at"], reverse=True)
|
|
448
|
-
|
|
449
|
-
deleted_old = 0
|
|
450
|
-
deleted_excess = 0
|
|
451
|
-
|
|
452
|
-
# Delete sessions older than TTL
|
|
453
|
-
for session in sessions:
|
|
454
|
-
if session["updated_at"] < cutoff_time:
|
|
455
|
-
try:
|
|
456
|
-
session["file"].unlink()
|
|
457
|
-
deleted_old += 1
|
|
458
|
-
logger.info(f"Deleted old session: {session['session_id']}")
|
|
459
|
-
except Exception as e:
|
|
460
|
-
logger.error(
|
|
461
|
-
f"Failed to delete session {session['session_id']}: {e}"
|
|
462
|
-
)
|
|
463
|
-
|
|
464
|
-
# Keep only most recent sessions up to max_sessions
|
|
465
|
-
if len(sessions) > max_sessions:
|
|
466
|
-
excess_sessions = sessions[max_sessions:]
|
|
467
|
-
for session in excess_sessions:
|
|
468
|
-
try:
|
|
469
|
-
if session[
|
|
470
|
-
"file"
|
|
471
|
-
].exists(): # Might have been deleted in TTL cleanup
|
|
472
|
-
session["file"].unlink()
|
|
473
|
-
deleted_excess += 1
|
|
474
|
-
logger.info(
|
|
475
|
-
f"Deleted excess session: {session['session_id']}"
|
|
476
|
-
)
|
|
477
|
-
except Exception as e:
|
|
478
|
-
logger.error(
|
|
479
|
-
f"Failed to delete session {session['session_id']}: {e}"
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
total_deleted = deleted_old + deleted_excess
|
|
483
|
-
remaining = len(sessions) - total_deleted
|
|
484
|
-
|
|
485
|
-
logger.info(
|
|
486
|
-
"Session cleanup complete: "
|
|
487
|
-
f"deleted {total_deleted} sessions ({deleted_old} old, {deleted_excess} excess), "
|
|
488
|
-
f"{remaining} remaining"
|
|
489
|
-
)
|
|
490
|
-
|
|
491
|
-
return {
|
|
492
|
-
"deleted_old": deleted_old,
|
|
493
|
-
"deleted_excess": deleted_excess,
|
|
494
|
-
"total_deleted": total_deleted,
|
|
495
|
-
"remaining_sessions": remaining,
|
|
496
|
-
"max_age_days": max_age_days,
|
|
497
|
-
"max_sessions": max_sessions,
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
except Exception as e:
|
|
501
|
-
logger.error(f"Error during session cleanup: {e}")
|
|
502
|
-
return {"error": str(e), "total_deleted": 0, "remaining_sessions": 0}
|
|
503
|
-
|
|
504
|
-
def clear_path_permissions(self):
|
|
505
|
-
"""Clear all cached path permissions."""
|
|
506
|
-
self.path_permissions.clear()
|
|
507
|
-
self._save_permissions()
|
|
508
|
-
logger.info("Cleared all path permissions")
|
|
1
|
+
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
"""
|
|
4
|
+
Session management for Chat Agent with path validation and history.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class PathPermission:
|
|
19
|
+
"""Track permission for a path."""
|
|
20
|
+
|
|
21
|
+
path: str
|
|
22
|
+
allowed: bool
|
|
23
|
+
timestamp: str
|
|
24
|
+
recursive: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ChatSession:
|
|
29
|
+
"""Chat session with history and indexed documents."""
|
|
30
|
+
|
|
31
|
+
session_id: str
|
|
32
|
+
created_at: str
|
|
33
|
+
updated_at: str
|
|
34
|
+
indexed_documents: List[str]
|
|
35
|
+
watched_directories: List[str]
|
|
36
|
+
chat_history: List[Dict[str, str]]
|
|
37
|
+
path_permissions: Dict[str, PathPermission]
|
|
38
|
+
metadata: Dict[str, any]
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> Dict:
|
|
41
|
+
"""Convert session to dictionary."""
|
|
42
|
+
data = asdict(self)
|
|
43
|
+
# Convert PathPermission objects to dicts
|
|
44
|
+
data["path_permissions"] = {
|
|
45
|
+
path: asdict(perm) for path, perm in self.path_permissions.items()
|
|
46
|
+
}
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, data: Dict) -> "ChatSession":
|
|
51
|
+
"""Create session from dictionary."""
|
|
52
|
+
# Convert path_permissions dicts back to PathPermission objects
|
|
53
|
+
if "path_permissions" in data:
|
|
54
|
+
data["path_permissions"] = {
|
|
55
|
+
path: PathPermission(**perm_dict)
|
|
56
|
+
for path, perm_dict in data["path_permissions"].items()
|
|
57
|
+
}
|
|
58
|
+
return cls(**data)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SessionManager:
|
|
62
|
+
"""Manage chat sessions with path validation and persistence."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, session_dir: str = ".gaia/sessions", auto_cleanup: bool = True):
|
|
65
|
+
"""
|
|
66
|
+
Initialize session manager with optional automatic cleanup.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
session_dir: Directory to store session files
|
|
70
|
+
auto_cleanup: Automatically clean up old sessions on init (default: True)
|
|
71
|
+
"""
|
|
72
|
+
self.session_dir = Path(session_dir)
|
|
73
|
+
self.session_dir.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
# Cache directory for path permissions
|
|
76
|
+
self.permissions_file = self.session_dir / "path_permissions.json"
|
|
77
|
+
self.path_permissions: Dict[str, PathPermission] = {}
|
|
78
|
+
self._load_permissions()
|
|
79
|
+
|
|
80
|
+
# Automatic session cleanup to prevent accumulation
|
|
81
|
+
if auto_cleanup:
|
|
82
|
+
try:
|
|
83
|
+
cleanup_stats = self.cleanup_old_sessions(
|
|
84
|
+
max_age_days=30, max_sessions=50
|
|
85
|
+
)
|
|
86
|
+
if cleanup_stats.get("total_deleted", 0) > 0:
|
|
87
|
+
logger.info(
|
|
88
|
+
f"Auto-cleanup: Removed {cleanup_stats['total_deleted']} old sessions"
|
|
89
|
+
)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.warning(f"Auto-cleanup failed: {e}")
|
|
92
|
+
|
|
93
|
+
def _load_permissions(self):
|
|
94
|
+
"""Load cached path permissions."""
|
|
95
|
+
if self.permissions_file.exists():
|
|
96
|
+
try:
|
|
97
|
+
with open(self.permissions_file, "r", encoding="utf-8") as f:
|
|
98
|
+
data = json.load(f)
|
|
99
|
+
self.path_permissions = {
|
|
100
|
+
path: PathPermission(**perm_dict)
|
|
101
|
+
for path, perm_dict in data.items()
|
|
102
|
+
}
|
|
103
|
+
logger.info(
|
|
104
|
+
f"Loaded {len(self.path_permissions)} cached path permissions"
|
|
105
|
+
)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Error loading path permissions: {e}")
|
|
108
|
+
self.path_permissions = {}
|
|
109
|
+
|
|
110
|
+
def _save_permissions(self):
|
|
111
|
+
"""Save path permissions to cache."""
|
|
112
|
+
try:
|
|
113
|
+
data = {path: asdict(perm) for path, perm in self.path_permissions.items()}
|
|
114
|
+
with open(self.permissions_file, "w", encoding="utf-8") as f:
|
|
115
|
+
json.dump(data, f, indent=2)
|
|
116
|
+
logger.debug(f"Saved {len(self.path_permissions)} path permissions")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Error saving path permissions: {e}")
|
|
119
|
+
|
|
120
|
+
def validate_path(
|
|
121
|
+
self, path: str, operation: str = "access", prompt_user: bool = True
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""
|
|
124
|
+
Validate if path access is allowed with user confirmation.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
path: Path to validate
|
|
128
|
+
operation: Operation type ('read', 'write', 'index', 'watch')
|
|
129
|
+
prompt_user: If True, prompt user for confirmation if not cached
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
True if access is allowed, False otherwise
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
# Resolve to absolute path
|
|
136
|
+
resolved_path = str(Path(path).resolve())
|
|
137
|
+
|
|
138
|
+
# Check cache first
|
|
139
|
+
if resolved_path in self.path_permissions:
|
|
140
|
+
perm = self.path_permissions[resolved_path]
|
|
141
|
+
logger.debug(
|
|
142
|
+
f"Using cached permission for {resolved_path}: {perm.allowed}"
|
|
143
|
+
)
|
|
144
|
+
return perm.allowed
|
|
145
|
+
|
|
146
|
+
# If not cached and prompting disabled, deny by default
|
|
147
|
+
if not prompt_user:
|
|
148
|
+
logger.warning(
|
|
149
|
+
f"Path not in cache and prompting disabled: {resolved_path}"
|
|
150
|
+
)
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
# Prompt user for confirmation
|
|
154
|
+
print(f"\n{'='*60}")
|
|
155
|
+
print("Path Access Request")
|
|
156
|
+
print(f"{'='*60}")
|
|
157
|
+
print(f"Path: {resolved_path}")
|
|
158
|
+
print(f"Operation: {operation}")
|
|
159
|
+
print(f"{'='*60}")
|
|
160
|
+
|
|
161
|
+
response = (
|
|
162
|
+
input("Allow access to this path? (yes/no/always): ").strip().lower()
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if response in ["yes", "y", "always", "a"]:
|
|
166
|
+
allowed = True
|
|
167
|
+
# Cache the decision
|
|
168
|
+
self.path_permissions[resolved_path] = PathPermission(
|
|
169
|
+
path=resolved_path,
|
|
170
|
+
allowed=True,
|
|
171
|
+
timestamp=datetime.now().isoformat(),
|
|
172
|
+
recursive=False,
|
|
173
|
+
)
|
|
174
|
+
self._save_permissions()
|
|
175
|
+
print("✅ Access granted and cached for future use")
|
|
176
|
+
else:
|
|
177
|
+
allowed = False
|
|
178
|
+
# Cache denial as well
|
|
179
|
+
self.path_permissions[resolved_path] = PathPermission(
|
|
180
|
+
path=resolved_path,
|
|
181
|
+
allowed=False,
|
|
182
|
+
timestamp=datetime.now().isoformat(),
|
|
183
|
+
recursive=False,
|
|
184
|
+
)
|
|
185
|
+
self._save_permissions()
|
|
186
|
+
print("❌ Access denied and cached")
|
|
187
|
+
|
|
188
|
+
return allowed
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error(f"Error validating path {path}: {e}")
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def validate_directory(
|
|
195
|
+
self, directory: str, operation: str = "watch", prompt_user: bool = True
|
|
196
|
+
) -> bool:
|
|
197
|
+
"""
|
|
198
|
+
Validate directory access with recursive option.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
directory: Directory path to validate
|
|
202
|
+
operation: Operation type
|
|
203
|
+
prompt_user: If True, prompt user for confirmation
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True if access is allowed
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
resolved_dir = str(Path(directory).resolve())
|
|
210
|
+
|
|
211
|
+
# Check if this directory or any parent has cached permission
|
|
212
|
+
for cached_path, perm in self.path_permissions.items():
|
|
213
|
+
try:
|
|
214
|
+
if perm.recursive and Path(resolved_dir).is_relative_to(
|
|
215
|
+
cached_path
|
|
216
|
+
):
|
|
217
|
+
logger.debug(f"Using recursive permission from {cached_path}")
|
|
218
|
+
return perm.allowed
|
|
219
|
+
except (ValueError, TypeError):
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Check exact match
|
|
223
|
+
if resolved_dir in self.path_permissions:
|
|
224
|
+
return self.path_permissions[resolved_dir].allowed
|
|
225
|
+
|
|
226
|
+
# Prompt user
|
|
227
|
+
if not prompt_user:
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
print(f"\n{'='*60}")
|
|
231
|
+
print("Directory Access Request")
|
|
232
|
+
print(f"{'='*60}")
|
|
233
|
+
print(f"Directory: {resolved_dir}")
|
|
234
|
+
print(f"Operation: {operation}")
|
|
235
|
+
print(f"{'='*60}")
|
|
236
|
+
|
|
237
|
+
response = (
|
|
238
|
+
input("Allow access? (yes/no/always/recursive): ").strip().lower()
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if response in ["yes", "y", "always", "a", "recursive", "r"]:
|
|
242
|
+
recursive = response in ["recursive", "r"]
|
|
243
|
+
self.path_permissions[resolved_dir] = PathPermission(
|
|
244
|
+
path=resolved_dir,
|
|
245
|
+
allowed=True,
|
|
246
|
+
timestamp=datetime.now().isoformat(),
|
|
247
|
+
recursive=recursive,
|
|
248
|
+
)
|
|
249
|
+
self._save_permissions()
|
|
250
|
+
print(
|
|
251
|
+
f"✅ Access granted ({'recursive' if recursive else 'single directory'})"
|
|
252
|
+
)
|
|
253
|
+
return True
|
|
254
|
+
else:
|
|
255
|
+
self.path_permissions[resolved_dir] = PathPermission(
|
|
256
|
+
path=resolved_dir,
|
|
257
|
+
allowed=False,
|
|
258
|
+
timestamp=datetime.now().isoformat(),
|
|
259
|
+
recursive=False,
|
|
260
|
+
)
|
|
261
|
+
self._save_permissions()
|
|
262
|
+
print("❌ Access denied")
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.error(f"Error validating directory {directory}: {e}")
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
def create_session(self, session_id: Optional[str] = None) -> ChatSession:
|
|
270
|
+
"""
|
|
271
|
+
Create a new chat session.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
session_id: Optional session ID, generated if not provided
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
New ChatSession instance
|
|
278
|
+
"""
|
|
279
|
+
if session_id is None:
|
|
280
|
+
session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
281
|
+
|
|
282
|
+
now = datetime.now().isoformat()
|
|
283
|
+
session = ChatSession(
|
|
284
|
+
session_id=session_id,
|
|
285
|
+
created_at=now,
|
|
286
|
+
updated_at=now,
|
|
287
|
+
indexed_documents=[],
|
|
288
|
+
watched_directories=[],
|
|
289
|
+
chat_history=[],
|
|
290
|
+
path_permissions=dict(self.path_permissions), # Copy current permissions
|
|
291
|
+
metadata={},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
logger.info(f"Created new session: {session_id}")
|
|
295
|
+
return session
|
|
296
|
+
|
|
297
|
+
def save_session(self, session: ChatSession) -> bool:
|
|
298
|
+
"""
|
|
299
|
+
Save session to disk.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
session: ChatSession to save
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
True if successful
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
session.updated_at = datetime.now().isoformat()
|
|
309
|
+
session_file = self.session_dir / f"{session.session_id}.json"
|
|
310
|
+
|
|
311
|
+
with open(session_file, "w", encoding="utf-8") as f:
|
|
312
|
+
json.dump(session.to_dict(), f, indent=2)
|
|
313
|
+
|
|
314
|
+
logger.info(f"Saved session: {session.session_id}")
|
|
315
|
+
return True
|
|
316
|
+
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.error(f"Error saving session {session.session_id}: {e}")
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
def load_session(self, session_id: str) -> Optional[ChatSession]:
|
|
322
|
+
"""
|
|
323
|
+
Load session from disk.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
session_id: Session ID to load
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
ChatSession if found, None otherwise
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
session_file = self.session_dir / f"{session_id}.json"
|
|
333
|
+
|
|
334
|
+
if not session_file.exists():
|
|
335
|
+
logger.warning(f"Session not found: {session_id}")
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
339
|
+
data = json.load(f)
|
|
340
|
+
|
|
341
|
+
session = ChatSession.from_dict(data)
|
|
342
|
+
logger.info(f"Loaded session: {session_id}")
|
|
343
|
+
return session
|
|
344
|
+
|
|
345
|
+
except Exception as e:
|
|
346
|
+
logger.error(f"Error loading session {session_id}: {e}")
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
def list_sessions(self) -> List[Dict[str, str]]:
|
|
350
|
+
"""
|
|
351
|
+
List all available sessions.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
List of session metadata (id, created_at, updated_at)
|
|
355
|
+
"""
|
|
356
|
+
sessions = []
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
for session_file in self.session_dir.glob("session_*.json"):
|
|
360
|
+
try:
|
|
361
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
362
|
+
data = json.load(f)
|
|
363
|
+
|
|
364
|
+
sessions.append(
|
|
365
|
+
{
|
|
366
|
+
"session_id": data["session_id"],
|
|
367
|
+
"created_at": data["created_at"],
|
|
368
|
+
"updated_at": data["updated_at"],
|
|
369
|
+
"num_documents": len(data.get("indexed_documents", [])),
|
|
370
|
+
"num_messages": len(data.get("chat_history", [])),
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
except Exception as e:
|
|
374
|
+
logger.error(f"Error reading session file {session_file}: {e}")
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
except Exception as e:
|
|
378
|
+
logger.error(f"Error listing sessions: {e}")
|
|
379
|
+
|
|
380
|
+
return sorted(sessions, key=lambda x: x["updated_at"], reverse=True)
|
|
381
|
+
|
|
382
|
+
def delete_session(self, session_id: str) -> bool:
|
|
383
|
+
"""
|
|
384
|
+
Delete a session.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
session_id: Session ID to delete
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
True if successful
|
|
391
|
+
"""
|
|
392
|
+
try:
|
|
393
|
+
session_file = self.session_dir / f"{session_id}.json"
|
|
394
|
+
|
|
395
|
+
if session_file.exists():
|
|
396
|
+
session_file.unlink()
|
|
397
|
+
logger.info(f"Deleted session: {session_id}")
|
|
398
|
+
return True
|
|
399
|
+
else:
|
|
400
|
+
logger.warning(f"Session not found: {session_id}")
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.error(f"Error deleting session {session_id}: {e}")
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
def cleanup_old_sessions(
|
|
408
|
+
self, max_age_days: int = 30, max_sessions: int = 50
|
|
409
|
+
) -> Dict[str, int]:
|
|
410
|
+
"""
|
|
411
|
+
Clean up old sessions to prevent unbounded growth.
|
|
412
|
+
|
|
413
|
+
Removes sessions that are:
|
|
414
|
+
1. Older than max_age_days (TTL-based cleanup)
|
|
415
|
+
2. Beyond max_sessions limit (keep only most recent)
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
max_age_days: Maximum age in days before deletion (default: 30)
|
|
419
|
+
max_sessions: Maximum number of sessions to keep (default: 50)
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Dictionary with cleanup statistics
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
cutoff_time = datetime.now() - timedelta(days=max_age_days)
|
|
426
|
+
sessions = []
|
|
427
|
+
|
|
428
|
+
# Collect all sessions with metadata
|
|
429
|
+
for session_file in self.session_dir.glob("session_*.json"):
|
|
430
|
+
try:
|
|
431
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
432
|
+
data = json.load(f)
|
|
433
|
+
|
|
434
|
+
updated_at = datetime.fromisoformat(data["updated_at"])
|
|
435
|
+
sessions.append(
|
|
436
|
+
{
|
|
437
|
+
"file": session_file,
|
|
438
|
+
"session_id": data["session_id"],
|
|
439
|
+
"updated_at": updated_at,
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
except Exception as e:
|
|
443
|
+
logger.error(f"Error reading session {session_file}: {e}")
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
# Sort by update time (newest first)
|
|
447
|
+
sessions.sort(key=lambda x: x["updated_at"], reverse=True)
|
|
448
|
+
|
|
449
|
+
deleted_old = 0
|
|
450
|
+
deleted_excess = 0
|
|
451
|
+
|
|
452
|
+
# Delete sessions older than TTL
|
|
453
|
+
for session in sessions:
|
|
454
|
+
if session["updated_at"] < cutoff_time:
|
|
455
|
+
try:
|
|
456
|
+
session["file"].unlink()
|
|
457
|
+
deleted_old += 1
|
|
458
|
+
logger.info(f"Deleted old session: {session['session_id']}")
|
|
459
|
+
except Exception as e:
|
|
460
|
+
logger.error(
|
|
461
|
+
f"Failed to delete session {session['session_id']}: {e}"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Keep only most recent sessions up to max_sessions
|
|
465
|
+
if len(sessions) > max_sessions:
|
|
466
|
+
excess_sessions = sessions[max_sessions:]
|
|
467
|
+
for session in excess_sessions:
|
|
468
|
+
try:
|
|
469
|
+
if session[
|
|
470
|
+
"file"
|
|
471
|
+
].exists(): # Might have been deleted in TTL cleanup
|
|
472
|
+
session["file"].unlink()
|
|
473
|
+
deleted_excess += 1
|
|
474
|
+
logger.info(
|
|
475
|
+
f"Deleted excess session: {session['session_id']}"
|
|
476
|
+
)
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logger.error(
|
|
479
|
+
f"Failed to delete session {session['session_id']}: {e}"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
total_deleted = deleted_old + deleted_excess
|
|
483
|
+
remaining = len(sessions) - total_deleted
|
|
484
|
+
|
|
485
|
+
logger.info(
|
|
486
|
+
"Session cleanup complete: "
|
|
487
|
+
f"deleted {total_deleted} sessions ({deleted_old} old, {deleted_excess} excess), "
|
|
488
|
+
f"{remaining} remaining"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
"deleted_old": deleted_old,
|
|
493
|
+
"deleted_excess": deleted_excess,
|
|
494
|
+
"total_deleted": total_deleted,
|
|
495
|
+
"remaining_sessions": remaining,
|
|
496
|
+
"max_age_days": max_age_days,
|
|
497
|
+
"max_sessions": max_sessions,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.error(f"Error during session cleanup: {e}")
|
|
502
|
+
return {"error": str(e), "total_deleted": 0, "remaining_sessions": 0}
|
|
503
|
+
|
|
504
|
+
def clear_path_permissions(self):
|
|
505
|
+
"""Clear all cached path permissions."""
|
|
506
|
+
self.path_permissions.clear()
|
|
507
|
+
self._save_permissions()
|
|
508
|
+
logger.info("Cleared all path permissions")
|