clawmate 1.2.0 → 1.3.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.
package/main/ai-bridge.js CHANGED
@@ -133,6 +133,12 @@ class AIBridge extends EventEmitter {
133
133
  this.emit('drop_file', payload);
134
134
  break;
135
135
 
136
+ case 'smart_file_op':
137
+ // 스마트 파일 조작 (텔레그램 또는 AI에서 트리거)
138
+ // payload: { phase: 'pick_up'|'drop'|'complete', fileName?, targetName?, ... }
139
+ this.emit('smart_file_op', payload);
140
+ break;
141
+
136
142
  // === 외형 변화 ===
137
143
  case 'evolve':
138
144
  // 진화 트리거
@@ -188,6 +194,43 @@ class AIBridge extends EventEmitter {
188
194
  this.emit('query_windows', payload);
189
195
  break;
190
196
 
197
+ // === 커스텀 이동 패턴 ===
198
+ case 'register_movement':
199
+ // OpenClaw이 커스텀 이동 패턴 등록
200
+ // payload: { name: string, definition: { type: 'waypoints'|'formula'|'sequence', ... } }
201
+ this.emit('register_movement', payload);
202
+ break;
203
+
204
+ case 'custom_move':
205
+ // 등록된 커스텀 이동 패턴 실행
206
+ // payload: { name: string, params?: object }
207
+ this.emit('custom_move', payload);
208
+ break;
209
+
210
+ case 'stop_custom_move':
211
+ // 현재 커스텀 이동 강제 중지
212
+ // payload: {}
213
+ this.emit('stop_custom_move', payload);
214
+ break;
215
+
216
+ case 'list_movements':
217
+ // 등록된 이동 패턴 목록 요청 → 응답은 renderer에서 reportToAI로 전송
218
+ // payload: {}
219
+ this.emit('list_movements', payload);
220
+ break;
221
+
222
+ // === 캐릭터 커스터마이징 ===
223
+ case 'set_character':
224
+ // AI가 생성한 캐릭터 데이터 적용
225
+ // payload: { colorMap?: {...}, frames?: {...} }
226
+ this.emit('set_character', payload);
227
+ break;
228
+
229
+ case 'reset_character':
230
+ // 원래 캐릭터로 리셋
231
+ this.emit('reset_character', payload);
232
+ break;
233
+
191
234
  // === 컨텍스트 질의 ===
192
235
  case 'query_state':
193
236
  // 현재 펫 상태 요청
@@ -285,6 +328,17 @@ class AIBridge extends EventEmitter {
285
328
  });
286
329
  }
287
330
 
331
+ /**
332
+ * 메트릭 데이터를 OpenClaw에 전송
333
+ * 렌더러에서 수집한 펫 동작 품질 메트릭을 AI에 전달
334
+ */
335
+ reportMetrics(summary) {
336
+ this.send('metrics_report', {
337
+ metrics: summary,
338
+ timestamp: Date.now(),
339
+ });
340
+ }
341
+
288
342
  // === 상태 업데이트 ===
289
343
 
