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