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,77 @@
|
|
|
1
|
+
"""Jarvis mode — proactive context-aware AI partner.
|
|
2
|
+
|
|
3
|
+
JarvisEngine is the facade that composes:
|
|
4
|
+
- ContextEngine: system state polling + trigger evaluation
|
|
5
|
+
- PatternLearner: interaction logging + pattern extraction
|
|
6
|
+
- DailyCoach: productivity coaching reports
|
|
7
|
+
- PersonaLayer: context-aware tone injection
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from ..events.bus import EventBus
|
|
16
|
+
from .coach import DailyCoach
|
|
17
|
+
from .context_engine import ContextEngine
|
|
18
|
+
from .pattern_learner import PatternLearner
|
|
19
|
+
from .persona import ContextState, PersonaLayer
|
|
20
|
+
|
|
21
|
+
logger = structlog.get_logger()
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"JarvisEngine",
|
|
25
|
+
"ContextState",
|
|
26
|
+
"PersonaLayer",
|
|
27
|
+
"PatternLearner",
|
|
28
|
+
"DailyCoach",
|
|
29
|
+
"ContextEngine",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class JarvisEngine:
|
|
34
|
+
"""Facade combining all Jarvis sub-modules."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
event_bus: EventBus,
|
|
39
|
+
target_chat_ids: List[int],
|
|
40
|
+
working_directory: str = "",
|
|
41
|
+
memory_base: str = "",
|
|
42
|
+
) -> None:
|
|
43
|
+
if not working_directory:
|
|
44
|
+
working_directory = str(Path.home())
|
|
45
|
+
if not memory_base:
|
|
46
|
+
memory_base = str(Path.home() / "claude-telegram" / "memory")
|
|
47
|
+
self.pattern_learner = PatternLearner(memory_base)
|
|
48
|
+
self.context_engine = ContextEngine(
|
|
49
|
+
event_bus=event_bus,
|
|
50
|
+
target_chat_ids=target_chat_ids,
|
|
51
|
+
working_directory=working_directory,
|
|
52
|
+
pattern_learner=self.pattern_learner,
|
|
53
|
+
)
|
|
54
|
+
self.coach = DailyCoach(memory_base)
|
|
55
|
+
self.persona = PersonaLayer()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def enabled(self) -> bool:
|
|
59
|
+
return self.context_engine.enabled
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def current_state(self) -> ContextState:
|
|
63
|
+
return self.context_engine.current_state
|
|
64
|
+
|
|
65
|
+
def toggle(self) -> bool:
|
|
66
|
+
"""Toggle Jarvis on/off. Returns new state."""
|
|
67
|
+
return self.context_engine.toggle()
|
|
68
|
+
|
|
69
|
+
async def start(self) -> None:
|
|
70
|
+
"""Start the context engine polling loop."""
|
|
71
|
+
await self.context_engine.start()
|
|
72
|
+
logger.info("Jarvis engine started")
|
|
73
|
+
|
|
74
|
+
async def stop(self) -> None:
|
|
75
|
+
"""Stop the context engine polling loop."""
|
|
76
|
+
await self.context_engine.stop()
|
|
77
|
+
logger.info("Jarvis engine stopped")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Daily coach — generates productivity coaching reports from interaction data.
|
|
2
|
+
|
|
3
|
+
Uses interactions.jsonl to analyze the day's work patterns
|
|
4
|
+
and produce a direct, actionable coaching report.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
|
|
14
|
+
from .pattern_learner import PatternLearner
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DailyCoach:
|
|
20
|
+
"""Generates daily coaching reports from interaction logs."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, memory_base: str | Path) -> None:
|
|
23
|
+
self.memory_base = Path(memory_base)
|
|
24
|
+
self.pattern_learner = PatternLearner(memory_base)
|
|
25
|
+
|
|
26
|
+
def calculate_metrics(
|
|
27
|
+
self, records: list[dict[str, Any]]
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Calculate productivity metrics from interaction records.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Dict with total, success_rate, context_switches, debug_ratio, peak_hour.
|
|
33
|
+
"""
|
|
34
|
+
if not records:
|
|
35
|
+
return {
|
|
36
|
+
"total": 0,
|
|
37
|
+
"avg_duration_ms": 0,
|
|
38
|
+
"context_switches": 0,
|
|
39
|
+
"debug_ratio": 0.0,
|
|
40
|
+
"peak_hour": None,
|
|
41
|
+
"request_types": {},
|
|
42
|
+
"hourly_distribution": {},
|
|
43
|
+
"states": {},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
types: Counter[str] = Counter()
|
|
47
|
+
hours: Counter[int] = Counter()
|
|
48
|
+
states: Counter[str] = Counter()
|
|
49
|
+
durations: list[int] = []
|
|
50
|
+
context_switches = 0
|
|
51
|
+
prev_state = None
|
|
52
|
+
|
|
53
|
+
for rec in records:
|
|
54
|
+
rtype = rec.get("request_type", "admin")
|
|
55
|
+
types[rtype] += 1
|
|
56
|
+
hours[rec.get("hour", 0)] += 1
|
|
57
|
+
state = rec.get("state", "UNKNOWN")
|
|
58
|
+
states[state] += 1
|
|
59
|
+
if "duration_ms" in rec:
|
|
60
|
+
durations.append(rec["duration_ms"])
|
|
61
|
+
if prev_state and state != prev_state:
|
|
62
|
+
context_switches += 1
|
|
63
|
+
prev_state = state
|
|
64
|
+
|
|
65
|
+
total = len(records)
|
|
66
|
+
debug_count = types.get("debug", 0)
|
|
67
|
+
peak_hour = hours.most_common(1)[0][0] if hours else None
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"total": total,
|
|
71
|
+
"avg_duration_ms": int(sum(durations) / len(durations)) if durations else 0,
|
|
72
|
+
"context_switches": context_switches,
|
|
73
|
+
"debug_ratio": round(debug_count / total, 2) if total else 0.0,
|
|
74
|
+
"peak_hour": peak_hour,
|
|
75
|
+
"request_types": dict(types),
|
|
76
|
+
"hourly_distribution": dict(hours),
|
|
77
|
+
"states": dict(states),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def generate_report(self, date: datetime | None = None) -> str:
|
|
81
|
+
"""Generate a coaching report prompt for Claude to process.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
date: Target date (defaults to today).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
A prompt string that Claude should process to produce the coaching report.
|
|
88
|
+
"""
|
|
89
|
+
if date is None:
|
|
90
|
+
date = datetime.now()
|
|
91
|
+
|
|
92
|
+
records = self.pattern_learner.get_today_records()
|
|
93
|
+
metrics = self.calculate_metrics(records)
|
|
94
|
+
|
|
95
|
+
# Also get weekly patterns for trend analysis
|
|
96
|
+
weekly = self.pattern_learner.extract_patterns(days=7)
|
|
97
|
+
|
|
98
|
+
date_str = date.strftime("%Y-%m-%d")
|
|
99
|
+
|
|
100
|
+
prompt = (
|
|
101
|
+
f"[Daily Coach] {date_str} 코칭 리포트를 작성해주세요.\n\n"
|
|
102
|
+
f"오늘의 데이터:\n"
|
|
103
|
+
f"- 총 요청: {metrics['total']}회\n"
|
|
104
|
+
f"- 평균 응답 시간: {metrics['avg_duration_ms']}ms\n"
|
|
105
|
+
f"- 컨텍스트 전환: {metrics['context_switches']}회\n"
|
|
106
|
+
f"- 디버깅 비율: {metrics['debug_ratio']:.0%}\n"
|
|
107
|
+
f"- 피크 시간: {metrics['peak_hour']}시\n"
|
|
108
|
+
f"- 요청 유형: {metrics['request_types']}\n"
|
|
109
|
+
f"- 시간대별 분포: {metrics['hourly_distribution']}\n"
|
|
110
|
+
f"- 상태 분포: {metrics['states']}\n\n"
|
|
111
|
+
f"주간 패턴 (7일):\n"
|
|
112
|
+
f"- 총 요청: {weekly['total']}회\n"
|
|
113
|
+
f"- 피크 시간대: {weekly['peak_hours']}\n"
|
|
114
|
+
f"- 요청 유형 분포: {weekly['request_types']}\n\n"
|
|
115
|
+
f"다음 형식으로 직설적인 코칭 리포트를 작성하세요:\n"
|
|
116
|
+
f"1. 생산성 점수 (1-10)\n"
|
|
117
|
+
f"2. 잘한 점 (bullet 2-3개)\n"
|
|
118
|
+
f"3. 개선 포인트 (구체적 제안 포함, bullet 2-3개)\n"
|
|
119
|
+
f"4. 이번 주 트렌드 (패턴 분석)\n\n"
|
|
120
|
+
f"직설적이되 건설적으로. 아첨 금지. 한국어로 작성."
|
|
121
|
+
)
|
|
122
|
+
return prompt
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Context engine — polls system state, detects context, evaluates triggers.
|
|
2
|
+
|
|
3
|
+
Mirrors ClipboardMonitor's asyncio.Task pattern:
|
|
4
|
+
- 5-minute polling via subprocess calls (osascript, pmset, sysctl, git, etc.)
|
|
5
|
+
- State classification (CODING, BROWSING, MEETING, ...)
|
|
6
|
+
- Rule-based trigger evaluation with cooldowns
|
|
7
|
+
- Publishes ScheduledEvent to EventBus on trigger fire
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from typing import Any, Deque, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
import structlog
|
|
18
|
+
|
|
19
|
+
from ..events.bus import EventBus
|
|
20
|
+
from ..events.types import ScheduledEvent
|
|
21
|
+
from .persona import ContextState, PersonaLayer
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger()
|
|
24
|
+
|
|
25
|
+
POLL_INTERVAL = 300 # 5 minutes
|
|
26
|
+
MAX_API_CALLS_PER_HOUR = 10
|
|
27
|
+
|
|
28
|
+
# App name -> state mapping
|
|
29
|
+
_APP_STATE_MAP: Dict[str, ContextState] = {
|
|
30
|
+
"code": ContextState.CODING,
|
|
31
|
+
"terminal": ContextState.CODING,
|
|
32
|
+
"iterm": ContextState.CODING,
|
|
33
|
+
"warp": ContextState.CODING,
|
|
34
|
+
"xcode": ContextState.CODING,
|
|
35
|
+
"cursor": ContextState.CODING,
|
|
36
|
+
"chrome": ContextState.BROWSING,
|
|
37
|
+
"safari": ContextState.BROWSING,
|
|
38
|
+
"firefox": ContextState.BROWSING,
|
|
39
|
+
"arc": ContextState.BROWSING,
|
|
40
|
+
"zoom": ContextState.MEETING,
|
|
41
|
+
"meet": ContextState.MEETING,
|
|
42
|
+
"teams": ContextState.MEETING,
|
|
43
|
+
"facetime": ContextState.MEETING,
|
|
44
|
+
"slack": ContextState.COMMUNICATION,
|
|
45
|
+
"discord": ContextState.COMMUNICATION,
|
|
46
|
+
"messages": ContextState.COMMUNICATION,
|
|
47
|
+
"telegram": ContextState.COMMUNICATION,
|
|
48
|
+
"kakaotalk": ContextState.COMMUNICATION,
|
|
49
|
+
"mail": ContextState.COMMUNICATION,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Trigger cooldowns (seconds)
|
|
53
|
+
_TRIGGER_COOLDOWNS: Dict[str, int] = {
|
|
54
|
+
"battery_warning": 1800, # 30 min
|
|
55
|
+
"meeting_prep": 3600, # 1 hour
|
|
56
|
+
"return_from_away": 1800, # 30 min
|
|
57
|
+
"long_coding_session": 3600, # 1 hour
|
|
58
|
+
"context_switch_overload": 1800, # 30 min
|
|
59
|
+
"terminal_error": 600, # 10 min
|
|
60
|
+
"stuck_detection": 3600, # 1 hour
|
|
61
|
+
"preemptive_routine": 7200, # 2 hours
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ContextSnapshot:
|
|
66
|
+
"""A single point-in-time system state snapshot."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
active_app: str = "",
|
|
71
|
+
battery_pct: int = 100,
|
|
72
|
+
battery_charging: bool = True,
|
|
73
|
+
cpu_load: float = 0.0,
|
|
74
|
+
git_branch: str = "",
|
|
75
|
+
git_dirty: bool = False,
|
|
76
|
+
recent_commands: List[str] | None = None,
|
|
77
|
+
chrome_tabs: List[str] | None = None,
|
|
78
|
+
timestamp: float = 0.0,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.active_app = active_app
|
|
81
|
+
self.battery_pct = battery_pct
|
|
82
|
+
self.battery_charging = battery_charging
|
|
83
|
+
self.cpu_load = cpu_load
|
|
84
|
+
self.git_branch = git_branch
|
|
85
|
+
self.git_dirty = git_dirty
|
|
86
|
+
self.recent_commands = recent_commands or []
|
|
87
|
+
self.chrome_tabs = chrome_tabs or []
|
|
88
|
+
self.timestamp = timestamp or time.time()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ContextEngine:
|
|
92
|
+
"""Polls system state, classifies context, evaluates triggers."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
event_bus: EventBus,
|
|
97
|
+
target_chat_ids: List[int],
|
|
98
|
+
working_directory: str = "",
|
|
99
|
+
pattern_learner: Any = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
self.event_bus = event_bus
|
|
102
|
+
self.target_chat_ids = target_chat_ids
|
|
103
|
+
self.working_directory = working_directory
|
|
104
|
+
self._pattern_learner = pattern_learner
|
|
105
|
+
|
|
106
|
+
self._running = False
|
|
107
|
+
self._enabled = True # Always-on when engine is started
|
|
108
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
109
|
+
|
|
110
|
+
# State tracking
|
|
111
|
+
self._current_state = ContextState.AWAY
|
|
112
|
+
self._state_start_time: float = time.time()
|
|
113
|
+
self._history: Deque[ContextSnapshot] = deque(maxlen=12) # 1 hour
|
|
114
|
+
self._api_calls_this_hour: int = 0
|
|
115
|
+
self._hour_reset_time: float = time.time()
|
|
116
|
+
|
|
117
|
+
# Trigger cooldowns
|
|
118
|
+
self._last_trigger_times: Dict[str, float] = {}
|
|
119
|
+
|
|
120
|
+
# State transition tracking for triggers
|
|
121
|
+
self._app_switch_times: List[float] = []
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def enabled(self) -> bool:
|
|
125
|
+
return self._enabled
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def current_state(self) -> ContextState:
|
|
129
|
+
return self._current_state
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def state_duration_min(self) -> int:
|
|
133
|
+
return int((time.time() - self._state_start_time) / 60)
|
|
134
|
+
|
|
135
|
+
def toggle(self) -> bool:
|
|
136
|
+
"""Toggle context engine. Returns new state."""
|
|
137
|
+
self._enabled = not self._enabled
|
|
138
|
+
if self._enabled:
|
|
139
|
+
logger.info("Jarvis context engine enabled")
|
|
140
|
+
else:
|
|
141
|
+
logger.info("Jarvis context engine disabled")
|
|
142
|
+
return self._enabled
|
|
143
|
+
|
|
144
|
+
def set_enabled(self, state: bool) -> None:
|
|
145
|
+
self._enabled = state
|
|
146
|
+
|
|
147
|
+
async def start(self) -> None:
|
|
148
|
+
"""Start the polling loop."""
|
|
149
|
+
if self._running:
|
|
150
|
+
return
|
|
151
|
+
self._running = True
|
|
152
|
+
self._task = asyncio.create_task(self._poll_loop())
|
|
153
|
+
logger.info("Context engine started (active)")
|
|
154
|
+
|
|
155
|
+
async def stop(self) -> None:
|
|
156
|
+
"""Stop the polling loop."""
|
|
157
|
+
if not self._running:
|
|
158
|
+
return
|
|
159
|
+
self._running = False
|
|
160
|
+
if self._task:
|
|
161
|
+
self._task.cancel()
|
|
162
|
+
try:
|
|
163
|
+
await self._task
|
|
164
|
+
except asyncio.CancelledError:
|
|
165
|
+
pass
|
|
166
|
+
logger.info("Context engine stopped")
|
|
167
|
+
|
|
168
|
+
async def _poll_loop(self) -> None:
|
|
169
|
+
"""Main polling loop."""
|
|
170
|
+
while self._running:
|
|
171
|
+
try:
|
|
172
|
+
if self._enabled:
|
|
173
|
+
await self._tick()
|
|
174
|
+
await asyncio.sleep(POLL_INTERVAL)
|
|
175
|
+
except asyncio.CancelledError:
|
|
176
|
+
break
|
|
177
|
+
except Exception:
|
|
178
|
+
logger.exception("Context engine poll error")
|
|
179
|
+
await asyncio.sleep(POLL_INTERVAL * 2)
|
|
180
|
+
|
|
181
|
+
async def _tick(self) -> None:
|
|
182
|
+
"""Single poll cycle: collect -> detect state -> evaluate triggers."""
|
|
183
|
+
# Reset hourly API counter
|
|
184
|
+
now = time.time()
|
|
185
|
+
if now - self._hour_reset_time >= 3600:
|
|
186
|
+
self._api_calls_this_hour = 0
|
|
187
|
+
self._hour_reset_time = now
|
|
188
|
+
|
|
189
|
+
snap = await self._collect_snapshot()
|
|
190
|
+
self._history.append(snap)
|
|
191
|
+
|
|
192
|
+
new_state = self._detect_state(snap)
|
|
193
|
+
|
|
194
|
+
# Track state transitions
|
|
195
|
+
if new_state != self._current_state:
|
|
196
|
+
self._app_switch_times.append(now)
|
|
197
|
+
# Clean old switch times (keep last 10 min)
|
|
198
|
+
self._app_switch_times = [
|
|
199
|
+
t for t in self._app_switch_times if now - t < 600
|
|
200
|
+
]
|
|
201
|
+
prev_state = self._current_state
|
|
202
|
+
self._current_state = new_state
|
|
203
|
+
self._state_start_time = now
|
|
204
|
+
logger.info(
|
|
205
|
+
"State transition",
|
|
206
|
+
from_state=prev_state.value,
|
|
207
|
+
to_state=new_state.value,
|
|
208
|
+
app=snap.active_app,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Evaluate triggers
|
|
212
|
+
triggers = self._evaluate_triggers(snap)
|
|
213
|
+
for trigger_id, prompt in triggers:
|
|
214
|
+
if self._api_calls_this_hour >= MAX_API_CALLS_PER_HOUR:
|
|
215
|
+
logger.warning("Jarvis hourly API limit reached, skipping trigger")
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
# Add persona prefix
|
|
219
|
+
from datetime import datetime
|
|
220
|
+
|
|
221
|
+
hour = datetime.now().hour
|
|
222
|
+
prefix = PersonaLayer.build_prompt_prefix(
|
|
223
|
+
self._current_state, hour, self.state_duration_min
|
|
224
|
+
)
|
|
225
|
+
full_prompt = f"{prefix}\n\n{prompt}"
|
|
226
|
+
|
|
227
|
+
event = ScheduledEvent(
|
|
228
|
+
job_id=f"jarvis-{trigger_id}",
|
|
229
|
+
job_name=f"jarvis-{trigger_id}",
|
|
230
|
+
prompt=full_prompt,
|
|
231
|
+
working_directory=self.working_directory,
|
|
232
|
+
target_chat_ids=self.target_chat_ids,
|
|
233
|
+
source="jarvis",
|
|
234
|
+
)
|
|
235
|
+
await self.event_bus.publish(event)
|
|
236
|
+
self._api_calls_this_hour += 1
|
|
237
|
+
self._last_trigger_times[trigger_id] = now
|
|
238
|
+
logger.info("Jarvis trigger fired", trigger=trigger_id)
|
|
239
|
+
|
|
240
|
+
async def _collect_snapshot(self) -> ContextSnapshot:
|
|
241
|
+
"""Collect system state via parallel subprocess calls."""
|
|
242
|
+
loop = asyncio.get_event_loop()
|
|
243
|
+
|
|
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 ""
|
|
255
|
+
|
|
256
|
+
# Run all collectors in parallel
|
|
257
|
+
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
|
+
]),
|
|
278
|
+
return_exceptions=True,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Parse results
|
|
282
|
+
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 []
|
|
306
|
+
|
|
307
|
+
return ContextSnapshot(
|
|
308
|
+
active_app=active_app,
|
|
309
|
+
battery_pct=battery_pct,
|
|
310
|
+
battery_charging=battery_charging,
|
|
311
|
+
cpu_load=cpu_load,
|
|
312
|
+
git_branch=git_branch,
|
|
313
|
+
git_dirty=git_dirty,
|
|
314
|
+
chrome_tabs=chrome_tabs,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def _detect_state(self, snap: ContextSnapshot) -> ContextState:
|
|
318
|
+
"""Classify current state from snapshot."""
|
|
319
|
+
app_lower = snap.active_app.lower()
|
|
320
|
+
|
|
321
|
+
# Check app-based state
|
|
322
|
+
for app_key, state in _APP_STATE_MAP.items():
|
|
323
|
+
if app_key in app_lower:
|
|
324
|
+
# Promote to DEEP_WORK if same coding app for 2+ hours
|
|
325
|
+
if (
|
|
326
|
+
state == ContextState.CODING
|
|
327
|
+
and self._current_state == ContextState.CODING
|
|
328
|
+
and self.state_duration_min >= 120
|
|
329
|
+
):
|
|
330
|
+
return ContextState.DEEP_WORK
|
|
331
|
+
return state
|
|
332
|
+
|
|
333
|
+
# No active app or unknown -> check if AWAY
|
|
334
|
+
if not app_lower or app_lower in ("loginwindow", "screensaver"):
|
|
335
|
+
return ContextState.AWAY
|
|
336
|
+
|
|
337
|
+
# Default: keep current state
|
|
338
|
+
return self._current_state
|
|
339
|
+
|
|
340
|
+
def _evaluate_triggers(
|
|
341
|
+
self, snap: ContextSnapshot
|
|
342
|
+
) -> List[Tuple[str, str]]:
|
|
343
|
+
"""Evaluate trigger rules against current snapshot.
|
|
344
|
+
|
|
345
|
+
Returns list of (trigger_id, prompt) for triggers that should fire.
|
|
346
|
+
DEEP_WORK state only allows battery + meeting triggers.
|
|
347
|
+
"""
|
|
348
|
+
now = time.time()
|
|
349
|
+
triggers: List[Tuple[str, str]] = []
|
|
350
|
+
|
|
351
|
+
def _cooled(trigger_id: str) -> bool:
|
|
352
|
+
last = self._last_trigger_times.get(trigger_id, 0)
|
|
353
|
+
cooldown = _TRIGGER_COOLDOWNS.get(trigger_id, 600)
|
|
354
|
+
return (now - last) >= cooldown
|
|
355
|
+
|
|
356
|
+
is_deep = self._current_state == ContextState.DEEP_WORK
|
|
357
|
+
|
|
358
|
+
# 1. Battery warning (< 20%, not charging)
|
|
359
|
+
if (
|
|
360
|
+
snap.battery_pct < 20
|
|
361
|
+
and not snap.battery_charging
|
|
362
|
+
and _cooled("battery_warning")
|
|
363
|
+
):
|
|
364
|
+
triggers.append((
|
|
365
|
+
"battery_warning",
|
|
366
|
+
f"배터리가 {snap.battery_pct}%입니다. "
|
|
367
|
+
f"충전기를 연결하거나 작업을 저장하세요.",
|
|
368
|
+
))
|
|
369
|
+
|
|
370
|
+
# 2. Meeting prep (check via calendar — simplified: just notify)
|
|
371
|
+
# This would ideally check Google Calendar MCP, but for now
|
|
372
|
+
# we skip this trigger (calendar-check job already handles it)
|
|
373
|
+
|
|
374
|
+
# Deep work gate: remaining triggers are suppressed
|
|
375
|
+
if is_deep:
|
|
376
|
+
return triggers
|
|
377
|
+
|
|
378
|
+
# 3. Return from AWAY
|
|
379
|
+
if (
|
|
380
|
+
len(self._history) >= 2
|
|
381
|
+
and self._history[-2].active_app.lower() in ("loginwindow", "screensaver", "")
|
|
382
|
+
and snap.active_app
|
|
383
|
+
and snap.active_app.lower() not in ("loginwindow", "screensaver", "")
|
|
384
|
+
and _cooled("return_from_away")
|
|
385
|
+
):
|
|
386
|
+
triggers.append((
|
|
387
|
+
"return_from_away",
|
|
388
|
+
f"돌아오셨네요. "
|
|
389
|
+
f"마지막 작업: {snap.git_branch or '알 수 없음'} 브랜치"
|
|
390
|
+
f"{' (변경사항 있음)' if snap.git_dirty else ''}",
|
|
391
|
+
))
|
|
392
|
+
|
|
393
|
+
# 4. Long coding session (3+ hours)
|
|
394
|
+
if (
|
|
395
|
+
self._current_state in (ContextState.CODING, ContextState.DEEP_WORK)
|
|
396
|
+
and self.state_duration_min >= 180
|
|
397
|
+
and _cooled("long_coding_session")
|
|
398
|
+
):
|
|
399
|
+
triggers.append((
|
|
400
|
+
"long_coding_session",
|
|
401
|
+
f"{self.state_duration_min}분째 코딩 중입니다. "
|
|
402
|
+
f"잠깐 쉬어가시죠. 스트레칭이나 물 한잔 어떠세요?",
|
|
403
|
+
))
|
|
404
|
+
|
|
405
|
+
# 5. Context switch overload (5+ switches in 10 min)
|
|
406
|
+
recent_switches = [
|
|
407
|
+
t for t in self._app_switch_times if now - t < 600
|
|
408
|
+
]
|
|
409
|
+
if len(recent_switches) >= 5 and _cooled("context_switch_overload"):
|
|
410
|
+
triggers.append((
|
|
411
|
+
"context_switch_overload",
|
|
412
|
+
f"최근 10분간 앱 전환이 {len(recent_switches)}회입니다. "
|
|
413
|
+
f"컨텍스트 전환이 잦으면 집중이 어렵습니다. "
|
|
414
|
+
f"하나의 작업에 집중해보시겠어요?",
|
|
415
|
+
))
|
|
416
|
+
|
|
417
|
+
# 6. Terminal error detection (check recent commands for error patterns)
|
|
418
|
+
for cmd in snap.recent_commands[-3:]:
|
|
419
|
+
if re.search(r"(error|fail|panic|traceback)", cmd, re.I):
|
|
420
|
+
if _cooled("terminal_error"):
|
|
421
|
+
triggers.append((
|
|
422
|
+
"terminal_error",
|
|
423
|
+
f"터미널에서 에러가 감지되었습니다: {cmd[:200]}. "
|
|
424
|
+
f"도움이 필요하신가요?",
|
|
425
|
+
))
|
|
426
|
+
break
|
|
427
|
+
|
|
428
|
+
# 7. Stuck detection (same branch, dirty, 30+ min in CODING)
|
|
429
|
+
if (
|
|
430
|
+
self._current_state == ContextState.CODING
|
|
431
|
+
and self.state_duration_min >= 30
|
|
432
|
+
and snap.git_dirty
|
|
433
|
+
and _cooled("stuck_detection")
|
|
434
|
+
):
|
|
435
|
+
# Check if git status hasn't changed (still dirty, same branch)
|
|
436
|
+
if len(self._history) >= 6:
|
|
437
|
+
old_snap = self._history[-6]
|
|
438
|
+
if (
|
|
439
|
+
old_snap.git_branch == snap.git_branch
|
|
440
|
+
and old_snap.git_dirty
|
|
441
|
+
):
|
|
442
|
+
triggers.append((
|
|
443
|
+
"stuck_detection",
|
|
444
|
+
f"30분 이상 같은 브랜치({snap.git_branch})에서 "
|
|
445
|
+
f"커밋 없이 작업 중입니다. 막히신 부분이 있나요?",
|
|
446
|
+
))
|
|
447
|
+
|
|
448
|
+
# 8. Preemptive routine (learned pattern)
|
|
449
|
+
if self._pattern_learner and _cooled("preemptive_routine"):
|
|
450
|
+
from datetime import datetime as _dt
|
|
451
|
+
|
|
452
|
+
routine = self._pattern_learner.check_preemptive(
|
|
453
|
+
self._current_state, _dt.now().hour
|
|
454
|
+
)
|
|
455
|
+
if routine:
|
|
456
|
+
rtype = routine.get("request_type", "")
|
|
457
|
+
triggers.append((
|
|
458
|
+
"preemptive_routine",
|
|
459
|
+
f"이 시간대에 보통 '{rtype}' 유형의 작업을 하시더라고요. "
|
|
460
|
+
f"미리 준비할 게 있을까요?",
|
|
461
|
+
))
|
|
462
|
+
|
|
463
|
+
return triggers
|