clawmate 1.2.0 → 1.4.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,360 @@
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
+ /**
301
+ * 모드/설정 변경 명령 감지
302
+ * @param {string} text - 사용자 메시지
303
+ * @returns {{ type: 'mode_change', mode: string }|{ type: 'setting', key, value }|null}
304
+ */
305
+ function parseSettingCommand(text) {
306
+ if (!text) return null;
307
+ const t = text.trim().toLowerCase();
308
+
309
+ // 모드 변경
310
+ if (/(?:펫|pet)\s*모드/.test(t)) return { type: 'mode_change', mode: 'pet' };
311
+ if (/(?:인카|인격|incarnation|claw)\s*모드/.test(t)) return { type: 'mode_change', mode: 'incarnation' };
312
+ if (/둘\s*다\s*모드|both\s*mode/i.test(t)) return { type: 'mode_change', mode: 'both' };
313
+
314
+ // 캐릭터 프리셋 선택 (트레이 프리셋과 동일)
315
+ const presetMap = {
316
+ '파란|파랑|blue': 'blue', '초록|green': 'green', '보라|purple': 'purple',
317
+ '골드|금색|gold': 'gold', '핑크|pink': 'pink',
318
+ '고양이|cat': 'cat', '로봇|robot': 'robot', '유령|ghost': 'ghost', '드래곤|dragon': 'dragon',
319
+ '기본|default|원래': 'default',
320
+ };
321
+ for (const [pattern, preset] of Object.entries(presetMap)) {
322
+ const regex = new RegExp(`(?:${pattern})\\s*(?:캐릭터|색|색상|으로)?\\s*(?:바꿔|변경|골라|선택|해줘)?`);
323
+ if (regex.test(t)) {
324
+ return { type: 'preset_character', preset };
325
+ }
326
+ }
327
+
328
+ return null;
329
+ }
330
+
331
+ function parseMessage(text) {
332
+ // 0순위: 설정/모드 변경 명령
333
+ const settingCmd = parseSettingCommand(text);
334
+ if (settingCmd) return settingCmd;
335
+
336
+ // 1순위: 캐릭터 변경 명령 (AI 생성)
337
+ const charCmd = parseCharacterCommand(text);
338
+ if (charCmd) return charCmd;
339
+
340
+ // 2순위: 파일 조작 명령
341
+ const fileCmd = parseFileCommand(text);
342
+ if (fileCmd) return fileCmd;
343
+
344
+ // 3순위: 행동 명령
345
+ const actionCmd = parseActionCommand(text);
346
+ if (actionCmd) return actionCmd;
347
+
348
+ // 4순위: 일반 대화 (speak)
349
+ return { type: 'speak', text };
350
+ }
351
+
352
+ module.exports = {
353
+ parseMessage,
354
+ parseFileCommand,
355
+ parseActionCommand,
356
+ parseCharacterCommand,
357
+ parseSettingCommand,
358
+ resolveSource,
359
+ AUTO_CATEGORIES,
360
+ };
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;
@@ -67,19 +69,27 @@ function createLauncherWindow() {
67
69
  }
68
70
 
69
71
  /**
70
- * AI Bridge 시작 — OpenClaw 에이전트가 접속하면 펫을 조종
72
+ * AI Bridge 시작 — AI 에이전트가 접속하면 펫을 조종
71
73
  */
72
74
  function startAIBridge(win) {
73
75
  aiBridge = new AIBridge();
74
76
  aiBridge.start();
75
77
 
76
- // OpenClaw → ClawMate 명령을 렌더러에 전달
78
+ // AI → ClawMate 명령을 렌더러에 전달
77
79
  const commandTypes = [
78
80
  'action', 'move', 'emote', 'speak', 'think',
79
81
  'carry_file', 'drop_file', 'set_mode', 'evolve',
80
82
  'accessorize', 'ai_decision',
81
- // 공간 이동 명령 (OpenClaw이 집처럼 돌아다니기)
83
+ // 공간 이동 명령 (펫이 집처럼 돌아다니기)
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',
91
+ // 인격체 전환 (Incarnation 모드)
92
+ 'set_persona',
83
93
  ];
84
94
 
85
95
  commandTypes.forEach((type) => {
@@ -90,7 +100,7 @@ function startAIBridge(win) {
90
100
  });
91
101
  });
92
102
 
