companionbot 0.6.0 → 0.7.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/README.md +26 -2
- package/dist/ai/claude.js +50 -56
- package/dist/calendar/index.js +15 -13
- package/dist/cron/parser.js +60 -28
- package/dist/cron/scheduler.js +18 -9
- package/dist/cron/store.js +79 -22
- package/dist/health/index.js +66 -0
- package/dist/heartbeat/index.js +23 -2
- package/dist/memory/embeddings.js +22 -3
- package/dist/memory/vectorStore.js +39 -17
- package/dist/session/state.js +18 -1
- package/dist/telegram/handlers/commands.js +33 -6
- package/dist/telegram/handlers/messages.js +71 -4
- package/dist/telegram/utils/url.js +39 -8
- package/dist/tools/index.js +12 -10
- package/dist/updates/index.js +52 -0
- package/dist/utils/constants.js +44 -0
- package/dist/utils/index.js +2 -0
- package/dist/utils/time.js +51 -0
- package/package.json +3 -2
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 봇 헬스 체크 모듈
|
|
3
|
+
* 봇의 상태를 추적하고 모니터링합니다.
|
|
4
|
+
* @module health
|
|
5
|
+
*/
|
|
6
|
+
let startTime = Date.now();
|
|
7
|
+
let lastActivity = Date.now();
|
|
8
|
+
let messageCount = 0;
|
|
9
|
+
let errorCount = 0;
|
|
10
|
+
/**
|
|
11
|
+
* 활동을 기록합니다.
|
|
12
|
+
* 메시지 처리 시 호출하여 마지막 활동 시간과 메시지 카운트를 업데이트합니다.
|
|
13
|
+
*/
|
|
14
|
+
export function recordActivity() {
|
|
15
|
+
lastActivity = Date.now();
|
|
16
|
+
messageCount++;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 오류 발생을 기록합니다.
|
|
20
|
+
*/
|
|
21
|
+
export function recordError() {
|
|
22
|
+
errorCount++;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 현재 봇의 건강 상태를 조회합니다.
|
|
26
|
+
* @returns 건강 상태 정보
|
|
27
|
+
*/
|
|
28
|
+
export function getHealthStatus() {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const uptime = Math.floor((now - startTime) / 1000);
|
|
31
|
+
const inactiveTime = now - lastActivity;
|
|
32
|
+
// 30분 이상 활동 없으면 unhealthy
|
|
33
|
+
const isHealthy = inactiveTime < 30 * 60 * 1000;
|
|
34
|
+
return {
|
|
35
|
+
uptime,
|
|
36
|
+
lastActivity,
|
|
37
|
+
messageCount,
|
|
38
|
+
errorCount,
|
|
39
|
+
isHealthy
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 가동 시간을 읽기 좋은 형태로 포맷합니다.
|
|
44
|
+
* @param seconds - 가동 시간 (초)
|
|
45
|
+
* @returns 포맷된 문자열 (예: "2일 3시간", "5시간 30분")
|
|
46
|
+
*/
|
|
47
|
+
export function formatUptime(seconds) {
|
|
48
|
+
const days = Math.floor(seconds / 86400);
|
|
49
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
50
|
+
const mins = Math.floor((seconds % 3600) / 60);
|
|
51
|
+
if (days > 0)
|
|
52
|
+
return `${days}일 ${hours}시간`;
|
|
53
|
+
if (hours > 0)
|
|
54
|
+
return `${hours}시간 ${mins}분`;
|
|
55
|
+
return `${mins}분`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 헬스 상태를 초기화합니다.
|
|
59
|
+
* 테스트나 재시작 시 사용합니다.
|
|
60
|
+
*/
|
|
61
|
+
export function resetHealth() {
|
|
62
|
+
startTime = Date.now();
|
|
63
|
+
lastActivity = Date.now();
|
|
64
|
+
messageCount = 0;
|
|
65
|
+
errorCount = 0;
|
|
66
|
+
}
|
package/dist/heartbeat/index.js
CHANGED
|
@@ -4,15 +4,21 @@ import { getWorkspacePath } from "../workspace/index.js";
|
|
|
4
4
|
import { chat } from "../ai/claude.js";
|
|
5
5
|
import { isCalendarConfigured, getTodayEvents, formatEvent } from "../calendar/index.js";
|
|
6
6
|
import { getSecret } from "../config/secrets.js";
|
|
7
|
+
import { checkForUpdates } from "../updates/index.js";
|
|
8
|
+
import { INTERVAL_30_MINUTES, INTERVAL_24_HOURS, hoursToMs } from "../utils/time.js";
|
|
7
9
|
// 활성 타이머
|
|
8
10
|
const activeTimers = new Map();
|
|
9
11
|
// 메모리 캐시: 타임스탬프는 메모리에만 유지하여 파일 쓰기 최소화
|
|
10
12
|
// lastCheckAt, lastMessageAt은 디버깅 용도라 매번 저장할 필요 없음
|
|
11
13
|
const timestampCache = new Map();
|
|
14
|
+
// 업데이트 체크 캐시 (하루에 한 번만)
|
|
15
|
+
let lastUpdateCheck = 0;
|
|
16
|
+
let cachedUpdateInfo = null;
|
|
17
|
+
const UPDATE_CHECK_INTERVAL = INTERVAL_24_HOURS;
|
|
12
18
|
// 봇 인스턴스
|
|
13
19
|
let botInstance = null;
|
|
14
20
|
// 기본 간격: 30분
|
|
15
|
-
const DEFAULT_INTERVAL_MS =
|
|
21
|
+
const DEFAULT_INTERVAL_MS = INTERVAL_30_MINUTES;
|
|
16
22
|
export function setHeartbeatBot(bot) {
|
|
17
23
|
botInstance = bot;
|
|
18
24
|
}
|
|
@@ -79,6 +85,21 @@ async function gatherContext() {
|
|
|
79
85
|
// 무시
|
|
80
86
|
}
|
|
81
87
|
}
|
|
88
|
+
// 업데이트 체크 (하루에 한 번)
|
|
89
|
+
const timeSinceLastCheck = Date.now() - lastUpdateCheck;
|
|
90
|
+
if (timeSinceLastCheck > UPDATE_CHECK_INTERVAL) {
|
|
91
|
+
try {
|
|
92
|
+
cachedUpdateInfo = await checkForUpdates();
|
|
93
|
+
lastUpdateCheck = Date.now();
|
|
94
|
+
console.log(`[Heartbeat] Update check: current=${cachedUpdateInfo.current}, latest=${cachedUpdateInfo.latest}`);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
console.error("[Heartbeat] Update check failed:", error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (cachedUpdateInfo?.hasUpdate) {
|
|
101
|
+
parts.push(`🆕 업데이트 알림: CompanionBot ${cachedUpdateInfo.latest} 버전이 출시됨! (현재: ${cachedUpdateInfo.current})`);
|
|
102
|
+
}
|
|
82
103
|
return parts.join("\n");
|
|
83
104
|
}
|
|
84
105
|
// Heartbeat 실행 - 메시지를 보냈으면 true 반환
|
|
@@ -257,7 +278,7 @@ export async function runHeartbeatNow(chatId) {
|
|
|
257
278
|
enabled: false,
|
|
258
279
|
intervalMs: DEFAULT_INTERVAL_MS,
|
|
259
280
|
lastCheckAt: Date.now(),
|
|
260
|
-
lastMessageAt: Date.now() - (8
|
|
281
|
+
lastMessageAt: Date.now() - hoursToMs(8), // 8시간 전으로 설정
|
|
261
282
|
};
|
|
262
283
|
return await executeHeartbeat(defaultConfig);
|
|
263
284
|
}
|
|
@@ -37,6 +37,10 @@ async function getEmbeddingPipeline() {
|
|
|
37
37
|
* @returns 384차원 임베딩 벡터
|
|
38
38
|
*/
|
|
39
39
|
export async function embed(text) {
|
|
40
|
+
// null/undefined 처리
|
|
41
|
+
if (text == null) {
|
|
42
|
+
return new Array(384).fill(0);
|
|
43
|
+
}
|
|
40
44
|
const pipe = await getEmbeddingPipeline();
|
|
41
45
|
// 텍스트 정규화
|
|
42
46
|
const cleanText = text.trim().slice(0, 512); // 최대 512자
|
|
@@ -52,13 +56,25 @@ export async function embed(text) {
|
|
|
52
56
|
}
|
|
53
57
|
/**
|
|
54
58
|
* 여러 텍스트를 배치로 임베딩합니다.
|
|
59
|
+
* 병렬로 처리하여 성능 향상 (모델 내부에서 순차 처리되더라도 Promise 오버헤드 감소)
|
|
55
60
|
* @param texts 변환할 텍스트 배열
|
|
56
61
|
* @returns 임베딩 벡터 배열
|
|
57
62
|
*/
|
|
58
63
|
export async function embedBatch(texts) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
// null/undefined 배열 처리
|
|
65
|
+
if (!texts || texts.length === 0)
|
|
66
|
+
return [];
|
|
67
|
+
if (texts.length === 1)
|
|
68
|
+
return [await embed(texts[0])];
|
|
69
|
+
// 동시성 제한 (메모리 보호)
|
|
70
|
+
const CONCURRENCY = 5;
|
|
71
|
+
const results = new Array(texts.length);
|
|
72
|
+
for (let i = 0; i < texts.length; i += CONCURRENCY) {
|
|
73
|
+
const batch = texts.slice(i, i + CONCURRENCY);
|
|
74
|
+
const batchResults = await Promise.all(batch.map(text => embed(text)));
|
|
75
|
+
for (let j = 0; j < batchResults.length; j++) {
|
|
76
|
+
results[i + j] = batchResults[j];
|
|
77
|
+
}
|
|
62
78
|
}
|
|
63
79
|
return results;
|
|
64
80
|
}
|
|
@@ -70,6 +86,9 @@ export async function embedBatch(texts) {
|
|
|
70
86
|
* normalized 파라미터가 true면 내적만 계산하여 성능 향상.
|
|
71
87
|
*/
|
|
72
88
|
export function cosineSimilarity(a, b, normalized = true) {
|
|
89
|
+
// null/undefined 또는 빈 배열 처리
|
|
90
|
+
if (!a || !b || a.length === 0 || b.length === 0)
|
|
91
|
+
return 0;
|
|
73
92
|
if (a.length !== b.length)
|
|
74
93
|
return 0;
|
|
75
94
|
let dotProduct = 0;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import * as fs from "fs/promises";
|
|
6
6
|
import * as path from "path";
|
|
7
7
|
import { getMemoryDirPath, getWorkspaceFilePath } from "../workspace/paths.js";
|
|
8
|
-
import { embed, cosineSimilarity } from "./embeddings.js";
|
|
8
|
+
import { embed, embedBatch, cosineSimilarity } from "./embeddings.js";
|
|
9
9
|
// 캐시된 청크들 (임베딩 포함)
|
|
10
10
|
let cachedChunks = [];
|
|
11
11
|
let cacheTimestamp = 0;
|
|
@@ -103,10 +103,16 @@ export async function loadAllMemoryChunks() {
|
|
|
103
103
|
try {
|
|
104
104
|
const chunks = await loadingPromise;
|
|
105
105
|
// 캐시 업데이트 (임베딩은 아직 없음)
|
|
106
|
+
// 빈 결과도 캐시하되 TTL을 짧게 (1분)
|
|
106
107
|
cachedChunks = chunks;
|
|
107
|
-
cacheTimestamp = Date.now();
|
|
108
|
+
cacheTimestamp = chunks.length > 0 ? Date.now() : Date.now() - CACHE_TTL_MS + 60000;
|
|
108
109
|
return chunks;
|
|
109
110
|
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// 로드 실패 시 캐시하지 않음
|
|
113
|
+
console.error("[VectorStore] Failed to load memory chunks:", error);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
110
116
|
finally {
|
|
111
117
|
loadingPromise = null;
|
|
112
118
|
}
|
|
@@ -122,25 +128,41 @@ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
|
|
|
122
128
|
if (chunks.length === 0) {
|
|
123
129
|
return [];
|
|
124
130
|
}
|
|
125
|
-
//
|
|
126
|
-
const
|
|
127
|
-
|
|
131
|
+
// 임베딩이 없는 청크들을 배치로 처리
|
|
132
|
+
const chunksNeedingEmbedding = chunks.filter(c => !c.embedding);
|
|
133
|
+
if (chunksNeedingEmbedding.length > 0) {
|
|
128
134
|
try {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (score >= minScore) {
|
|
135
|
-
results.push({
|
|
136
|
-
text: chunk.text,
|
|
137
|
-
source: chunk.source,
|
|
138
|
-
score,
|
|
139
|
-
});
|
|
135
|
+
const texts = chunksNeedingEmbedding.map(c => c.text);
|
|
136
|
+
const embeddings = await embedBatch(texts);
|
|
137
|
+
// 임베딩 할당
|
|
138
|
+
for (let i = 0; i < chunksNeedingEmbedding.length; i++) {
|
|
139
|
+
chunksNeedingEmbedding[i].embedding = embeddings[i];
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
catch {
|
|
143
|
-
//
|
|
143
|
+
// 배치 실패 시 개별 처리 폴백
|
|
144
|
+
for (const chunk of chunksNeedingEmbedding) {
|
|
145
|
+
try {
|
|
146
|
+
chunk.embedding = await embed(chunk.text);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// 개별 실패 무시
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// 유사도 계산 및 필터링
|
|
155
|
+
const results = [];
|
|
156
|
+
for (const chunk of chunks) {
|
|
157
|
+
if (!chunk.embedding)
|
|
158
|
+
continue;
|
|
159
|
+
const score = cosineSimilarity(queryEmbedding, chunk.embedding);
|
|
160
|
+
if (score >= minScore) {
|
|
161
|
+
results.push({
|
|
162
|
+
text: chunk.text,
|
|
163
|
+
source: chunk.source,
|
|
164
|
+
score,
|
|
165
|
+
});
|
|
144
166
|
}
|
|
145
167
|
}
|
|
146
168
|
// 유사도 점수로 정렬하고 상위 K개 반환
|
package/dist/session/state.js
CHANGED
|
@@ -9,6 +9,16 @@ const sessions = new Map();
|
|
|
9
9
|
// AsyncLocalStorage for chatId context
|
|
10
10
|
const chatIdStorage = new AsyncLocalStorage();
|
|
11
11
|
function getSession(chatId) {
|
|
12
|
+
// chatId 유효성 검사
|
|
13
|
+
if (chatId == null || isNaN(chatId)) {
|
|
14
|
+
console.warn(`[Session] Invalid chatId: ${chatId}, using fallback session`);
|
|
15
|
+
// 임시 세션 반환 (저장하지 않음)
|
|
16
|
+
return {
|
|
17
|
+
history: [],
|
|
18
|
+
model: "sonnet",
|
|
19
|
+
lastAccessedAt: Date.now(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
12
22
|
const existing = sessions.get(chatId);
|
|
13
23
|
const now = Date.now();
|
|
14
24
|
if (existing) {
|
|
@@ -44,13 +54,20 @@ function cleanupSessions() {
|
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
export function getHistory(chatId) {
|
|
47
|
-
|
|
57
|
+
const session = getSession(chatId);
|
|
58
|
+
// 참조 반환 (외부 수정 허용 - 의도적)
|
|
59
|
+
// 필요시 [...session.history]로 복사본 반환 가능
|
|
60
|
+
return session.history ?? [];
|
|
48
61
|
}
|
|
49
62
|
/**
|
|
50
63
|
* 히스토리를 토큰 기반으로 트리밍한다.
|
|
51
64
|
* 최대 토큰 한도를 초과하면 가장 오래된 메시지부터 제거 (최소 2개는 유지).
|
|
52
65
|
*/
|
|
53
66
|
export function trimHistoryByTokens(history) {
|
|
67
|
+
// null/undefined/빈 배열 처리
|
|
68
|
+
if (!history || history.length === 0) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
54
71
|
while (estimateMessagesTokens(history) > MAX_HISTORY_TOKENS && history.length > 2) {
|
|
55
72
|
history.shift();
|
|
56
73
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "crypto";
|
|
2
|
+
import { getHealthStatus, formatUptime } from "../../health/index.js";
|
|
2
3
|
import { chat, MODELS } from "../../ai/claude.js";
|
|
3
4
|
import { estimateMessagesTokens } from "../../utils/tokens.js";
|
|
4
5
|
// 대화 요약 생성 함수
|
|
@@ -458,8 +459,12 @@ export function registerCommands(bot) {
|
|
|
458
459
|
await ctx.reply("❌ 인증 실패. 다시 시도해주세요.");
|
|
459
460
|
}
|
|
460
461
|
})
|
|
461
|
-
.catch(() => {
|
|
462
|
-
|
|
462
|
+
.catch(async (error) => {
|
|
463
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
464
|
+
console.error(`[Calendar] Auth server error for chatId=${ctx.chat.id}:`, errorMsg);
|
|
465
|
+
if (errorMsg.includes("timeout") || errorMsg.includes("Timeout")) {
|
|
466
|
+
await ctx.reply("⏰ 인증 시간이 만료됐어요. /calendar_setup 으로 다시 시도해주세요.");
|
|
467
|
+
}
|
|
463
468
|
});
|
|
464
469
|
}
|
|
465
470
|
return;
|
|
@@ -512,8 +517,12 @@ export function registerCommands(bot) {
|
|
|
512
517
|
await ctx.reply("❌ 인증 실패. 다시 시도해주세요.");
|
|
513
518
|
}
|
|
514
519
|
})
|
|
515
|
-
.catch(() => {
|
|
516
|
-
|
|
520
|
+
.catch(async (error) => {
|
|
521
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
522
|
+
console.error("[Calendar] Auth server error:", errorMsg);
|
|
523
|
+
if (errorMsg.includes("timeout") || errorMsg.includes("Timeout")) {
|
|
524
|
+
await ctx.reply("⏰ 인증 시간이 만료됐어요. /calendar_setup 으로 다시 시도해주세요.");
|
|
525
|
+
}
|
|
517
526
|
});
|
|
518
527
|
}
|
|
519
528
|
return;
|
|
@@ -540,8 +549,17 @@ export function registerCommands(bot) {
|
|
|
540
549
|
await ctx.reply(message);
|
|
541
550
|
}
|
|
542
551
|
catch (error) {
|
|
543
|
-
|
|
544
|
-
|
|
552
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
553
|
+
console.error(`[Calendar] chatId=${ctx.chat.id} getTodayEvents error:`, errorMsg);
|
|
554
|
+
if (errorMsg.includes("invalid_grant") || errorMsg.includes("Token")) {
|
|
555
|
+
await ctx.reply("캘린더 인증이 만료됐어요. /calendar_setup 으로 다시 연동해주세요.");
|
|
556
|
+
}
|
|
557
|
+
else if (errorMsg.includes("timeout") || errorMsg.includes("ETIMEDOUT")) {
|
|
558
|
+
await ctx.reply("Google 서버 응답이 느려요. 잠시 후 다시 시도해주세요.");
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
await ctx.reply("캘린더를 불러오지 못했어요. 잠시 후 다시 시도해주세요.");
|
|
562
|
+
}
|
|
545
563
|
}
|
|
546
564
|
});
|
|
547
565
|
// /briefing 명령어 - 토글 방식
|
|
@@ -582,4 +600,13 @@ export function registerCommands(bot) {
|
|
|
582
600
|
`"10분마다 체크해줘"로 간격 변경 가능`);
|
|
583
601
|
}
|
|
584
602
|
});
|
|
603
|
+
// /health 명령어 - 봇 상태 확인
|
|
604
|
+
bot.command("health", async (ctx) => {
|
|
605
|
+
const status = getHealthStatus();
|
|
606
|
+
await ctx.reply(`🏥 봇 상태\n\n` +
|
|
607
|
+
`⏱ 가동: ${formatUptime(status.uptime)}\n` +
|
|
608
|
+
`💬 메시지: ${status.messageCount}개\n` +
|
|
609
|
+
`❌ 에러: ${status.errorCount}개\n` +
|
|
610
|
+
`🔋 상태: ${status.isHealthy ? "정상 ✅" : "점검 필요 ⚠️"}`);
|
|
611
|
+
});
|
|
585
612
|
}
|
|
@@ -1,7 +1,43 @@
|
|
|
1
1
|
import { chat, chatSmart } from "../../ai/claude.js";
|
|
2
|
+
import { recordActivity, recordError } from "../../health/index.js";
|
|
2
3
|
import { getHistory, getModel, runWithChatId, trimHistoryByTokens, } from "../../session/state.js";
|
|
3
4
|
import { updateLastMessageTime } from "../../heartbeat/index.js";
|
|
4
5
|
import { extractUrls, fetchWebContent, buildSystemPrompt, } from "../utils/index.js";
|
|
6
|
+
import { estimateMessagesTokens } from "../../utils/tokens.js";
|
|
7
|
+
const MAX_CONTEXT_TOKENS = 100000; // Claude 컨텍스트
|
|
8
|
+
const COMPACTION_THRESHOLD = 0.6; // 60%
|
|
9
|
+
/**
|
|
10
|
+
* 토큰 사용량이 임계치를 넘으면 자동으로 히스토리 압축
|
|
11
|
+
* 실패해도 메시지 처리에 영향 없도록 에러를 조용히 처리
|
|
12
|
+
*/
|
|
13
|
+
async function autoCompactIfNeeded(ctx, history) {
|
|
14
|
+
try {
|
|
15
|
+
const tokens = estimateMessagesTokens(history);
|
|
16
|
+
const usage = tokens / MAX_CONTEXT_TOKENS;
|
|
17
|
+
if (usage > COMPACTION_THRESHOLD && history.length > 6) {
|
|
18
|
+
// 자동 compaction 실행
|
|
19
|
+
console.log(`[AutoCompact] chatId=${ctx.chat?.id} usage=${(usage * 100).toFixed(1)}% - compacting...`);
|
|
20
|
+
// 앞부분 요약 생성 (최근 4개 메시지 제외)
|
|
21
|
+
const oldMessages = history.slice(0, -4);
|
|
22
|
+
const summaryPrompt = "다음 대화를 3-4문장으로 요약해줘:\n\n" +
|
|
23
|
+
oldMessages
|
|
24
|
+
.map((m) => `${m.role}: ${typeof m.content === "string" ? m.content : "[media]"}`)
|
|
25
|
+
.join("\n");
|
|
26
|
+
const summary = await chat([{ role: "user", content: summaryPrompt }], "", "haiku");
|
|
27
|
+
// 히스토리 교체
|
|
28
|
+
const recentMessages = history.slice(-4);
|
|
29
|
+
history.splice(0, history.length);
|
|
30
|
+
history.push({ role: "user", content: `[이전 대화 요약]\n${summary}` });
|
|
31
|
+
history.push(...recentMessages);
|
|
32
|
+
const newTokens = estimateMessagesTokens(history);
|
|
33
|
+
await ctx.reply(`📦 자동 정리: ${tokens} → ${newTokens} 토큰`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
// 자동 압축 실패는 치명적이지 않음 - 로깅만 하고 계속 진행
|
|
38
|
+
console.warn(`[AutoCompact] Failed for chatId=${ctx.chat?.id}:`, error instanceof Error ? error.message : error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
5
41
|
/**
|
|
6
42
|
* 스트리밍 응답 전송 (Telegram 메시지 실시간 업데이트)
|
|
7
43
|
*/
|
|
@@ -57,6 +93,7 @@ export function registerMessageHandlers(bot) {
|
|
|
57
93
|
bot.on("message:photo", async (ctx) => {
|
|
58
94
|
const chatId = ctx.chat.id;
|
|
59
95
|
await runWithChatId(chatId, async () => {
|
|
96
|
+
recordActivity();
|
|
60
97
|
const history = getHistory(chatId);
|
|
61
98
|
const modelId = getModel(chatId);
|
|
62
99
|
await ctx.replyWithChatAction("typing");
|
|
@@ -112,8 +149,19 @@ export function registerMessageHandlers(bot) {
|
|
|
112
149
|
}
|
|
113
150
|
}
|
|
114
151
|
catch (error) {
|
|
115
|
-
|
|
116
|
-
|
|
152
|
+
recordError();
|
|
153
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
154
|
+
console.error(`[Photo] chatId=${chatId} error:`, errorMsg);
|
|
155
|
+
// 사용자 친화적 에러 메시지
|
|
156
|
+
if (errorMsg.includes("rate limit") || errorMsg.includes("429")) {
|
|
157
|
+
await ctx.reply("지금 요청이 많아서 사진을 분석할 수 없어. 잠시 후 다시 보내줄래?");
|
|
158
|
+
}
|
|
159
|
+
else if (errorMsg.includes("timeout")) {
|
|
160
|
+
await ctx.reply("사진 분석이 너무 오래 걸렸어. 다시 보내줄래?");
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
await ctx.reply("사진을 분석하다가 문제가 생겼어. 다시 보내줄래?");
|
|
164
|
+
}
|
|
117
165
|
}
|
|
118
166
|
});
|
|
119
167
|
});
|
|
@@ -125,6 +173,8 @@ export function registerMessageHandlers(bot) {
|
|
|
125
173
|
if (!userMessage.trim())
|
|
126
174
|
return;
|
|
127
175
|
await runWithChatId(chatId, async () => {
|
|
176
|
+
// Health 추적: 활동 기록
|
|
177
|
+
recordActivity();
|
|
128
178
|
// Heartbeat 마지막 대화 시간 업데이트
|
|
129
179
|
updateLastMessageTime(chatId);
|
|
130
180
|
const history = getHistory(chatId);
|
|
@@ -156,12 +206,29 @@ export function registerMessageHandlers(bot) {
|
|
|
156
206
|
history.push({ role: "assistant", content: response });
|
|
157
207
|
// 토큰 기반 히스토리 트리밍
|
|
158
208
|
trimHistoryByTokens(history);
|
|
209
|
+
// 자동 compaction 체크
|
|
210
|
+
await autoCompactIfNeeded(ctx, history);
|
|
159
211
|
}
|
|
160
212
|
catch (error) {
|
|
161
213
|
// 에러 시 방금 추가한 사용자 메시지 롤백 (히스토리 오염 방지)
|
|
162
214
|
history.pop();
|
|
163
|
-
|
|
164
|
-
|
|
215
|
+
recordError();
|
|
216
|
+
// 구체적인 에러 로깅
|
|
217
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
218
|
+
console.error(`[Chat] chatId=${chatId} error:`, errorMsg);
|
|
219
|
+
// 사용자 친화적 에러 메시지
|
|
220
|
+
if (errorMsg.includes("rate limit") || errorMsg.includes("429")) {
|
|
221
|
+
await ctx.reply("지금 요청이 많아서 잠깐 쉬어야 해. 30초 후에 다시 시도해줄래?");
|
|
222
|
+
}
|
|
223
|
+
else if (errorMsg.includes("timeout") || errorMsg.includes("ETIMEDOUT")) {
|
|
224
|
+
await ctx.reply("응답이 너무 오래 걸려서 중단됐어. 다시 시도해줄래?");
|
|
225
|
+
}
|
|
226
|
+
else if (errorMsg.includes("context") || errorMsg.includes("token")) {
|
|
227
|
+
await ctx.reply("대화가 너무 길어졌어. /compact 로 정리하고 다시 시도해줘!");
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
await ctx.reply("메시지 처리 중 문제가 생겼어. 다시 시도해줄래?");
|
|
231
|
+
}
|
|
165
232
|
}
|
|
166
233
|
});
|
|
167
234
|
});
|
|
@@ -8,6 +8,13 @@ export function extractUrls(text) {
|
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
10
|
* URL 안전성 검사 (SSRF 방지)
|
|
11
|
+
*
|
|
12
|
+
* 차단 대상:
|
|
13
|
+
* - 모든 사설 IPv4 (10.x, 172.16-31.x, 192.168.x, 127.x, 0.x, 169.254.x)
|
|
14
|
+
* - 모든 사설/특수 IPv6 (::1, fe80::, fd00::/8, fc00::/7)
|
|
15
|
+
* - IPv4-mapped IPv6 (::ffff:127.0.0.1 등)
|
|
16
|
+
* - 클라우드 메타데이터 엔드포인트
|
|
17
|
+
* - .local, .internal 도메인
|
|
11
18
|
*/
|
|
12
19
|
export function isSafeUrl(url) {
|
|
13
20
|
try {
|
|
@@ -17,20 +24,44 @@ export function isSafeUrl(url) {
|
|
|
17
24
|
return false;
|
|
18
25
|
}
|
|
19
26
|
const hostname = parsed.hostname.toLowerCase();
|
|
20
|
-
//
|
|
27
|
+
// localhost 및 특수 도메인 차단
|
|
21
28
|
if (hostname === "localhost" ||
|
|
22
|
-
hostname === "
|
|
23
|
-
hostname === "0.0.0.0" ||
|
|
24
|
-
hostname.startsWith("192.168.") ||
|
|
25
|
-
hostname.startsWith("10.") ||
|
|
29
|
+
hostname === "localhost.localdomain" ||
|
|
26
30
|
hostname.endsWith(".local") ||
|
|
27
31
|
hostname.endsWith(".internal") ||
|
|
28
|
-
hostname
|
|
32
|
+
hostname.endsWith(".localhost")) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
// 클라우드 메타데이터 엔드포인트 차단
|
|
36
|
+
if (hostname === "169.254.169.254" || // AWS/GCP/Azure metadata
|
|
37
|
+
hostname === "metadata.google.internal" ||
|
|
29
38
|
(hostname.endsWith(".amazonaws.com") && hostname.includes("metadata"))) {
|
|
30
39
|
return false;
|
|
31
40
|
}
|
|
32
|
-
//
|
|
33
|
-
|
|
41
|
+
// IPv4 사설 주소 차단
|
|
42
|
+
const ipv4PrivatePatterns = [
|
|
43
|
+
/^127\./, // 127.0.0.0/8 loopback
|
|
44
|
+
/^10\./, // 10.0.0.0/8
|
|
45
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
|
|
46
|
+
/^192\.168\./, // 192.168.0.0/16
|
|
47
|
+
/^0\./, // 0.0.0.0/8
|
|
48
|
+
/^169\.254\./, // link-local
|
|
49
|
+
];
|
|
50
|
+
if (ipv4PrivatePatterns.some((p) => p.test(hostname))) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
// IPv6 사설/특수 주소 차단 (브라켓 제거 후 검사)
|
|
54
|
+
const ipv6Host = hostname.replace(/^\[|\]$/g, "");
|
|
55
|
+
const ipv6PrivatePatterns = [
|
|
56
|
+
/^::1$/, // loopback
|
|
57
|
+
/^fe80:/i, // link-local
|
|
58
|
+
/^fd[0-9a-f]{2}:/i, // unique local (fd00::/8)
|
|
59
|
+
/^fc[0-9a-f]{2}:/i, // unique local (fc00::/7)
|
|
60
|
+
/^::$/, // unspecified
|
|
61
|
+
// IPv4-mapped IPv6 (::ffff:127.0.0.1 등)
|
|
62
|
+
/^::ffff:(127\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|0\.|169\.254\.)/i,
|
|
63
|
+
];
|
|
64
|
+
if (ipv6PrivatePatterns.some((p) => p.test(ipv6Host))) {
|
|
34
65
|
return false;
|
|
35
66
|
}
|
|
36
67
|
return true;
|
package/dist/tools/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { promisify } from "util";
|
|
|
6
6
|
import { randomUUID } from "crypto";
|
|
7
7
|
import { MODELS } from "../ai/claude.js";
|
|
8
8
|
import { getCurrentChatId, setModel, getModel } from "../session/state.js";
|
|
9
|
+
import { SESSION_MAX_OUTPUT_LINES, SESSION_CLEANUP_INTERVAL_MS, SESSION_TTL_MS, } from "../utils/constants.js";
|
|
9
10
|
// Note: getCurrentChatId uses AsyncLocalStorage - must be called within runWithChatId context
|
|
10
11
|
import { getWorkspacePath, saveWorkspaceFile, appendToMemory, deleteBootstrap, } from "../workspace/index.js";
|
|
11
12
|
import { getSecret } from "../config/secrets.js";
|
|
@@ -22,11 +23,6 @@ import { reindexAll } from '../memory/indexer.js';
|
|
|
22
23
|
const execAsync = promisify(exec);
|
|
23
24
|
// 메모리에 세션 저장
|
|
24
25
|
const sessions = new Map();
|
|
25
|
-
// Output buffer 최대 크기 (라인 수)
|
|
26
|
-
const MAX_OUTPUT_LINES = 1000;
|
|
27
|
-
// 세션 정리 간격 및 TTL (메모리 누수 방지)
|
|
28
|
-
const SESSION_CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10분마다 정리
|
|
29
|
-
const SESSION_TTL_MS = 60 * 60 * 1000; // 완료된 세션 1시간 후 삭제
|
|
30
26
|
// 완료된 세션 자동 정리 함수
|
|
31
27
|
function cleanupStaleSessions() {
|
|
32
28
|
const now = Date.now();
|
|
@@ -46,20 +42,25 @@ function appendOutput(session, data) {
|
|
|
46
42
|
const lines = data.split("\n");
|
|
47
43
|
session.outputBuffer.push(...lines);
|
|
48
44
|
// 버퍼 크기 제한
|
|
49
|
-
if (session.outputBuffer.length >
|
|
50
|
-
session.outputBuffer = session.outputBuffer.slice(-
|
|
45
|
+
if (session.outputBuffer.length > SESSION_MAX_OUTPUT_LINES) {
|
|
46
|
+
session.outputBuffer = session.outputBuffer.slice(-SESSION_MAX_OUTPUT_LINES);
|
|
51
47
|
}
|
|
52
48
|
}
|
|
53
49
|
// 홈 디렉토리
|
|
54
50
|
const home = process.env.HOME || "";
|
|
55
|
-
// 허용된 디렉토리 설정
|
|
51
|
+
// 허용된 디렉토리 설정 (캐시됨 - 환경변수는 런타임 중 변경되지 않음)
|
|
56
52
|
// - COMPANIONBOT_FULL_ACCESS=true: 홈 디렉토리 전체 접근 (위험한 파일 패턴은 여전히 차단)
|
|
57
53
|
// - COMPANIONBOT_ALLOWED_PATHS: 콜론(:)으로 구분된 추가 경로 (예: /tmp:/var/data)
|
|
58
54
|
// - 기본값: ~/Documents, ~/projects, 워크스페이스
|
|
55
|
+
let _cachedAllowedPaths = null;
|
|
59
56
|
function getAllowedPaths() {
|
|
57
|
+
if (_cachedAllowedPaths) {
|
|
58
|
+
return _cachedAllowedPaths;
|
|
59
|
+
}
|
|
60
60
|
// 전체 접근 모드
|
|
61
61
|
if (process.env.COMPANIONBOT_FULL_ACCESS === "true") {
|
|
62
|
-
|
|
62
|
+
_cachedAllowedPaths = [home];
|
|
63
|
+
return _cachedAllowedPaths;
|
|
63
64
|
}
|
|
64
65
|
// 기본 경로
|
|
65
66
|
const paths = [
|
|
@@ -77,7 +78,8 @@ function getAllowedPaths() {
|
|
|
77
78
|
paths.push(expanded);
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
+
_cachedAllowedPaths = paths;
|
|
82
|
+
return _cachedAllowedPaths;
|
|
81
83
|
}
|
|
82
84
|
// 위험한 파일 패턴
|
|
83
85
|
// SSRF 방지: 사설 IP 체크
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 업데이트 체크 모듈
|
|
3
|
+
* npm 레지스트리에서 최신 버전을 확인합니다.
|
|
4
|
+
* @module updates
|
|
5
|
+
*/
|
|
6
|
+
import { exec } from "child_process";
|
|
7
|
+
import { promisify } from "util";
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
/**
|
|
10
|
+
* 현재 설치된 버전을 반환합니다.
|
|
11
|
+
* @returns 현재 버전 문자열 (예: "0.6.0")
|
|
12
|
+
*/
|
|
13
|
+
export function getCurrentVersion() {
|
|
14
|
+
// package.json에서 버전 읽기
|
|
15
|
+
return require("../../package.json").version;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* npm 레지스트리에서 최신 버전을 확인합니다.
|
|
19
|
+
* @returns 업데이트 정보 (현재 버전, 최신 버전, 업데이트 가능 여부)
|
|
20
|
+
*/
|
|
21
|
+
export async function checkForUpdates() {
|
|
22
|
+
const current = getCurrentVersion();
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await execAsync("npm view companionbot version");
|
|
25
|
+
const latest = stdout.trim();
|
|
26
|
+
return {
|
|
27
|
+
current,
|
|
28
|
+
latest,
|
|
29
|
+
hasUpdate: latest !== current && compareVersions(latest, current) > 0
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { current, latest: current, hasUpdate: false };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 두 semver 버전을 비교합니다.
|
|
38
|
+
* @param a - 첫 번째 버전
|
|
39
|
+
* @param b - 두 번째 버전
|
|
40
|
+
* @returns a > b면 1, a < b면 -1, 같으면 0
|
|
41
|
+
*/
|
|
42
|
+
function compareVersions(a, b) {
|
|
43
|
+
const pa = a.split('.').map(Number);
|
|
44
|
+
const pb = b.split('.').map(Number);
|
|
45
|
+
for (let i = 0; i < 3; i++) {
|
|
46
|
+
if (pa[i] > pb[i])
|
|
47
|
+
return 1;
|
|
48
|
+
if (pa[i] < pb[i])
|
|
49
|
+
return -1;
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|