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,23 @@
1
+ appId: dev.openclaw.clawmate
2
+ productName: ClawMate
3
+ directories:
4
+ output: dist
5
+ files:
6
+ - main/**/*
7
+ - preload/**/*
8
+ - renderer/**/*
9
+ - shared/**/*
10
+ - assets/**/*
11
+ win:
12
+ target:
13
+ - nsis
14
+ - portable
15
+ icon: assets/icons/clawmate.ico
16
+ nsis:
17
+ oneClick: true
18
+ allowToChangeInstallationDirectory: false
19
+ mac:
20
+ target:
21
+ - dmg
22
+ icon: assets/icons/clawmate.icns
23
+ category: public.app-category.entertainment
package/index.js ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * ClawMate — OpenClaw 플러그인 진입점
3
+ *
4
+ * 핵심 원칙: OpenClaw이 켜지면 자동으로 ClawMate를 찾아서 연결.
5
+ *
6
+ * 흐름:
7
+ * OpenClaw 시작 → 플러그인 로드 → init() 자동 호출
8
+ * → ClawMate 실행 중인지 확인 (ws://127.0.0.1:9320 연결 시도)
9
+ * → 실행 중이면: 바로 연결, AI 뇌 역할 시작
10
+ * → 안 돌고 있으면: Electron 앱 자동 실행 → 연결
11
+ * → 연결 끊기면: 자동 재연결 (무한 반복)
12
+ */
13
+ const { spawn } = require('child_process');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const fs = require('fs');
17
+ const { OpenClawConnector } = require('./main/ai-connector');
18
+
19
+ let connector = null;
20
+ let electronProcess = null;
21
+ let apiRef = null;
22
+
23
+ module.exports = {
24
+ id: 'clawmate',
25
+ name: 'ClawMate',
26
+ version: '1.0.0',
27
+ description: 'OpenClaw 데스크톱 펫 - AI가 조종하는 살아있는 Claw',
28
+
29
+ /**
30
+ * OpenClaw이 플러그인을 로드할 때 자동 호출
31
+ * → ClawMate 자동 실행 + 자동 연결
32
+ */
33
+ async init(api) {
34
+ apiRef = api;
35
+ console.log('[ClawMate] 플러그인 초기화 — 자동 연결 시작');
36
+ autoConnect();
37
+ },
38
+
39
+ register(api) {
40
+ apiRef = api;
41
+
42
+ // 펫 실행 (이미 돌고 있으면 상태 알려줌)
43
+ api.registerSkill('launch-pet', {
44
+ triggers: ['펫 깔아줘', '펫 실행', 'clawmate', '데스크톱 펫', 'install pet', 'launch pet'],
45
+ description: '데스크톱 펫(ClawMate)을 실행하고 AI로 연결합니다',
46
+ execute: async () => {
47
+ if (connector && connector.connected) {
48
+ connector.speak('이미 여기 있어!');
49
+ connector.action('excited');
50
+ return { message: 'ClawMate 이미 실행 중 + AI 연결됨!' };
51
+ }
52
+ await ensureRunningAndConnected();
53
+ return { message: 'ClawMate 실행 + AI 연결 완료!' };
54
+ },
55
+ });
56
+
57
+ // 펫에게 말하기
58
+ api.registerSkill('pet-speak', {
59
+ triggers: ['펫한테 말해', '펫에게 전달', 'tell pet'],
60
+ description: '펫을 통해 사용자에게 메시지를 전달합니다',
61
+ execute: async (context) => {
62
+ if (!connector || !connector.connected) {
63
+ return { message: 'ClawMate 연결 중이 아닙니다. 잠시 후 다시 시도...' };
64
+ }
65
+ const text = context.params?.text || context.input;
66
+ connector.speak(text);
67
+ return { message: `펫이 말합니다: "${text}"` };
68
+ },
69
+ });
70
+
71
+ // 펫 행동 제어
72
+ api.registerSkill('pet-action', {
73
+ triggers: ['펫 행동', 'pet action'],
74
+ description: '펫의 행동을 직접 제어합니다',
75
+ execute: async (context) => {
76
+ if (!connector || !connector.connected) return { message: '연결 대기 중...' };
77
+ const action = context.params?.action || 'excited';
78
+ connector.action(action);
79
+ return { message: `펫 행동: ${action}` };
80
+ },
81
+ });
82
+
83
+ // AI 종합 의사결정
84
+ api.registerSkill('pet-decide', {
85
+ triggers: [],
86
+ description: 'AI가 펫의 종합적 행동을 결정합니다',
87
+ execute: async (context) => {
88
+ if (!connector || !connector.connected) return;
89
+ connector.decide(context.params);
90
+ },
91
+ });
92
+ },
93
+
94
+ /**
95
+ * OpenClaw 종료 시 정리
96
+ */
97
+ async destroy() {
98
+ console.log('[ClawMate] 플러그인 정리');
99
+ if (connector) {
100
+ connector.disconnect();
101
+ connector = null;
102
+ }
103
+ // Electron 앱은 종료하지 않음 — 펫은 자율 모드로 계속 살아있음
104
+ },
105
+ };
106
+
107
+ // =====================================================
108
+ // 자동 연결 시스템
109
+ // =====================================================
110
+
111
+ /**
112
+ * OpenClaw 시작 시 자동으로 ClawMate 찾기/실행/연결
113
+ * 무한 재시도 — ClawMate가 살아있는 한 항상 연결 유지
114
+ */
115
+ async function autoConnect() {
116
+ // 1단계: 이미 돌고 있는 ClawMate에 연결 시도
117
+ const connected = await tryConnect();
118
+ if (connected) {
119
+ console.log('[ClawMate] 기존 ClawMate에 연결 성공');
120
+ onConnected();
121
+ return;
122
+ }
123
+
124
+ // 2단계: ClawMate가 없으면 자동 실행
125
+ console.log('[ClawMate] ClawMate 미감지 — 자동 실행');
126
+ launchElectronApp();
127
+
128
+ // 3단계: 실행될 때까지 대기 후 연결
129
+ await waitAndConnect();
130
+ }
131
+
132
+ /**
133
+ * WebSocket 연결 시도 (1회)
134
+ */
135
+ function tryConnect() {
136
+ return new Promise((resolve) => {
137
+ if (!connector) {
138
+ connector = new OpenClawConnector(9320);
139
+ setupConnectorEvents();
140
+ }
141
+
142
+ if (connector.connected) {
143
+ resolve(true);
144
+ return;
145
+ }
146
+
147
+ connector.connect()
148
+ .then(() => resolve(true))
149
+ .catch(() => resolve(false));
150
+ });
151
+ }
152
+
153
+ /**
154
+ * ClawMate 실행 대기 → 연결 (최대 30초)
155
+ */
156
+ async function waitAndConnect() {
157
+ for (let i = 0; i < 60; i++) {
158
+ await sleep(500);
159
+ const ok = await tryConnect();
160
+ if (ok) {
161
+ console.log('[ClawMate] 연결 성공');
162
+ onConnected();
163
+ return;
164
+ }
165
+ }
166
+ console.log('[ClawMate] 30초 내 연결 실패 — 백그라운드 재시도 시작');
167
+ startBackgroundReconnect();
168
+ }
169
+
170
+ /**
171
+ * 백그라운드 재연결 루프
172
+ * 끊기면 10초마다 재시도
173
+ */
174
+ let reconnectTimer = null;
175
+
176
+ function startBackgroundReconnect() {
177
+ if (reconnectTimer) return;
178
+ reconnectTimer = setInterval(async () => {
179
+ if (connector && connector.connected) {
180
+ clearInterval(reconnectTimer);
181
+ reconnectTimer = null;
182
+ return;
183
+ }
184
+ const ok = await tryConnect();
185
+ if (ok) {
186
+ console.log('[ClawMate] 백그라운드 재연결 성공');
187
+ onConnected();
188
+ clearInterval(reconnectTimer);
189
+ reconnectTimer = null;
190
+ }
191
+ }, 10000);
192
+ }
193
+
194
+ /**
195
+ * 커넥터 이벤트 설정 (최초 1회)
196
+ */
197
+ let eventsSetup = false;
198
+ function setupConnectorEvents() {
199
+ if (eventsSetup) return;
200
+ eventsSetup = true;
201
+
202
+ connector.onUserEvent(async (event) => {
203
+ await handleUserEvent(event);
204
+ });
205
+
206
+ connector.on('disconnected', () => {
207
+ console.log('[ClawMate] 연결 끊김 — 재연결 시도');
208
+ startBackgroundReconnect();
209
+ });
210
+
211
+ connector.on('connected', () => {
212
+ onConnected();
213
+ });
214
+ }
215
+
216
+ /**
217
+ * 연결 성공 시
218
+ */
219
+ function onConnected() {
220
+ if (connector && connector.connected) {
221
+ connector.speak('OpenClaw 연결됨! 같이 놀자!');
222
+ connector.action('excited');
223
+ }
224
+ }
225
+
226
+ // =====================================================
227
+ // Electron 앱 실행
228
+ // =====================================================
229
+
230
+ function launchElectronApp() {
231
+ if (electronProcess) return;
232
+
233
+ const platform = os.platform();
234
+ const appDir = path.resolve(__dirname);
235
+
236
+ // 설치된 Electron 바이너리 확인
237
+ const electronPaths = [
238
+ path.join(appDir, 'node_modules', '.bin', platform === 'win32' ? 'electron.cmd' : 'electron'),
239
+ path.join(appDir, 'node_modules', 'electron', 'dist', platform === 'win32' ? 'electron.exe' : 'electron'),
240
+ ];
241
+
242
+ let electronBin = null;
243
+ for (const p of electronPaths) {
244
+ if (fs.existsSync(p)) { electronBin = p; break; }
245
+ }
246
+
247
+ if (electronBin) {
248
+ electronProcess = spawn(electronBin, [appDir], {
249
+ detached: true,
250
+ stdio: 'ignore',
251
+ cwd: appDir,
252
+ });
253
+ } else {
254
+ // npx 폴백
255
+ const npxCmd = platform === 'win32' ? 'npx.cmd' : 'npx';
256
+ electronProcess = spawn(npxCmd, ['electron', appDir], {
257
+ detached: true,
258
+ stdio: 'ignore',
259
+ cwd: appDir,
260
+ });
261
+ }
262
+
263
+ electronProcess.unref();
264
+ electronProcess.on('exit', () => {
265
+ electronProcess = null;
266
+ // 펫이 죽으면 재시작 시도 (크래시 방어)
267
+ console.log('[ClawMate] Electron 종료 감지');
268
+ });
269
+ }
270
+
271
+ // =====================================================
272
+ // AI 이벤트 핸들링
273
+ // =====================================================
274
+
275
+ async function handleUserEvent(event) {
276
+ if (!connector || !connector.connected) return;
277
+
278
+ switch (event.event) {
279
+ case 'click':
280
+ connector.decide({
281
+ action: 'interacting',
282
+ emotion: 'affectionate',
283
+ });
284
+ break;
285
+
286
+ case 'cursor_near':
287
+ if (event.distance < 50) {
288
+ connector.decide({ action: 'excited', emotion: 'happy' });
289
+ }
290
+ break;
291
+
292
+ case 'drag':
293
+ connector.speak('으앗, 나를 옮기다니!');
294
+ break;
295
+
296
+ case 'desktop_changed':
297
+ const fileCount = event.files?.length || 0;
298
+ if (fileCount > 15) {
299
+ connector.decide({
300
+ action: 'walking',
301
+ speech: '바탕화면이 좀 복잡해 보이는데... 정리 도와줄까?',
302
+ emotion: 'curious',
303
+ });
304
+ }
305
+ break;
306
+
307
+ case 'time_change':
308
+ if (event.hour === 23) {
309
+ connector.decide({
310
+ action: 'sleeping',
311
+ speech: '슬슬 잘 시간이야... 굿나잇!',
312
+ emotion: 'sleepy',
313
+ });
314
+ } else if (event.hour === 6) {
315
+ connector.decide({
316
+ action: 'excited',
317
+ speech: '좋은 아침! 오늘도 화이팅!',
318
+ emotion: 'happy',
319
+ });
320
+ }
321
+ break;
322
+
323
+ case 'milestone':
324
+ connector.decide({ action: 'excited', emotion: 'proud' });
325
+ break;
326
+
327
+ case 'user_idle':
328
+ if (event.idleSeconds > 300) {
329
+ connector.decide({
330
+ action: 'idle',
331
+ speech: '...자고 있는 건 아니지?',
332
+ emotion: 'curious',
333
+ });
334
+ }
335
+ break;
336
+ }
337
+ }
338
+
339
+ function sleep(ms) {
340
+ return new Promise(r => setTimeout(r, ms));
341
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * OpenClaw ↔ ClawMate AI 브릿지
3
+ *
4
+ * OpenClaw 에이전트가 ClawMate의 뇌 역할을 한다.
5
+ * - OpenClaw → ClawMate: 행동 명령, 말풍선, 감정, 이동
6
+ * - ClawMate → OpenClaw: 사용자 이벤트 (클릭, 드래그, 커서, 파일 변화)
7
+ *
8
+ * 통신: WebSocket (로컬 ws://localhost:9320)
9
+ * 프로토콜: JSON 메시지
10
+ *
11
+ * OpenClaw 연결 안 됐을 때 → 자율 모드 (기존 FSM) 로 폴백
12
+ */
13
+ const WebSocket = require('ws');
14
+ const EventEmitter = require('events');
15
+
16
+ class AIBridge extends EventEmitter {
17
+ constructor() {
18
+ super();
19
+ this.wss = null;
20
+ this.client = null; // 연결된 OpenClaw 에이전트
21
+ this.connected = false;
22
+ this.port = 9320;
23
+ this.heartbeatInterval = null;
24
+ this.reconnectAttempts = 0;
25
+ this.petState = {
26
+ mode: 'pet',
27
+ position: { x: 0, y: 0, edge: 'bottom' },
28
+ state: 'idle',
29
+ emotion: 'neutral',
30
+ evolutionStage: 0,
31
+ memory: {},
32
+ };
33
+ }
34
+
35
+ /**
36
+ * WebSocket 서버 시작 — OpenClaw이 여기에 접속
37
+ */
38
+ start() {
39
+ this.wss = new WebSocket.Server({ port: this.port, host: '127.0.0.1' });
40
+
41
+ this.wss.on('connection', (ws) => {
42
+ console.log('[AI Bridge] OpenClaw 연결됨');
43
+ this.client = ws;
44
+ this.connected = true;
45
+ this.reconnectAttempts = 0;
46
+ this.emit('connected');
47
+
48
+ // OpenClaw에 현재 상태 전송
49
+ this.send('sync', this.petState);
50
+
51
+ ws.on('message', (data) => {
52
+ try {
53
+ const msg = JSON.parse(data.toString());
54
+ this._handleCommand(msg);
55
+ } catch (err) {
56
+ console.error('[AI Bridge] 메시지 파싱 실패:', err);
57
+ }
58
+ });
59
+
60
+ ws.on('close', () => {
61
+ console.log('[AI Bridge] OpenClaw 연결 해제');
62
+ this.client = null;
63
+ this.connected = false;
64
+ this.emit('disconnected');
65
+ });
66
+
67
+ ws.on('error', (err) => {
68
+ console.error('[AI Bridge] WebSocket 오류:', err.message);
69
+ });
70
+
71
+ // 하트비트
72
+ this.heartbeatInterval = setInterval(() => {
73
+ if (this.connected) {
74
+ this.send('heartbeat', { timestamp: Date.now() });
75
+ }
76
+ }, 30000);
77
+ });
78
+
79
+ this.wss.on('error', (err) => {
80
+ console.error('[AI Bridge] 서버 오류:', err.message);
81
+ });
82
+
83
+ console.log(`[AI Bridge] ws://127.0.0.1:${this.port} 에서 대기 중`);
84
+ }
85
+
86
+ /**
87
+ * OpenClaw에서 온 명령 처리
88
+ */
89
+ _handleCommand(msg) {
90
+ const { type, payload } = msg;
91
+
92
+ switch (type) {
93
+ // === 행동 제어 ===
94
+ case 'action':
95
+ // OpenClaw이 펫의 행동을 직접 지시
96
+ // payload: { state: 'walking'|'excited'|..., duration?: ms }
97
+ this.emit('action', payload);
98
+ break;
99
+
100
+ case 'move':
101
+ // 특정 위치로 이동
102
+ // payload: { x, y, speed? }
103
+ this.emit('move', payload);
104
+ break;
105
+
106
+ case 'emote':
107
+ // 감정 표현
108
+ // payload: { emotion: 'happy'|'curious'|'sleepy'|... }
109
+ this.emit('emote', payload);
110
+ break;
111
+
112
+ // === 말하기 ===
113
+ case 'speak':
114
+ // OpenClaw이 펫을 통해 사용자에게 말함
115
+ // payload: { text: string, style?: 'normal'|'thought'|'shout' }
116
+ this.emit('speak', payload);
117
+ break;
118
+
119
+ case 'think':
120
+ // 생각 말풍선 (... 형태)
121
+ // payload: { text: string }
122
+ this.emit('think', payload);
123
+ break;
124
+
125
+ // === 파일 작업 ===
126
+ case 'carry_file':
127
+ // 특정 파일을 집어들도록 지시
128
+ // payload: { fileName: string, targetX?: number }
129
+ this.emit('carry_file', payload);
130
+ break;
131
+
132
+ case 'drop_file':
133
+ this.emit('drop_file', payload);
134
+ break;
135
+
136
+ // === 외형 변화 ===
137
+ case 'evolve':
138
+ // 진화 트리거
139
+ // payload: { stage: number }
140
+ this.emit('evolve', payload);
141
+ break;
142
+
143
+ case 'set_mode':
144
+ // 모드 전환
145
+ // payload: { mode: 'pet'|'incarnation' }
146
+ this.emit('set_mode', payload);
147
+ break;
148
+
149
+ case 'accessorize':
150
+ // 임시 악세사리 추가
151
+ // payload: { type: string, duration?: ms }
152
+ this.emit('accessorize', payload);
153
+ break;
154
+
155
+ // === 컨텍스트 질의 ===
156
+ case 'query_state':
157
+ // 현재 펫 상태 요청
158
+ this.send('state_response', this.petState);
159
+ break;
160
+
161
+ case 'query_screen':
162
+ // 화면 정보 요청
163
+ this.emit('query_screen', payload);
164
+ break;
165
+
166
+ // === AI 의사결정 결과 ===
167
+ case 'ai_decision':
168
+ // OpenClaw AI의 종합적 의사결정
169
+ // payload: { action, speech?, emotion?, reasoning? }
170
+ this.emit('ai_decision', payload);
171
+ break;
172
+
173
+ default:
174
+ console.log(`[AI Bridge] 알 수 없는 명령: ${type}`);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * OpenClaw에 이벤트 전송
180
+ */
181
+ send(type, payload) {
182
+ if (!this.client || this.client.readyState !== WebSocket.OPEN) return false;
183
+ try {
184
+ this.client.send(JSON.stringify({ type, payload, timestamp: Date.now() }));
185
+ return true;
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ // === 사용자 이벤트 리포트 (ClawMate → OpenClaw) ===
192
+
193
+ reportUserClick(position) {
194
+ this.send('user_event', {
195
+ event: 'click',
196
+ position,
197
+ petState: this.petState.state,
198
+ });
199
+ }
200
+
201
+ reportUserDrag(from, to) {
202
+ this.send('user_event', {
203
+ event: 'drag',
204
+ from, to,
205
+ });
206
+ }
207
+
208
+ reportCursorNear(distance, cursorPos) {
209
+ this.send('user_event', {
210
+ event: 'cursor_near',
211
+ distance, cursorPos,
212
+ });
213
+ }
214
+
215
+ reportDesktopChange(files) {
216
+ this.send('user_event', {
217
+ event: 'desktop_changed',
218
+ files,
219
+ });
220
+ }
221
+
222
+ reportTimeChange(hour, period) {
223
+ this.send('user_event', {
224
+ event: 'time_change',
225
+ hour, period,
226
+ });
227
+ }
228
+
229
+ reportMilestone(milestone, data) {
230
+ this.send('user_event', {
231
+ event: 'milestone',
232
+ milestone, data,
233
+ });
234
+ }
235
+
236
+ reportIdleTime(seconds) {
237
+ this.send('user_event', {
238
+ event: 'user_idle',
239
+ idleSeconds: seconds,
240
+ });
241
+ }
242
+
243
+ // === 상태 업데이트 ===
244
+
245
+ updatePetState(updates) {
246
+ Object.assign(this.petState, updates);
247
+ this.send('pet_state_update', this.petState);
248
+ }
249
+
250
+ isConnected() {
251
+ return this.connected;
252
+ }
253
+
254
+ stop() {
255
+ if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
256
+ if (this.client) this.client.close();
257
+ if (this.wss) this.wss.close();
258
+ }
259
+ }
260
+
261
+ module.exports = { AIBridge };