290
344
  updatePetState(updates) {
@@ -95,6 +95,11 @@ class OpenClawConnector extends EventEmitter {
95
95
  this.emit('window_positions', payload);
96
96
  break;
97
97
 
98
+ case 'metrics_report':
99
+ // 메트릭 데이터 수신 → OpenClaw AI가 분석
100
+ this.emit('metrics_report', payload);
101
+ break;
102
+
98
103
  case 'heartbeat':
99
104
  break;
100
105
  }
@@ -197,6 +202,74 @@ class OpenClawConnector extends EventEmitter {
197
202
  return this._send('query_windows', {});
198
203
  }
199
204
 
205
+ // === 커스텀 이동 패턴 API ===
206
+
207
+ /**
208
+ * 커스텀 이동 패턴 등록
209
+ * ClawMate에 새로운 이동 패턴을 동적으로 추가
210
+ *
211
+ * @param {string} name - 패턴 이름 (예: 'figure8', 'spiral')
212
+ * @param {object} definition - 패턴 정의
213
+ * type: 'waypoints' | 'formula' | 'sequence'
214
+ * waypoints?: [{x, y, pause?}] — 웨이포인트 순차 이동
215
+ * formula?: { xAmp, yAmp, xFreq, yFreq, xPhase, yPhase } — 수학 궤도
216
+ * sequence?: ['zigzag', 'shake'] — 기존 패턴 순차 실행
217
+ * duration?: number — 지속 시간 (ms, formula 타입)
218
+ * speed?: number — 이동 속도
219
+ *
220
+ * 사용 예:
221
+ * connector.registerMovement('figure8', {
222
+ * type: 'formula',
223
+ * formula: { xAmp: 80, yAmp: 40, xFreq: 1, yFreq: 2 },
224
+ * duration: 4000,
225
+ * });
226
+ */
227
+ registerMovement(name, definition) {
228
+ return this._send('register_movement', { name, definition });
229
+ }
230
+
231
+ /**
232
+ * 등록된 커스텀 이동 패턴 실행
233
+ *
234
+ * @param {string} name - 실행할 패턴 이름
235
+ * 사전 등록 패턴: 'zigzag', 'patrol', 'circle', 'shake', 'dance'
236
+ * 또는 registerMovement()로 등록한 커스텀 패턴
237
+ * @param {object} params - 실행 파라미터 (패턴별로 다름)
238
+ *
239
+ * 사용 예:
240
+ * connector.customMove('zigzag', { distance: 200, amplitude: 30 });
241
+ * connector.customMove('patrol', { pointAX: 100, pointBX: 500, laps: 5 });
242
+ * connector.customMove('shake', { intensity: 6, duration: 1000 });
243
+ */
244
+ customMove(name, params = {}) {
245
+ return this._send('custom_move', { name, params });
246
+ }
247
+
248
+ /** 현재 실행 중인 커스텀 이동 강제 중지 */
249
+ stopCustomMove() {
250
+ return this._send('stop_custom_move', {});
251
+ }
252
+
253
+ /** 등록된 이동 패턴 목록 요청 (응답은 user_event로 수신) */
254
+ listMovements() {
255
+ return this._send('list_movements', {});
256
+ }
257
+
258
+ /** 스마트 파일 조작 명령 전송 */
259
+ smartFileOp(payload) {
260
+ return this._send('smart_file_op', payload);
261
+ }
262
+
263
+ /** 캐릭터 데이터 전송 (AI 생성 캐릭터 적용) */
264
+ setCharacter(data) {
265
+ return this._send('set_character', data);
266
+ }
267
+
268
+ /** 원래 캐릭터로 리셋 */
269
+ resetCharacter() {
270
+ return this._send('reset_character', {});
271
+ }
272
+
200
273
  /**
201
274
  * 현재 펫 상태 요청 (Promise 반환)
202
275
  * 서버에서 state_response가 오면 resolve, 타임아웃 시 캐시된 상태 반환
@@ -234,6 +307,11 @@ class OpenClawConnector extends EventEmitter {
234
307
  this.on('user_event', callback);
235
308
  }
236
309
 
310
+ /** 메트릭 리포트 리스너 등록 */
311
+ onMetrics(callback) {
312
+ this.on('metrics_report', callback);
313
+ }
314
+
237
315
  disconnect() {
238
316
  this._autoReconnect = false;
239
317
  if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
@@ -0,0 +1,324 @@
1
+ /**
2
+ * 파일 조작 명령 파서
3
+ *
4
+ * 한국어/영어 자연어를 파싱하여 파일 조작 의도를 추출한다.
5
+ * 텔레그램 메시지에서 파일 이동, 정리 등의 명령을 감지.
6
+ *
7
+ * 지원 패턴:
8
+ * - "바탕화면의 .md 파일을 tata 폴더에 넣어줘"
9
+ * - "스크린샷 폴더에 .png 정리해"
10
+ * - "바탕화면 정리해"
11
+ * - "move .txt files to docs folder"
12
+ */
13
+
14
+ const os = require('os');
15
+ const path = require('path');
16
+
17
+ // 알려진 소스 경로 별칭 (한국어 + 영어)
18
+ const SOURCE_ALIASES = {
19
+ '바탕화면': () => _getDesktopPath(),
20
+ '데스크탑': () => _getDesktopPath(),
21
+ '데스크톱': () => _getDesktopPath(),
22
+ 'desktop': () => _getDesktopPath(),
23
+ '다운로드': () => path.join(os.homedir(), 'Downloads'),
24
+ '다운': () => path.join(os.homedir(), 'Downloads'),
25
+ 'downloads': () => path.join(os.homedir(), 'Downloads'),
26
+ '문서': () => path.join(os.homedir(), 'Documents'),
27
+ 'documents': () => path.join(os.homedir(), 'Documents'),
28
+ };
29
+
30
+ // 행동 명령 키워드 → 액션 매핑
31
+ const ACTION_KEYWORDS = {
32
+ '점프': 'jumping',
33
+ '점프해': 'jumping',
34
+ '뛰어': 'jumping',
35
+ 'jump': 'jumping',
36
+ '잠자': 'sleeping',
37
+ '자': 'sleeping',
38
+ '잠잘래': 'sleeping',
39
+ 'sleep': 'sleeping',
40
+ '춤춰': 'excited',
41
+ '춤': 'excited',
42
+ 'dance': 'excited',
43
+ '걸어': 'walking',
44
+ '걸어다녀': 'walking',
45
+ 'walk': 'walking',
46
+ '올라가': 'climbing_up',
47
+ '기어올라': 'climbing_up',
48
+ 'climb': 'climbing_up',
49
+ '신나': 'excited',
50
+ '신나게': 'excited',
51
+ '놀아': 'playing',
52
+ '놀자': 'playing',
53
+ 'play': 'playing',
54
+ '무서워': 'scared',
55
+ '깜짝': 'scared',
56
+ '레펠': 'rappelling',
57
+ '내려와': 'rappelling',
58
+ 'rappel': 'rappelling',
59
+ };
60
+
61
+ // 파일 조작 감지 키워드
62
+ const FILE_OP_PATTERNS = [
63
+ // "~의 .ext 파일을 ~폴더에 넣어줘/옮겨줘/이동해"
64
+ /(?:(.+?)(?:의|에서|에 있는)\s+)?([.\w*]+)\s*파일(?:을|들을)?\s+(.+?)(?:폴더)?(?:에|으로)\s*(?:넣어|옮겨|이동|정리|보내)/,
65
+ // "~폴더에 .ext 정리해"
66
+ /(.+?)(?:폴더)?(?:에|으로)\s+([.\w*]+)\s*(?:파일\s*)?(?:정리|넣어|옮겨|이동)/,
67
+ // "바탕화면 정리해"
68
+ /(.+?)\s*(?:정리|청소|깔끔하게)\s*(?:해|해줘|하자|좀)/,
69
+ // 영어: "move .ext files to folder"
70
+ /move\s+([.\w*]+)\s+files?\s+(?:to|into)\s+(\S+)/i,
71
+ // 영어: "clean up desktop"
72
+ /clean\s*(?:up)?\s+(\S+)/i,
73
+ // 영어: "organize desktop"
74
+ /organize\s+(\S+)/i,
75
+ ];
76
+
77
+ /**
78
+ * 데스크톱 경로 가져오기 (file-ops와 동일 로직)
79
+ */
80
+ function _getDesktopPath() {
81
+ try {
82
+ const { getDesktopPath } = require('./desktop-path');
83
+ return getDesktopPath();
84
+ } catch {
85
+ return path.join(os.homedir(), 'Desktop');
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 소스 별칭을 실제 경로로 변환
91
+ * @param {string} alias - 소스 별칭 (예: "바탕화면")
92
+ * @returns {string|null} 실제 경로 또는 null
93
+ */
94
+ function resolveSource(alias) {
95
+ if (!alias) return null;
96
+ const trimmed = alias.trim().toLowerCase();
97
+ for (const [key, resolver] of Object.entries(SOURCE_ALIASES)) {
98
+ if (trimmed === key || trimmed.includes(key)) {
99
+ return resolver();
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * 자동 분류 확장자 → 폴더명 매핑
107
+ * "바탕화면 정리해" 같은 범용 명령에서 사용
108
+ */
109
+ const AUTO_CATEGORIES = {
110
+ '.png': '이미지',
111
+ '.jpg': '이미지',
112
+ '.jpeg': '이미지',
113
+ '.gif': '이미지',
114
+ '.bmp': '이미지',
115
+ '.webp': '이미지',
116
+ '.svg': '이미지',
117
+ '.pdf': '문서',
118
+ '.doc': '문서',
119
+ '.docx': '문서',
120
+ '.xlsx': '문서',
121
+ '.xls': '문서',
122
+ '.pptx': '문서',
123
+ '.ppt': '문서',
124
+ '.txt': '문서',
125
+ '.hwp': '문서',
126
+ '.md': '문서',
127
+ '.zip': '압축파일',
128
+ '.rar': '압축파일',
129
+ '.7z': '압축파일',
130
+ '.tar': '압축파일',
131
+ '.gz': '압축파일',
132
+ '.mp3': '음악',
133
+ '.wav': '음악',
134
+ '.flac': '음악',
135
+ '.aac': '음악',
136
+ '.mp4': '동영상',
137
+ '.avi': '동영상',
138
+ '.mkv': '동영상',
139
+ '.mov': '동영상',
140
+ '.wmv': '동영상',
141
+ };
142
+
143
+ /**
144
+ * 메시지에서 행동 명령을 감지
145
+ * @param {string} text - 사용자 메시지
146
+ * @returns {{ type: 'action', action: string }|null}
147
+ */
148
+ function parseActionCommand(text) {
149
+ if (!text) return null;
150
+ const trimmed = text.trim().toLowerCase();
151
+
152
+ for (const [keyword, action] of Object.entries(ACTION_KEYWORDS)) {
153
+ if (trimmed === keyword || trimmed.includes(keyword)) {
154
+ return { type: 'action', action };
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+
160
+ /**
161
+ * 메시지에서 파일 조작 명령을 감지
162
+ * @param {string} text - 사용자 메시지
163
+ * @returns {{ type: 'smart_file_op', source: string, filter: string, target: string, autoCategory: boolean }|null}
164
+ */
165
+ function parseFileCommand(text) {
166
+ if (!text) return null;
167
+ const trimmed = text.trim();
168
+
169
+ // 패턴 1: "바탕화면의 .md 파일을 tata 폴더에 넣어줘"
170
+ const pattern1 = /(?:(.+?)(?:의|에서|에 있는)\s+)?([.\w*]+)\s*파일(?:을|들을)?\s+(.+?)(?:\s*폴더)?(?:에|으로)\s*(?:넣어|옮겨|이동|정리|보내)/;
171
+ let match = trimmed.match(pattern1);
172
+ if (match) {
173
+ const sourceName = match[1] || '바탕화면';
174
+ const filter = match[2];
175
+ const target = match[3].trim();
176
+ const source = resolveSource(sourceName) || _getDesktopPath();
177
+
178
+ return {
179
+ type: 'smart_file_op',
180
+ source,
181
+ filter: filter.startsWith('.') ? filter : `.${filter}`,
182
+ target,
183
+ autoCategory: false,
184
+ };
185
+ }
186
+
187
+ // 패턴 2: "스크린샷 폴더에 .png 정리해"
188
+ const pattern2 = /(.+?)(?:\s*폴더)?(?:에|으로)\s+([.\w*]+)\s*(?:파일\s*)?(?:정리|넣어|옮겨|이동)/;
189
+ match = trimmed.match(pattern2);
190
+ if (match) {
191
+ const target = match[1].trim();
192
+ const filter = match[2];
193
+
194
+ return {
195
+ type: 'smart_file_op',
196
+ source: _getDesktopPath(),
197
+ filter: filter.startsWith('.') ? filter : `.${filter}`,
198
+ target,
199
+ autoCategory: false,
200
+ };
201
+ }
202
+
203
+ // 패턴 3: "바탕화면 정리해" (자동 분류)
204
+ const pattern3 = /(.+?)\s*(?:정리|청소|깔끔하게)\s*(?:해|해줘|하자|좀)?$/;
205
+ match = trimmed.match(pattern3);
206
+ if (match) {
207
+ const sourceName = match[1].trim();
208
+ const source = resolveSource(sourceName);
209
+ if (source) {
210
+ return {
211
+ type: 'smart_file_op',
212
+ source,
213
+ filter: '*',
214
+ target: 'auto',
215
+ autoCategory: true,
216
+ };
217
+ }
218
+ }
219
+
220
+ // 패턴 4 (영어): "move .txt files to docs"
221
+ const pattern4 = /move\s+([.\w*]+)\s+files?\s+(?:to|into)\s+(\S+)/i;
222
+ match = trimmed.match(pattern4);
223
+ if (match) {
224
+ const filter = match[1];
225
+ const target = match[2];
226
+
227
+ return {
228
+ type: 'smart_file_op',
229
+ source: _getDesktopPath(),
230
+ filter: filter.startsWith('.') ? filter : `.${filter}`,
231
+ target,
232
+ autoCategory: false,
233
+ };
234
+ }
235
+
236
+ // 패턴 5 (영어): "clean up desktop" / "organize desktop"
237
+ const pattern5 = /(?:clean\s*(?:up)?|organize)\s+(\S+)/i;
238
+ match = trimmed.match(pattern5);
239
+ if (match) {
240
+ const sourceName = match[1].trim();
241
+ const source = resolveSource(sourceName);
242
+ if (source) {
243
+ return {
244
+ type: 'smart_file_op',
245
+ source,
246
+ filter: '*',
247
+ target: 'auto',
248
+ autoCategory: true,
249
+ };
250
+ }
251
+ }
252
+
253
+ return null;
254
+ }
255
+
256
+ /**
257
+ * 캐릭터 변경 명령 감지
258
+ * @param {string} text - 사용자 메시지
259
+ * @returns {{ type: 'character_change', concept: string }|null}
260
+ */
261
+ function parseCharacterCommand(text) {
262
+ if (!text) return null;
263
+ const trimmed = text.trim();
264
+
265
+ // 한국어 캐릭터 변경 패턴
266
+ const krPatterns = [
267
+ /(?:캐릭터|펫|모습|외형|외모)(?:를|을)?\s*(.+?)(?:로|으로)\s*(?:바꿔|변경|변신|만들어|바꿀래|바꾸고|바꿔줘|변경해|만들어줘)/,
268
+ /(.+?)(?:로|으로)\s*(?:캐릭터|펫|모습|외형)\s*(?:바꿔|변경|변신|변경해|바꿔줘)/,
269
+ /(.+?)\s*(?:캐릭터|펫)\s*(?:만들어|만들어줘|생성|생성해)/,
270
+ /(.+?)(?:로|으로)\s*변신(?:해|해줘|시켜|시켜줘)?/,
271
+ ];
272
+ for (const pattern of krPatterns) {
273
+ const match = trimmed.match(pattern);
274
+ if (match) {
275
+ return { type: 'character_change', concept: match[1].trim() };
276
+ }
277
+ }
278
+
279
+ // 영어 캐릭터 변경 패턴
280
+ const enPatterns = [
281
+ /(?:change|switch|transform)\s+(?:character|pet|look)\s+(?:to|into)\s+(.+)/i,
282
+ /(?:make|create|generate)\s+(?:a\s+)?(.+?)\s+(?:character|pet)/i,
283
+ /(?:become|turn into)\s+(?:a\s+)?(.+)/i,
284
+ ];
285
+ for (const pattern of enPatterns) {
286
+ const match = trimmed.match(pattern);
287
+ if (match) {
288
+ return { type: 'character_change', concept: match[1].trim() };
289
+ }
290
+ }
291
+
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * 메시지 종합 파싱: 캐릭터 변경 > 파일 조작 > 행동 명령 > 일반 대화 순으로 판별
297
+ * @param {string} text - 사용자 메시지
298
+ * @returns {{ type: string, ... }}
299
+ */
300
+ function parseMessage(text) {
301
+ // 0순위: 캐릭터 변경 명령
302
+ const charCmd = parseCharacterCommand(text);
303
+ if (charCmd) return charCmd;
304
+
305
+ // 1순위: 파일 조작 명령
306
+ const fileCmd = parseFileCommand(text);
307
+ if (fileCmd) return fileCmd;
308
+
309
+ // 2순위: 행동 명령
310
+ const actionCmd = parseActionCommand(text);
311
+ if (actionCmd) return actionCmd;
312
+
313
+ // 3순위: 일반 대화 (speak)
314
+ return { type: 'speak', text };
315
+ }
316
+
317
+ module.exports = {
318
+ parseMessage,
319
+ parseFileCommand,
320
+ parseActionCommand,
321
+ parseCharacterCommand,
322
+ resolveSource,
323
+ AUTO_CATEGORIES,
324
+ };
package/main/index.js CHANGED
@@ -3,10 +3,12 @@ const path = require('path');
3
3
  const { setupTray } = require('./tray');
4
4
  const { registerIpcHandlers } = require('./ipc-handlers');
5
5
  const { AIBridge } = require('./ai-bridge');
6
+ const { TelegramBot } = require('./telegram');
6
7
 
7
8
  let mainWindow = null;
8
9
  let launcherWindow = null;
9
10
  let aiBridge = null;
11
+ let telegramBot = null;
10
12
 
11
13
  function createMainWindow() {
12
14
  const { width, height } = screen.getPrimaryDisplay().workAreaSize;
@@ -80,6 +82,12 @@ function startAIBridge(win) {
80
82
  'accessorize', 'ai_decision',
81
83
  // 공간 이동 명령 (OpenClaw이 집처럼 돌아다니기)
82
84
  'jump_to', 'rappel', 'release_thread', 'move_to_center', 'walk_on_window',
85
+ // 커스텀 이동 패턴
86
+ 'register_movement', 'custom_move', 'stop_custom_move', 'list_movements',
87
+ // 스마트 파일 조작 (텔레그램/AI에서 트리거한 파일 이동 애니메이션)
88
+ 'smart_file_op',
89
+ // 캐릭터 커스터마이징 (텔레그램에서 AI 생성)
90
+ 'set_character', 'reset_character',
83
91
  ];
84
92
 
85
93
  commandTypes.forEach((type) => {
@@ -149,6 +157,9 @@ app.whenReady().then(() => {
149
157
  const bridge = startAIBridge(win);
150
158
  setupTray(win, bridge);
151
159
 
160
+ // 텔레그램 봇 초기화 (토큰 없으면 조용히 무시)
161
+ telegramBot = new TelegramBot(bridge);
162
+
152
163
  // 최초 설치 시 자동 시작 등록
153
164
  const { enableAutoStart, isAutoStartEnabled } = require('./autostart');
154
165
  if (!isAutoStartEnabled()) {
@@ -165,6 +176,7 @@ app.on('window-all-closed', () => {
165
176
  });
166
177
 
167
178
  app.on('before-quit', () => {
179
+ if (telegramBot) telegramBot.stop();
168
180
  if (aiBridge) aiBridge.stop();
169
181
  });
170
182
 
@@ -1,5 +1,7 @@
1
1
  const { ipcMain, screen, desktopCapturer } = require('electron');
2
2
  const { getDesktopFiles, moveFile, undoFileMove, undoAllMoves, getFileManifest } = require('./file-ops');
3
+ const { executeSmartFileOp, undoSmartMove, undoAllSmartMoves, listFilteredFiles } = require('./smart-file-ops');
4
+ const { parseMessage } = require('./file-command-parser');
3
5
  const Store = require('./store');
4
6
 
5
7
  const store = new Store('clawmate-config', {
@@ -119,6 +121,14 @@ function registerIpcHandlers(getMainWindow, getAIBridge) {
119
121
  case 'user_idle':
120
122
  bridge.reportIdleTime(data.idleSeconds);
121
123
  break;
124
+ case 'browsing':
125
+ // 브라우징 컨텍스트 (제목 + 커서 위치 + 화면 캡처) → AI 코멘트 생성
126
+ bridge.send('user_event', { event: 'browsing', ...data });
127
+ break;
128
+ default:
129
+ // 알 수 없는 이벤트도 AI에 전달 (확장성)
130
+ bridge.send('user_event', { event, ...data });
131
+ break;
122
132
  }
123
133
  }
124
134
  });
@@ -129,11 +139,103 @@ function registerIpcHandlers(getMainWindow, getAIBridge) {
129
139
  return bridge ? bridge.isConnected() : false;
130
140
  });
131
141
 
142
+ // 메트릭 보고 (렌더러 → main → OpenClaw)
143
+ ipcMain.on('report-metrics', (_, summary) => {
144
+ const bridge = getAIBridge();
145
+ if (bridge && bridge.isConnected()) {
146
+ bridge.reportMetrics(summary);
147
+ }
148
+ });
149
+
132
150
  // 열린 윈도우 위치/크기 조회
133
151
  ipcMain.handle('get-window-positions', async () => {
134
152
  const { getWindowPositions } = require('./platform');
135
153
  return await getWindowPositions();
136
154
  });
155
+
156
+ // 활성 윈도우 제목 조회 (브라우저 감시용)
157
+ ipcMain.handle('get-active-window-title', async () => {
158
+ const { getActiveWindowTitle } = require('./platform');
159
+ return await getActiveWindowTitle();
160
+ });
161
+
162
+ // 커서 위치 조회 (화면 좌표)
163
+ ipcMain.handle('get-cursor-position', () => {
164
+ const point = screen.getCursorScreenPoint();
165
+ return { x: point.x, y: point.y };
166
+ });
167
+
168
+ // === 스마트 파일 조작 IPC ===
169
+
170
+ // 파일 명령 파싱 (렌더러에서도 사용 가능)
171
+ ipcMain.handle('parse-file-command', (_, text) => {
172
+ return parseMessage(text);
173
+ });
174
+
175
+ // 필터된 파일 목록 조회
176
+ ipcMain.handle('list-filtered-files', async (_, sourceDir, filter) => {
177
+ return listFilteredFiles(sourceDir, filter);
178
+ });
179
+
180
+ // 스마트 파일 조작 실행
181
+ // 렌더러에서 직접 실행할 때 사용 (텔레그램 경유가 아닌 경우)
182
+ ipcMain.handle('smart-file-op', async (_, command) => {
183
+ const win = getMainWindow();
184
+ const callbacks = {
185
+ onStart: (totalFiles) => {
186
+ if (win && !win.isDestroyed()) {
187
+ win.webContents.send('ai-command', {
188
+ type: 'smart_file_op',
189
+ payload: { phase: 'start', totalFiles },
190
+ });
191
+ }
192
+ },
193
+ onPickUp: (fileName, index) => {
194
+ if (win && !win.isDestroyed()) {
195
+ win.webContents.send('ai-command', {
196
+ type: 'smart_file_op',
197
+ payload: { phase: 'pick_up', fileName, index },
198
+ });
199
+ }
200
+ },
201
+ onDrop: (fileName, targetName, index) => {
202
+ if (win && !win.isDestroyed()) {
203
+ win.webContents.send('ai-command', {
204
+ type: 'smart_file_op',
205
+ payload: { phase: 'drop', fileName, targetName, index },
206
+ });
207
+ }
208
+ },
209
+ onComplete: (result) => {
210
+ if (win && !win.isDestroyed()) {
211
+ win.webContents.send('ai-command', {
212
+ type: 'smart_file_op',
213
+ payload: { phase: 'complete', ...result },
214
+ });
215
+ }
216
+ },
217
+ onError: (error) => {
218
+ if (win && !win.isDestroyed()) {
219
+ win.webContents.send('ai-command', {
220
+ type: 'smart_file_op',
221
+ payload: { phase: 'error', error },
222
+ });
223
+ }
224
+ },
225
+ };
226
+
227
+ return await executeSmartFileOp(command, callbacks);
228
+ });
229
+
230
+ // 스마트 이동 되돌리기 (단일)
231
+ ipcMain.handle('undo-smart-move', async (_, moveId) => {
232
+ return undoSmartMove(moveId);
233
+ });
234
+
235
+ // 스마트 이동 전체 되돌리기
236
+ ipcMain.handle('undo-all-smart-moves', async () => {
237
+ return undoAllSmartMoves();
238
+ });
137
239
  }
138
240
 
139
241
  module.exports = { registerIpcHandlers };