|
1 | 1 | """Context engine — polls system state, detects context, evaluates triggers. |
2 | 2 |
|
3 | 3 | Mirrors ClipboardMonitor's asyncio.Task pattern: |
4 | | -- 5-minute polling via subprocess calls (osascript, pmset, sysctl, git, etc.) |
| 4 | +- 5-minute polling via platform-abstracted collectors |
5 | 5 | - State classification (CODING, BROWSING, MEETING, ...) |
6 | 6 | - Rule-based trigger evaluation with cooldowns |
7 | 7 | - Publishes ScheduledEvent to EventBus on trigger fire |
8 | 8 | """ |
9 | 9 |
|
10 | 10 | import asyncio |
11 | 11 | import re |
12 | | -import subprocess |
13 | 12 | import time |
14 | 13 | from collections import deque |
15 | 14 | from typing import Any, Deque, Dict, List, Optional, Tuple |
|
19 | 18 | from ..events.bus import EventBus |
20 | 19 | from ..events.types import ScheduledEvent |
21 | 20 | from .persona import ContextState, PersonaLayer |
| 21 | +from .platform import ( |
| 22 | + FEATURES, |
| 23 | + collect_active_app, |
| 24 | + collect_battery, |
| 25 | + collect_chrome_tabs, |
| 26 | + collect_cpu_load, |
| 27 | + collect_git_info, |
| 28 | +) |
22 | 29 |
|
23 | 30 | logger = structlog.get_logger() |
24 | 31 |
|
@@ -238,71 +245,38 @@ async def _tick(self) -> None: |
238 | 245 | logger.info("Jarvis trigger fired", trigger=trigger_id) |
239 | 246 |
|
240 | 247 | async def _collect_snapshot(self) -> ContextSnapshot: |
241 | | - """Collect system state via parallel subprocess calls.""" |
242 | | - loop = asyncio.get_event_loop() |
| 248 | + """Collect system state via platform-abstracted collectors. |
243 | 249 |
|
244 | | - async def _run(cmd: List[str], timeout: int = 5) -> str: |
245 | | - try: |
246 | | - result = await loop.run_in_executor( |
247 | | - None, |
248 | | - lambda: subprocess.run( |
249 | | - cmd, capture_output=True, text=True, timeout=timeout |
250 | | - ), |
251 | | - ) |
252 | | - return result.stdout.strip() if result.returncode == 0 else "" |
253 | | - except Exception: |
254 | | - return "" |
| 250 | + Uses platform.py for cross-platform support: |
| 251 | + - macOS: osascript, pmset, sysctl (native) |
| 252 | + - Linux: xdotool, psutil (fallback) |
| 253 | + - Windows: psutil only (Jarvis limited) |
| 254 | + """ |
| 255 | + loop = asyncio.get_running_loop() |
| 256 | + |
| 257 | + # Run all collectors in parallel via executor (they do subprocess calls) |
| 258 | + active_app_fut = loop.run_in_executor(None, collect_active_app) |
| 259 | + battery_fut = loop.run_in_executor(None, collect_battery) |
| 260 | + cpu_fut = loop.run_in_executor(None, collect_cpu_load) |
| 261 | + git_fut = loop.run_in_executor( |
| 262 | + None, collect_git_info, self.working_directory |
| 263 | + ) |
| 264 | + chrome_fut = loop.run_in_executor(None, collect_chrome_tabs) |
255 | 265 |
|
256 | | - # Run all collectors in parallel |
257 | 266 | results = await asyncio.gather( |
258 | | - _run([ |
259 | | - "osascript", "-e", |
260 | | - 'tell app "System Events" to get name of first process ' |
261 | | - "whose frontmost is true", |
262 | | - ]), |
263 | | - _run(["pmset", "-g", "batt"]), |
264 | | - _run(["sysctl", "-n", "vm.loadavg"]), |
265 | | - _run([ |
266 | | - "git", "-C", self.working_directory, |
267 | | - "branch", "--show-current", |
268 | | - ]), |
269 | | - _run([ |
270 | | - "git", "-C", self.working_directory, |
271 | | - "status", "--short", |
272 | | - ]), |
273 | | - _run([ |
274 | | - "osascript", "-e", |
275 | | - 'tell application "Google Chrome" to get title of active tab ' |
276 | | - "of front window", |
277 | | - ]), |
| 267 | + active_app_fut, battery_fut, cpu_fut, git_fut, chrome_fut, |
278 | 268 | return_exceptions=True, |
279 | 269 | ) |
280 | 270 |
|
281 | | - # Parse results |
282 | 271 | active_app = results[0] if isinstance(results[0], str) else "" |
283 | | - |
284 | | - battery_pct = 100 |
285 | | - battery_charging = True |
286 | | - batt_str = results[1] if isinstance(results[1], str) else "" |
287 | | - batt_match = re.search(r"(\d+)%", batt_str) |
288 | | - if batt_match: |
289 | | - battery_pct = int(batt_match.group(1)) |
290 | | - battery_charging = "charging" in batt_str.lower() or "charged" in batt_str.lower() |
291 | | - |
292 | | - cpu_load = 0.0 |
293 | | - load_str = results[2] if isinstance(results[2], str) else "" |
294 | | - load_match = re.search(r"[\d.]+", load_str) |
295 | | - if load_match: |
296 | | - try: |
297 | | - cpu_load = float(load_match.group()) |
298 | | - except ValueError: |
299 | | - pass |
300 | | - |
301 | | - git_branch = results[3] if isinstance(results[3], str) else "" |
302 | | - git_dirty = bool(results[4]) if isinstance(results[4], str) else False |
303 | | - |
304 | | - chrome_tab = results[5] if isinstance(results[5], str) else "" |
305 | | - chrome_tabs = [chrome_tab] if chrome_tab else [] |
| 272 | + battery_pct, battery_charging = ( |
| 273 | + results[1] if isinstance(results[1], tuple) else (100, True) |
| 274 | + ) |
| 275 | + cpu_load = results[2] if isinstance(results[2], float) else 0.0 |
| 276 | + git_branch, git_dirty = ( |
| 277 | + results[3] if isinstance(results[3], tuple) else ("", False) |
| 278 | + ) |
| 279 | + chrome_tabs = results[4] if isinstance(results[4], list) else [] |
306 | 280 |
|
307 | 281 | return ContextSnapshot( |
308 | 282 | active_app=active_app, |
|
0 commit comments