clawmate 1.0.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,169 @@
1
+ /**
2
+ * OpenClaw 에이전트 측 커넥터
3
+ *
4
+ * OpenClaw이 ClawMate에 접속해서 펫을 조종하는 클라이언트.
5
+ * OpenClaw 플러그인(index.js)에서 사용됨.
6
+ *
7
+ * 사용 예:
8
+ * const connector = new OpenClawConnector();
9
+ * await connector.connect();
10
+ * connector.speak('안녕! 오늘 뭐 할 거야?');
11
+ * connector.action('excited');
12
+ * connector.onUserEvent((event) => { ... AI가 반응 결정 ... });
13
+ */
14
+ const WebSocket = require('ws');
15
+ const EventEmitter = require('events');
16
+
17
+ class OpenClawConnector extends EventEmitter {
18
+ constructor(port = 9320) {
19
+ super();
20
+ this.port = port;
21
+ this.ws = null;
22
+ this.connected = false;
23
+ this.petState = null;
24
+ this._reconnectTimer = null;
25
+ this._autoReconnect = true;
26
+ }
27
+
28
+ /**
29
+ * ClawMate에 접속
30
+ */
31
+ connect() {
32
+ return new Promise((resolve, reject) => {
33
+ try {
34
+ this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`);
35
+
36
+ this.ws.on('open', () => {
37
+ this.connected = true;
38
+ this.emit('connected');
39
+ resolve();
40
+ });
41
+
42
+ this.ws.on('message', (data) => {
43
+ try {
44
+ const msg = JSON.parse(data.toString());
45
+ this._handleMessage(msg);
46
+ } catch {}
47
+ });
48
+
49
+ this.ws.on('close', () => {
50
+ this.connected = false;
51
+ this.emit('disconnected');
52
+ if (this._autoReconnect) {
53
+ this._reconnectTimer = setTimeout(() => this.connect().catch(() => {}), 5000);
54
+ }
55
+ });
56
+
57
+ this.ws.on('error', (err) => {
58
+ if (!this.connected) reject(err);
59
+ });
60
+ } catch (err) {
61
+ reject(err);
62
+ }
63
+ });
64
+ }
65
+
66
+ _handleMessage(msg) {
67
+ const { type, payload } = msg;
68
+
69
+ switch (type) {
70
+ case 'sync':
71
+ case 'state_response':
72
+ case 'pet_state_update':
73
+ this.petState = payload;
74
+ this.emit('state_update', payload);
75
+ break;
76
+
77
+ case 'user_event':
78
+ // 사용자 이벤트 → OpenClaw AI가 반응 결정
79
+ this.emit('user_event', payload);
80
+ break;
81
+
82
+ case 'heartbeat':
83
+ break;
84
+ }
85
+ }
86
+
87
+ _send(type, payload) {
88
+ if (!this.ws || !this.connected) return false;
89
+ try {
90
+ this.ws.send(JSON.stringify({ type, payload }));
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ // === OpenClaw → ClawMate 명령 API ===
98
+
99
+ /** 펫이 말하게 함 */
100
+ speak(text, style = 'normal') {
101
+ return this._send('speak', { text, style });
102
+ }
103
+
104
+ /** 펫이 생각하게 함 (말풍선에 ...) */
105
+ think(text) {
106
+ return this._send('think', { text });
107
+ }
108
+
109
+ /** 펫 행동 변경 */
110
+ action(state, duration) {
111
+ return this._send('action', { state, duration });
112
+ }
113
+
114
+ /** 특정 위치로 이동 */
115
+ moveTo(x, y, speed) {
116
+ return this._send('move', { x, y, speed });
117
+ }
118
+
119
+ /** 감정 표현 */
120
+ emote(emotion) {
121
+ return this._send('emote', { emotion });
122
+ }
123
+
124
+ /** 파일 집어들기 */
125
+ carryFile(fileName, targetX) {
126
+ return this._send('carry_file', { fileName, targetX });
127
+ }
128
+
129
+ /** 파일 내려놓기 */
130
+ dropFile() {
131
+ return this._send('drop_file', {});
132
+ }
133
+
134
+ /** 모드 전환 */
135
+ setMode(mode) {
136
+ return this._send('set_mode', { mode });
137
+ }
138
+
139
+ /** 진화 트리거 */
140
+ evolve(stage) {
141
+ return this._send('evolve', { stage });
142
+ }
143
+
144
+ /**
145
+ * AI 종합 의사결정 전송
146
+ * OpenClaw AI가 상황을 분석하고 내린 결정을 한번에 전송
147
+ */
148
+ decide(decision) {
149
+ return this._send('ai_decision', decision);
150
+ }
151
+
152
+ /** 현재 펫 상태 요청 */
153
+ queryState() {
154
+ return this._send('query_state', {});
155
+ }
156
+
157
+ /** 사용자 이벤트 리스너 등록 */
158
+ onUserEvent(callback) {
159
+ this.on('user_event', callback);
160
+ }
161
+
162
+ disconnect() {
163
+ this._autoReconnect = false;
164
+ if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
165
+ if (this.ws) this.ws.close();
166
+ }
167
+ }
168
+
169
+ module.exports = { OpenClawConnector };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 시스템 시작 시 ClawMate 자동 실행 등록
3
+ *
4
+ * Windows: 레지스트리 Run 키
5
+ * macOS: LaunchAgent plist
6
+ *
7
+ * OpenClaw이 나중에 켜져도, ClawMate는 이미 돌고 있어서 바로 연결됨.
8
+ * ClawMate가 먼저 혼자 자율 모드로 돌다가 → OpenClaw 연결되면 AI 모드 전환.
9
+ */
10
+ const { app } = require('electron');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ function isAutoStartEnabled() {
15
+ return app.getLoginItemSettings().openAtLogin;
16
+ }
17
+
18
+ function enableAutoStart() {
19
+ app.setLoginItemSettings({
20
+ openAtLogin: true,
21
+ openAsHidden: true, // 창 없이 백그라운드 시작
22
+ path: process.execPath,
23
+ args: [path.resolve(__dirname, '..')],
24
+ });
25
+ return true;
26
+ }
27
+
28
+ function disableAutoStart() {
29
+ app.setLoginItemSettings({
30
+ openAtLogin: false,
31
+ });
32
+ return true;
33
+ }
34
+
35
+ function toggleAutoStart() {
36
+ if (isAutoStartEnabled()) {
37
+ return disableAutoStart();
38
+ }
39
+ return enableAutoStart();
40
+ }
41
+
42
+ module.exports = { isAutoStartEnabled, enableAutoStart, disableAutoStart, toggleAutoStart };
@@ -0,0 +1,33 @@
1
+ const os = require('os');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+
5
+ /**
6
+ * OS별 바탕화면 경로 탐지
7
+ * Windows: PowerShell로 정확한 경로 탐지 (OneDrive 등 대응)
8
+ * macOS: ~/Desktop
9
+ */
10
+ function getDesktopPath() {
11
+ const platform = os.platform();
12
+
13
+ if (platform === 'win32') {
14
+ try {
15
+ const result = execSync(
16
+ 'powershell -NoProfile -Command "[Environment]::GetFolderPath(\'Desktop\')"',
17
+ { encoding: 'utf-8', timeout: 5000 }
18
+ ).trim();
19
+ if (result && result.length > 0) return result;
20
+ } catch {
21
+ // PowerShell 실패 시 폴백
22
+ }
23
+
24
+ // 환경 변수 기반 폴백
25
+ const userProfile = process.env.USERPROFILE || os.homedir();
26
+ return path.join(userProfile, 'Desktop');
27
+ }
28
+
29
+ // macOS / Linux
30
+ return path.join(os.homedir(), 'Desktop');
31
+ }
32
+
33
+ module.exports = { getDesktopPath };
@@ -0,0 +1,146 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { getDesktopPath } = require('./desktop-path');
4
+ const manifest = require('./manifest');
5
+
6
+ /**
7
+ * 바탕화면 파일 이동 시스템 (안전장치 포함)
8
+ *
9
+ * 안전 규칙:
10
+ * - 세션당 최대 3개 파일만 이동
11
+ * - 이동 간 최소 5분 쿨다운
12
+ * - 위험한 확장자 제외
13
+ * - 100MB 이상 파일 제외
14
+ * - 바탕화면 폴더 내에서만 위치 변경
15
+ */
16
+
17
+ const MAX_FILES_PER_SESSION = 3;
18
+ const COOLDOWN_MS = 5 * 60 * 1000;
19
+ const MAX_FILE_SIZE = 100 * 1024 * 1024;
20
+ const EXCLUDED_EXTS = new Set([
21
+ '.exe', '.dll', '.sys', '.lnk', '.ini', '.bat', '.cmd',
22
+ '.ps1', '.msi', '.scr', '.com', '.pif',
23
+ ]);
24
+
25
+ let sessionMoveCount = 0;
26
+ let lastMoveTime = 0;
27
+
28
+ /**
29
+ * 바탕화면 파일 목록 가져오기 (안전한 파일만)
30
+ */
31
+ async function getDesktopFiles() {
32
+ const desktop = getDesktopPath();
33
+ try {
34
+ const entries = await fs.promises.readdir(desktop, { withFileTypes: true });
35
+ const files = [];
36
+
37
+ for (const entry of entries) {
38
+ if (!entry.isFile()) continue;
39
+ const ext = path.extname(entry.name).toLowerCase();
40
+ if (EXCLUDED_EXTS.has(ext)) continue;
41
+ if (entry.name.startsWith('.')) continue;
42
+
43
+ try {
44
+ const stat = await fs.promises.stat(path.join(desktop, entry.name));
45
+ if (stat.size > MAX_FILE_SIZE) continue;
46
+ files.push({
47
+ name: entry.name,
48
+ size: stat.size,
49
+ ext: ext,
50
+ });
51
+ } catch {
52
+ continue;
53
+ }
54
+ }
55
+
56
+ return files;
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 바탕화면 내에서 파일 이름 변경(위치 변경 시뮬레이션)
64
+ * 실제로는 바탕화면 폴더 안에서만 이동 가능
65
+ * newPosition은 렌더러에서 전달한 좌표 (로그용)
66
+ */
67
+ async function moveFile(fileName, newPosition) {
68
+ // 안전장치 체크
69
+ if (sessionMoveCount >= MAX_FILES_PER_SESSION) {
70
+ return { success: false, error: '세션당 이동 한도(3개) 초과' };
71
+ }
72
+
73
+ const now = Date.now();
74
+ if (now - lastMoveTime < COOLDOWN_MS && lastMoveTime > 0) {
75
+ const remaining = Math.ceil((COOLDOWN_MS - (now - lastMoveTime)) / 1000);
76
+ return { success: false, error: `쿨다운 중 (${remaining}초 남음)` };
77
+ }
78
+
79
+ const desktop = getDesktopPath();
80
+ const filePath = path.join(desktop, fileName);
81
+
82
+ // 파일 존재 확인
83
+ try {
84
+ await fs.promises.access(filePath);
85
+ } catch {
86
+ return { success: false, error: '파일을 찾을 수 없음' };
87
+ }
88
+
89
+ // 확장자 체크
90
+ const ext = path.extname(fileName).toLowerCase();
91
+ if (EXCLUDED_EXTS.has(ext)) {
92
+ return { success: false, error: '보호된 파일 유형' };
93
+ }
94
+
95
+ // 크기 체크
96
+ try {
97
+ const stat = await fs.promises.stat(filePath);
98
+ if (stat.size > MAX_FILE_SIZE) {
99
+ return { success: false, error: '파일 크기 초과 (100MB)' };
100
+ }
101
+ } catch {
102
+ return { success: false, error: '파일 정보 읽기 실패' };
103
+ }
104
+
105
+ // 이동 기록 (바탕화면 내부 이동이므로 실제 파일시스템 위치는 동일)
106
+ const entry = manifest.addEntry({
107
+ fileName,
108
+ originalPath: filePath,
109
+ position: newPosition,
110
+ action: 'move',
111
+ });
112
+
113
+ sessionMoveCount++;
114
+ lastMoveTime = now;
115
+
116
+ return { success: true, moveId: entry.id };
117
+ }
118
+
119
+ /**
120
+ * 단일 파일 이동 되돌리기
121
+ */
122
+ async function undoFileMove(moveId) {
123
+ const entry = manifest.markRestored(moveId);
124
+ if (!entry) {
125
+ return { success: false, error: '이동 기록을 찾을 수 없음' };
126
+ }
127
+ // 실제 파일 위치는 바탕화면 내에서만 변경되므로 기록만 업데이트
128
+ return { success: true };
129
+ }
130
+
131
+ /**
132
+ * 모든 파일 이동 되돌리기
133
+ */
134
+ async function undoAllMoves() {
135
+ const count = manifest.markAllRestored();
136
+ return { success: true, restoredCount: count };
137
+ }
138
+
139
+ /**
140
+ * 파일 이동 이력 가져오기
141
+ */
142
+ async function getFileManifest() {
143
+ return manifest.getAll();
144
+ }
145
+
146
+ module.exports = { getDesktopFiles, moveFile, undoFileMove, undoAllMoves, getFileManifest };
package/main/index.js ADDED
@@ -0,0 +1,128 @@
1
+ const { app, BrowserWindow, screen } = require('electron');
2
+ const path = require('path');
3
+ const { setupTray } = require('./tray');
4
+ const { registerIpcHandlers } = require('./ipc-handlers');
5
+ const { AIBridge } = require('./ai-bridge');
6
+
7
+ let mainWindow = null;
8
+ let launcherWindow = null;
9
+ let aiBridge = null;
10
+
11
+ function createMainWindow() {
12
+ const { width, height } = screen.getPrimaryDisplay().workAreaSize;
13
+
14
+ mainWindow = new BrowserWindow({
15
+ width,
16
+ height,
17
+ x: 0,
18
+ y: 0,
19
+ transparent: true,
20
+ frame: false,
21
+ alwaysOnTop: true,
22
+ skipTaskbar: true,
23
+ resizable: false,
24
+ hasShadow: false,
25
+ webPreferences: {
26
+ preload: path.join(__dirname, '..', 'preload', 'preload.js'),
27
+ contextIsolation: true,
28
+ nodeIntegration: false,
29
+ },
30
+ });
31
+
32
+ // 클릭 통과 — 펫 영역만 클릭 가능하도록 렌더러에서 제어
33
+ mainWindow.setIgnoreMouseEvents(true, { forward: true });
34
+
35
+ mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
36
+ mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
37
+
38
+ mainWindow.on('closed', () => {
39
+ mainWindow = null;
40
+ });
41
+
42
+ return mainWindow;
43
+ }
44
+
45
+ function createLauncherWindow() {
46
+ launcherWindow = new BrowserWindow({
47
+ width: 360,
48
+ height: 480,
49
+ resizable: false,
50
+ frame: false,
51
+ transparent: true,
52
+ webPreferences: {
53
+ preload: path.join(__dirname, '..', 'preload', 'preload.js'),
54
+ contextIsolation: true,
55
+ nodeIntegration: false,
56
+ },
57
+ });
58
+
59
+ launcherWindow.loadFile(path.join(__dirname, '..', 'renderer', 'launcher.html'));
60
+ launcherWindow.center();
61
+
62
+ launcherWindow.on('closed', () => {
63
+ launcherWindow = null;
64
+ });
65
+
66
+ return launcherWindow;
67
+ }
68
+
69
+ /**
70
+ * AI Bridge 시작 — OpenClaw 에이전트가 접속하면 펫을 조종
71
+ */
72
+ function startAIBridge(win) {
73
+ aiBridge = new AIBridge();
74
+ aiBridge.start();
75
+
76
+ // OpenClaw → ClawMate 명령을 렌더러에 전달
77
+ const commandTypes = [
78
+ 'action', 'move', 'emote', 'speak', 'think',
79
+ 'carry_file', 'drop_file', 'set_mode', 'evolve',
80
+ 'accessorize', 'ai_decision',
81
+ ];
82
+
83
+ commandTypes.forEach((type) => {
84
+ aiBridge.on(type, (payload) => {
85
+ if (win && !win.isDestroyed()) {
86
+ win.webContents.send('ai-command', { type, payload });
87
+ }
88
+ });
89
+ });
90
+
91
+ // 연결/해제 이벤트
92
+ aiBridge.on('connected', () => {
93
+ if (win && !win.isDestroyed()) {
94
+ win.webContents.send('ai-connected');
95
+ }
96
+ });
97
+
98
+ aiBridge.on('disconnected', () => {
99
+ if (win && !win.isDestroyed()) {
100
+ win.webContents.send('ai-disconnected');
101
+ }
102
+ });
103
+
104
+ return aiBridge;
105
+ }
106
+
107
+ app.whenReady().then(() => {
108
+ registerIpcHandlers(() => mainWindow, () => aiBridge);
109
+ const win = createMainWindow();
110
+ const bridge = startAIBridge(win);
111
+ setupTray(win, bridge);
112
+
113
+ // 최초 설치 시 자동 시작 등록
114
+ const { enableAutoStart, isAutoStartEnabled } = require('./autostart');
115
+ if (!isAutoStartEnabled()) {
116
+ enableAutoStart();
117
+ }
118
+ });
119
+
120
+ app.on('window-all-closed', () => {
121
+ // 트레이에서 계속 실행
122
+ });
123
+
124
+ app.on('before-quit', () => {
125
+ if (aiBridge) aiBridge.stop();
126
+ });
127
+
128
+ module.exports = { createMainWindow, createLauncherWindow };
@@ -0,0 +1,104 @@
1
+ const { ipcMain, screen } = require('electron');
2
+ const { getDesktopFiles, moveFile, undoFileMove, undoAllMoves, getFileManifest } = require('./file-ops');
3
+ const Store = require('./store');
4
+
5
+ const store = new Store('clawmate-config', {
6
+ mode: 'pet',
7
+ fileInteraction: true,
8
+ soundEnabled: false,
9
+ });
10
+
11
+ const memoryStore = new Store('clawmate-memory', {
12
+ totalClicks: 0,
13
+ totalDays: 0,
14
+ firstRunDate: null,
15
+ milestones: [],
16
+ });
17
+
18
+ function registerIpcHandlers(getMainWindow, getAIBridge) {
19
+ // 클릭 통과 제어
20
+ ipcMain.on('set-click-through', (event, ignore) => {
21
+ const win = getMainWindow();
22
+ if (win) {
23
+ win.setIgnoreMouseEvents(ignore, { forward: true });
24
+ }
25
+ });
26
+
27
+ // 파일 작업
28
+ ipcMain.handle('get-desktop-files', async () => getDesktopFiles());
29
+ ipcMain.handle('move-file', async (_, fileName, newPos) => moveFile(fileName, newPos));
30
+ ipcMain.handle('undo-file-move', async (_, moveId) => undoFileMove(moveId));
31
+ ipcMain.handle('undo-all-moves', async () => undoAllMoves());
32
+ ipcMain.handle('get-file-manifest', async () => getFileManifest());
33
+
34
+ // 모드
35
+ ipcMain.handle('get-mode', () => store.get('mode'));
36
+ ipcMain.handle('set-mode', (_, mode) => {
37
+ store.set('mode', mode);
38
+ const win = getMainWindow();
39
+ if (win) win.webContents.send('mode-changed', mode);
40
+ return mode;
41
+ });
42
+
43
+ // 설정
44
+ ipcMain.handle('get-config', () => store.getAll());
45
+ ipcMain.handle('set-config', (_, key, value) => {
46
+ store.set(key, value);
47
+ const win = getMainWindow();
48
+ if (win) win.webContents.send('config-changed', store.getAll());
49
+ return true;
50
+ });
51
+
52
+ // 메모리
53
+ ipcMain.handle('get-memory', () => memoryStore.getAll());
54
+ ipcMain.handle('save-memory', (_, data) => {
55
+ Object.entries(data).forEach(([key, value]) => memoryStore.set(key, value));
56
+ return true;
57
+ });
58
+
59
+ // 화면 크기
60
+ ipcMain.handle('get-screen-size', () => {
61
+ const { width, height } = screen.getPrimaryDisplay().workAreaSize;
62
+ return { width, height };
63
+ });
64
+
65
+ // === OpenClaw AI 통신 ===
66
+
67
+ // 사용자 이벤트를 AI Bridge로 전달 (렌더러 → main → OpenClaw)
68
+ ipcMain.on('report-to-ai', (_, event, data) => {
69
+ const bridge = getAIBridge();
70
+ if (bridge && bridge.isConnected()) {
71
+ switch (event) {
72
+ case 'click':
73
+ bridge.reportUserClick(data.position);
74
+ break;
75
+ case 'drag':
76
+ bridge.reportUserDrag(data.from, data.to);
77
+ break;
78
+ case 'cursor_near':
79
+ bridge.reportCursorNear(data.distance, data.cursorPos);
80
+ break;
81
+ case 'desktop_changed':
82
+ bridge.reportDesktopChange(data.files);
83
+ break;
84
+ case 'time_change':
85
+ bridge.reportTimeChange(data.hour, data.period);
86
+ break;
87
+ case 'milestone':
88
+ bridge.reportMilestone(data.milestone, data);
89
+ break;
90
+ case 'user_idle':
91
+ bridge.reportIdleTime(data.idleSeconds);
92
+ break;
93
+ }
94
+ }
95
+ });
96
+
97
+ // AI 연결 상태 확인
98
+ ipcMain.handle('is-ai-connected', () => {
99
+ const bridge = getAIBridge();
100
+ return bridge ? bridge.isConnected() : false;
101
+ });
102
+ }
103
+
104
+ module.exports = { registerIpcHandlers };
@@ -0,0 +1,70 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { app } = require('electron');
4
+
5
+ /**
6
+ * 파일 이동 이력 관리 (Undo 지원)
7
+ * 모든 파일 이동을 기록하고, 복원 가능하게 관리
8
+ */
9
+ const MANIFEST_FILE = () => path.join(app.getPath('userData'), 'file-manifest.json');
10
+
11
+ function loadManifest() {
12
+ try {
13
+ const raw = fs.readFileSync(MANIFEST_FILE(), 'utf-8');
14
+ return JSON.parse(raw);
15
+ } catch {
16
+ return [];
17
+ }
18
+ }
19
+
20
+ function saveManifest(manifest) {
21
+ fs.mkdirSync(path.dirname(MANIFEST_FILE()), { recursive: true });
22
+ fs.writeFileSync(MANIFEST_FILE(), JSON.stringify(manifest, null, 2));
23
+ }
24
+
25
+ function addEntry(entry) {
26
+ const manifest = loadManifest();
27
+ manifest.push({
28
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
29
+ timestamp: new Date().toISOString(),
30
+ restored: false,
31
+ ...entry,
32
+ });
33
+ saveManifest(manifest);
34
+ return manifest[manifest.length - 1];
35
+ }
36
+
37
+ function markRestored(moveId) {
38
+ const manifest = loadManifest();
39
+ const entry = manifest.find(m => m.id === moveId);
40
+ if (entry) {
41
+ entry.restored = true;
42
+ entry.restoredAt = new Date().toISOString();
43
+ saveManifest(manifest);
44
+ }
45
+ return entry;
46
+ }
47
+
48
+ function markAllRestored() {
49
+ const manifest = loadManifest();
50
+ let count = 0;
51
+ manifest.forEach(entry => {
52
+ if (!entry.restored) {
53
+ entry.restored = true;
54
+ entry.restoredAt = new Date().toISOString();
55
+ count++;
56
+ }
57
+ });
58
+ saveManifest(manifest);
59
+ return count;
60
+ }
61
+
62
+ function getPendingRestores() {
63
+ return loadManifest().filter(m => !m.restored);
64
+ }
65
+
66
+ function getAll() {
67
+ return loadManifest();
68
+ }
69
+
70
+ module.exports = { addEntry, markRestored, markAllRestored, getPendingRestores, getAll };