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/index.js CHANGED
@@ -30,6 +30,16 @@ let lastDesktopCheckTime = 0;
30
30
  let lastScreenCheckTime = 0;
31
31
  let lastGreetingDate = null; // 하루에 한번만 인사
32
32
 
33
+ // 브라우징 감시 시스템 상태
34
+ let browsingContext = {
35
+ title: '', // 현재 윈도우 제목
36
+ category: '', // 카테고리 (shopping, video, dev 등)
37
+ lastCommentTime: 0, // 마지막 AI 코멘트 시각
38
+ screenImage: null, // 최근 화면 캡처 (base64)
39
+ cursorX: 0, // 커서 X 좌표
40
+ cursorY: 0, // 커서 Y 좌표
41
+ };
42
+
33
43
  // 공간 탐험 시스템 상태
34
44
  let knownWindows = []; // 알고 있는 윈도우 목록
35
45
  let lastWindowCheckTime = 0;
@@ -38,10 +48,23 @@ let explorationHistory = []; // 탐험한 위치 기록
38
48
  let lastExploreTime = 0;
39
49
  let lastFolderCarryTime = 0;
40
50
 
51
+ // =====================================================
52
+ // 자기 관찰 시스템 상태 (Metrics)
53
+ // =====================================================
54
+ let latestMetrics = null; // 가장 최근 수신한 메트릭 데이터
55
+ let metricsHistory = []; // 최근 10개 메트릭 보고 이력
56
+ let behaviorAdjustments = { // 현재 적용 중인 행동 조정값
57
+ speechCooldownMultiplier: 1.0, // 말풍선 빈도 조절 (1.0=기본, >1=줄임, <1=늘림)
58
+ actionCooldownMultiplier: 1.0, // 행동 빈도 조절
59
+ explorationBias: 0, // 탐험 편향 (양수=더 탐험, 음수=덜 탐험)
60
+ activityLevel: 1.0, // 활동 수준 (0.5=차분, 1.0=보통, 1.5=활발)
61
+ };
62
+ let lastMetricsLogTime = 0; // 마지막 품질 보고서 로그 시각
63
+
41
64
  module.exports = {
42
65
  id: 'clawmate',
43
66
  name: 'ClawMate',
44
- version: '1.2.0',
67
+ version: '1.3.0',
45
68
  description: 'OpenClaw 데스크톱 펫 - AI가 조종하는 살아있는 Claw',
46
69
 
47
70
  /**
@@ -116,6 +139,34 @@ module.exports = {
116
139
  connector.decide(context.params);
117
140
  },
118
141
  });
142
+
143
+ // 스마트 파일 정리 (텔레그램에서 트리거 가능)
144
+ api.registerSkill('pet-file-organize', {
145
+ triggers: [
146
+ '바탕화면 정리', '파일 정리', '파일 옮겨',
147
+ 'organize desktop', 'clean desktop', 'move files',
148
+ ],
149
+ description: '펫이 바탕화면 파일을 정리합니다',
150
+ execute: async (context) => {
151
+ if (!connector || !connector.connected) {
152
+ return { message: 'ClawMate 연결 중이 아닙니다.' };
153
+ }
154
+ const text = context.params?.text || context.input;
155
+ const { parseMessage } = require('./main/file-command-parser');
156
+ const parsed = parseMessage(text);
157
+
158
+ if (parsed.type === 'smart_file_op') {
159
+ // smart_file_op 명령을 커넥터를 통해 Electron 측에 전달
160
+ connector._send('smart_file_op', {
161
+ command: parsed,
162
+ fromPlugin: true,
163
+ });
164
+ return { message: `파일 정리 시작: ${text}` };
165
+ }
166
+
167
+ return { message: '파일 정리 명령을 이해하지 못했습니다.' };
168
+ },
169
+ });
119
170
  },
120
171
 
121
172
  /**
@@ -231,6 +282,11 @@ function setupConnectorEvents() {
231
282
  await handleUserEvent(event);
232
283
  });
233
284
 
285
+ // 메트릭 리포트 수신 → 자기 관찰 시스템에서 분석
286
+ connector.onMetrics((data) => {
287
+ handleMetrics(data);
288
+ });
289
+
234
290
  // 윈도우 위치 정보 수신 → 탐험 시스템에서 활용
235
291
  connector.on('window_positions', (data) => {
236
292
  knownWindows = data.windows || [];
@@ -375,6 +431,14 @@ async function handleUserEvent(event) {
375
431
  });
376
432
  }
377
433
  break;
434
+
435
+ case 'browsing':
436
+ handleBrowsingComment(event);
437
+ break;
438
+
439
+ case 'character_request':
440
+ handleCharacterRequest(event);
441
+ break;
378
442
  }
379
443
  }
380
444
 
@@ -382,6 +446,496 @@ function sleep(ms) {
382
446
  return new Promise(r => setTimeout(r, ms));
383
447
  }
384
448
 
449
+ // =====================================================
450
+ // 브라우징 AI 코멘트 시스템
451
+ // 윈도우 제목 + 화면 캡처 + 커서 위치 기반 맥락 코멘트
452
+ // =====================================================
453
+
454
+ /**
455
+ * 브라우징 컨텍스트 수신 → AI 코멘트 생성
456
+ *
457
+ * 렌더러(BrowserWatcher)가 감지한 브라우징 활동을 받아서
458
+ * 화면 캡처와 제목을 분석하여 맥락에 맞는 코멘트를 생성한다.
459
+ *
460
+ * @param {object} event - { title, category, cursorX, cursorY, screen?, titleChanged }
461
+ */
462
+ async function handleBrowsingComment(event) {
463
+ if (!connector || !connector.connected) return;
464
+
465
+ const now = Date.now();
466
+ // AI 코멘트 쿨다운 (45초)
467
+ if (now - browsingContext.lastCommentTime < 45000) return;
468
+
469
+ browsingContext.title = event.title || '';
470
+ browsingContext.category = event.category || '';
471
+ browsingContext.cursorX = event.cursorX || 0;
472
+ browsingContext.cursorY = event.cursorY || 0;
473
+
474
+ // 화면 캡처 데이터 저장 (있으면)
475
+ if (event.screen?.image) {
476
+ browsingContext.screenImage = event.screen.image;
477
+ }
478
+
479
+ let comment = null;
480
+
481
+ // 1차: apiRef.generate()로 AI 텍스트 생성 시도
482
+ if (apiRef?.generate) {
483
+ try {
484
+ const prompt = buildBrowsingPrompt(event);
485
+ comment = await apiRef.generate(prompt);
486
+ // 너무 긴 응답은 자르기
487
+ if (comment && comment.length > 50) {
488
+ comment = comment.slice(0, 50);
489
+ }
490
+ } catch {}
491
+ }
492
+
493
+ // 2차: apiRef.chat()으로 시도
494
+ if (!comment && apiRef?.chat) {
495
+ try {
496
+ const prompt = buildBrowsingPrompt(event);
497
+ const response = await apiRef.chat([
498
+ { role: 'system', content: '넌 데스크톱 위의 작은 펫이야. 짧고 재치있게 한마디 해. 20자 이내. 한국어.' },
499
+ { role: 'user', content: prompt },
500
+ ]);
501
+ comment = response?.text || response?.content || response;
502
+ if (comment && typeof comment === 'string' && comment.length > 50) {
503
+ comment = comment.slice(0, 50);
504
+ }
505
+ } catch {}
506
+ }
507
+
508
+ // 3차: 이미지 분석으로 시도 (화면 캡처가 있을 때)
509
+ if (!comment && apiRef?.analyzeImage && browsingContext.screenImage) {
510
+ try {
511
+ comment = await apiRef.analyzeImage(browsingContext.screenImage, {
512
+ prompt: `사용자가 "${browsingContext.title}"을 보고 있어. 커서 위치: (${browsingContext.cursorX}, ${browsingContext.cursorY}). 데스크톱 펫으로서 화면 내용에 대해 재치있게 한마디 해줘. 20자 이내. 한국어.`,
513
+ });
514
+ } catch {}
515
+ }
516
+
517
+ // 4차: 스마트 폴백 — 타이틀 분석 기반 코멘트
518
+ if (!comment || typeof comment !== 'string') {
519
+ comment = generateSmartBrowsingComment(browsingContext);
520
+ }
521
+
522
+ if (comment) {
523
+ connector.decide({
524
+ action: Math.random() < 0.3 ? 'excited' : 'idle',
525
+ speech: comment,
526
+ emotion: 'curious',
527
+ });
528
+ browsingContext.lastCommentTime = now;
529
+ lastSpeechTime = now;
530
+ console.log(`[ClawMate] 브라우징 코멘트: ${comment}`);
531
+
532
+ // 1.5초 후 원래 상태로
533
+ setTimeout(() => {
534
+ if (connector?.connected) connector.action('idle');
535
+ }, 1500);
536
+ }
537
+
538
+ // 캡처 데이터 정리 (메모리 절약)
539
+ browsingContext.screenImage = null;
540
+ }
541
+
542
+ /**
543
+ * AI 코멘트 생성용 프롬프트 구성
544
+ */
545
+ function buildBrowsingPrompt(event) {
546
+ const title = event.title || '';
547
+ const category = event.category || 'unknown';
548
+ const cursor = event.cursorX && event.cursorY
549
+ ? `커서 위치: (${event.cursorX}, ${event.cursorY}).`
550
+ : '';
551
+
552
+ return `사용자가 지금 "${title}" 화면을 보고 있어. ` +
553
+ `카테고리: ${category}. ${cursor} ` +
554
+ `이 상황에 대해 짧고 재치있게 한마디 해줘. 20자 이내. 한국어로. ` +
555
+ `너는 데스크톱 위의 작은 귀여운 펫이야. 친근하고 장난스러운 톤으로.`;
556
+ }
557
+
558
+ /**
559
+ * 타이틀 분석 기반 스마트 코멘트 생성
560
+ *
561
+ * AI API가 없어도 윈도우 제목에서 실제 맥락을 추출하여
562
+ * 프리셋보다 훨씬 자연스러운 코멘트를 생성한다.
563
+ *
564
+ * 예: "React hooks tutorial - YouTube" → "리액트 훅 공부하고 있구나!"
565
+ * "Pull Request #42 - GitHub" → "PR 리뷰 중? 꼼꼼히 봐!"
566
+ */
567
+ function generateSmartBrowsingComment(ctx) {
568
+ const title = ctx.title || '';
569
+ const category = ctx.category || '';
570
+ const titleLower = title.toLowerCase();
571
+
572
+ // 타이틀에서 사이트명과 페이지 제목 분리
573
+ // 일반적 패턴: "페이지 제목 - 사이트명" or "사이트명: 페이지 제목"
574
+ const parts = title.split(/\s[-–|:]\s/);
575
+ const pageName = (parts[0] || title).trim();
576
+ const pageShort = pageName.slice(0, 20);
577
+
578
+ // 카테고리별 맥락 인식 코멘트 생성기
579
+ const generators = {
580
+ shopping: () => {
581
+ const templates = [
582
+ `${pageShort} 보고 있구나? 좋은 거 찾으면 알려줘!`,
583
+ `쇼핑 중이네! ${pageShort}... 살 거야?`,
584
+ `${pageShort} 괜찮아 보이는데? 장바구니 담을 거야?`,
585
+ ];
586
+ return pick(templates);
587
+ },
588
+ video: () => {
589
+ if (titleLower.includes('youtube') || titleLower.includes('유튜브')) {
590
+ return `"${pageShort}" 재미있어? 나도 궁금!`;
591
+ }
592
+ if (titleLower.includes('netflix') || titleLower.includes('넷플릭스') ||
593
+ titleLower.includes('tving') || titleLower.includes('watcha')) {
594
+ return `뭐 보는 거야? "${pageShort}" 재밌어?`;
595
+ }
596
+ return `영상 보고 있구나! "${pageShort}" 추천할 만해?`;
597
+ },
598
+ sns: () => {
599
+ if (titleLower.includes('twitter') || titleLower.includes('x.com')) {
600
+ return '트윗 보고 있구나~ 재미있는 거 있어?';
601
+ }
602
+ if (titleLower.includes('instagram') || titleLower.includes('인스타')) {
603
+ return '인스타 구경 중? 좋은 사진 보여줘!';
604
+ }
605
+ if (titleLower.includes('reddit')) {
606
+ return '레딧 탐색 중! 어떤 서브레딧이야?';
607
+ }
608
+ return 'SNS 하고 있구나~ 무한 스크롤 조심!';
609
+ },
610
+ news: () => {
611
+ return `"${pageShort}" — 무슨 뉴스야? 좋은 소식이길!`;
612
+ },
613
+ dev: () => {
614
+ if (titleLower.includes('pull request') || titleLower.includes('pr #')) {
615
+ return 'PR 리뷰 중이구나! 꼼꼼히 봐~';
616
+ }
617
+ if (titleLower.includes('issue')) {
618
+ return '이슈 처리 중? 화이팅!';
619
+ }
620
+ if (titleLower.includes('stackoverflow') || titleLower.includes('stack overflow')) {
621
+ return '스택오버플로우! 뭐가 막혔어? 도와줄까?';
622
+ }
623
+ if (titleLower.includes('github')) {
624
+ return `GitHub에서 "${pageShort}" 작업 중?`;
625
+ }
626
+ if (titleLower.includes('docs') || titleLower.includes('documentation')) {
627
+ return '문서 읽고 있구나! 공부 열심히~';
628
+ }
629
+ return `코딩 관련! "${pageShort}" 화이팅!`;
630
+ },
631
+ search: () => {
632
+ // "검색어 - Google 검색" 패턴에서 검색어 추출
633
+ const searchMatch = title.match(/(.+?)\s*[-–]\s*(Google|Bing|네이버|Naver|검색)/i);
634
+ if (searchMatch) {
635
+ const query = searchMatch[1].trim().slice(0, 15);
636
+ const templates = [
637
+ `"${query}" 궁금해? 내가 알려줄 수도 있는데!`,
638
+ `"${query}" 검색하고 있구나~ 찾으면 알려줘!`,
639
+ `오, "${query}" 나도 궁금하다!`,
640
+ ];
641
+ return pick(templates);
642
+ }
643
+ return '뭐 찾고 있어? 궁금한 거 있으면 물어봐!';
644
+ },
645
+ game: () => {
646
+ return `${pageShort} 하고 있어? 이기고 있어?!`;
647
+ },
648
+ music: () => {
649
+ return `뭐 듣고 있어? "${pageShort}" 좋은 노래야?`;
650
+ },
651
+ mail: () => {
652
+ return '메일 확인 중~ 중요한 거 있어?';
653
+ },
654
+ general: () => {
655
+ const templates = [
656
+ `"${pageShort}" 보고 있구나~`,
657
+ `오, ${pageShort}! 뭐 하는 거야?`,
658
+ ];
659
+ return pick(templates);
660
+ },
661
+ };
662
+
663
+ const gen = generators[category];
664
+ if (gen) return gen();
665
+
666
+ // 카테고리 미매칭: 제목 기반 일반 코멘트
667
+ if (pageName.length > 3) {
668
+ const templates = [
669
+ `"${pageShort}" 보고 있구나!`,
670
+ `오, ${pageShort}! 재미있어?`,
671
+ `${pageShort}... 뭐 하는 거야?`,
672
+ ];
673
+ return pick(templates);
674
+ }
675
+
676
+ return null;
677
+ }
678
+
679
+ /** 배열에서 랜덤 선택 */
680
+ function pick(arr) {
681
+ return arr[Math.floor(Math.random() * arr.length)];
682
+ }
683
+
684
+ // =====================================================
685
+ // AI 캐릭터 생성 시스템
686
+ // 텔레그램 컨셉 설명 → AI가 16x16 픽셀 아트 생성
687
+ // =====================================================
688
+
689
+ /**
690
+ * 캐릭터 생성 요청 처리 (텔레그램에서 트리거)
691
+ *
692
+ * 1차: apiRef로 AI 캐릭터 생성 (색상 + 프레임 데이터)
693
+ * 2차: 키워드 기반 색상 변환 (AI 없을 때 폴백)
694
+ *
695
+ * @param {object} event - { concept, chatId }
696
+ */
697
+ async function handleCharacterRequest(event) {
698
+ if (!connector || !connector.connected) return;
699
+
700
+ const concept = event.concept || '';
701
+ if (!concept) return;
702
+
703
+ console.log(`[ClawMate] 캐릭터 생성 요청: "${concept}"`);
704
+
705
+ let characterData = null;
706
+
707
+ // 1차: AI로 색상 팔레트 + 프레임 데이터 생성
708
+ if (apiRef?.generate) {
709
+ try {
710
+ characterData = await generateCharacterWithAI(concept);
711
+ } catch (err) {
712
+ console.log(`[ClawMate] AI 캐릭터 생성 실패: ${err.message}`);
713
+ }
714
+ }
715
+
716
+ // 2차: AI chat으로 시도
717
+ if (!characterData && apiRef?.chat) {
718
+ try {
719
+ characterData = await generateCharacterWithChat(concept);
720
+ } catch (err) {
721
+ console.log(`[ClawMate] AI chat 캐릭터 생성 실패: ${err.message}`);
722
+ }
723
+ }
724
+
725
+ // 3차: 키워드 기반 색상만 변환 (폴백)
726
+ if (!characterData) {
727
+ characterData = generateCharacterFromKeywords(concept);
728
+ }
729
+
730
+ if (characterData) {
731
+ // 캐릭터 데이터를 렌더러에 전송
732
+ connector._send('set_character', {
733
+ ...characterData,
734
+ speech: `${concept} 변신 완료!`,
735
+ });
736
+ console.log(`[ClawMate] 캐릭터 생성 완료: "${concept}"`);
737
+ }
738
+ }
739
+
740
+ /**
741
+ * AI generate()로 캐릭터 생성
742
+ */
743
+ async function generateCharacterWithAI(concept) {
744
+ const prompt = buildCharacterPrompt(concept);
745
+ const response = await apiRef.generate(prompt);
746
+ return parseCharacterResponse(response);
747
+ }
748
+
749
+ /**
750
+ * AI chat()으로 캐릭터 생성
751
+ */
752
+ async function generateCharacterWithChat(concept) {
753
+ const prompt = buildCharacterPrompt(concept);
754
+ const response = await apiRef.chat([
755
+ { role: 'system', content: '넌 16x16 픽셀 아트 캐릭터 디자이너야. JSON으로 캐릭터 데이터를 출력해.' },
756
+ { role: 'user', content: prompt },
757
+ ]);
758
+ const text = response?.text || response?.content || response;
759
+ return parseCharacterResponse(text);
760
+ }
761
+
762
+ /**
763
+ * 캐릭터 생성 프롬프트
764
+ */
765
+ function buildCharacterPrompt(concept) {
766
+ return `"${concept}" 컨셉의 16x16 픽셀 아트 캐릭터를 만들어줘.
767
+
768
+ JSON 형식으로 출력해:
769
+ {
770
+ "colorMap": {
771
+ "primary": "#hex색상", // 메인 몸통 색
772
+ "secondary": "#hex색상", // 보조 색 (배, 볼 등)
773
+ "dark": "#hex색상", // 어두운 부분 (다리, 그림자)
774
+ "eye": "#hex색상", // 눈 흰자
775
+ "pupil": "#hex색상", // 눈동자
776
+ "claw": "#hex색상" // 집게/손/특징 부위
777
+ },
778
+ "frames": {
779
+ "idle": [
780
+ [16x16 숫자 배열 - frame 0],
781
+ [16x16 숫자 배열 - frame 1]
782
+ ]
783
+ }
784
+ }
785
+
786
+ 숫자 의미: 0=투명, 1=primary, 2=secondary, 3=dark, 4=eye, 5=pupil, 6=claw
787
+ 캐릭터는 눈(4+5), 몸통(1+2), 다리(3), 특징(6)을 포함해야 해.
788
+ idle 프레임 2개만 만들어줘. 귀엽게!
789
+ JSON만 출력해.`;
790
+ }
791
+
792
+ /**
793
+ * AI 응답에서 캐릭터 데이터 파싱
794
+ */
795
+ function parseCharacterResponse(response) {
796
+ if (!response || typeof response !== 'string') return null;
797
+
798
+ // JSON 블록 추출 (```json ... ``` 또는 { ... })
799
+ let jsonStr = response;
800
+ const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/);
801
+ if (jsonMatch) {
802
+ jsonStr = jsonMatch[1].trim();
803
+ } else {
804
+ const braceMatch = response.match(/\{[\s\S]*\}/);
805
+ if (braceMatch) {
806
+ jsonStr = braceMatch[0];
807
+ }
808
+ }
809
+
810
+ try {
811
+ const data = JSON.parse(jsonStr);
812
+
813
+ // colorMap 검증
814
+ if (data.colorMap) {
815
+ const required = ['primary', 'secondary', 'dark', 'eye', 'pupil', 'claw'];
816
+ for (const key of required) {
817
+ if (!data.colorMap[key]) return null;
818
+ }
819
+ } else {
820
+ return null;
821
+ }
822
+
823
+ // frames 검증 (있으면)
824
+ if (data.frames?.idle) {
825
+ for (const frame of data.frames.idle) {
826
+ if (!Array.isArray(frame) || frame.length !== 16) {
827
+ delete data.frames; // 프레임 데이터 불량 → 색상만 사용
828
+ break;
829
+ }
830
+ for (const row of frame) {
831
+ if (!Array.isArray(row) || row.length !== 16) {
832
+ delete data.frames;
833
+ break;
834
+ }
835
+ }
836
+ if (!data.frames) break;
837
+ }
838
+ }
839
+
840
+ return data;
841
+ } catch {
842
+ // JSON 파싱 실패 → 색상만 추출 시도
843
+ const colorMatch = response.match(/"primary"\s*:\s*"(#[0-9a-fA-F]{6})"/);
844
+ if (colorMatch) {
845
+ // 최소한 primary 색상이라도 추출
846
+ return generateCharacterFromKeywords(response);
847
+ }
848
+ return null;
849
+ }
850
+ }
851
+
852
+ /**
853
+ * 키워드 기반 캐릭터 색상 생성 (AI 없을 때 폴백)
854
+ *
855
+ * 컨셉에서 색상/생물 키워드를 추출하여 팔레트 생성
856
+ */
857
+ function generateCharacterFromKeywords(concept) {
858
+ const c = (concept || '').toLowerCase();
859
+
860
+ // 색상 키워드 매핑
861
+ const colorMap = {
862
+ '파란|파랑|blue': { primary: '#4488ff', secondary: '#6699ff', dark: '#223388', claw: '#4488ff' },
863
+ '초록|녹색|green': { primary: '#44cc44', secondary: '#66dd66', dark: '#226622', claw: '#44cc44' },
864
+ '보라|purple': { primary: '#8844cc', secondary: '#aa66dd', dark: '#442266', claw: '#8844cc' },
865
+ '노란|금색|yellow|gold': { primary: '#ffcc00', secondary: '#ffdd44', dark: '#886600', claw: '#ffcc00' },
866
+ '분홍|핑크|pink': { primary: '#ff69b4', secondary: '#ff8cc4', dark: '#8B3060', claw: '#ff69b4' },
867
+ '하얀|흰|white': { primary: '#eeeeee', secondary: '#ffffff', dark: '#999999', claw: '#dddddd' },
868
+ '검정|까만|black': { primary: '#333333', secondary: '#555555', dark: '#111111', claw: '#444444' },
869
+ '주황|orange': { primary: '#ff8800', secondary: '#ffaa33', dark: '#884400', claw: '#ff8800' },
870
+ '민트|틸|teal': { primary: '#00BFA5', secondary: '#33D4BC', dark: '#006655', claw: '#00BFA5' },
871
+ };
872
+
873
+ // 생물 키워드 매핑
874
+ const creatureMap = {
875
+ '고양이|cat': { primary: '#ff9944', secondary: '#ffbb66', dark: '#663300', claw: '#ff9944' },
876
+ '로봇|robot': { primary: '#888888', secondary: '#aaaaaa', dark: '#444444', claw: '#66aaff' },
877
+ '슬라임|slime': { primary: '#44dd44', secondary: '#88ff88', dark: '#228822', claw: '#44dd44' },
878
+ '유령|ghost': { primary: '#ccccff', secondary: '#eeeeff', dark: '#6666aa', claw: '#ccccff' },
879
+ '드래곤|dragon': { primary: '#cc2222', secondary: '#ff4444', dark: '#661111', claw: '#ffaa00' },
880
+ '펭귄|penguin': { primary: '#222222', secondary: '#ffffff', dark: '#111111', claw: '#ff8800' },
881
+ '토끼|rabbit': { primary: '#ffcccc', secondary: '#ffeeee', dark: '#ff8888', claw: '#ffcccc' },
882
+ '악마|demon': { primary: '#660066', secondary: '#880088', dark: '#330033', claw: '#ff0000' },
883
+ '천사|angel': { primary: '#ffffff', secondary: '#ffffcc', dark: '#ddddaa', claw: '#ffdd00' },
884
+ '강아지|dog': { primary: '#cc8844', secondary: '#ddaa66', dark: '#664422', claw: '#cc8844' },
885
+ '불|fire': { primary: '#ff4400', secondary: '#ffaa00', dark: '#881100', claw: '#ff6600' },
886
+ '얼음|ice': { primary: '#88ccff', secondary: '#bbddff', dark: '#446688', claw: '#aaddff' },
887
+ };
888
+
889
+ // 색상 키워드 먼저 체크
890
+ for (const [keywords, palette] of Object.entries(colorMap)) {
891
+ for (const kw of keywords.split('|')) {
892
+ if (c.includes(kw)) {
893
+ return {
894
+ colorMap: { ...palette, eye: '#ffffff', pupil: '#111111' },
895
+ };
896
+ }
897
+ }
898
+ }
899
+
900
+ // 생물 키워드 체크
901
+ for (const [keywords, palette] of Object.entries(creatureMap)) {
902
+ for (const kw of keywords.split('|')) {
903
+ if (c.includes(kw)) {
904
+ return {
905
+ colorMap: { ...palette, eye: '#ffffff', pupil: '#111111' },
906
+ };
907
+ }
908
+ }
909
+ }
910
+
911
+ // 매칭 실패 → 랜덤 색상
912
+ const hue = Math.floor(Math.random() * 360);
913
+ const s = 70, l = 55;
914
+ return {
915
+ colorMap: {
916
+ primary: hslToHex(hue, s, l),
917
+ secondary: hslToHex(hue, s, l + 15),
918
+ dark: hslToHex(hue, s - 10, l - 30),
919
+ eye: '#ffffff',
920
+ pupil: '#111111',
921
+ claw: hslToHex(hue, s, l),
922
+ },
923
+ };
924
+ }
925
+
926
+ /** HSL → HEX 변환 */
927
+ function hslToHex(h, s, l) {
928
+ s /= 100;
929
+ l /= 100;
930
+ const a = s * Math.min(l, 1 - l);
931
+ const f = (n) => {
932
+ const k = (n + h / 30) % 12;
933
+ const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
934
+ return Math.round(255 * color).toString(16).padStart(2, '0');
935
+ };
936
+ return `#${f(0)}${f(8)}${f(4)}`;
937
+ }
938
+
385
939
  // =====================================================
