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,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