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.
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * AI 행동 컨트롤러
3
3
  *
4
- * OpenClaw이 연결되면 → AI가 모든 행동을 결정
5
- * OpenClaw이 끊기면 → 자율 모드 (기존 FSM) 로 폴백
4
+ * AI가 연결되면 → AI가 모든 행동을 결정
5
+ * AI가 끊기면 → 자율 모드 (기존 FSM) 로 폴백
6
6
  *
7
- * OpenClaw AI가 결정하는 것:
7
+ * AI가 결정하는 것:
8
8
  * - 언제 뭐라고 말할지
9
9
  * - 어디로 움직일지
10
10
  * - 어떤 감정을 표현할지
@@ -32,7 +32,7 @@ const AIController = (() => {
32
32
  window.clawmate.onAIConnected(() => {
33
33
  connected = true;
34
34
  autonomousMode = false;
35
- Speech.show('OpenClaw 연결됨... 의식이 깨어난다.');
35
+ Speech.show('AI 연결됨... 의식이 깨어난다.');
36
36
  StateMachine.forceState('excited');
37
37
  });
38
38
  }
@@ -47,7 +47,7 @@ const AIController = (() => {
47
47
  }
48
48
 
49
49
  /**
50
- * OpenClaw AI로부터 온 명령 실행
50
+ * AI로부터 온 명령 실행
51
51
  */
52
52
  function handleAICommand(command) {
53
53
  const { type, payload } = command;
@@ -140,12 +140,317 @@ const AIController = (() => {
140
140
  // payload: { windowId, x, y }
141
141
  PetEngine.jumpTo(payload.x, payload.y);
142
142
  break;
143
+
144
+ // === 커스텀 이동 패턴 ===
145
+
146
+ case 'register_movement':
147
+ // AI가 JSON으로 이동 패턴 정의를 보내면 등록
148
+ // payload: { name, definition }
149
+ // definition: { type, params } — 각 타입별 파라미터
150
+ _registerAIMovement(payload.name, payload.definition);
151
+ break;
152
+
153
+ case 'custom_move':
154
+ // 등록된 커스텀 이동 패턴 실행
155
+ // payload: { name, params? }
156
+ if (!PetEngine.executeCustomMovement(payload.name, payload.params || {})) {
157
+ // 실행 실패 시 AI에 알림
158
+ if (window.clawmate.reportToAI) {
159
+ window.clawmate.reportToAI('custom_move_failed', {
160
+ name: payload.name,
161
+ available: PetEngine.getRegisteredMovements(),
162
+ });
163
+ }
164
+ }
165
+ break;
166
+
167
+ case 'stop_custom_move':
168
+ // 현재 커스텀 이동 강제 중지
169
+ PetEngine.stopCustomMovement();
170
+ break;
171
+
172
+ case 'list_movements':
173
+ // 등록된 이동 패턴 목록 요청
174
+ if (window.clawmate.reportToAI) {
175
+ window.clawmate.reportToAI('movement_list', {
176
+ movements: PetEngine.getRegisteredMovements(),
177
+ });
178
+ }
179
+ break;
180
+
181
+ // === 캐릭터 커스터마이징 ===
182
+ case 'set_character':
183
+ // AI가 생성한 새 캐릭터 데이터 적용
184
+ Character.setCharacterData(payload);
185
+ if (payload.speech) {
186
+ Speech.show(payload.speech);
187
+ } else {
188
+ Speech.show('변신 완료!');
189
+ }
190
+ StateMachine.forceState('excited');
191
+ setTimeout(() => {
192
+ if (StateMachine.getState() === 'excited') StateMachine.forceState('idle');
193
+ }, 2000);
194
+ break;
195
+
196
+ case 'reset_character':
197
+ // 원래 캐릭터로 복원
198
+ Character.resetCharacter();
199
+ Speech.show('원래 모습으로 돌아왔어!');
200
+ StateMachine.forceState('excited');
201
+ break;
202
+
203
+ // === 인격체 전환 (Incarnation 모드) ===
204
+ case 'set_persona':
205
+ // 봇 인격체 데이터 적용
206
+ if (typeof ModeManager !== 'undefined') {
207
+ ModeManager.setPersona(payload);
208
+ const name = payload.name || 'Claw';
209
+ Speech.show(`${name}의 인격이 깨어났다.`);
210
+ StateMachine.forceState('excited');
211
+ }
212
+ break;
213
+
214
+ // === 스마트 파일 조작 애니메이션 ===
215
+ case 'smart_file_op':
216
+ handleSmartFileOp(payload);
217
+ break;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * 스마트 파일 조작 애니메이션 처리
223
+ *
224
+ * 텔레그램이나 AI에서 트리거된 파일 이동 작업의
225
+ * 각 단계(phase)에 따라 펫 애니메이션을 순차 실행.
226
+ *
227
+ * phase:
228
+ * - start: 작업 시작, 총 파일 수 표시
229
+ * - pick_up: 파일 집어들기 (carrying 상태 + 말풍선)
230
+ * - drop: 파일 내려놓기 (걷기 상태 + 말풍선)
231
+ * - complete: 완료 (excited 상태 + 결과 말풍선)
232
+ * - error: 오류 (scared 상태 + 에러 말풍선)
233
+ */
234
+ function handleSmartFileOp(payload) {
235
+ switch (payload.phase) {
236
+ case 'start':
237
+ StateMachine.forceState('excited');
238
+ Speech.show(`${payload.totalFiles}개 파일 정리 시작!`);
239
+ break;
240
+
241
+ case 'pick_up':
242
+ // 펫이 파일 위치로 이동 (화면 내 랜덤 위치)
243
+ _smartFileJumpToSource(payload.index);
244
+ // 집어들기 애니메이션
245
+ setTimeout(() => {
246
+ StateMachine.forceState('carrying');
247
+ Speech.show(`${payload.fileName} 집었다!`);
248
+ }, 400);
249
+ break;
250
+
251
+ case 'drop':
252
+ // 대상 폴더 위치로 이동
253
+ _smartFileJumpToTarget(payload.index);
254
+ // 내려놓기 애니메이션
255
+ setTimeout(() => {
256
+ StateMachine.forceState('walking');
257
+ Speech.show(`여기! (${payload.targetName})`);
258
+ }, 400);
259
+ break;
260
+
261
+ case 'complete':
262
+ StateMachine.forceState('excited');
263
+ if (payload.movedCount > 0) {
264
+ Speech.show(`${payload.movedCount}개 파일 옮겼어!`);
265
+ } else {
266
+ Speech.show('옮길 파일이 없었어!');
267
+ }
268
+ break;
269
+
270
+ case 'error':
271
+ StateMachine.forceState('scared');
272
+ Speech.show('앗, 뭔가 잘못됐어...');
273
+ break;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * 파일 집어들기 위치로 점프
279
+ * 파일 인덱스에 따라 화면 좌측 영역의 다른 위치로 이동
280
+ */
281
+ function _smartFileJumpToSource(index) {
282
+ const screenW = window.innerWidth;
283
+ const screenH = window.innerHeight;
284
+ // 화면 왼쪽 1/3 영역에서 세로 위치를 파일 인덱스에 따라 분산
285
+ const targetX = screenW * 0.1 + (index % 3) * 50;
286
+ const targetY = screenH * 0.3 + ((index * 80) % (screenH * 0.5));
287
+ PetEngine.jumpTo(targetX, targetY);
288
+ }
289
+
290
+ /**
291
+ * 파일 내려놓기 위치로 점프
292
+ * 화면 오른쪽 영역으로 이동
293
+ */
294
+ function _smartFileJumpToTarget(index) {
295
+ const screenW = window.innerWidth;
296
+ const screenH = window.innerHeight;
297
+ // 화면 오른쪽 1/3 영역
298
+ const targetX = screenW * 0.7 + (index % 3) * 50;
299
+ const targetY = screenH * 0.4 + ((index * 60) % (screenH * 0.4));
300
+ PetEngine.jumpTo(targetX, targetY);
301
+ }
302
+
303
+ /**
304
+ * AI가 JSON으로 정의한 이동 패턴을 동적으로 등록
305
+ * 안전한 실행을 위해 Function 생성자 대신 사전정의된 행동 유형 조합 사용
306
+ *
307
+ * definition 형식:
308
+ * {
309
+ * type: 'waypoints' | 'formula' | 'sequence',
310
+ * waypoints?: [{x, y, pause?}], // waypoints 타입
311
+ * formula?: { xExpr, yExpr }, // formula 타입 (sin, cos 기반)
312
+ * sequence?: ['zigzag', 'shake', ...], // sequence 타입 (기존 패턴 순차 실행)
313
+ * duration?: number,
314
+ * speed?: number,
315
+ * }
316
+ */
317
+ function _registerAIMovement(name, definition) {
318
+ if (!name || !definition || !definition.type) {
319
+ console.warn('[AIController] 이동 패턴 등록 실패: name, definition.type 필수');
320
+ return;
321
+ }
322
+
323
+ let handler;
324
+
325
+ switch (definition.type) {
326
+ // 웨이포인트 타입: 지정된 좌표들을 순서대로 이동
327
+ case 'waypoints':
328
+ handler = {
329
+ init(params) {
330
+ return {
331
+ waypoints: definition.waypoints || [],
332
+ currentIdx: 0,
333
+ speed: definition.speed || 2,
334
+ pauseTime: 0,
335
+ pausing: false,
336
+ };
337
+ },
338
+ update(dt, state, ctx) {
339
+ if (state.currentIdx >= state.waypoints.length) return;
340
+
341
+ const wp = state.waypoints[state.currentIdx];
342
+
343
+ // 웨이포인트에서 멈춤 중
344
+ if (state.pausing) {
345
+ state.pauseTime -= dt;
346
+ if (state.pauseTime <= 0) {
347
+ state.pausing = false;
348
+ state.currentIdx++;
349
+ }
350
+ return;
351
+ }
352
+
353
+ const dx = wp.x - ctx.x;
354
+ const dy = wp.y - ctx.y;
355
+ const dist = Math.hypot(dx, dy);
356
+
357
+ if (dist < 5) {
358
+ // 웨이포인트 도달
359
+ if (wp.pause && wp.pause > 0) {
360
+ state.pausing = true;
361
+ state.pauseTime = wp.pause;
362
+ } else {
363
+ state.currentIdx++;
364
+ }
365
+ return;
366
+ }
367
+
368
+ const step = state.speed * (dt / 16);
369
+ const ratio = Math.min(1, step / dist);
370
+ ctx.setPos(ctx.x + dx * ratio, ctx.y + dy * ratio);
371
+ ctx.setFlip(dx < 0);
372
+ },
373
+ isComplete(state) {
374
+ return state.currentIdx >= (state.waypoints || []).length;
375
+ },
376
+ cleanup() {},
377
+ };
378
+ break;
379
+
380
+ // 수식 타입: sin/cos 기반 수학적 궤도
381
+ case 'formula':
382
+ handler = {
383
+ init(params) {
384
+ return {
385
+ duration: definition.duration || 3000,
386
+ elapsed: 0,
387
+ originX: params.x,
388
+ originY: params.y,
389
+ xAmp: definition.formula?.xAmp || 50,
390
+ yAmp: definition.formula?.yAmp || 30,
391
+ xFreq: definition.formula?.xFreq || 1,
392
+ yFreq: definition.formula?.yFreq || 1,
393
+ xPhase: definition.formula?.xPhase || 0,
394
+ yPhase: definition.formula?.yPhase || 0,
395
+ };
396
+ },
397
+ update(dt, state, ctx) {
398
+ state.elapsed += dt;
399
+ const t = (state.elapsed / state.duration) * Math.PI * 2;
400
+ const nx = state.originX + Math.sin(t * state.xFreq + state.xPhase) * state.xAmp;
401
+ const ny = state.originY + Math.sin(t * state.yFreq + state.yPhase) * state.yAmp;
402
+ ctx.setPos(nx, ny);
403
+ ctx.setFlip(Math.cos(t * state.xFreq + state.xPhase) < 0);
404
+ },
405
+ isComplete(state) {
406
+ return state.elapsed >= state.duration;
407
+ },
408
+ cleanup() {},
409
+ };
410
+ break;
411
+
412
+ // 시퀀스 타입: 기존 등록된 패턴들을 순차 실행
413
+ case 'sequence':
414
+ handler = {
415
+ init(params) {
416
+ return {
417
+ sequence: definition.sequence || [],
418
+ currentIdx: 0,
419
+ subStarted: false,
420
+ };
421
+ },
422
+ update(dt, state, ctx) {
423
+ if (state.currentIdx >= state.sequence.length) return;
424
+
425
+ if (!state.subStarted) {
426
+ const subName = state.sequence[state.currentIdx];
427
+ // 서브 패턴을 직접 실행하지 않고 상태만 추적
428
+ PetEngine.executeCustomMovement(subName, {
429
+ x: ctx.x, y: ctx.y,
430
+ screenW: ctx.screenW, screenH: ctx.screenH,
431
+ });
432
+ state.subStarted = true;
433
+ }
434
+ },
435
+ isComplete(state) {
436
+ return state.currentIdx >= (state.sequence || []).length;
437
+ },
438
+ cleanup() {},
439
+ };
440
+ break;
441
+
442
+ default:
443
+ console.warn(`[AIController] 알 수 없는 이동 패턴 타입: ${definition.type}`);
444
+ return;
143
445
  }
446
+
447
+ PetEngine.registerMovement(name, handler);
448
+ console.log(`[AIController] AI 이동 패턴 등록됨: ${name} (${definition.type})`);
144
449
  }
145
450
 
146
451
  /**
147
452
  * AI 종합 의사결정 실행
148
- * OpenClaw이 상황을 분석하고 내린 복합적 결정
453
+ * AI가 상황을 분석하고 내린 복합적 결정
149
454
  *
150
455
  * 예시:
151
456
  * {
@@ -202,7 +507,7 @@ const AIController = (() => {
202
507
  StateMachine.forceState(state);
203
508
  }
204
509
 
205
- // === 사용자 이벤트 → OpenClaw에 리포트 ===
510
+ // === 사용자 이벤트 → AI에 리포트 ===
206
511
 
207
512
  function reportClick(position) {
208
513
  if (window.clawmate.reportToAI) {
@@ -2,12 +2,12 @@
2
2
  * ClawMate 렌더러 초기화
3
3
  *
4
4
  * 아키텍처:
5
- * OpenClaw AI (뇌) ←→ AI Bridge (WebSocket) ←→ AI Controller (렌더러)
5
+ * AI (뇌) ←→ AI Bridge (WebSocket) ←→ AI Controller (렌더러)
6
6
  * ↓
7
7
  * StateMachine / PetEngine / Speech
8
8
  *
9
- * OpenClaw 연결 시: AI가 모든 행동/말/감정 결정
10
- * OpenClaw 미연결 시: 자율 모드 (FSM 기반) 로 혼자 놀기
9
+ * AI 연결 시: AI가 모든 행동/말/감정 결정
10
+ * AI 미연결 시: 자율 모드 (FSM 기반) 로 혼자 놀기
11
11
  */
12
12
  (async function initClawMate() {
13
13
  const petContainer = document.getElementById('pet-container');
@@ -45,7 +45,10 @@
45
45
  Interactions.spawnStarEffect();
46
46
  }
47
47
 
48
- // 상태 변화를 OpenClaw에 리포트
48
+ // 모션 히스토리 기록
49
+ Memory.recordMotion(newState);
50
+
51
+ // 상태 변화를 AI에 리포트
49
52
  if (window.clawmate.reportToAI) {
50
53
  window.clawmate.reportToAI('state_change', {
51
54
  from: prevState, to: newState,
@@ -62,7 +65,7 @@
62
65
  // 메모리 초기화 (진화 상태 포함)
63
66
  await Memory.init();
64
67
 
65
- // AI 컨트롤러 초기화 (OpenClaw 연결 관리)
68
+ // AI 컨트롤러 초기화 (AI 연결 관리)
66
69
  AIController.init();
67
70
 
68
71
  // 상호작용 초기화
@@ -71,6 +74,16 @@
71
74
  // 시간 인식 초기화 (자율 모드에서만 주도적으로 동작)
72
75
  TimeAware.init();
73
76
 
77
+ // 메트릭 수집기 초기화 (선택적 — 없어도 앱 정상 동작)
78
+ if (typeof Metrics !== 'undefined') {
79
+ Metrics.init();
80
+ }
81
+
82
+ // 브라우저 감시 초기화 (참견쟁이 모드)
83
+ if (typeof BrowserWatcher !== 'undefined') {
84
+ BrowserWatcher.init();
85
+ }
86
+
74
87
  // 엔진 시작
75
88
  PetEngine.start();
76
89
 
@@ -82,7 +95,7 @@
82
95
  // AI 연결 상태 표시
83
96
  const connected = await window.clawmate.isAIConnected();
84
97
  if (connected) {
85
- Speech.show('OpenClaw과 연결됨. 지시를 기다리는 중...');
98
+ Speech.show('AI와 연결됨. 지시를 기다리는 중...');
86
99
  } else {
87
100
  Speech.show('안녕! 나 혼자서도 잘 놀 수 있어!');
88
101
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * 브라우저 활동 감시 + AI 코멘트 시스템
3
+ *
4
+ * 두 가지 모드:
5
+ * AI 연결 시: 윈도우 제목 + 화면 캡처 + 커서 위치를 AI에 전송 → AI가 맥락 있는 코멘트 생성
6
+ * AI 미연결 시: 프리셋 메시지로 폴백 (자율 모드)
7
+ *
8
+ * 동작:
9
+ * 1. 15초마다 활성 윈도우 제목 + 커서 위치 조회
10
+ * 2. 브라우저/앱 감지 시 AI에 컨텍스트 리포트 (제목 + 화면 캡처)
11
+ * 3. AI가 제목/캡처를 분석해서 상황 맞는 코멘트 생성
12
+ * 4. 자율 모드에서는 프리셋 메시지 폴백
13
+ */
14
+ const BrowserWatcher = (() => {
15
+ const CHECK_INTERVAL = 15000; // 활성 윈도우 체크 주기 (15초)
16
+ const AI_COOLDOWN = 45000; // AI 코멘트 쿨다운 (45초)
17
+ const FALLBACK_COOLDOWN = 90000; // 자율 모드 코멘트 쿨다운 (90초)
18
+ const COMMENT_CHANCE = 0.4; // 코멘트 확률 (40%)
19
+ const SITE_CHANGE_BONUS = 0.3; // 사이트 변경 시 추가 확률
20
+
21
+ let intervalId = null;
22
+ let lastCategory = null;
23
+ let lastCommentTime = 0;
24
+ let lastTitle = '';
25
+ let enabled = true;
26
+
27
+ function init() {
28
+ intervalId = setInterval(check, CHECK_INTERVAL);
29
+ // 첫 체크는 10초 후 (앱 시작 직후는 건너뜀)
30
+ setTimeout(check, 10000);
31
+ }
32
+
33
+ async function check() {
34
+ if (!enabled) return;
35
+ if (typeof Speech === 'undefined') return;
36
+
37
+ // sleeping 상태면 참견 안 함
38
+ if (typeof StateMachine !== 'undefined' && StateMachine.getState() === 'sleeping') return;
39
+
40
+ try {
41
+ const title = await window.clawmate.getActiveWindowTitle();
42
+ if (!title) return;
43
+
44
+ const titleLower = title.toLowerCase();
45
+ const titleChanged = title !== lastTitle;
46
+ lastTitle = title;
47
+
48
+ const now = Date.now();
49
+
50
+ // 카테고리 매칭 (AI/자율 모두 사용)
51
+ const msgs = window._messages;
52
+ const match = msgs?.browsing ? findCategory(titleLower, msgs.browsing) : null;
53
+ const category = match?.category || 'unknown';
54
+
55
+ // 쿨다운 체크
56
+ const isAI = typeof AIController !== 'undefined' && AIController.isConnected();
57
+ const cooldown = isAI ? AI_COOLDOWN : FALLBACK_COOLDOWN;
58
+ if (now - lastCommentTime < cooldown) return;
59
+
60
+ // 같은 카테고리 + 제목 미변경 시 스킵
61
+ if (category === lastCategory && !titleChanged) return;
62
+
63
+ // 확률 체크
64
+ let chance = COMMENT_CHANCE;
65
+ if (titleChanged) chance += SITE_CHANGE_BONUS;
66
+ if (Math.random() > chance) return;
67
+
68
+ // === AI vs 자율 모드 분기 ===
69
+ if (isAI) {
70
+ await reportBrowsingToAI(title, category, titleChanged);
71
+ } else {
72
+ showFallbackComment(match);
73
+ }
74
+
75
+ lastCategory = category;
76
+ lastCommentTime = now;
77
+ } catch {
78
+ // IPC 실패 무시
79
+ }
80
+ }
81
+
82
+ /**
83
+ * AI에 브라우징 컨텍스트 전송
84
+ * 제목 + 커서 위치 + 화면 캡처를 한번에 전송
85
+ * AI가 분석하고 코멘트 생성
86
+ */
87
+ async function reportBrowsingToAI(title, category, titleChanged) {
88
+ if (!window.clawmate.reportToAI) return;
89
+
90
+ // 커서 위치 조회
91
+ let cursorX = 0, cursorY = 0;
92
+ try {
93
+ if (window.clawmate.getCursorPosition) {
94
+ const pos = await window.clawmate.getCursorPosition();
95
+ cursorX = pos.x;
96
+ cursorY = pos.y;
97
+ }
98
+ } catch {}
99
+
100
+ // 화면 캡처 (AI가 페이지 내용을 시각적으로 분석하기 위해)
101
+ let screenData = null;
102
+ try {
103
+ const capture = await window.clawmate.screen.capture();
104
+ if (capture?.success) {
105
+ screenData = {
106
+ image: capture.image,
107
+ width: capture.width,
108
+ height: capture.height,
109
+ };
110
+ }
111
+ } catch {}
112
+
113
+ // 통합 브라우징 리포트 전송
114
+ window.clawmate.reportToAI('browsing', {
115
+ title,
116
+ category,
117
+ titleChanged,
118
+ cursorX,
119
+ cursorY,
120
+ screen: screenData,
121
+ timestamp: Date.now(),
122
+ });
123
+ }
124
+
125
+ /**
126
+ * 자율 모드 폴백: 프리셋 메시지 표시
127
+ */
128
+ function showFallbackComment(match) {
129
+ if (!match?.data?.comments) return;
130
+
131
+ const comments = match.data.comments;
132
+ const comment = comments[Math.floor(Math.random() * comments.length)];
133
+ Speech.show(comment);
134
+
135
+ // 50% 확률로 흥분 애니메이션
136
+ if (typeof StateMachine !== 'undefined') {
137
+ const state = StateMachine.getState();
138
+ if ((state === 'idle' || state === 'walking') && Math.random() < 0.5) {
139
+ StateMachine.forceState('excited');
140
+ setTimeout(() => {
141
+ if (StateMachine.getState() === 'excited') StateMachine.forceState('idle');
142
+ }, 1500);
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * 카테고리 매칭 (키워드 기반)
149
+ * general은 다른 카테고리 매칭 안 될 때만 사용
150
+ */
151
+ function findCategory(titleLower, browsingMsgs) {
152
+ let generalMatch = null;
153
+ for (const [category, data] of Object.entries(browsingMsgs)) {
154
+ if (!data.keywords) continue;
155
+ for (const keyword of data.keywords) {
156
+ if (titleLower.includes(keyword.toLowerCase())) {
157
+ if (category === 'general') {
158
+ generalMatch = { category, data };
159
+ } else {
160
+ return { category, data };
161
+ }
162
+ }
163
+ }
164
+ }
165
+ return generalMatch;
166
+ }
167
+
168
+ function setEnabled(val) { enabled = val; }
169
+ function stop() { if (intervalId) clearInterval(intervalId); }
170
+
171
+ return { init, stop, setEnabled, check };
172
+ })();