mrstack 1.1.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.
- mrstack/__init__.py +4 -0
- mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
- mrstack/_data/config/mcp-config.example.json +23 -0
- mrstack/_data/config/start-daemon.sh +53 -0
- mrstack/_data/config/start.sh +29 -0
- mrstack/_data/schedulers/manage-jobs.sh +87 -0
- mrstack/_data/schedulers/morning-briefing.sh +29 -0
- mrstack/_data/schedulers/register-jobs.py +182 -0
- mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
- mrstack/_data/schedulers/weekly-review.sh +26 -0
- mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
- mrstack/_data/templates/alert.md +56 -0
- mrstack/_data/templates/evening-summary.md +73 -0
- mrstack/_data/templates/jarvis-alert.md +64 -0
- mrstack/_data/templates/morning-briefing.md +53 -0
- mrstack/_data/templates/weekly-review.md +79 -0
- mrstack/_overlay/api/dashboard.py +223 -0
- mrstack/_overlay/api/templates/dashboard.html +328 -0
- mrstack/_overlay/bot/handlers/callback.py +1432 -0
- mrstack/_overlay/bot/handlers/command.py +1541 -0
- mrstack/_overlay/bot/utils/keyboards.py +125 -0
- mrstack/_overlay/bot/utils/ui_components.py +166 -0
- mrstack/_overlay/claude/session.py +341 -0
- mrstack/_overlay/jarvis/__init__.py +77 -0
- mrstack/_overlay/jarvis/coach.py +122 -0
- mrstack/_overlay/jarvis/context_engine.py +463 -0
- mrstack/_overlay/jarvis/pattern_learner.py +255 -0
- mrstack/_overlay/jarvis/persona.py +84 -0
- mrstack/_overlay/jarvis/platform.py +182 -0
- mrstack/_overlay/knowledge/__init__.py +6 -0
- mrstack/_overlay/knowledge/manager.py +464 -0
- mrstack/_overlay/knowledge/memory_index.py +180 -0
- mrstack/cli.py +330 -0
- mrstack/constants.py +77 -0
- mrstack/daemon.py +325 -0
- mrstack/patcher.py +169 -0
- mrstack/wizard.py +271 -0
- mrstack-1.1.0.dist-info/METADATA +640 -0
- mrstack-1.1.0.dist-info/RECORD +42 -0
- mrstack-1.1.0.dist-info/WHEEL +4 -0
- mrstack-1.1.0.dist-info/entry_points.txt +2 -0
- mrstack-1.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Pattern learner — logs interactions and extracts usage patterns.
|
|
2
|
+
|
|
3
|
+
Records every bot interaction to interactions.jsonl, then provides
|
|
4
|
+
pattern extraction for coaching and preemptive action triggers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from collections import Counter, defaultdict
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
from .persona import ContextState
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
# Request type classification patterns
|
|
21
|
+
_TYPE_PATTERNS: list[tuple[str, list[re.Pattern[str]]]] = [
|
|
22
|
+
(
|
|
23
|
+
"debug",
|
|
24
|
+
[
|
|
25
|
+
re.compile(r"(에러|error|bug|fix|왜.*안|traceback|오류|디버그)", re.I),
|
|
26
|
+
re.compile(r"(안\s*[되됨]|문제|crash|fail)", re.I),
|
|
27
|
+
],
|
|
28
|
+
),
|
|
29
|
+
(
|
|
30
|
+
"feature",
|
|
31
|
+
[
|
|
32
|
+
re.compile(r"(만들어|추가|구현|implement|add|create|build)", re.I),
|
|
33
|
+
re.compile(r"(기능|feature|새로)", re.I),
|
|
34
|
+
],
|
|
35
|
+
),
|
|
36
|
+
(
|
|
37
|
+
"question",
|
|
38
|
+
[
|
|
39
|
+
re.compile(r"(뭐야|무엇|어떻게|왜|what|how|why|explain|설명)", re.I),
|
|
40
|
+
re.compile(r"\?$"),
|
|
41
|
+
],
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"brainstorm",
|
|
45
|
+
[
|
|
46
|
+
re.compile(r"(아이디어|설계|design|plan|구조|architecture|방법)", re.I),
|
|
47
|
+
re.compile(r"(어떤.*좋|제안|suggest|recommend)", re.I),
|
|
48
|
+
],
|
|
49
|
+
),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
DEFAULT_TYPE = "admin"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PatternLearner:
|
|
56
|
+
"""Logs interactions and extracts behavioral patterns."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, memory_base: str | Path) -> None:
|
|
59
|
+
self.memory_base = Path(memory_base)
|
|
60
|
+
self.patterns_dir = self.memory_base / "patterns"
|
|
61
|
+
self.patterns_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
self.interactions_path = self.patterns_dir / "interactions.jsonl"
|
|
63
|
+
self.routines_path = self.patterns_dir / "routines.json"
|
|
64
|
+
|
|
65
|
+
def log_interaction(
|
|
66
|
+
self,
|
|
67
|
+
user_id: int,
|
|
68
|
+
prompt: str,
|
|
69
|
+
response: str,
|
|
70
|
+
duration_ms: int,
|
|
71
|
+
tools_used: list[str] | None = None,
|
|
72
|
+
state: ContextState | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Append an interaction record to interactions.jsonl."""
|
|
75
|
+
now = datetime.now()
|
|
76
|
+
record = {
|
|
77
|
+
"ts": now.isoformat(),
|
|
78
|
+
"dow": now.strftime("%a").lower(),
|
|
79
|
+
"hour": now.hour,
|
|
80
|
+
"user_id": user_id,
|
|
81
|
+
"state": state.value if state else "UNKNOWN",
|
|
82
|
+
"request_type": self._classify_request(prompt),
|
|
83
|
+
"prompt_length": len(prompt),
|
|
84
|
+
"response_length": len(response),
|
|
85
|
+
"duration_ms": duration_ms,
|
|
86
|
+
"tools_used": tools_used or [],
|
|
87
|
+
}
|
|
88
|
+
try:
|
|
89
|
+
with open(self.interactions_path, "a", encoding="utf-8") as f:
|
|
90
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
91
|
+
except Exception:
|
|
92
|
+
logger.exception("Failed to log interaction")
|
|
93
|
+
|
|
94
|
+
def _classify_request(self, prompt: str) -> str:
|
|
95
|
+
"""Classify a user prompt into a request type."""
|
|
96
|
+
for rtype, patterns in _TYPE_PATTERNS:
|
|
97
|
+
for pat in patterns:
|
|
98
|
+
if pat.search(prompt):
|
|
99
|
+
return rtype
|
|
100
|
+
return DEFAULT_TYPE
|
|
101
|
+
|
|
102
|
+
def extract_patterns(self, days: int = 7) -> dict[str, Any]:
|
|
103
|
+
"""Extract usage patterns from recent interactions.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Dict with hourly_counts, peak_hours, request_types, avg_duration.
|
|
107
|
+
"""
|
|
108
|
+
cutoff = datetime.now() - timedelta(days=days)
|
|
109
|
+
records = self._load_records(cutoff)
|
|
110
|
+
|
|
111
|
+
if not records:
|
|
112
|
+
return {
|
|
113
|
+
"hourly_counts": {},
|
|
114
|
+
"peak_hours": [],
|
|
115
|
+
"request_types": {},
|
|
116
|
+
"avg_duration": 0,
|
|
117
|
+
"total": 0,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
hourly: Counter[int] = Counter()
|
|
121
|
+
types: Counter[str] = Counter()
|
|
122
|
+
durations: list[int] = []
|
|
123
|
+
|
|
124
|
+
for rec in records:
|
|
125
|
+
hourly[rec.get("hour", 0)] += 1
|
|
126
|
+
types[rec.get("request_type", DEFAULT_TYPE)] += 1
|
|
127
|
+
if "duration_ms" in rec:
|
|
128
|
+
durations.append(rec["duration_ms"])
|
|
129
|
+
|
|
130
|
+
sorted_hours = sorted(hourly.items(), key=lambda x: x[1], reverse=True)
|
|
131
|
+
peak_hours = [h for h, _ in sorted_hours[:4]]
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"hourly_counts": dict(hourly),
|
|
135
|
+
"peak_hours": peak_hours,
|
|
136
|
+
"request_types": dict(types),
|
|
137
|
+
"avg_duration": int(sum(durations) / len(durations)) if durations else 0,
|
|
138
|
+
"total": len(records),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def update_routines(self, days: int = 14) -> None:
|
|
142
|
+
"""Analyze interactions and auto-generate routines.json.
|
|
143
|
+
|
|
144
|
+
Detects recurring (day_of_week, hour, request_type) combos.
|
|
145
|
+
Called by memory-sync or a dedicated scheduled job.
|
|
146
|
+
"""
|
|
147
|
+
records = self._load_records(
|
|
148
|
+
cutoff=datetime.now() - timedelta(days=days)
|
|
149
|
+
)
|
|
150
|
+
if len(records) < 20:
|
|
151
|
+
return # Not enough data
|
|
152
|
+
|
|
153
|
+
# Count (dow, hour, type) triples
|
|
154
|
+
combos: Counter[tuple[str, int, str]] = Counter()
|
|
155
|
+
dow_hour_total: Counter[tuple[str, int]] = Counter()
|
|
156
|
+
|
|
157
|
+
for rec in records:
|
|
158
|
+
dow = rec.get("dow", "")
|
|
159
|
+
hour = rec.get("hour", 0)
|
|
160
|
+
rtype = rec.get("request_type", DEFAULT_TYPE)
|
|
161
|
+
combos[(dow, hour, rtype)] += 1
|
|
162
|
+
dow_hour_total[(dow, hour)] += 1
|
|
163
|
+
|
|
164
|
+
# How many unique weeks in the data?
|
|
165
|
+
weeks = max(1, days // 7)
|
|
166
|
+
|
|
167
|
+
routines = []
|
|
168
|
+
for (dow, hour, rtype), count in combos.most_common(20):
|
|
169
|
+
# confidence = occurrences / weeks (capped at 1.0)
|
|
170
|
+
confidence = round(min(count / weeks, 1.0), 2)
|
|
171
|
+
if confidence < 0.5:
|
|
172
|
+
continue
|
|
173
|
+
routines.append({
|
|
174
|
+
"pattern": f"{dow} {hour}:00",
|
|
175
|
+
"request_type": rtype,
|
|
176
|
+
"confidence": confidence,
|
|
177
|
+
"count": count,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
if not routines:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
self.routines_path.write_text(
|
|
185
|
+
json.dumps({"routines": routines, "updated": datetime.now().isoformat()},
|
|
186
|
+
ensure_ascii=False, indent=2),
|
|
187
|
+
encoding="utf-8",
|
|
188
|
+
)
|
|
189
|
+
logger.info("Routines updated", count=len(routines))
|
|
190
|
+
except Exception:
|
|
191
|
+
logger.exception("Failed to write routines.json")
|
|
192
|
+
|
|
193
|
+
def check_preemptive(
|
|
194
|
+
self, state: ContextState, hour: int
|
|
195
|
+
) -> Optional[dict[str, Any]]:
|
|
196
|
+
"""Check if a preemptive action should trigger based on learned routines.
|
|
197
|
+
|
|
198
|
+
Returns matching routine dict if confidence > 0.7, else None.
|
|
199
|
+
"""
|
|
200
|
+
if not self.routines_path.exists():
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
data = json.loads(self.routines_path.read_text(encoding="utf-8"))
|
|
205
|
+
except Exception:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
dow = datetime.now().strftime("%a").lower()
|
|
209
|
+
|
|
210
|
+
for routine in data.get("routines", []):
|
|
211
|
+
confidence = routine.get("confidence", 0)
|
|
212
|
+
if confidence < 0.7:
|
|
213
|
+
continue
|
|
214
|
+
pattern = routine.get("pattern", "")
|
|
215
|
+
# Simple matching: check if day-of-week or hour appears in pattern
|
|
216
|
+
if dow in pattern.lower() or str(hour) in pattern:
|
|
217
|
+
return routine
|
|
218
|
+
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def _load_records(
|
|
222
|
+
self, cutoff: Optional[datetime] = None
|
|
223
|
+
) -> list[dict[str, Any]]:
|
|
224
|
+
"""Load interaction records, optionally filtering by cutoff date."""
|
|
225
|
+
if not self.interactions_path.exists():
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
records: list[dict[str, Any]] = []
|
|
229
|
+
try:
|
|
230
|
+
with open(self.interactions_path, "r", encoding="utf-8") as f:
|
|
231
|
+
for line in f:
|
|
232
|
+
line = line.strip()
|
|
233
|
+
if not line:
|
|
234
|
+
continue
|
|
235
|
+
try:
|
|
236
|
+
rec = json.loads(line)
|
|
237
|
+
except json.JSONDecodeError:
|
|
238
|
+
continue
|
|
239
|
+
if cutoff:
|
|
240
|
+
ts = rec.get("ts", "")
|
|
241
|
+
try:
|
|
242
|
+
if datetime.fromisoformat(ts) < cutoff:
|
|
243
|
+
continue
|
|
244
|
+
except ValueError:
|
|
245
|
+
continue
|
|
246
|
+
records.append(rec)
|
|
247
|
+
except Exception:
|
|
248
|
+
logger.exception("Failed to load interaction records")
|
|
249
|
+
|
|
250
|
+
return records
|
|
251
|
+
|
|
252
|
+
def get_today_records(self) -> list[dict[str, Any]]:
|
|
253
|
+
"""Get all interaction records from today."""
|
|
254
|
+
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
255
|
+
return self._load_records(cutoff=today_start)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Persona layer — context-aware tone and style for Jarvis messages.
|
|
2
|
+
|
|
3
|
+
Maps (ContextState, hour, activity_duration) to a prompt prefix
|
|
4
|
+
that shapes Claude's response tone.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContextState(str, Enum):
|
|
11
|
+
CODING = "CODING"
|
|
12
|
+
BROWSING = "BROWSING"
|
|
13
|
+
MEETING = "MEETING"
|
|
14
|
+
COMMUNICATION = "COMMUNICATION"
|
|
15
|
+
BREAK = "BREAK"
|
|
16
|
+
DEEP_WORK = "DEEP_WORK"
|
|
17
|
+
AWAY = "AWAY"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PersonaLayer:
|
|
21
|
+
"""Builds a system prompt prefix based on current context."""
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def build_prompt_prefix(
|
|
25
|
+
state: ContextState,
|
|
26
|
+
hour: int,
|
|
27
|
+
activity_duration_min: int = 0,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Return a tone instruction string for Claude.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
state: Current detected user state.
|
|
33
|
+
hour: Current hour (0-23).
|
|
34
|
+
activity_duration_min: Minutes spent in current state.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A short instruction string to prepend to Claude prompts.
|
|
38
|
+
"""
|
|
39
|
+
parts: list[str] = ["[Jarvis]"]
|
|
40
|
+
|
|
41
|
+
# Late night concern (22:00+)
|
|
42
|
+
if hour >= 22:
|
|
43
|
+
parts.append(
|
|
44
|
+
"사용자가 야간에 작업 중입니다. "
|
|
45
|
+
"걱정하는 톤으로, 마무리를 권유하세요. 간결하게."
|
|
46
|
+
)
|
|
47
|
+
return " ".join(parts)
|
|
48
|
+
|
|
49
|
+
# State-specific tone rules
|
|
50
|
+
if state == ContextState.DEEP_WORK:
|
|
51
|
+
parts.append(
|
|
52
|
+
"사용자가 딥워크 중입니다. "
|
|
53
|
+
"최소한의 응답만 하세요. 불필요한 말 금지."
|
|
54
|
+
)
|
|
55
|
+
elif state == ContextState.CODING:
|
|
56
|
+
parts.append(
|
|
57
|
+
"사용자가 코딩 중입니다. "
|
|
58
|
+
"기술적이고 간결하게 응답하세요. 파일명:라인 형식 선호."
|
|
59
|
+
)
|
|
60
|
+
if activity_duration_min >= 180:
|
|
61
|
+
parts.append(
|
|
62
|
+
f"({activity_duration_min}분째 코딩 중 — 휴식 권유 한마디 추가)"
|
|
63
|
+
)
|
|
64
|
+
elif state == ContextState.BREAK:
|
|
65
|
+
parts.append(
|
|
66
|
+
"사용자가 휴식에서 돌아왔습니다. "
|
|
67
|
+
"따뜻하고 간결하게 응답하세요."
|
|
68
|
+
)
|
|
69
|
+
elif state == ContextState.MEETING:
|
|
70
|
+
parts.append(
|
|
71
|
+
"사용자가 미팅 중이거나 직후입니다. "
|
|
72
|
+
"단답형으로 핵심만 전달하세요."
|
|
73
|
+
)
|
|
74
|
+
elif state == ContextState.BROWSING:
|
|
75
|
+
parts.append("사용자가 브라우징 중입니다. 간결하게 응답하세요.")
|
|
76
|
+
elif state == ContextState.COMMUNICATION:
|
|
77
|
+
parts.append("사용자가 커뮤니케이션 중입니다. 간결하게 응답하세요.")
|
|
78
|
+
elif state == ContextState.AWAY:
|
|
79
|
+
parts.append(
|
|
80
|
+
"사용자가 자리를 비운 상태입니다. "
|
|
81
|
+
"돌아오면 알아볼 수 있게 핵심 요약으로 응답하세요."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return " ".join(parts)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Platform abstraction layer for Jarvis context engine.
|
|
2
|
+
|
|
3
|
+
Detects the current platform and provides cross-platform alternatives
|
|
4
|
+
for system monitoring APIs (active app, battery, CPU, etc.).
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from .platform import PLATFORM, can, collect_battery, collect_cpu_load
|
|
8
|
+
|
|
9
|
+
if can("active_app"):
|
|
10
|
+
app = collect_active_app()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import List, Optional
|
|
20
|
+
|
|
21
|
+
import structlog
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger()
|
|
24
|
+
|
|
25
|
+
# ── Platform detection ─────────────────────────────────
|
|
26
|
+
PLATFORM: str = sys.platform # 'darwin', 'linux', 'win32'
|
|
27
|
+
IS_MACOS = PLATFORM == "darwin"
|
|
28
|
+
IS_LINUX = PLATFORM == "linux"
|
|
29
|
+
IS_WINDOWS = PLATFORM == "win32"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PlatformFeatures:
|
|
34
|
+
"""Which features are available on this platform."""
|
|
35
|
+
|
|
36
|
+
active_app: bool = False
|
|
37
|
+
battery: bool = False
|
|
38
|
+
cpu_load: bool = True # psutil works everywhere
|
|
39
|
+
chrome_tabs: bool = False
|
|
40
|
+
sleep_prevent: bool = False
|
|
41
|
+
keychain: bool = True # keyring library is cross-platform
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Build feature map at import time
|
|
45
|
+
def _detect_features() -> PlatformFeatures:
|
|
46
|
+
f = PlatformFeatures()
|
|
47
|
+
if IS_MACOS:
|
|
48
|
+
f.active_app = shutil.which("osascript") is not None
|
|
49
|
+
f.battery = shutil.which("pmset") is not None
|
|
50
|
+
f.chrome_tabs = f.active_app
|
|
51
|
+
f.sleep_prevent = shutil.which("caffeinate") is not None
|
|
52
|
+
elif IS_LINUX:
|
|
53
|
+
# xdotool or xprop can detect active window on X11
|
|
54
|
+
f.active_app = shutil.which("xdotool") is not None
|
|
55
|
+
f.battery = _has_psutil_battery()
|
|
56
|
+
elif IS_WINDOWS:
|
|
57
|
+
f.battery = _has_psutil_battery()
|
|
58
|
+
return f
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _has_psutil_battery() -> bool:
|
|
62
|
+
try:
|
|
63
|
+
import psutil
|
|
64
|
+
|
|
65
|
+
return psutil.sensors_battery() is not None
|
|
66
|
+
except Exception:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
FEATURES = _detect_features()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def can(feature: str) -> bool:
|
|
74
|
+
"""Check if a feature is available on this platform."""
|
|
75
|
+
return getattr(FEATURES, feature, False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Cross-platform collectors ──────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def collect_active_app() -> str:
|
|
82
|
+
"""Return the name of the frontmost application."""
|
|
83
|
+
if IS_MACOS:
|
|
84
|
+
return _run_cmd([
|
|
85
|
+
"osascript", "-e",
|
|
86
|
+
'tell app "System Events" to get name of first process '
|
|
87
|
+
"whose frontmost is true",
|
|
88
|
+
])
|
|
89
|
+
if IS_LINUX:
|
|
90
|
+
# xdotool approach
|
|
91
|
+
wid = _run_cmd(["xdotool", "getactivewindow"])
|
|
92
|
+
if wid:
|
|
93
|
+
name = _run_cmd(["xdotool", "getactivewindow", "getwindowclassname"])
|
|
94
|
+
return name
|
|
95
|
+
# Windows / unsupported
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def collect_battery() -> tuple[int, bool]:
|
|
100
|
+
"""Return (battery_percent, is_charging).
|
|
101
|
+
|
|
102
|
+
Returns (100, True) if battery info is unavailable.
|
|
103
|
+
"""
|
|
104
|
+
if IS_MACOS:
|
|
105
|
+
import re
|
|
106
|
+
|
|
107
|
+
out = _run_cmd(["pmset", "-g", "batt"])
|
|
108
|
+
if out:
|
|
109
|
+
m = re.search(r"(\d+)%", out)
|
|
110
|
+
pct = int(m.group(1)) if m else 100
|
|
111
|
+
charging = "charging" in out.lower() or "charged" in out.lower()
|
|
112
|
+
return pct, charging
|
|
113
|
+
|
|
114
|
+
# Cross-platform fallback via psutil
|
|
115
|
+
try:
|
|
116
|
+
import psutil
|
|
117
|
+
|
|
118
|
+
batt = psutil.sensors_battery()
|
|
119
|
+
if batt:
|
|
120
|
+
return int(batt.percent), batt.power_plugged or False
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return 100, True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def collect_cpu_load() -> float:
|
|
128
|
+
"""Return 1-minute load average (or CPU percent on Windows)."""
|
|
129
|
+
if IS_MACOS:
|
|
130
|
+
import re
|
|
131
|
+
|
|
132
|
+
out = _run_cmd(["sysctl", "-n", "vm.loadavg"])
|
|
133
|
+
if out:
|
|
134
|
+
m = re.search(r"[\d.]+", out)
|
|
135
|
+
if m:
|
|
136
|
+
try:
|
|
137
|
+
return float(m.group())
|
|
138
|
+
except ValueError:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# Cross-platform via psutil
|
|
142
|
+
try:
|
|
143
|
+
import psutil
|
|
144
|
+
|
|
145
|
+
if hasattr(psutil, "getloadavg"):
|
|
146
|
+
return psutil.getloadavg()[0]
|
|
147
|
+
return psutil.cpu_percent(interval=1) / 100.0
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
return 0.0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def collect_chrome_tabs() -> List[str]:
|
|
155
|
+
"""Return list of Chrome tab titles (macOS only)."""
|
|
156
|
+
if IS_MACOS:
|
|
157
|
+
out = _run_cmd([
|
|
158
|
+
"osascript", "-e",
|
|
159
|
+
'tell application "Google Chrome" to get title of active tab '
|
|
160
|
+
"of front window",
|
|
161
|
+
])
|
|
162
|
+
if out:
|
|
163
|
+
return [out]
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def collect_git_info(working_directory: str) -> tuple[str, bool]:
|
|
168
|
+
"""Return (branch_name, is_dirty)."""
|
|
169
|
+
branch = _run_cmd(["git", "-C", working_directory, "branch", "--show-current"])
|
|
170
|
+
dirty_out = _run_cmd(["git", "-C", working_directory, "status", "--short"])
|
|
171
|
+
return branch, bool(dirty_out)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Helper ─────────────────────────────────────────────
|
|
175
|
+
def _run_cmd(cmd: list[str], timeout: int = 5) -> str:
|
|
176
|
+
try:
|
|
177
|
+
result = subprocess.run(
|
|
178
|
+
cmd, capture_output=True, text=True, timeout=timeout,
|
|
179
|
+
)
|
|
180
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
181
|
+
except Exception:
|
|
182
|
+
return ""
|