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.
Files changed (42) hide show
  1. mrstack/__init__.py +4 -0
  2. mrstack/_data/config/com.mrstack.claude-telegram.plist +25 -0
  3. mrstack/_data/config/mcp-config.example.json +23 -0
  4. mrstack/_data/config/start-daemon.sh +53 -0
  5. mrstack/_data/config/start.sh +29 -0
  6. mrstack/_data/schedulers/manage-jobs.sh +87 -0
  7. mrstack/_data/schedulers/morning-briefing.sh +29 -0
  8. mrstack/_data/schedulers/register-jobs.py +182 -0
  9. mrstack/_data/schedulers/run-threads-briefing.sh +36 -0
  10. mrstack/_data/schedulers/weekly-review.sh +26 -0
  11. mrstack/_data/templates/DESIGN-GUIDE.md +160 -0
  12. mrstack/_data/templates/alert.md +56 -0
  13. mrstack/_data/templates/evening-summary.md +73 -0
  14. mrstack/_data/templates/jarvis-alert.md +64 -0
  15. mrstack/_data/templates/morning-briefing.md +53 -0
  16. mrstack/_data/templates/weekly-review.md +79 -0
  17. mrstack/_overlay/api/dashboard.py +223 -0
  18. mrstack/_overlay/api/templates/dashboard.html +328 -0
  19. mrstack/_overlay/bot/handlers/callback.py +1432 -0
  20. mrstack/_overlay/bot/handlers/command.py +1541 -0
  21. mrstack/_overlay/bot/utils/keyboards.py +125 -0
  22. mrstack/_overlay/bot/utils/ui_components.py +166 -0
  23. mrstack/_overlay/claude/session.py +341 -0
  24. mrstack/_overlay/jarvis/__init__.py +77 -0
  25. mrstack/_overlay/jarvis/coach.py +122 -0
  26. mrstack/_overlay/jarvis/context_engine.py +463 -0
  27. mrstack/_overlay/jarvis/pattern_learner.py +255 -0
  28. mrstack/_overlay/jarvis/persona.py +84 -0
  29. mrstack/_overlay/jarvis/platform.py +182 -0
  30. mrstack/_overlay/knowledge/__init__.py +6 -0
  31. mrstack/_overlay/knowledge/manager.py +464 -0
  32. mrstack/_overlay/knowledge/memory_index.py +180 -0
  33. mrstack/cli.py +330 -0
  34. mrstack/constants.py +77 -0
  35. mrstack/daemon.py +325 -0
  36. mrstack/patcher.py +169 -0
  37. mrstack/wizard.py +271 -0
  38. mrstack-1.1.0.dist-info/METADATA +640 -0
  39. mrstack-1.1.0.dist-info/RECORD +42 -0
  40. mrstack-1.1.0.dist-info/WHEEL +4 -0
  41. mrstack-1.1.0.dist-info/entry_points.txt +2 -0
  42. 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 ""
@@ -0,0 +1,6 @@
1
+ """Knowledge module — memory indexing and knowledge base management."""
2
+
3
+ from .manager import KnowledgeItem, KnowledgeManager
4
+ from .memory_index import MemoryIndex
5
+
6
+ __all__ = ["MemoryIndex", "KnowledgeManager", "KnowledgeItem"]