shellmate-cli 3.0.0__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 (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,175 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ import warnings
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ warnings.warn(
8
+ "agents.context_gatherer is deprecated. Use orchestration.analysis_graph instead.",
9
+ DeprecationWarning,
10
+ stacklevel=2,
11
+ )
12
+
13
+ from shellmate.agents.base import AgentResponse, get_agent_manager
14
+ from shellmate.agents.watcher import get_watcher_agent
15
+ from shellmate.agents.librarian import get_librarian_agent
16
+ from shellmate.parser.command_parser import parse_command
17
+ from shellmate.config.secrets import redact_dict
18
+
19
+ logger = logging.getLogger("shellmate.context_gatherer")
20
+
21
+ CONTEXT_TIMEOUT = 0.5 # 500ms hard cutoff
22
+
23
+ FILE_REF_PATTERN = ["./", "../", "/", ".\\", "..\\"]
24
+ CATEGORY_NEEDS_LIBRARIAN = {
25
+ "file_ops", "file", "fs", "rm", "mv", "cp", "cat", "less", "more",
26
+ "head", "tail", "nano", "vim", "vi", "code", "explorer",
27
+ "open", "xdg-open", "start",
28
+ }
29
+
30
+
31
+ class ContextGatherer:
32
+ def __init__(self):
33
+ self._agent_manager = get_agent_manager()
34
+
35
+ async def gather(self, command: str) -> Dict[str, Any]:
36
+ start = time.monotonic()
37
+ result: Dict[str, Any] = {
38
+ "watcher": {},
39
+ "librarian": {"matches": []},
40
+ "timed_out": [],
41
+ "errors": {},
42
+ "partial": False,
43
+ "latency_ms": 0.0,
44
+ "agent_latency": {},
45
+ }
46
+
47
+ needs_watcher = True
48
+ needs_librarian = self._needs_librarian(command)
49
+
50
+ tasks = {}
51
+ if needs_watcher:
52
+ tasks["watcher"] = asyncio.create_task(
53
+ self._query_watcher(command)
54
+ )
55
+ if needs_librarian:
56
+ tasks["librarian"] = asyncio.create_task(
57
+ self._query_librarian(command)
58
+ )
59
+
60
+ if not tasks:
61
+ result["latency_ms"] = 0.0
62
+ return result
63
+
64
+ remaining = CONTEXT_TIMEOUT - (time.monotonic() - start)
65
+ if remaining <= 0:
66
+ for name in tasks:
67
+ result["timed_out"].append(name)
68
+ result["partial"] = True
69
+ result["latency_ms"] = (time.monotonic() - start) * 1000
70
+ return result
71
+
72
+ done, pending = await asyncio.wait(
73
+ tasks.values(),
74
+ timeout=max(remaining, 0.001),
75
+ return_when=asyncio.ALL_COMPLETED,
76
+ )
77
+
78
+ for name, task in tasks.items():
79
+ if task in pending:
80
+ task.cancel()
81
+ result["timed_out"].append(name)
82
+ result["partial"] = True
83
+ continue
84
+
85
+ try:
86
+ agent_result = await task
87
+ if name == "watcher":
88
+ result["watcher"] = agent_result or {}
89
+ result["agent_latency"]["watcher"] = agent_result.get("_latency_ms", 0) if agent_result else 0
90
+ elif name == "librarian":
91
+ result["librarian"] = agent_result or {"matches": []}
92
+ result["agent_latency"]["librarian"] = agent_result.get("_latency_ms", 0) if agent_result else 0
93
+ except asyncio.CancelledError:
94
+ result["timed_out"].append(name)
95
+ result["partial"] = True
96
+ except Exception as e:
97
+ result["errors"][name] = str(e)
98
+ result["partial"] = True
99
+ logger.warning(f"Context gatherer error for {name}: {e}")
100
+
101
+ result["latency_ms"] = round((time.monotonic() - start) * 1000, 1)
102
+ return result
103
+
104
+ async def _query_watcher(self, command: str) -> Dict[str, Any]:
105
+ start = time.monotonic()
106
+ try:
107
+ watcher = get_watcher_agent()
108
+ if watcher.status.value != "ready":
109
+ return {}
110
+ response = await watcher.send_request(
111
+ "get_context",
112
+ timeout=CONTEXT_TIMEOUT,
113
+ )
114
+ elapsed = (time.monotonic() - start) * 1000
115
+ if response.status == "ok":
116
+ result = redact_dict(response.data)
117
+ result["_latency_ms"] = round(elapsed, 1)
118
+ return result
119
+ return {}
120
+ except Exception as e:
121
+ logger.debug(f"Watcher query failed: {e}")
122
+ return {}
123
+
124
+ async def _query_librarian(self, command: str) -> Dict[str, Any]:
125
+ start = time.monotonic()
126
+ try:
127
+ librarian = get_librarian_agent()
128
+ if not librarian.is_indexed:
129
+ return {"matches": []}
130
+ response = await librarian.send_request(
131
+ "search",
132
+ data={"query": command},
133
+ timeout=CONTEXT_TIMEOUT,
134
+ )
135
+ elapsed = (time.monotonic() - start) * 1000
136
+ if response.status == "ok":
137
+ data = response.data
138
+ data["_latency_ms"] = round(elapsed, 1)
139
+ return data
140
+ return {"matches": []}
141
+ except Exception as e:
142
+ logger.debug(f"Librarian query failed: {e}")
143
+ return {"matches": []}
144
+
145
+ def _needs_librarian(self, command: str) -> bool:
146
+ tokens = command.split()
147
+ if not tokens:
148
+ return False
149
+
150
+ cmd = tokens[0].lower()
151
+ if cmd in CATEGORY_NEEDS_LIBRARIAN:
152
+ return True
153
+
154
+ for token in tokens:
155
+ for prefix in FILE_REF_PATTERN:
156
+ if token.startswith(prefix) and len(token) > len(prefix):
157
+ return True
158
+
159
+ parsed = parse_command(command)
160
+ if isinstance(parsed, dict):
161
+ cmd_type = parsed.get("command_type", "")
162
+ if cmd_type in ("file_ops", "file_access"):
163
+ return True
164
+
165
+ return False
166
+
167
+
168
+ _context_gatherer: Optional[ContextGatherer] = None
169
+
170
+
171
+ def get_context_gatherer() -> ContextGatherer:
172
+ global _context_gatherer
173
+ if _context_gatherer is None:
174
+ _context_gatherer = ContextGatherer()
175
+ return _context_gatherer
@@ -0,0 +1,363 @@
1
+ """
2
+ EnvironmentAgent: Detects execution environment context for risk assessment.
3
+
4
+ Provides environment type, time context, blast radius, and resource constraints
5
+ to inform risk calculations.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from datetime import datetime, timedelta
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Optional
18
+ from zoneinfo import ZoneInfo
19
+
20
+ from shellmate.agents.base import Agent, AgentRequest, AgentResponse
21
+ from shellmate.config.defaults import get_default_config
22
+ from shellmate.daemon.session import get_environment_cache_file
23
+
24
+
25
+ class EnvironmentAgent(Agent):
26
+ """Detects execution environment context for risk assessment."""
27
+
28
+ def __init__(self):
29
+ super().__init__(name="environment")
30
+ self._cache: Optional[Dict[str, Any]] = None
31
+ self._cache_time: Optional[float] = None
32
+ self._cache_file = get_environment_cache_file()
33
+
34
+ async def initialize(self) -> None:
35
+ """Initialize the agent and load cache if available."""
36
+ if self._cache_file.exists():
37
+ try:
38
+ with open(self._cache_file, "r", encoding="utf-8") as f:
39
+ cached_data = json.load(f)
40
+ self._cache = cached_data.get("context")
41
+ self._cache_time = cached_data.get("time")
42
+ except (json.JSONDecodeError, OSError):
43
+ pass
44
+
45
+ async def shutdown(self) -> None:
46
+ """Shutdown the agent and save cache."""
47
+ if self._cache and self._cache_time:
48
+ try:
49
+ with open(self._cache_file, "w", encoding="utf-8") as f:
50
+ json.dump({"context": self._cache, "time": self._cache_time}, f)
51
+ except OSError:
52
+ pass
53
+
54
+ async def process(self, request: AgentRequest) -> AgentResponse:
55
+ """Process agent requests by dispatching to appropriate methods."""
56
+ if request.command == "detect_context":
57
+ start_time = time.time()
58
+ try:
59
+ data = await self.detect_context(request.data.get("overrides", {}))
60
+ latency = (time.time() - start_time) * 1000
61
+ return AgentResponse(
62
+ request.request_id,
63
+ self.name,
64
+ status="ok",
65
+ data=data,
66
+ latency_ms=latency,
67
+ )
68
+ except Exception as e:
69
+ latency = (time.time() - start_time) * 1000
70
+ return AgentResponse(
71
+ request.request_id,
72
+ self.name,
73
+ status="error",
74
+ error=str(e),
75
+ latency_ms=latency,
76
+ )
77
+ return AgentResponse(
78
+ request.request_id,
79
+ self.name,
80
+ status="error",
81
+ error=f"Unknown command: {request.command}",
82
+ )
83
+
84
+ async def detect_context(
85
+ self, overrides: Optional[Dict[str, Any]] = None
86
+ ) -> Dict[str, Any]:
87
+ """
88
+ Detect environment context with caching.
89
+
90
+ Args:
91
+ overrides: User-provided override values (e.g., [@env=production])
92
+
93
+ Returns:
94
+ Dictionary containing environment type, time context, blast radius,
95
+ and resource constraints.
96
+ """
97
+ # Check cache validity
98
+ if self._is_cache_valid():
99
+ context = self._cache.copy()
100
+ else:
101
+ # Detect all context factors
102
+ context = {
103
+ "type": await self._detect_environment_type(),
104
+ "time": await self._detect_time_context(),
105
+ "blast_radius": await self._detect_blast_radius(),
106
+ "resources": await self._detect_resource_constraints(),
107
+ }
108
+ # Cache results
109
+ self._cache_context(context)
110
+
111
+ # Apply user overrides
112
+ if overrides:
113
+ context = self._apply_overrides(context, overrides)
114
+
115
+ return context
116
+
117
+ def _is_cache_valid(self) -> bool:
118
+ """Check if cached context is still valid based on TTL."""
119
+ if not self._cache or not self._cache_time:
120
+ return False
121
+
122
+ config = get_default_config()
123
+ ttl_minutes = (
124
+ config.get("v3", {}).get("environment", {}).get("cache_ttl_minutes", 5)
125
+ )
126
+ ttl_seconds = ttl_minutes * 60
127
+
128
+ return (time.time() - self._cache_time) < ttl_seconds
129
+
130
+ def _cache_context(self, context: Dict[str, Any]) -> None:
131
+ """Cache the detected context with timestamp."""
132
+ self._cache = context
133
+ self._cache_time = time.time()
134
+
135
+ def _apply_overrides(
136
+ self, context: Dict[str, Any], overrides: Dict[str, Any]
137
+ ) -> Dict[str, Any]:
138
+ """Apply user overrides to the detected context."""
139
+ if "env" in overrides:
140
+ context["type"] = overrides["env"]
141
+ if "time" in overrides:
142
+ # Allow overriding time context
143
+ context["time"].update(overrides["time"])
144
+ return context
145
+
146
+ async def _detect_environment_type(self) -> str:
147
+ """
148
+ Detect environment type using multiple heuristics.
149
+
150
+ Returns one of: production, staging, development, ci, container, kubernetes
151
+ """
152
+ # Check file markers
153
+ if Path("/etc/production").exists():
154
+ return "production"
155
+ if Path("/.dockerenv").exists():
156
+ return "container"
157
+
158
+ # Check environment variables
159
+ env_vars = os.environ
160
+ explicit = (
161
+ env_vars.get("SHELLMATE_ENV")
162
+ or env_vars.get("SM_ENV_TYPE")
163
+ or env_vars.get("SM_ENV")
164
+ )
165
+ if explicit:
166
+ normalized = explicit.strip().lower()
167
+ if normalized in {
168
+ "production",
169
+ "staging",
170
+ "development",
171
+ "ci",
172
+ "container",
173
+ "kubernetes",
174
+ }:
175
+ return normalized
176
+
177
+ if "CI" in env_vars or "GITHUB_ACTIONS" in env_vars:
178
+ return "ci"
179
+ if "KUBERNETES_SERVICE_HOST" in env_vars:
180
+ return "kubernetes"
181
+ if "STAGING" in env_vars:
182
+ return "staging"
183
+
184
+ # Check hostname patterns
185
+ try:
186
+ hostname = socket.gethostname().lower()
187
+ if any(pattern in hostname for pattern in ["prod", "-prd", "production"]):
188
+ return "production"
189
+ if "staging" in hostname or "stg" in hostname:
190
+ return "staging"
191
+ except Exception:
192
+ pass
193
+
194
+ # Default to development
195
+ return "development"
196
+
197
+ async def _detect_time_context(self) -> Dict[str, Any]:
198
+ """
199
+ Detect time-related context factors.
200
+
201
+ Returns:
202
+ Dictionary with current_time, hour, is_business_hours, is_weekend, timezone
203
+ """
204
+ config = get_default_config()
205
+ env_cfg = config.get("v3", {}).get("environment", {})
206
+ tz_name = env_cfg.get("timezone", "UTC")
207
+ biz_start = env_cfg.get("business_hours_start", 9)
208
+ biz_end = env_cfg.get("business_hours_end", 17)
209
+
210
+ # Get current time in configured timezone
211
+ try:
212
+ tz = ZoneInfo(tz_name)
213
+ except Exception:
214
+ tz = ZoneInfo("UTC")
215
+
216
+ now = datetime.now(tz)
217
+ hour = now.hour
218
+
219
+ is_business_hours = biz_start <= hour < biz_end and now.weekday() < 5
220
+ return {
221
+ "current_time": now.isoformat(),
222
+ "hour": hour,
223
+ "is_business_hours": is_business_hours,
224
+ "after_hours": not is_business_hours,
225
+ "is_weekend": now.weekday() >= 5,
226
+ "timezone": tz_name,
227
+ }
228
+
229
+ async def _detect_blast_radius(self) -> Dict[str, Any]:
230
+ """
231
+ Detect factors affecting command blast radius.
232
+
233
+ Returns:
234
+ Dictionary with is_multi_user, active_users, critical_services
235
+ """
236
+ blast_radius = {
237
+ "is_multi_user": False,
238
+ "active_users": 0,
239
+ "critical_services": [],
240
+ }
241
+
242
+ try:
243
+ if sys.platform == "win32":
244
+ # Windows detection
245
+ result = subprocess.run(
246
+ ["query", "user"],
247
+ capture_output=True,
248
+ text=True,
249
+ timeout=1,
250
+ )
251
+ if result.returncode == 0:
252
+ # Count lines (users) in output
253
+ lines = [line for line in result.stdout.split("\n") if line.strip()]
254
+ blast_radius["active_users"] = max(0, len(lines) - 1)
255
+ else:
256
+ # Linux/Unix detection
257
+ result = subprocess.run(
258
+ ["who"], capture_output=True, text=True, timeout=1
259
+ )
260
+ if result.returncode == 0:
261
+ blast_radius["active_users"] = len(
262
+ result.stdout.strip().split("\n")
263
+ )
264
+
265
+ blast_radius["is_multi_user"] = blast_radius["active_users"] > 1
266
+
267
+ # Detect critical services (platform-specific)
268
+ blast_radius["critical_services"] = await self._detect_services()
269
+
270
+ except Exception:
271
+ # Fallback to safe defaults
272
+ pass
273
+
274
+ return blast_radius
275
+
276
+ async def _detect_services(self) -> list:
277
+ """Detect critical running services."""
278
+ services = []
279
+ try:
280
+ if sys.platform == "win32":
281
+ # Check for common Windows services
282
+ result = subprocess.run(
283
+ ["sc", "query", "state=", "running"],
284
+ capture_output=True,
285
+ text=True,
286
+ timeout=1,
287
+ )
288
+ if result.returncode == 0:
289
+ # Look for critical service names
290
+ for service in ["Docker", "PostgreSQL", "MySQL", "nginx"]:
291
+ if service.lower() in result.stdout.lower():
292
+ services.append(service.lower())
293
+ else:
294
+ # Check for common Linux/Unix services
295
+ for service in ["nginx", "postgres", "docker", "mysql"]:
296
+ result = subprocess.run(
297
+ ["systemctl", "is-active", service],
298
+ capture_output=True,
299
+ text=True,
300
+ timeout=0.5,
301
+ )
302
+ if result.returncode == 0 and "active" in result.stdout:
303
+ services.append(service)
304
+ except Exception:
305
+ pass
306
+
307
+ return services
308
+
309
+ async def _detect_resource_constraints(self) -> Dict[str, Any]:
310
+ """
311
+ Detect system resource constraints.
312
+
313
+ Returns:
314
+ Dictionary with disk_free_percent, disk_low, memory_used_percent,
315
+ memory_high, cpu_load_percent, cpu_high
316
+ """
317
+ try:
318
+ import psutil
319
+
320
+ # Disk space
321
+ if sys.platform == "win32":
322
+ disk = psutil.disk_usage("C:\\")
323
+ else:
324
+ disk = psutil.disk_usage("/")
325
+ disk_free_percent = (disk.free / disk.total) * 100
326
+
327
+ # Memory
328
+ memory = psutil.virtual_memory()
329
+ memory_used_percent = memory.percent
330
+
331
+ # CPU load (1-minute average)
332
+ cpu_load = psutil.cpu_percent(interval=0.1)
333
+
334
+ return {
335
+ "disk_free_percent": round(disk_free_percent, 1),
336
+ "disk_low": disk_free_percent < 10,
337
+ "memory_used_percent": round(memory_used_percent, 1),
338
+ "memory_high": memory_used_percent > 80,
339
+ "cpu_load_percent": round(cpu_load, 1),
340
+ "cpu_high": cpu_load > 90,
341
+ }
342
+ except Exception:
343
+ # Fallback to safe defaults (no constraints detected)
344
+ return {
345
+ "disk_free_percent": 50.0,
346
+ "disk_low": False,
347
+ "memory_used_percent": 50.0,
348
+ "memory_high": False,
349
+ "cpu_load_percent": 50.0,
350
+ "cpu_high": False,
351
+ }
352
+
353
+
354
+ # Singleton instance
355
+ _environment_agent: Optional[EnvironmentAgent] = None
356
+
357
+
358
+ def get_environment_agent() -> EnvironmentAgent:
359
+ """Get the singleton EnvironmentAgent instance."""
360
+ global _environment_agent
361
+ if _environment_agent is None:
362
+ _environment_agent = EnvironmentAgent()
363
+ return _environment_agent