clawmate 1.4.2 → 1.5.0

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.
@@ -0,0 +1,1009 @@
1
+ /**
2
+ * Proactive Monitor - Watches user activity and triggers pet interventions
3
+ *
4
+ * Detects clipboard changes, active window switches, idle patterns,
5
+ * and complex behavioral patterns to decide when the pet should react.
6
+ *
7
+ * Architecture:
8
+ * ClipboardWatcher (500ms) --+
9
+ * ActiveWindowWatcher (5s) --+--> ContextAnalyzer --> InterventionDecider (cooldown)
10
+ * IdleDetector (10s) --------+ |
11
+ * +-----------+-----------+
12
+ * v v
13
+ * AIBridge.send() IPC 'proactive-event'
14
+ * (when AI connected) |
15
+ * v
16
+ * Renderer ProactiveController
17
+ */
18
+ const { clipboard, powerMonitor, desktopCapturer, screen } = require('electron');
19
+ const EventEmitter = require('events');
20
+
21
+ // =========================================================================
22
+ // Visual Triggers - 화면 캡처가 AI 판단에 도움이 되는 트리거들
23
+ // =========================================================================
24
+ const VISUAL_TRIGGERS = new Set([
25
+ 'error_detected', 'error_loop', 'checkout_detected', 'shopping_detected',
26
+ 'coding_detected', 'terminal_active', 'idle_return',
27
+ 'video_watching', 'news_reading', 'document_editing', 'learning_activity',
28
+ 'finance_activity', 'food_ordering', 'travel_planning', 'search_detected',
29
+ 'gaming_detected', 'meeting_detected', 'dev_web_detected', 'reading_pdf',
30
+ 'deep_focus', 'social_scrolling', 'wiki_rabbit_hole', 'price_comparison',
31
+ 'file_management', 'wiki_browsing', 'email_checking',
32
+ 'research_mode', 'procrastination', 'focus_break', 'repeated_search',
33
+ ]);
34
+
35
+ // =========================================================================
36
+ // Site/App Category Definitions (for window title matching)
37
+ // =========================================================================
38
+ const SITE_CATEGORIES = {
39
+ shopping: {
40
+ patterns: [
41
+ 'amazon', 'ebay', 'coupang', '\uCFE0\uD321', 'gmarket', 'g\uB9C8\uCF13', '11st', '11\uBC88\uAC00',
42
+ 'aliexpress', 'shopee', 'etsy', 'auction', '\uC625\uC158', 'tmon', '\uD2F0\uBAAC',
43
+ 'wemakeprice', '\uC704\uBA54\uD504', 'musinsa', '\uBB34\uC2E0\uC0AC', 'oliveyoung',
44
+ '\uC62C\uB9AC\uBE0C\uC601', 'walmart', 'target.com', 'bestbuy', 'newegg',
45
+ 'rakuten', 'taobao', 'jd.com', 'lazada', 'zalando',
46
+ ],
47
+ trigger: 'shopping_detected',
48
+ cooldown: 120000,
49
+ },
50
+ checkout: {
51
+ patterns: [
52
+ 'cart', 'checkout', '\uC7A5\uBC14\uAD6C\uB2C8', '\uACB0\uC81C', 'payment',
53
+ '\uC8FC\uBB38', 'order confirm', '\uC8FC\uBB38\uD655\uC778', 'place order',
54
+ 'buy now', '\uAD6C\uB9E4\uD558\uAE30', 'proceed to',
55
+ ],
56
+ trigger: 'checkout_detected',
57
+ cooldown: 60000,
58
+ },
59
+ news: {
60
+ patterns: [
61
+ 'cnn', 'bbc', 'nytimes', 'reuters', 'bloomberg',
62
+ 'naver.com/news', '\uB124\uC774\uBC84\uB274\uC2A4', 'daum.net/news', '\uB2E4\uC74C\uB274\uC2A4',
63
+ 'hacker news', 'techcrunch', 'the verge', 'ars technica',
64
+ '\uC870\uC120\uC77C\uBCF4', '\uC911\uC559\uC77C\uBCF4', '\uB3D9\uC544\uC77C\uBCF4', '\uD55C\uACBD\uB808',
65
+ ],
66
+ trigger: 'news_reading',
67
+ cooldown: 120000,
68
+ },
69
+ social: {
70
+ patterns: [
71
+ 'instagram', 'twitter', 'x.com', 'facebook', 'threads',
72
+ 'tiktok', 'reddit', 'mastodon', 'bluesky', 'tumblr',
73
+ 'linkedin feed', 'pinterest',
74
+ ],
75
+ trigger: 'social_scrolling',
76
+ cooldown: 120000,
77
+ },
78
+ video: {
79
+ patterns: [
80
+ 'youtube', 'twitch', 'netflix', 'disney+', 'wavve', 'tving',
81
+ 'watcha', 'hulu', 'prime video', 'crunchyroll', 'vimeo',
82
+ 'dailymotion', 'bilibili', 'niconico',
83
+ ],
84
+ trigger: 'video_watching',
85
+ cooldown: 120000,
86
+ },
87
+ coding: {
88
+ patterns: [
89
+ 'visual studio code', 'vscode', 'intellij', 'pycharm', 'webstorm',
90
+ 'sublime text', 'atom', 'vim ', 'neovim', 'emacs', 'cursor',
91
+ 'zed', 'android studio', 'xcode', 'rider', 'goland',
92
+ ],
93
+ trigger: 'coding_detected',
94
+ cooldown: 300000,
95
+ },
96
+ terminal: {
97
+ patterns: [
98
+ 'powershell', 'cmd.exe', 'command prompt', 'windows terminal',
99
+ 'git bash', 'wsl', 'terminal', 'iterm', 'hyper', 'alacritty',
100
+ 'warp', 'kitty',
101
+ ],
102
+ trigger: 'terminal_active',
103
+ cooldown: 300000,
104
+ },
105
+ music: {
106
+ patterns: [
107
+ 'spotify', 'apple music', 'youtube music', 'soundcloud',
108
+ 'melon', 'genie', 'bugs', 'flo', 'vibe', 'tidal',
109
+ 'deezer', 'pandora', 'amazon music',
110
+ ],
111
+ trigger: 'music_playing',
112
+ cooldown: 300000,
113
+ },
114
+ food: {
115
+ patterns: [
116
+ '\uBC30\uB2EC\uC758\uBBFC\uC871', 'baemin', '\uCFE0\uD321\uC774\uCE20', 'coupangeats',
117
+ '\uC694\uAE30\uC694', 'yogiyo', 'ubereats', 'uber eats',
118
+ 'doordash', 'grubhub', 'deliveroo', 'just eat',
119
+ ],
120
+ trigger: 'food_ordering',
121
+ cooldown: 120000,
122
+ },
123
+ travel: {
124
+ patterns: [
125
+ 'booking.com', 'airbnb', 'hotels.com', 'expedia', 'agoda',
126
+ '\uC57C\uB180\uC790', '\uC5EC\uAE30\uC5B4\uB54C', 'trip.com', 'skyscanner',
127
+ 'google flights', 'kayak', '\uD2B8\uB9AC\uD50C', 'tripadvisor',
128
+ '\uC778\uD130\uD30C\uD06C', '\uB9C8\uC774\uB9AC\uC5BC\uD2B8\uB9BD',
129
+ ],
130
+ trigger: 'travel_planning',
131
+ cooldown: 120000,
132
+ },
133
+ learning: {
134
+ patterns: [
135
+ 'udemy', 'coursera', 'khan academy', '\uC778\uD504\uB7F0', 'inflearn',
136
+ 'nomadcoders', '\uB178\uB9C8\uB4DC\uCF54\uB354', 'edx', 'skillshare',
137
+ 'pluralsight', 'leetcode', 'hackerrank', 'codecademy',
138
+ 'duolingo', 'brilliant',
139
+ ],
140
+ trigger: 'learning_activity',
141
+ cooldown: 300000,
142
+ },
143
+ email: {
144
+ patterns: [
145
+ 'gmail', 'outlook', 'yahoo mail', 'naver mail', '\uB124\uC774\uBC84 \uBA54\uC77C',
146
+ 'protonmail', 'zoho mail', 'thunderbird', 'mail -',
147
+ ],
148
+ trigger: 'email_checking',
149
+ cooldown: 120000,
150
+ },
151
+ gaming: {
152
+ patterns: [
153
+ 'steam', 'epic games', 'league of legends', 'valorant',
154
+ 'overwatch', 'minecraft', 'roblox', 'genshin', 'fortnite',
155
+ 'apex legends', 'counter-strike', 'dota', 'diablo',
156
+ 'lost ark', '\uB85C\uC2A4\uD2B8\uC544\uD06C', 'maplestory', '\uBA54\uC774\uD50C\uC2A4\uD1A0\uB9AC',
157
+ ],
158
+ trigger: 'gaming_detected',
159
+ cooldown: 300000,
160
+ },
161
+ login: {
162
+ patterns: [
163
+ 'sign in', 'log in', '\uB85C\uADF8\uC778', 'login', 'sign up',
164
+ '\uD68C\uC6D0\uAC00\uC785', 'create account', 'forgot password',
165
+ '\uBE44\uBC00\uBC88\uD638 \uCC3E\uAE30', 'reset password',
166
+ ],
167
+ trigger: 'login_page',
168
+ cooldown: 60000,
169
+ },
170
+ finance: {
171
+ patterns: [
172
+ '\uD1A0\uC2A4', 'toss', '\uCE74\uCE74\uC624\uBC45\uD06C', 'kakaobank',
173
+ '\uD0A4\uC6C0\uC99D\uAD8C', '\uBBF8\uB798\uC5D0\uC14B', '\uC0BC\uC131\uC99D\uAD8C',
174
+ '\uC2E0\uD55C\uD22C\uC790', 'robinhood', 'coinbase',
175
+ 'binance', 'upbit', '\uC5C5\uBE44\uD2B8', 'trading',
176
+ '\uC8FC\uC2DD', 'stock', '\uC740\uD589', 'bank',
177
+ ],
178
+ trigger: 'finance_activity',
179
+ cooldown: 120000,
180
+ },
181
+ document: {
182
+ patterns: [
183
+ 'google docs', 'google sheets', 'google slides',
184
+ 'notion', 'microsoft word', 'microsoft excel', 'powerpoint',
185
+ 'confluence', 'obsidian', 'roam research', 'bear',
186
+ 'typora', 'mark text', '\uD55C\uAE00', 'hwp',
187
+ ],
188
+ trigger: 'document_editing',
189
+ cooldown: 300000,
190
+ },
191
+ search: {
192
+ patterns: [
193
+ 'google.com/search', 'google - ', 'bing.com/search',
194
+ 'naver.com/search', '\uB124\uC774\uBC84 \uAC80\uC0C9', 'duckduckgo',
195
+ 'search results', '\uAC80\uC0C9\uACB0\uACFC',
196
+ ],
197
+ trigger: 'search_detected',
198
+ cooldown: 30000,
199
+ },
200
+ meeting: {
201
+ patterns: [
202
+ 'zoom', 'teams', 'google meet', 'webex', 'slack huddle',
203
+ 'discord call', 'skype', '\uD654\uC0C1\uD68C\uC758',
204
+ ],
205
+ trigger: 'meeting_detected',
206
+ cooldown: 300000,
207
+ },
208
+ wiki: {
209
+ patterns: [
210
+ 'wikipedia', '\uC704\uD0A4\uD53C\uB514\uC544', '\uB098\uBB34\uC704\uD0A4', 'namu.wiki',
211
+ 'fandom.com', 'wikia',
212
+ ],
213
+ trigger: 'wiki_browsing',
214
+ cooldown: 60000,
215
+ },
216
+ dev_web: {
217
+ patterns: [
218
+ 'github', 'gitlab', 'bitbucket', 'stackoverflow', 'stack overflow',
219
+ 'npm', 'pypi', 'crates.io', 'developer', 'documentation', 'docs',
220
+ 'api reference', 'mdn web',
221
+ ],
222
+ trigger: 'dev_web_detected',
223
+ cooldown: 120000,
224
+ },
225
+ download: {
226
+ patterns: [
227
+ 'download', '\uB2E4\uC6B4\uB85C\uB4DC', 'thanks for downloading',
228
+ 'save as', '\uC800\uC7A5',
229
+ ],
230
+ trigger: 'download_detected',
231
+ cooldown: 60000,
232
+ },
233
+ reading: {
234
+ patterns: [
235
+ '.pdf', 'adobe reader', 'preview', 'kindle',
236
+ 'e-book', 'ebook', 'epub',
237
+ ],
238
+ trigger: 'reading_pdf',
239
+ cooldown: 300000,
240
+ },
241
+ file_manager: {
242
+ patterns: [
243
+ 'file explorer', '\uD30C\uC77C \uD0D0\uC0C9\uAE30', 'finder', 'nautilus',
244
+ 'dolphin', 'thunar', 'files',
245
+ ],
246
+ trigger: 'file_management',
247
+ cooldown: 120000,
248
+ },
249
+ };
250
+
251
+ // Error patterns in window titles
252
+ const ERROR_PATTERNS = [
253
+ 'error', '404', '500', '503', 'not found', 'crashed',
254
+ 'fatal', 'exception', 'fail', 'FAILED', 'denied', 'refused',
255
+ 'timed out', 'timeout', '\uC624\uB958', '\uC5D0\uB7EC', '\uC2E4\uD328',
256
+ ];
257
+
258
+ // Clipboard content patterns
259
+ const CLIPBOARD_PATTERNS = {
260
+ url: /^https?:\/\//i,
261
+ code: /(?:function\s|const\s|let\s|var\s|import\s|from\s|class\s|def\s|return\s|if\s*\(|for\s*\(|while\s*\(|\{[\s\S]*\}|;$|=>)/m,
262
+ email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
263
+ phone: /^[\d\s\-+()]{7,20}$/,
264
+ longText: null, // checked by length
265
+ };
266
+
267
+ // =========================================================================
268
+ // ProactiveMonitor Class
269
+ // =========================================================================
270
+ class ProactiveMonitor extends EventEmitter {
271
+ constructor() {
272
+ super();
273
+ this.enabled = false;
274
+ this.mainWindow = null;
275
+ this.aiBridge = null;
276
+
277
+ // Watcher intervals
278
+ this._clipboardInterval = null;
279
+ this._windowInterval = null;
280
+ this._idleInterval = null;
281
+
282
+ // Clipboard state
283
+ this._lastClipText = '';
284
+ this._lastClipHasImage = false;
285
+ this._clipHistory = []; // { text, timestamp }
286
+ this._maxClipHistory = 20;
287
+
288
+ // Window state
289
+ this._lastTitle = '';
290
+ this._lastAppName = '';
291
+ this._lastCategory = null;
292
+ this._titleHistory = []; // { title, category, timestamp }
293
+ this._maxTitleHistory = 50;
294
+ this._sameTitleSince = 0; // timestamp of when current title started
295
+ this._sameAppSince = 0; // timestamp of when current app started
296
+
297
+ // Idle state
298
+ this._wasIdle = false;
299
+ this._idleStart = 0;
300
+
301
+ // Cooldown tracking
302
+ this._globalCooldown = 8000;
303
+ this._lastEventTime = 0;
304
+ this._triggerCooldowns = {}; // { triggerType: lastFireTime }
305
+
306
+ // Pattern detection state
307
+ this._appSwitchCount = 0;
308
+ this._appSwitchWindow = []; // timestamps of recent switches
309
+ this._errorCount = 0;
310
+ this._errorWindow = []; // timestamps of recent errors
311
+ this._categoryHistory = []; // recent categories for procrastination detection
312
+ }
313
+
314
+ /**
315
+ * Start monitoring
316
+ */
317
+ start(mainWindow, aiBridge) {
318
+ this.mainWindow = mainWindow;
319
+ this.aiBridge = aiBridge;
320
+ this.enabled = true;
321
+
322
+ // Initialize clipboard state
323
+ try {
324
+ this._lastClipText = clipboard.readText() || '';
325
+ this._lastClipHasImage = !clipboard.readImage().isEmpty();
326
+ } catch {}
327
+
328
+ this._lastTitle = '';
329
+ this._sameTitleSince = Date.now();
330
+ this._sameAppSince = Date.now();
331
+
332
+ // Start watchers
333
+ this._clipboardInterval = setInterval(() => this._checkClipboard(), 500);
334
+ this._windowInterval = setInterval(() => this._checkActiveWindow(), 5000);
335
+ this._idleInterval = setInterval(() => this._checkIdle(), 10000);
336
+
337
+ // Check time-based triggers every 60s
338
+ this._timeInterval = setInterval(() => this._checkTimeTriggers(), 60000);
339
+
340
+ console.log('[ProactiveMonitor] Started');
341
+ }
342
+
343
+ /**
344
+ * Stop monitoring
345
+ */
346
+ stop() {
347
+ this.enabled = false;
348
+ if (this._clipboardInterval) clearInterval(this._clipboardInterval);
349
+ if (this._windowInterval) clearInterval(this._windowInterval);
350
+ if (this._idleInterval) clearInterval(this._idleInterval);
351
+ if (this._timeInterval) clearInterval(this._timeInterval);
352
+ this._clipboardInterval = null;
353
+ this._windowInterval = null;
354
+ this._idleInterval = null;
355
+ this._timeInterval = null;
356
+ console.log('[ProactiveMonitor] Stopped');
357
+ }
358
+
359
+ setEnabled(val) {
360
+ this.enabled = val;
361
+ if (!val) {
362
+ this.stop();
363
+ }
364
+ }
365
+
366
+ // =========================================================================
367
+ // Clipboard Watcher (500ms)
368
+ // =========================================================================
369
+ _checkClipboard() {
370
+ if (!this.enabled) return;
371
+ const now = Date.now();
372
+
373
+ try {
374
+ // Check for screenshot (image in clipboard)
375
+ const img = clipboard.readImage();
376
+ const hasImage = !img.isEmpty();
377
+
378
+ if (hasImage && !this._lastClipHasImage) {
379
+ this._lastClipHasImage = true;
380
+ this._fire('clipboard_screenshot', {
381
+ hasImage: true,
382
+ imageSize: img.getSize(),
383
+ });
384
+ return;
385
+ }
386
+ this._lastClipHasImage = hasImage;
387
+
388
+ // Check for text changes
389
+ const text = clipboard.readText() || '';
390
+ if (text && text !== this._lastClipText) {
391
+ const prevText = this._lastClipText;
392
+ this._lastClipText = text;
393
+
394
+ // Record history
395
+ this._clipHistory.push({ text, timestamp: now });
396
+ if (this._clipHistory.length > this._maxClipHistory) {
397
+ this._clipHistory.shift();
398
+ }
399
+
400
+ // Determine clipboard content type
401
+ const context = { text: text.substring(0, 200), length: text.length };
402
+
403
+ // URL copied
404
+ if (CLIPBOARD_PATTERNS.url.test(text.trim())) {
405
+ this._fire('url_copied', { ...context, url: text.trim().substring(0, 500) });
406
+ }
407
+ // Code copied
408
+ else if (CLIPBOARD_PATTERNS.code.test(text)) {
409
+ this._fire('code_copied', context);
410
+ }
411
+ // Email copied
412
+ else if (CLIPBOARD_PATTERNS.email.test(text.trim())) {
413
+ this._fire('email_copied', { ...context, email: text.trim() });
414
+ }
415
+ // Phone copied
416
+ else if (CLIPBOARD_PATTERNS.phone.test(text.trim())) {
417
+ this._fire('phone_copied', context);
418
+ }
419
+ // Long text copied (500+ chars)
420
+ else if (text.length >= 500) {
421
+ this._fire('long_text_copied', context);
422
+ }
423
+ // General clipboard copy
424
+ else {
425
+ this._fire('clipboard_copy', context);
426
+ }
427
+
428
+ // Check repeated copy (3+ copies in 60s)
429
+ const recentCopies = this._clipHistory.filter(
430
+ (h) => now - h.timestamp < 60000
431
+ );
432
+ if (recentCopies.length >= 3) {
433
+ this._fire('repeated_copy', {
434
+ count: recentCopies.length,
435
+ timespan: 60,
436
+ });
437
+ }
438
+
439
+ // Check search pattern (copy then search engine within 30s)
440
+ this._checkSearchPattern(text, now);
441
+ }
442
+ } catch {
443
+ // Clipboard access can fail silently
444
+ }
445
+ }
446
+
447
+ // =========================================================================
448
+ // Active Window Watcher (5s)
449
+ // =========================================================================
450
+ async _checkActiveWindow() {
451
+ if (!this.enabled) return;
452
+ const now = Date.now();
453
+
454
+ try {
455
+ const { getActiveWindowTitle } = require('./platform');
456
+ const title = await getActiveWindowTitle();
457
+ if (!title) return;
458
+
459
+ const titleLower = title.toLowerCase();
460
+ const titleChanged = title !== this._lastTitle;
461
+
462
+ if (titleChanged) {
463
+ const prevTitle = this._lastTitle;
464
+ const prevCategory = this._lastCategory;
465
+ this._lastTitle = title;
466
+
467
+ // Detect app name from title (part before " - " or " | ")
468
+ const appName = this._extractAppName(title);
469
+ const appChanged = appName !== this._lastAppName;
470
+
471
+ if (appChanged) {
472
+ const prevApp = this._lastAppName;
473
+ this._lastAppName = appName;
474
+ this._sameAppSince = now;
475
+
476
+ // Fire app_switch
477
+ this._fire('app_switch', {
478
+ from: prevApp,
479
+ to: appName,
480
+ title,
481
+ });
482
+
483
+ // Track app switches for rapid_switching detection
484
+ this._appSwitchWindow.push(now);
485
+ this._appSwitchWindow = this._appSwitchWindow.filter(
486
+ (t) => now - t < 60000
487
+ );
488
+ if (this._appSwitchWindow.length >= 5) {
489
+ this._fire('rapid_switching', {
490
+ count: this._appSwitchWindow.length,
491
+ timespan: 60,
492
+ });
493
+ }
494
+ }
495
+
496
+ // Title changed but same app -> might be tab change
497
+ this._sameTitleSince = now;
498
+
499
+ // Record title history
500
+ const category = this._categorizeTitle(titleLower);
501
+ this._lastCategory = category;
502
+ this._titleHistory.push({ title, category, timestamp: now });
503
+ if (this._titleHistory.length > this._maxTitleHistory) {
504
+ this._titleHistory.shift();
505
+ }
506
+
507
+ // Category history for pattern detection
508
+ if (category) {
509
+ this._categoryHistory.push({ category, timestamp: now });
510
+ if (this._categoryHistory.length > 30) {
511
+ this._categoryHistory.shift();
512
+ }
513
+ }
514
+
515
+ // Fire category-specific triggers
516
+ if (category) {
517
+ const catDef = SITE_CATEGORIES[category];
518
+ if (catDef) {
519
+ this._fire(catDef.trigger, {
520
+ title,
521
+ category,
522
+ appName,
523
+ }, catDef.cooldown);
524
+ }
525
+ }
526
+
527
+ // Error detection
528
+ if (this._isErrorTitle(titleLower)) {
529
+ this._errorWindow.push(now);
530
+ this._errorWindow = this._errorWindow.filter(
531
+ (t) => now - t < 300000
532
+ );
533
+ this._fire('error_detected', { title });
534
+
535
+ // Error loop: 3+ errors in 5 minutes
536
+ if (this._errorWindow.length >= 3) {
537
+ this._fire('error_loop', {
538
+ count: this._errorWindow.length,
539
+ timespan: 300,
540
+ });
541
+ }
542
+ }
543
+
544
+ // Complex pattern detection
545
+ this._checkWikiRabbitHole(titleLower, now);
546
+ this._checkPriceComparison(now);
547
+ this._checkResearchMode(now);
548
+ this._checkProcrastination(now);
549
+ this._checkRepeatedSearch(now);
550
+ } else {
551
+ // Same title - check for long focus
552
+ const focusDuration = now - this._sameTitleSince;
553
+
554
+ // long_focus: same app for 10+ minutes
555
+ if (focusDuration >= 600000) {
556
+ this._fire('long_focus', {
557
+ title: this._lastTitle,
558
+ duration: Math.floor(focusDuration / 1000),
559
+ appName: this._lastAppName,
560
+ });
561
+ }
562
+
563
+ // deep_focus: same app (IDE/document) for 20+ minutes
564
+ const appDuration = now - this._sameAppSince;
565
+ if (appDuration >= 1200000) {
566
+ const cat = this._lastCategory;
567
+ if (cat === 'coding' || cat === 'document' || cat === 'terminal') {
568
+ this._fire('deep_focus', {
569
+ title: this._lastTitle,
570
+ duration: Math.floor(appDuration / 1000),
571
+ category: cat,
572
+ });
573
+ }
574
+ }
575
+
576
+ // social_scrolling: same social media for 10+ minutes
577
+ if (this._lastCategory === 'social' && focusDuration >= 600000) {
578
+ this._fire('social_scrolling', {
579
+ title: this._lastTitle,
580
+ duration: Math.floor(focusDuration / 1000),
581
+ });
582
+ }
583
+ }
584
+ } catch (err) {
585
+ // Window title fetch can fail
586
+ }
587
+ }
588
+
589
+ // =========================================================================
590
+ // Idle Detector (10s)
591
+ // =========================================================================
592
+ _checkIdle() {
593
+ if (!this.enabled) return;
594
+
595
+ try {
596
+ const idleTime = powerMonitor.getSystemIdleTime(); // seconds
597
+
598
+ if (idleTime > 60 && !this._wasIdle) {
599
+ this._wasIdle = true;
600
+ this._idleStart = Date.now();
601
+ }
602
+
603
+ if (this._wasIdle && idleTime < 5) {
604
+ // User returned from idle
605
+ const idleDuration = Math.floor((Date.now() - this._idleStart) / 1000);
606
+ this._wasIdle = false;
607
+ this._fire('idle_return', {
608
+ idleDuration,
609
+ });
610
+ }
611
+ } catch {}
612
+ }
613
+
614
+ // =========================================================================
615
+ // Time-based Triggers (60s)
616
+ // =========================================================================
617
+ _checkTimeTriggers() {
618
+ if (!this.enabled) return;
619
+ const now = new Date();
620
+ const hour = now.getHours();
621
+ const day = now.getDay(); // 0=Sun, 6=Sat
622
+
623
+ // late_night: 23:00 ~ 05:00
624
+ if (hour >= 23 || hour < 5) {
625
+ this._fire('late_night', { hour }, 600000);
626
+
627
+ // dawn_coding: 02:00 ~ 05:00 + IDE active
628
+ if (hour >= 2 && hour < 5) {
629
+ const cat = this._lastCategory;
630
+ if (cat === 'coding' || cat === 'terminal') {
631
+ this._fire('dawn_coding', { hour, category: cat }, 600000);
632
+ }
633
+ }
634
+ }
635
+
636
+ // pre_lunch: 11:30 ~ 12:00
637
+ if (hour === 11 && now.getMinutes() >= 30) {
638
+ this._fire('pre_lunch', { hour }, 1800000); // 30min cooldown
639
+ }
640
+
641
+ // end_of_work: 17:30 ~ 18:30
642
+ if ((hour === 17 && now.getMinutes() >= 30) || (hour === 18 && now.getMinutes() <= 30)) {
643
+ this._fire('end_of_work', { hour }, 1800000);
644
+ }
645
+
646
+ // weekend_work: Sat/Sun + work-related app
647
+ if (day === 0 || day === 6) {
648
+ const cat = this._lastCategory;
649
+ if (cat === 'coding' || cat === 'document' || cat === 'terminal' || cat === 'email') {
650
+ this._fire('weekend_work', { day, category: cat }, 3600000); // 1hr cooldown
651
+ }
652
+ }
653
+ }
654
+
655
+ // =========================================================================
656
+ // Complex Pattern Detection
657
+ // =========================================================================
658
+
659
+ /**
660
+ * Wiki rabbit hole: 3+ wiki page changes in 60s
661
+ */
662
+ _checkWikiRabbitHole(titleLower, now) {
663
+ if (!titleLower.includes('wikipedia') && !titleLower.includes('namu.wiki') &&
664
+ !titleLower.includes('\uB098\uBB34\uC704\uD0A4') && !titleLower.includes('fandom')) return;
665
+
666
+ const recentWiki = this._titleHistory.filter(
667
+ (h) => now - h.timestamp < 60000 && h.category === 'wiki'
668
+ );
669
+ if (recentWiki.length >= 3) {
670
+ this._fire('wiki_rabbit_hole', {
671
+ count: recentWiki.length,
672
+ titles: recentWiki.map((h) => h.title).slice(-3),
673
+ });
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Price comparison: switching between shopping sites 3+ times in 60s
679
+ */
680
+ _checkPriceComparison(now) {
681
+ const recentShopping = this._titleHistory.filter(
682
+ (h) => now - h.timestamp < 60000 && h.category === 'shopping'
683
+ );
684
+ // Need 3+ distinct titles from shopping category
685
+ const uniqueTitles = new Set(recentShopping.map((h) => h.title));
686
+ if (uniqueTitles.size >= 3) {
687
+ this._fire('price_comparison', {
688
+ count: uniqueTitles.size,
689
+ timespan: 60,
690
+ });
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Research mode: search engine + clipboard copies in 30s window
696
+ */
697
+ _checkResearchMode(now) {
698
+ const recentSearch = this._titleHistory.filter(
699
+ (h) => now - h.timestamp < 30000 && h.category === 'search'
700
+ );
701
+ const recentCopies = this._clipHistory.filter(
702
+ (h) => now - h.timestamp < 30000
703
+ );
704
+ if (recentSearch.length >= 1 && recentCopies.length >= 2) {
705
+ this._fire('research_mode', {
706
+ searches: recentSearch.length,
707
+ copies: recentCopies.length,
708
+ });
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Procrastination: rapid switching between work (coding/doc/terminal)
714
+ * and entertainment (social/video/gaming) 3+ times in 60s
715
+ */
716
+ _checkProcrastination(now) {
717
+ const recent = this._categoryHistory.filter(
718
+ (h) => now - h.timestamp < 60000
719
+ );
720
+ if (recent.length < 4) return;
721
+
722
+ const workCats = new Set(['coding', 'document', 'terminal', 'dev_web']);
723
+ const funCats = new Set(['social', 'video', 'gaming', 'news']);
724
+
725
+ let switches = 0;
726
+ for (let i = 1; i < recent.length; i++) {
727
+ const prev = recent[i - 1].category;
728
+ const curr = recent[i].category;
729
+ if (
730
+ (workCats.has(prev) && funCats.has(curr)) ||
731
+ (funCats.has(prev) && workCats.has(curr))
732
+ ) {
733
+ switches++;
734
+ }
735
+ }
736
+
737
+ if (switches >= 3) {
738
+ this._fire('procrastination', {
739
+ switches,
740
+ timespan: 60,
741
+ });
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Repeated search: 3+ different search queries in 60s
747
+ * (indicates user can't find what they need)
748
+ */
749
+ _checkRepeatedSearch(now) {
750
+ const recentSearch = this._titleHistory.filter(
751
+ (h) => now - h.timestamp < 60000 && h.category === 'search'
752
+ );
753
+ const uniqueSearches = new Set(recentSearch.map((h) => h.title));
754
+ if (uniqueSearches.size >= 3) {
755
+ this._fire('repeated_search', {
756
+ count: uniqueSearches.size,
757
+ timespan: 60,
758
+ });
759
+ }
760
+ }
761
+
762
+ /**
763
+ * Search pattern: copy text then visit search engine within 30s
764
+ */
765
+ _checkSearchPattern(copiedText, now) {
766
+ // Set a flag, check when next window title arrives
767
+ this._pendingSearchCheck = { text: copiedText, timestamp: now };
768
+
769
+ // Also check immediately with current title
770
+ if (this._lastCategory === 'search') {
771
+ this._fire('search_pattern', {
772
+ copiedText: copiedText.substring(0, 100),
773
+ searchTitle: this._lastTitle,
774
+ });
775
+ }
776
+ }
777
+
778
+ // =========================================================================
779
+ // Focus break detection (called when app switches from deep focus)
780
+ // =========================================================================
781
+ _checkFocusBreak(fromCategory, toCategory) {
782
+ const workCats = new Set(['coding', 'document', 'terminal', 'dev_web']);
783
+ const funCats = new Set(['social', 'video', 'gaming']);
784
+
785
+ if (workCats.has(fromCategory) && funCats.has(toCategory)) {
786
+ const focusDuration = Date.now() - this._sameAppSince;
787
+ if (focusDuration >= 1200000) { // was focused 20+ minutes
788
+ this._fire('focus_break', {
789
+ fromCategory,
790
+ toCategory,
791
+ focusDuration: Math.floor(focusDuration / 1000),
792
+ });
793
+ }
794
+ }
795
+ }
796
+
797
+ // =========================================================================
798
+ // Helper: Categorize title
799
+ // =========================================================================
800
+ _categorizeTitle(titleLower) {
801
+ for (const [category, def] of Object.entries(SITE_CATEGORIES)) {
802
+ for (const pattern of def.patterns) {
803
+ if (titleLower.includes(pattern.toLowerCase())) {
804
+ return category;
805
+ }
806
+ }
807
+ }
808
+ return null;
809
+ }
810
+
811
+ /**
812
+ * Extract app name from window title
813
+ * "Document.txt - Notepad" -> "Notepad"
814
+ * "Google - Chrome" -> "Chrome"
815
+ */
816
+ _extractAppName(title) {
817
+ // Common separators: " - ", " | ", " \u2014 "
818
+ const separators = [' - ', ' | ', ' \u2014 ', ' \u2013 '];
819
+ for (const sep of separators) {
820
+ const idx = title.lastIndexOf(sep);
821
+ if (idx > 0) {
822
+ return title.substring(idx + sep.length).trim();
823
+ }
824
+ }
825
+ return title.trim();
826
+ }
827
+
828
+ /**
829
+ * Check if title contains error indicators
830
+ */
831
+ _isErrorTitle(titleLower) {
832
+ return ERROR_PATTERNS.some((p) => titleLower.includes(p.toLowerCase()));
833
+ }
834
+
835
+ // =========================================================================
836
+ // Screen Capture (for visual triggers)
837
+ // =========================================================================
838
+
839
+ /**
840
+ * 현재 화면을 캡처하여 base64 JPEG로 반환
841
+ * 실패 시 null (graceful degradation)
842
+ */
843
+ async _captureScreen() {
844
+ try {
845
+ const sources = await desktopCapturer.getSources({
846
+ types: ['screen'],
847
+ thumbnailSize: { width: 960, height: 540 },
848
+ });
849
+ if (sources.length === 0) return null;
850
+
851
+ const thumbnail = sources[0].thumbnail;
852
+ const jpegBuffer = thumbnail.toJPEG(40);
853
+ return {
854
+ image: jpegBuffer.toString('base64'),
855
+ width: thumbnail.getSize().width,
856
+ height: thumbnail.getSize().height,
857
+ };
858
+ } catch {
859
+ return null;
860
+ }
861
+ }
862
+
863
+ /**
864
+ * 현재 커서 위치 반환
865
+ */
866
+ _getCursorPosition() {
867
+ try {
868
+ const point = screen.getCursorScreenPoint();
869
+ return { x: point.x, y: point.y };
870
+ } catch {
871
+ return null;
872
+ }
873
+ }
874
+
875
+ // =========================================================================
876
+ // Intervention Decider (cooldown + fire)
877
+ // =========================================================================
878
+ async _fire(triggerType, context, customCooldown) {
879
+ if (!this.enabled) return;
880
+
881
+ const now = Date.now();
882
+
883
+ // Global cooldown
884
+ if (now - this._lastEventTime < this._globalCooldown) return;
885
+
886
+ // Per-trigger cooldown
887
+ const cooldown = customCooldown || this._getDefaultCooldown(triggerType);
888
+ const lastFire = this._triggerCooldowns[triggerType] || 0;
889
+ if (now - lastFire < cooldown) return;
890
+
891
+ // Update cooldown tracking
892
+ this._lastEventTime = now;
893
+ this._triggerCooldowns[triggerType] = now;
894
+
895
+ // Build event payload
896
+ const event = {
897
+ trigger: triggerType,
898
+ context: context || {},
899
+ timestamp: now,
900
+ activeTitle: this._lastTitle,
901
+ activeApp: this._lastAppName,
902
+ };
903
+
904
+ // Emit for internal listeners
905
+ this.emit('trigger', event);
906
+
907
+ // Send to renderer via IPC
908
+ if (this.mainWindow && !this.mainWindow.isDestroyed()) {
909
+ this.mainWindow.webContents.send('proactive-event', event);
910
+ }
911
+
912
+ // Send to AI Bridge if connected (시각 트리거는 화면 캡처 포함)
913
+ if (this.aiBridge && this.aiBridge.isConnected()) {
914
+ const aiContext = {
915
+ ...context,
916
+ activeTitle: this._lastTitle,
917
+ activeApp: this._lastAppName,
918
+ };
919
+
920
+ // 시각 트리거: 화면 캡처 + 커서 위치 번들링
921
+ if (VISUAL_TRIGGERS.has(triggerType)) {
922
+ const [screenData, cursor] = await Promise.all([
923
+ this._captureScreen(),
924
+ Promise.resolve(this._getCursorPosition()),
925
+ ]);
926
+ if (screenData) {
927
+ aiContext.screen = screenData;
928
+ }
929
+ if (cursor) {
930
+ aiContext.cursor = cursor;
931
+ }
932
+ }
933
+
934
+ this.aiBridge.reportProactiveEvent(triggerType, aiContext);
935
+ }
936
+
937
+ console.log(`[ProactiveMonitor] Fired: ${triggerType}${VISUAL_TRIGGERS.has(triggerType) ? ' (with screen)' : ''}`);
938
+ }
939
+
940
+ /**
941
+ * Default cooldowns per trigger type
942
+ */
943
+ _getDefaultCooldown(trigger) {
944
+ const cooldowns = {
945
+ // Clipboard triggers
946
+ clipboard_copy: 10000,
947
+ clipboard_screenshot: 30000,
948
+ repeated_copy: 60000,
949
+ url_copied: 15000,
950
+ code_copied: 20000,
951
+ long_text_copied: 30000,
952
+ email_copied: 30000,
953
+ phone_copied: 30000,
954
+
955
+ // App/window triggers
956
+ app_switch: 20000,
957
+ error_detected: 30000,
958
+ error_loop: 120000,
959
+ meeting_detected: 300000,
960
+ rapid_switching: 120000,
961
+
962
+ // Behavior pattern triggers
963
+ search_pattern: 30000,
964
+ idle_return: 60000,
965
+ long_focus: 300000,
966
+ deep_focus: 600000,
967
+ social_scrolling: 300000,
968
+ wiki_rabbit_hole: 120000,
969
+ price_comparison: 120000,
970
+ research_mode: 60000,
971
+ procrastination: 120000,
972
+ focus_break: 120000,
973
+ repeated_search: 60000,
974
+
975
+ // Time triggers
976
+ late_night: 600000,
977
+ dawn_coding: 600000,
978
+ pre_lunch: 1800000,
979
+ end_of_work: 1800000,
980
+ weekend_work: 3600000,
981
+
982
+ // Category triggers (defaults, overridden by SITE_CATEGORIES)
983
+ shopping_detected: 120000,
984
+ checkout_detected: 60000,
985
+ news_reading: 120000,
986
+ video_watching: 120000,
987
+ coding_detected: 300000,
988
+ terminal_active: 300000,
989
+ music_playing: 300000,
990
+ food_ordering: 120000,
991
+ travel_planning: 120000,
992
+ learning_activity: 300000,
993
+ email_checking: 120000,
994
+ gaming_detected: 300000,
995
+ login_page: 60000,
996
+ finance_activity: 120000,
997
+ document_editing: 300000,
998
+ search_detected: 30000,
999
+ wiki_browsing: 60000,
1000
+ dev_web_detected: 120000,
1001
+ download_detected: 60000,
1002
+ reading_pdf: 300000,
1003
+ file_management: 120000,
1004
+ };
1005
+ return cooldowns[trigger] || 30000;
1006
+ }
1007
+ }
1008
+
1009
+ module.exports = { ProactiveMonitor };