amd-gaia 0.15.0__py3-none-any.whl → 0.15.2__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 (185) hide show
  1. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/METADATA +222 -223
  2. amd_gaia-0.15.2.dist-info/RECORD +182 -0
  3. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/WHEEL +1 -1
  4. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/entry_points.txt +1 -0
  5. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/licenses/LICENSE.md +20 -20
  6. gaia/__init__.py +29 -29
  7. gaia/agents/__init__.py +19 -19
  8. gaia/agents/base/__init__.py +9 -9
  9. gaia/agents/base/agent.py +2132 -2177
  10. gaia/agents/base/api_agent.py +119 -120
  11. gaia/agents/base/console.py +1967 -1841
  12. gaia/agents/base/errors.py +237 -237
  13. gaia/agents/base/mcp_agent.py +86 -86
  14. gaia/agents/base/tools.py +88 -83
  15. gaia/agents/blender/__init__.py +7 -0
  16. gaia/agents/blender/agent.py +553 -556
  17. gaia/agents/blender/agent_simple.py +133 -135
  18. gaia/agents/blender/app.py +211 -211
  19. gaia/agents/blender/app_simple.py +41 -41
  20. gaia/agents/blender/core/__init__.py +16 -16
  21. gaia/agents/blender/core/materials.py +506 -506
  22. gaia/agents/blender/core/objects.py +316 -316
  23. gaia/agents/blender/core/rendering.py +225 -225
  24. gaia/agents/blender/core/scene.py +220 -220
  25. gaia/agents/blender/core/view.py +146 -146
  26. gaia/agents/chat/__init__.py +9 -9
  27. gaia/agents/chat/agent.py +809 -835
  28. gaia/agents/chat/app.py +1065 -1058
  29. gaia/agents/chat/session.py +508 -508
  30. gaia/agents/chat/tools/__init__.py +15 -15
  31. gaia/agents/chat/tools/file_tools.py +96 -96
  32. gaia/agents/chat/tools/rag_tools.py +1744 -1729
  33. gaia/agents/chat/tools/shell_tools.py +437 -436
  34. gaia/agents/code/__init__.py +7 -7
  35. gaia/agents/code/agent.py +549 -549
  36. gaia/agents/code/cli.py +377 -0
  37. gaia/agents/code/models.py +135 -135
  38. gaia/agents/code/orchestration/__init__.py +24 -24
  39. gaia/agents/code/orchestration/checklist_executor.py +1763 -1763
  40. gaia/agents/code/orchestration/checklist_generator.py +713 -713
  41. gaia/agents/code/orchestration/factories/__init__.py +9 -9
  42. gaia/agents/code/orchestration/factories/base.py +63 -63
  43. gaia/agents/code/orchestration/factories/nextjs_factory.py +118 -118
  44. gaia/agents/code/orchestration/factories/python_factory.py +106 -106
  45. gaia/agents/code/orchestration/orchestrator.py +841 -841
  46. gaia/agents/code/orchestration/project_analyzer.py +391 -391
  47. gaia/agents/code/orchestration/steps/__init__.py +67 -67
  48. gaia/agents/code/orchestration/steps/base.py +188 -188
  49. gaia/agents/code/orchestration/steps/error_handler.py +314 -314
  50. gaia/agents/code/orchestration/steps/nextjs.py +828 -828
  51. gaia/agents/code/orchestration/steps/python.py +307 -307
  52. gaia/agents/code/orchestration/template_catalog.py +469 -469
  53. gaia/agents/code/orchestration/workflows/__init__.py +14 -14
  54. gaia/agents/code/orchestration/workflows/base.py +80 -80
  55. gaia/agents/code/orchestration/workflows/nextjs.py +186 -186
  56. gaia/agents/code/orchestration/workflows/python.py +94 -94
  57. gaia/agents/code/prompts/__init__.py +11 -11
  58. gaia/agents/code/prompts/base_prompt.py +77 -77
  59. gaia/agents/code/prompts/code_patterns.py +2034 -2036
  60. gaia/agents/code/prompts/nextjs_prompt.py +40 -40
  61. gaia/agents/code/prompts/python_prompt.py +109 -109
  62. gaia/agents/code/schema_inference.py +365 -365
  63. gaia/agents/code/system_prompt.py +41 -41
  64. gaia/agents/code/tools/__init__.py +42 -42
  65. gaia/agents/code/tools/cli_tools.py +1138 -1138
  66. gaia/agents/code/tools/code_formatting.py +319 -319
  67. gaia/agents/code/tools/code_tools.py +769 -769
  68. gaia/agents/code/tools/error_fixing.py +1347 -1347
  69. gaia/agents/code/tools/external_tools.py +180 -180
  70. gaia/agents/code/tools/file_io.py +845 -845
  71. gaia/agents/code/tools/prisma_tools.py +190 -190
  72. gaia/agents/code/tools/project_management.py +1016 -1016
  73. gaia/agents/code/tools/testing.py +321 -321
  74. gaia/agents/code/tools/typescript_tools.py +122 -122
  75. gaia/agents/code/tools/validation_parsing.py +461 -461
  76. gaia/agents/code/tools/validation_tools.py +806 -806
  77. gaia/agents/code/tools/web_dev_tools.py +1758 -1758
  78. gaia/agents/code/validators/__init__.py +16 -16
  79. gaia/agents/code/validators/antipattern_checker.py +241 -241
  80. gaia/agents/code/validators/ast_analyzer.py +197 -197
  81. gaia/agents/code/validators/requirements_validator.py +145 -145
  82. gaia/agents/code/validators/syntax_validator.py +171 -171
  83. gaia/agents/docker/__init__.py +7 -7
  84. gaia/agents/docker/agent.py +643 -642
  85. gaia/agents/emr/__init__.py +8 -8
  86. gaia/agents/emr/agent.py +1504 -1506
  87. gaia/agents/emr/cli.py +1322 -1322
  88. gaia/agents/emr/constants.py +475 -475
  89. gaia/agents/emr/dashboard/__init__.py +4 -4
  90. gaia/agents/emr/dashboard/server.py +1972 -1974
  91. gaia/agents/jira/__init__.py +11 -11
  92. gaia/agents/jira/agent.py +894 -894
  93. gaia/agents/jira/jql_templates.py +299 -299
  94. gaia/agents/routing/__init__.py +7 -7
  95. gaia/agents/routing/agent.py +567 -570
  96. gaia/agents/routing/system_prompt.py +75 -75
  97. gaia/agents/summarize/__init__.py +11 -0
  98. gaia/agents/summarize/agent.py +885 -0
  99. gaia/agents/summarize/prompts.py +129 -0
  100. gaia/api/__init__.py +23 -23
  101. gaia/api/agent_registry.py +238 -238
  102. gaia/api/app.py +305 -305
  103. gaia/api/openai_server.py +575 -575
  104. gaia/api/schemas.py +186 -186
  105. gaia/api/sse_handler.py +373 -373
  106. gaia/apps/__init__.py +4 -4
  107. gaia/apps/llm/__init__.py +6 -6
  108. gaia/apps/llm/app.py +184 -169
  109. gaia/apps/summarize/app.py +116 -633
  110. gaia/apps/summarize/html_viewer.py +133 -133
  111. gaia/apps/summarize/pdf_formatter.py +284 -284
  112. gaia/audio/__init__.py +2 -2
  113. gaia/audio/audio_client.py +439 -439
  114. gaia/audio/audio_recorder.py +269 -269
  115. gaia/audio/kokoro_tts.py +599 -599
  116. gaia/audio/whisper_asr.py +432 -432
  117. gaia/chat/__init__.py +16 -16
  118. gaia/chat/app.py +428 -430
  119. gaia/chat/prompts.py +522 -522
  120. gaia/chat/sdk.py +1228 -1225
  121. gaia/cli.py +5659 -5632
  122. gaia/database/__init__.py +10 -10
  123. gaia/database/agent.py +176 -176
  124. gaia/database/mixin.py +290 -290
  125. gaia/database/testing.py +64 -64
  126. gaia/eval/batch_experiment.py +2332 -2332
  127. gaia/eval/claude.py +542 -542
  128. gaia/eval/config.py +37 -37
  129. gaia/eval/email_generator.py +512 -512
  130. gaia/eval/eval.py +3179 -3179
  131. gaia/eval/groundtruth.py +1130 -1130
  132. gaia/eval/transcript_generator.py +582 -582
  133. gaia/eval/webapp/README.md +167 -167
  134. gaia/eval/webapp/package-lock.json +875 -875
  135. gaia/eval/webapp/package.json +20 -20
  136. gaia/eval/webapp/public/app.js +3402 -3402
  137. gaia/eval/webapp/public/index.html +87 -87
  138. gaia/eval/webapp/public/styles.css +3661 -3661
  139. gaia/eval/webapp/server.js +415 -415
  140. gaia/eval/webapp/test-setup.js +72 -72
  141. gaia/installer/__init__.py +23 -0
  142. gaia/installer/init_command.py +1275 -0
  143. gaia/installer/lemonade_installer.py +619 -0
  144. gaia/llm/__init__.py +10 -2
  145. gaia/llm/base_client.py +60 -0
  146. gaia/llm/exceptions.py +12 -0
  147. gaia/llm/factory.py +70 -0
  148. gaia/llm/lemonade_client.py +3421 -3221
  149. gaia/llm/lemonade_manager.py +294 -294
  150. gaia/llm/providers/__init__.py +9 -0
  151. gaia/llm/providers/claude.py +108 -0
  152. gaia/llm/providers/lemonade.py +118 -0
  153. gaia/llm/providers/openai_provider.py +79 -0
  154. gaia/llm/vlm_client.py +382 -382
  155. gaia/logger.py +189 -189
  156. gaia/mcp/agent_mcp_server.py +245 -245
  157. gaia/mcp/blender_mcp_client.py +138 -138
  158. gaia/mcp/blender_mcp_server.py +648 -648
  159. gaia/mcp/context7_cache.py +332 -332
  160. gaia/mcp/external_services.py +518 -518
  161. gaia/mcp/mcp_bridge.py +811 -550
  162. gaia/mcp/servers/__init__.py +6 -6
  163. gaia/mcp/servers/docker_mcp.py +83 -83
  164. gaia/perf_analysis.py +361 -0
  165. gaia/rag/__init__.py +10 -10
  166. gaia/rag/app.py +293 -293
  167. gaia/rag/demo.py +304 -304
  168. gaia/rag/pdf_utils.py +235 -235
  169. gaia/rag/sdk.py +2194 -2194
  170. gaia/security.py +183 -163
  171. gaia/talk/app.py +287 -289
  172. gaia/talk/sdk.py +538 -538
  173. gaia/testing/__init__.py +87 -87
  174. gaia/testing/assertions.py +330 -330
  175. gaia/testing/fixtures.py +333 -333
  176. gaia/testing/mocks.py +493 -493
  177. gaia/util.py +46 -46
  178. gaia/utils/__init__.py +33 -33
  179. gaia/utils/file_watcher.py +675 -675
  180. gaia/utils/parsing.py +223 -223
  181. gaia/version.py +100 -100
  182. amd_gaia-0.15.0.dist-info/RECORD +0 -168
  183. gaia/agents/code/app.py +0 -266
  184. gaia/llm/llm_client.py +0 -723
  185. {amd_gaia-0.15.0.dist-info → amd_gaia-0.15.2.dist-info}/top_level.txt +0 -0
@@ -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")