386
940
  // AI Think Loop — 주기적 자율 사고 시스템
387
941
  // =====================================================
@@ -574,7 +1128,7 @@ function handleTimeGreeting(now, hour, todayStr) {
574
1128
  * 최소 30초 쿨타임, 야간에는 확률 대폭 감소
575
1129
  */
576
1130
  function handleIdleSpeech(now, isNightMode) {
577
- const speechCooldown = 30000; // 30
1131
+ const speechCooldown = 30000 * behaviorAdjustments.speechCooldownMultiplier; // 기본 30초, 메트릭에 의해 조절
578
1132
  if (now - lastSpeechTime < speechCooldown) return;
579
1133
 
580
1134
  // 야간: 5% 확률 / 주간: 25% 확률
@@ -592,7 +1146,7 @@ function handleIdleSpeech(now, isNightMode) {
592
1146
  * 최소 5초 쿨타임, 가중치 기반 랜덤 선택
593
1147
  */
594
1148
  function handleRandomAction(now, hour, isNightMode, state) {
595
- const actionCooldown = 5000; // 5
1149
+ const actionCooldown = 5000 * behaviorAdjustments.actionCooldownMultiplier; // 기본 5초, 메트릭에 의해 조절
596
1150
  if (now - lastActionTime < actionCooldown) return;
597
1151
 
598
1152
  // 야간: 10% 확률 / 주간: 40% 확률
@@ -743,8 +1297,9 @@ function handleExploration(now, state) {
743
1297
  const exploreInterval = 20000; // 20초
744
1298
  if (now - lastExploreTime < exploreInterval) return;
745
1299
 
746
- // 20% 확률
747
- if (Math.random() > 0.2) return;
1300
+ // 기본 20% 확률 + explorationBias 보정 (bias가 양수면 탐험 확률 증가)
1301
+ const exploreChance = Math.max(0.05, Math.min(0.8, 0.2 + behaviorAdjustments.explorationBias));
1302
+ if (Math.random() > exploreChance) return;
748
1303
  lastExploreTime = now;
749
1304
 
750
1305
  // 가중치 기반 탐험 행동 선택
@@ -855,6 +1410,195 @@ function handleFolderCarry(now) {
855
1410
  }
856
1411
  }
857
1412
 
1413
+ // =====================================================
1414
+ // 자기 관찰 시스템 (Metrics → 행동 조정)
1415
+ // =====================================================
1416
+
1417
+ /**
1418
+ * 메트릭 데이터 수신 처리
1419
+ * 렌더러에서 30초마다 전송되는 동작 품질 메트릭을 분석하고,
1420
+ * 이상을 감지하여 행동 패턴을 자동 조정한다.
1421
+ *
1422
+ * @param {object} data - { metrics: {...}, timestamp }
1423
+ */
1424
+ function handleMetrics(data) {
1425
+ if (!data || !data.metrics) return;
1426
+ const metrics = data.metrics;
1427
+ latestMetrics = metrics;
1428
+
1429
+ // 이력 유지 (최근 10개)
1430
+ metricsHistory.push(metrics);
1431
+ if (metricsHistory.length > 10) metricsHistory.shift();
1432
+
1433
+ // 이상 감지 및 반응
1434
+ _detectAnomalies(metrics);
1435
+
1436
+ // 행동 자동 조정
1437
+ adjustBehavior(metrics);
1438
+
1439
+ // 주기적 품질 보고서 (5분마다 콘솔 로그)
1440
+ const now = Date.now();
1441
+ if (now - lastMetricsLogTime >= 5 * 60 * 1000) {
1442
+ lastMetricsLogTime = now;
1443
+ _logQualityReport(metrics);
1444
+ }
1445
+ }
1446
+
1447
+ /**
1448
+ * 이상 감지: 메트릭 임계값을 초과하면 즉시 반응
1449
+ *
1450
+ * - FPS < 30 → 성능 경고, 행동 빈도 축소
1451
+ * - idle 비율 > 80% → 너무 멈춰있음, 활동 촉진
1452
+ * - 탐험 커버리지 < 30% → 새 영역 탐험 유도
1453
+ * - 사용자 클릭 0회 (장시간) → 관심 끌기 행동
1454
+ */
1455
+ function _detectAnomalies(metrics) {
1456
+ if (!connector || !connector.connected) return;
1457
+
1458
+ // --- FPS 저하 감지 ---
1459
+ if (metrics.fps < 30 && metrics.fps > 0) {
1460
+ console.log(`[ClawMate][Metrics] FPS 저하 감지: ${metrics.fps}`);
1461
+ connector.speak('화면이 좀 버벅이네... 잠깐 쉴게.');
1462
+ connector.action('idle');
1463
+
1464
+ // 행동 빈도를 즉시 줄여 렌더링 부하 감소
1465
+ behaviorAdjustments.actionCooldownMultiplier = 3.0;
1466
+ behaviorAdjustments.speechCooldownMultiplier = 2.0;
1467
+ behaviorAdjustments.activityLevel = 0.5;
1468
+ return; // FPS 문제 시 다른 조정은 보류
1469
+ }
1470
+
1471
+ // --- idle 비율 과다 ---
1472
+ if (metrics.idleRatio > 0.8) {
1473
+ console.log(`[ClawMate][Metrics] idle 비율 과다: ${(metrics.idleRatio * 100).toFixed(0)}%`);
1474
+
1475
+ // 10% 확률로 각성 멘트 (매번 말하면 스팸)
1476
+ if (Math.random() < 0.1) {
1477
+ const idleReactions = [
1478
+ '가만히 있으면 재미없지! 좀 돌아다녀볼까~',
1479
+ '멍때리고 있었네... 움직여야지!',
1480
+ '심심해~ 탐험 가자!',
1481
+ ];
1482
+ const text = idleReactions[Math.floor(Math.random() * idleReactions.length)];
1483
+ connector.speak(text);
1484
+ }
1485
+ }
1486
+
1487
+ // --- 탐험 커버리지 부족 ---
1488
+ if (metrics.explorationCoverage < 0.3 && metrics.period >= 25000) {
1489
+ console.log(`[ClawMate][Metrics] 탐험 커버리지 부족: ${(metrics.explorationCoverage * 100).toFixed(0)}%`);
1490
+
1491
+ // 5% 확률로 탐험 유도 (빈도 조절)
1492
+ if (Math.random() < 0.05) {
1493
+ connector.speak('아직 안 가본 곳이 많네~ 탐험해볼까!');
1494
+ }
1495
+ }
1496
+
1497
+ // --- 사용자 상호작용 감소 ---
1498
+ // 최근 3개 보고에서 연속으로 클릭 0회이면 관심 끌기
1499
+ if (metricsHistory.length >= 3) {
1500
+ const recent3 = metricsHistory.slice(-3);
1501
+ const noClicks = recent3.every(m => (m.userClicks || 0) === 0);
1502
+ if (noClicks) {
1503
+ // 5% 확률로 관심 끌기 (연속 감지 시)
1504
+ if (Math.random() < 0.05) {
1505
+ connector.decide({
1506
+ action: 'excited',
1507
+ speech: '나 여기 있어~ 심심하면 클릭해줘!',
1508
+ emotion: 'playful',
1509
+ });
1510
+ console.log('[ClawMate][Metrics] 사용자 상호작용 감소 → 관심 끌기');
1511
+ }
1512
+ }
1513
+ }
1514
+ }
1515
+
1516
+ /**
1517
+ * 행동 패턴 자동 조정
1518
+ * 메트릭 데이터를 기반으로 행동 빈도/패턴을 실시간 튜닝한다.
1519
+ *
1520
+ * 조정 원칙:
1521
+ * - FPS가 낮으면 행동 빈도를 줄여 렌더링 부하 감소
1522
+ * - idle이 너무 많으면 행동을 활발하게
1523
+ * - 탐험 커버리지가 낮으면 탐험 확률 증가
1524
+ * - 사용자 상호작용이 활발하면 대응 빈도 증가
1525
+ *
1526
+ * @param {object} metrics - 현재 메트릭 데이터
1527
+ */
1528
+ function adjustBehavior(metrics) {
1529
+ // --- FPS 기반 활동 수준 조절 ---
1530
+ if (metrics.fps >= 50) {
1531
+ // 충분한 성능 → 정상 활동
1532
+ behaviorAdjustments.activityLevel = 1.0;
1533
+ behaviorAdjustments.actionCooldownMultiplier = 1.0;
1534
+ } else if (metrics.fps >= 30) {
1535
+ // 성능 약간 부족 → 활동 약간 축소
1536
+ behaviorAdjustments.activityLevel = 0.8;
1537
+ behaviorAdjustments.actionCooldownMultiplier = 1.5;
1538
+ } else {
1539
+ // 성능 부족 → 활동 대폭 축소 (_detectAnomalies에서 이미 처리)
1540
+ behaviorAdjustments.activityLevel = 0.5;
1541
+ behaviorAdjustments.actionCooldownMultiplier = 3.0;
1542
+ }
1543
+
1544
+ // --- idle 비율 기반 활동 조절 ---
1545
+ if (metrics.idleRatio > 0.8) {
1546
+ // 너무 멈춰있음 → 행동 쿨타임 단축, 활동 수준 증가
1547
+ behaviorAdjustments.actionCooldownMultiplier = Math.max(0.5,
1548
+ behaviorAdjustments.actionCooldownMultiplier * 0.7);
1549
+ behaviorAdjustments.activityLevel = Math.min(1.5,
1550
+ behaviorAdjustments.activityLevel * 1.3);
1551
+ } else if (metrics.idleRatio < 0.1) {
1552
+ // 너무 바쁨 → 약간 쉬게
1553
+ behaviorAdjustments.actionCooldownMultiplier = Math.max(1.0,
1554
+ behaviorAdjustments.actionCooldownMultiplier * 1.2);
1555
+ }
1556
+
1557
+ // --- 탐험 커버리지 기반 탐험 편향 ---
1558
+ if (metrics.explorationCoverage < 0.3) {
1559
+ // 탐험 부족 → 탐험 확률 증가
1560
+ behaviorAdjustments.explorationBias = 0.15;
1561
+ } else if (metrics.explorationCoverage > 0.7) {
1562
+ // 충분히 탐험함 → 탐험 확률 기본으로
1563
+ behaviorAdjustments.explorationBias = 0;
1564
+ } else {
1565
+ // 중간 → 약간 증가
1566
+ behaviorAdjustments.explorationBias = 0.05;
1567
+ }
1568
+
1569
+ // --- 사용자 상호작용 기반 말풍선 빈도 ---
1570
+ if (metrics.userClicks > 3) {
1571
+ // 사용자가 활발히 클릭 → 말풍선 빈도 증가 (반응적)
1572
+ behaviorAdjustments.speechCooldownMultiplier = 0.7;
1573
+ } else if (metrics.userClicks === 0 && metrics.speechCount > 5) {
1574
+ // 사용자 무반응인데 말이 많음 → 말풍선 줄이기
1575
+ behaviorAdjustments.speechCooldownMultiplier = 1.5;
1576
+ } else {
1577
+ behaviorAdjustments.speechCooldownMultiplier = 1.0;
1578
+ }
1579
+
1580
+ // 값 범위 클램핑 (안전 장치)
1581
+ behaviorAdjustments.activityLevel = Math.max(0.3, Math.min(2.0, behaviorAdjustments.activityLevel));
1582
+ behaviorAdjustments.actionCooldownMultiplier = Math.max(0.3, Math.min(5.0, behaviorAdjustments.actionCooldownMultiplier));
1583
+ behaviorAdjustments.speechCooldownMultiplier = Math.max(0.3, Math.min(5.0, behaviorAdjustments.speechCooldownMultiplier));
1584
+ behaviorAdjustments.explorationBias = Math.max(-0.15, Math.min(0.3, behaviorAdjustments.explorationBias));
1585
+ }
1586
+
1587
+ /**
1588
+ * 품질 보고서 콘솔 출력 (5분마다)
1589
+ * OpenClaw 개발자/운영자가 펫의 동작 품질을 모니터링할 수 있도록 한다.
1590
+ */
1591
+ function _logQualityReport(metrics) {
1592
+ const adj = behaviorAdjustments;
1593
+ console.log('=== [ClawMate] 동작 품질 보고서 ===');
1594
+ console.log(` FPS: ${metrics.fps} | 프레임 일관성: ${metrics.animationFrameConsistency}`);
1595
+ console.log(` 이동 부드러움: ${metrics.movementSmoothness} | 벽면 밀착: ${metrics.wallContactAccuracy}`);
1596
+ console.log(` idle 비율: ${(metrics.idleRatio * 100).toFixed(0)}% | 탐험 커버리지: ${(metrics.explorationCoverage * 100).toFixed(0)}%`);
1597
+ console.log(` 응답 시간: ${metrics.interactionResponseMs}ms | 말풍선: ${metrics.speechCount}회 | 클릭: ${metrics.userClicks}회`);
1598
+ console.log(` [조정] 활동수준: ${adj.activityLevel.toFixed(2)} | 행동쿨타임: x${adj.actionCooldownMultiplier.toFixed(2)} | 말풍선쿨타임: x${adj.speechCooldownMultiplier.toFixed(2)} | 탐험편향: ${adj.explorationBias.toFixed(2)}`);
1599
+ console.log('====================================');
1600
+ }
1601
+
858
1602
  // =====================================================
859
1603
  // npm 패키지 버전 체크 (npm install -g 사용자용)
860
1604
  // =====================================================