93
- // OpenClaw 윈도우 위치 정보 요청 처리
103
+ // AI 윈도우 위치 정보 요청 처리
94
104
  aiBridge.on('query_windows', async () => {
95
105
  try {
96
106
  const { getWindowPositions } = require('./platform');
@@ -102,7 +112,7 @@ function startAIBridge(win) {
102
112
  }
103
113
  });
104
114
 
105
- // OpenClaw 화면 캡처 요청 처리 (main process에서 직접 캡처)
115
+ // AI 화면 캡처 요청 처리 (main process에서 직접 캡처)
106
116
  aiBridge.on('query_screen', async () => {
107
117
  try {
108
118
  const primaryDisplay = screen.getPrimaryDisplay();
@@ -149,6 +159,9 @@ app.whenReady().then(() => {
149
159
  const bridge = startAIBridge(win);
150
160
  setupTray(win, bridge);
151
161
 
162
+ // 텔레그램 봇 초기화 (토큰 없으면 조용히 무시)
163
+ telegramBot = new TelegramBot(bridge);
164
+
152
165
  // 최초 설치 시 자동 시작 등록
153
166
  const { enableAutoStart, isAutoStartEnabled } = require('./autostart');
154
167
  if (!isAutoStartEnabled()) {
@@ -165,6 +178,7 @@ app.on('window-all-closed', () => {
165
178
  });
166
179
 
167
180
  app.on('before-quit', () => {
181
+ if (telegramBot) telegramBot.stop();
168
182
  if (aiBridge) aiBridge.stop();
169
183
  });
170
184
 
@@ -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', {
@@ -91,9 +93,9 @@ function registerIpcHandlers(getMainWindow, getAIBridge) {
91
93
  }
92
94
  });
93
95
 
94
- // === OpenClaw AI 통신 ===
96
+ // === AI 통신 ===
95
97
 
96
- // 사용자 이벤트를 AI Bridge로 전달 (렌더러 → main → OpenClaw)
98
+ // 사용자 이벤트를 AI Bridge로 전달 (렌더러 → main → AI)
97
99
  ipcMain.on('report-to-ai', (_, event, data) => {
98
100
  const bridge = getAIBridge();
99
101
  if (bridge && bridge.isConnected()) {
@@ -107,6 +109,9 @@ function registerIpcHandlers(getMainWindow, getAIBridge) {
107
109
  case 'cursor_near':
108
110
  bridge.reportCursorNear(data.distance, data.cursorPos);
109
111
  break;
112
+ case 'double_click':
113
+ bridge.send('user_event', { event: 'double_click', ...data });
114
+ break;
110
115
  case 'desktop_changed':
111
116
  bridge.reportDesktopChange(data.files);
112
117
  break;
@@ -119,6 +124,14 @@ function registerIpcHandlers(getMainWindow, getAIBridge) {
119
124
  case 'user_idle':
120
125
  bridge.reportIdleTime(data.idleSeconds);
121
126
  break;
127
+ case 'browsing':
128
+ // 브라우징 컨텍스트 (제목 + 커서 위치 + 화면 캡처) → AI 코멘트 생성
129
+ bridge.send('user_event', { event: 'browsing', ...data });
130
+ break;
131
+ default:
132
+ // 알 수 없는 이벤트도 AI에 전달 (확장성)
133
+ bridge.send('user_event', { event, ...data });
134
+ break;
122
135
  }
123
136
  }
124
137
  });
@@ -129,11 +142,103 @@ function registerIpcHandlers(getMainWindow, getAIBridge) {
129
142
  return bridge ? bridge.isConnected() : false;
130
143
  });
131
144
 
145
+ // 메트릭 보고 (렌더러 → main → AI)
146
+ ipcMain.on('report-metrics', (_, summary) => {
147
+ const bridge = getAIBridge();
148
+ if (bridge && bridge.isConnected()) {
149
+ bridge.reportMetrics(summary);
150
+ }
151
+ });
152
+
132
153
  // 열린 윈도우 위치/크기 조회
133
154
  ipcMain.handle('get-window-positions', async () => {
134
155
  const { getWindowPositions } = require('./platform');
135
156
  return await getWindowPositions();
136
157
  });
158
+
159
+ // 활성 윈도우 제목 조회 (브라우저 감시용)
160
+ ipcMain.handle('get-active-window-title', async () => {
161
+ const { getActiveWindowTitle } = require('./platform');
162
+ return await getActiveWindowTitle();
163
+ });
164
+
165
+ // 커서 위치 조회 (화면 좌표)
166
+ ipcMain.handle('get-cursor-position', () => {
167
+ const point = screen.getCursorScreenPoint();
168
+ return { x: point.x, y: point.y };
169
+ });
170
+
171
+ // === 스마트 파일 조작 IPC ===
172
+
173
+ // 파일 명령 파싱 (렌더러에서도 사용 가능)
174
+ ipcMain.handle('parse-file-command', (_, text) => {
175
+ return parseMessage(text);
176
+ });
177
+
178
+ // 필터된 파일 목록 조회
179
+ ipcMain.handle('list-filtered-files', async (_, sourceDir, filter) => {
180
+ return listFilteredFiles(sourceDir, filter);
181
+ });
182
+
183
+ // 스마트 파일 조작 실행
184
+ // 렌더러에서 직접 실행할 때 사용 (텔레그램 경유가 아닌 경우)
185
+ ipcMain.handle('smart-file-op', async (_, command) => {
186
+ const win = getMainWindow();
187
+ const callbacks = {
188
+ onStart: (totalFiles) => {
189
+ if (win && !win.isDestroyed()) {
190
+ win.webContents.send('ai-command', {
191
+ type: 'smart_file_op',
192
+ payload: { phase: 'start', totalFiles },
193
+ });
194
+ }
195
+ },
196
+ onPickUp: (fileName, index) => {
197
+ if (win && !win.isDestroyed()) {
198
+ win.webContents.send('ai-command', {
199
+ type: 'smart_file_op',
200
+ payload: { phase: 'pick_up', fileName, index },
201
+ });
202
+ }
203
+ },
204
+ onDrop: (fileName, targetName, index) => {
205
+ if (win && !win.isDestroyed()) {
206
+ win.webContents.send('ai-command', {
207
+ type: 'smart_file_op',
208
+ payload: { phase: 'drop', fileName, targetName, index },
209
+ });
210
+ }
211
+ },
212
+ onComplete: (result) => {
213
+ if (win && !win.isDestroyed()) {
214
+ win.webContents.send('ai-command', {
215
+ type: 'smart_file_op',
216
+ payload: { phase: 'complete', ...result },
217
+ });
218
+ }
219
+ },
220
+ onError: (error) => {
221
+ if (win && !win.isDestroyed()) {
222
+ win.webContents.send('ai-command', {
223
+ type: 'smart_file_op',
224
+ payload: { phase: 'error', error },
225
+ });
226
+ }
227
+ },
228
+ };
229
+
230
+ return await executeSmartFileOp(command, callbacks);
231
+ });
232
+
233
+ // 스마트 이동 되돌리기 (단일)
234
+ ipcMain.handle('undo-smart-move', async (_, moveId) => {
235
+ return undoSmartMove(moveId);
236
+ });
237
+
238
+ // 스마트 이동 전체 되돌리기
239
+ ipcMain.handle('undo-all-smart-moves', async () => {
240
+ return undoAllSmartMoves();
241
+ });
137
242
  }
138
243
 
139
244
  module.exports = { registerIpcHandlers };
package/main/platform.js CHANGED
@@ -213,4 +213,51 @@ async function getWindowPositionsLinux() {
213
213
  }
214
214
  }
215
215
 
216
- module.exports = { getDesktopPath, getTrayIconExt, isWindows, isMac, isLinux, platform, getWindowPositions };
216
+ /**
217
+ * 현재 포커스된 (최상위) 윈도우의 제목 반환
218
+ * 브라우저 탭 제목을 감지하여 펫이 참견할 수 있게 함
219
+ */
220
+ async function getActiveWindowTitle() {
221
+ try {
222
+ if (platform === 'win32') {
223
+ const psScript = `
224
+ Add-Type @"
225
+ using System;
226
+ using System.Runtime.InteropServices;
227
+ using System.Text;
228
+ public class FGWin {
229
+ [DllImport("user32.dll")] static extern IntPtr GetForegroundWindow();
230
+ [DllImport("user32.dll")] static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
231
+ public static string Get() {
232
+ IntPtr h = GetForegroundWindow();
233
+ StringBuilder sb = new StringBuilder(512);
234
+ GetWindowText(h, sb, 512);
235
+ return sb.ToString();
236
+ }
237
+ }
238
+ "@
239
+ [FGWin]::Get()
240
+ `.trim();
241
+ const { stdout } = await execAsync(
242
+ `powershell -NoProfile -Command -`,
243
+ { input: psScript, timeout: 3000, encoding: 'utf-8' }
244
+ );
245
+ return (stdout || '').trim();
246
+ } else if (platform === 'darwin') {
247
+ const { stdout } = await execAsync(
248
+ `osascript -e 'tell application "System Events" to get name of first window of (first process whose frontmost is true)'`,
249
+ { timeout: 3000, encoding: 'utf-8' }
250
+ );
251
+ return (stdout || '').trim();
252
+ } else {
253
+ const { stdout } = await execAsync('xdotool getactivewindow getwindowname', {
254
+ timeout: 3000, encoding: 'utf-8',
255
+ });
256
+ return (stdout || '').trim();
257
+ }
258
+ } catch {
259
+ return '';
260
+ }
261
+ }
262
+
263
+ module.exports = { getDesktopPath, getTrayIconExt, isWindows, isMac, isLinux, platform, getWindowPositions, getActiveWindowTitle };