companionbot 0.6.0 → 0.8.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/cli/main.js +178 -27
- 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 +5 -2
package/dist/cron/scheduler.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { getDueJobs, markJobExecuted, loadJobs, addJob, removeJob, updateJob, getJobsByChat } from "./store.js";
|
|
8
8
|
import { chat } from "../ai/claude.js";
|
|
9
9
|
import { buildSystemPrompt } from "../telegram/utils/prompt.js";
|
|
10
|
+
import { INTERVAL_1_MINUTE } from "../utils/time.js";
|
|
11
|
+
import { TELEGRAM_SAFE_LIMIT } from "../utils/constants.js";
|
|
10
12
|
// Scheduler state
|
|
11
13
|
let schedulerInterval = null;
|
|
12
14
|
let botInstance = null;
|
|
@@ -36,7 +38,7 @@ export class CronScheduler {
|
|
|
36
38
|
// Then check every minute
|
|
37
39
|
this.interval = setInterval(() => {
|
|
38
40
|
this.checkAndRun().catch((err) => console.error("[CronScheduler] Check failed:", err));
|
|
39
|
-
},
|
|
41
|
+
}, INTERVAL_1_MINUTE);
|
|
40
42
|
console.log("[CronScheduler] Started - checking every minute");
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
@@ -167,21 +169,24 @@ async function executeAgentTurn(job, payload, bot) {
|
|
|
167
169
|
// Call Claude API
|
|
168
170
|
const response = await chat(messages, systemPrompt, "sonnet");
|
|
169
171
|
// Send the response to the chat
|
|
170
|
-
|
|
172
|
+
const trimmedResponse = response?.trim();
|
|
173
|
+
if (trimmedResponse) {
|
|
171
174
|
// Split long messages (Telegram limit is 4096 characters)
|
|
172
|
-
const maxLength =
|
|
173
|
-
if (
|
|
174
|
-
await bot.api.sendMessage(job.chatId,
|
|
175
|
+
const maxLength = TELEGRAM_SAFE_LIMIT;
|
|
176
|
+
if (trimmedResponse.length <= maxLength) {
|
|
177
|
+
await bot.api.sendMessage(job.chatId, trimmedResponse, {
|
|
175
178
|
parse_mode: "Markdown",
|
|
176
179
|
});
|
|
177
180
|
}
|
|
178
181
|
else {
|
|
179
182
|
// Split into multiple messages
|
|
180
|
-
const chunks = splitMessage(
|
|
183
|
+
const chunks = splitMessage(trimmedResponse, maxLength);
|
|
181
184
|
for (const chunk of chunks) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
+
if (chunk) {
|
|
186
|
+
await bot.api.sendMessage(job.chatId, chunk, {
|
|
187
|
+
parse_mode: "Markdown",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
185
190
|
}
|
|
186
191
|
}
|
|
187
192
|
}
|
|
@@ -202,6 +207,10 @@ async function executeAgentTurn(job, payload, bot) {
|
|
|
202
207
|
* Split a long message into chunks
|
|
203
208
|
*/
|
|
204
209
|
function splitMessage(text, maxLength) {
|
|
210
|
+
// null/undefined/빈 문자열 처리
|
|
211
|
+
if (!text || text.trim().length === 0) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
205
214
|
const chunks = [];
|
|
206
215
|
let remaining = text;
|
|
207
216
|
while (remaining.length > 0) {
|
package/dist/cron/store.js
CHANGED
|
@@ -6,13 +6,11 @@
|
|
|
6
6
|
import * as fs from "fs/promises";
|
|
7
7
|
import * as path from "path";
|
|
8
8
|
import { getWorkspacePath } from "../workspace/paths.js";
|
|
9
|
+
import { sleep } from "../utils/time.js";
|
|
10
|
+
import { LOCK_TIMEOUT_MS, LOCK_RETRY_MS, LOCK_MAX_RETRIES } from "../utils/constants.js";
|
|
9
11
|
const CRON_FILE = "cron-jobs.json";
|
|
10
12
|
const LOCK_FILE = "cron-jobs.lock";
|
|
11
13
|
const STORE_VERSION = 1;
|
|
12
|
-
// Lock configuration
|
|
13
|
-
const LOCK_TIMEOUT_MS = 5000;
|
|
14
|
-
const LOCK_RETRY_MS = 50;
|
|
15
|
-
const LOCK_MAX_RETRIES = 100;
|
|
16
14
|
function getCronFilePath() {
|
|
17
15
|
return path.join(getWorkspacePath(), CRON_FILE);
|
|
18
16
|
}
|
|
@@ -73,9 +71,6 @@ async function releaseLock() {
|
|
|
73
71
|
// Ignore errors on unlock
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
|
-
function sleep(ms) {
|
|
77
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
-
}
|
|
79
74
|
/**
|
|
80
75
|
* Execute a function with file lock protection
|
|
81
76
|
*/
|
|
@@ -94,13 +89,39 @@ async function withLock(fn) {
|
|
|
94
89
|
async function loadJobsInternal() {
|
|
95
90
|
try {
|
|
96
91
|
const data = await fs.readFile(getCronFilePath(), "utf-8");
|
|
92
|
+
// 빈 파일 체크
|
|
93
|
+
if (!data || data.trim() === "") {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
97
96
|
const store = JSON.parse(data);
|
|
98
|
-
|
|
97
|
+
// jobs 배열 유효성 검사
|
|
98
|
+
if (!store || !Array.isArray(store.jobs)) {
|
|
99
|
+
console.warn("[Cron] Invalid store format, returning empty array");
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return store.jobs;
|
|
99
103
|
}
|
|
100
104
|
catch (error) {
|
|
101
105
|
if (error.code === "ENOENT") {
|
|
102
106
|
return [];
|
|
103
107
|
}
|
|
108
|
+
// JSON 파싱 오류
|
|
109
|
+
if (error instanceof SyntaxError) {
|
|
110
|
+
console.error("[Cron] Corrupted cron-jobs.json file:", error.message);
|
|
111
|
+
// 백업 파일 생성 시도
|
|
112
|
+
try {
|
|
113
|
+
const backupPath = `${getCronFilePath()}.corrupted.${Date.now()}`;
|
|
114
|
+
const data = await fs.readFile(getCronFilePath(), "utf-8").catch(() => "");
|
|
115
|
+
if (data) {
|
|
116
|
+
await fs.writeFile(backupPath, data, "utf-8");
|
|
117
|
+
console.log(`[Cron] Corrupted file backed up to: ${backupPath}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// 백업 실패는 무시
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
104
125
|
console.error("[Cron] Failed to load jobs:", error);
|
|
105
126
|
return [];
|
|
106
127
|
}
|
|
@@ -446,12 +467,21 @@ function getDaysInMonth(year, month) {
|
|
|
446
467
|
* Parse cron field like "1,3,5" or "1-5" or step values into array of numbers
|
|
447
468
|
*/
|
|
448
469
|
function parseCronField(field, min, max) {
|
|
449
|
-
|
|
470
|
+
// 빈 문자열/null/undefined 처리
|
|
471
|
+
if (!field || field.trim() === "") {
|
|
472
|
+
return Array.from({ length: max - min + 1 }, (_, i) => i + min); // wildcard로 처리
|
|
473
|
+
}
|
|
474
|
+
const trimmedField = field.trim();
|
|
475
|
+
if (trimmedField === "*") {
|
|
450
476
|
return Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
|
451
477
|
}
|
|
452
478
|
// Handle step values like */5
|
|
453
|
-
if (
|
|
454
|
-
const step = parseInt(
|
|
479
|
+
if (trimmedField.startsWith("*/")) {
|
|
480
|
+
const step = parseInt(trimmedField.slice(2), 10);
|
|
481
|
+
if (isNaN(step) || step <= 0) {
|
|
482
|
+
console.warn(`[Cron] Invalid step value in field: ${field}, using default`);
|
|
483
|
+
return Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
|
484
|
+
}
|
|
455
485
|
const values = [];
|
|
456
486
|
for (let i = min; i <= max; i += step) {
|
|
457
487
|
values.push(i);
|
|
@@ -459,39 +489,66 @@ function parseCronField(field, min, max) {
|
|
|
459
489
|
return values;
|
|
460
490
|
}
|
|
461
491
|
const values = [];
|
|
462
|
-
const parts =
|
|
492
|
+
const parts = trimmedField.split(",");
|
|
463
493
|
for (const part of parts) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
494
|
+
const trimmedPart = part.trim();
|
|
495
|
+
if (!trimmedPart)
|
|
496
|
+
continue;
|
|
497
|
+
if (trimmedPart.includes("-") && !trimmedPart.includes("/")) {
|
|
498
|
+
const [startStr, endStr] = trimmedPart.split("-");
|
|
499
|
+
const start = Number(startStr);
|
|
500
|
+
const end = Number(endStr);
|
|
501
|
+
if (!isNaN(start) && !isNaN(end)) {
|
|
502
|
+
for (let i = start; i <= end; i++) {
|
|
503
|
+
values.push(i);
|
|
504
|
+
}
|
|
468
505
|
}
|
|
469
506
|
}
|
|
470
|
-
else if (
|
|
507
|
+
else if (trimmedPart.includes("/")) {
|
|
471
508
|
// Handle range with step like 0-30/5
|
|
472
|
-
const [range, stepStr] =
|
|
509
|
+
const [range, stepStr] = trimmedPart.split("/");
|
|
473
510
|
const step = parseInt(stepStr, 10);
|
|
511
|
+
if (isNaN(step) || step <= 0)
|
|
512
|
+
continue;
|
|
474
513
|
let rangeStart = min;
|
|
475
514
|
let rangeEnd = max;
|
|
476
|
-
if (range.includes("-")) {
|
|
477
|
-
[
|
|
515
|
+
if (range && range.includes("-")) {
|
|
516
|
+
const [rs, re] = range.split("-").map(Number);
|
|
517
|
+
if (!isNaN(rs))
|
|
518
|
+
rangeStart = rs;
|
|
519
|
+
if (!isNaN(re))
|
|
520
|
+
rangeEnd = re;
|
|
478
521
|
}
|
|
479
522
|
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
|
480
523
|
values.push(i);
|
|
481
524
|
}
|
|
482
525
|
}
|
|
483
526
|
else {
|
|
484
|
-
|
|
527
|
+
const num = parseInt(trimmedPart, 10);
|
|
528
|
+
if (!isNaN(num)) {
|
|
529
|
+
values.push(num);
|
|
530
|
+
}
|
|
485
531
|
}
|
|
486
532
|
}
|
|
487
|
-
|
|
533
|
+
// 유효한 값이 없으면 wildcard로 폴백
|
|
534
|
+
const filtered = values.filter((v) => v >= min && v <= max);
|
|
535
|
+
return filtered.length > 0 ? filtered : Array.from({ length: max - min + 1 }, (_, i) => i + min);
|
|
488
536
|
}
|
|
489
537
|
/**
|
|
490
538
|
* Get all jobs for a specific chat
|
|
491
539
|
*/
|
|
492
540
|
export async function getJobsByChat(chatId) {
|
|
541
|
+
// null/undefined 처리
|
|
542
|
+
if (chatId == null) {
|
|
543
|
+
return [];
|
|
544
|
+
}
|
|
493
545
|
const jobs = await loadJobs();
|
|
494
546
|
const numericChatId = typeof chatId === "string" ? parseInt(chatId, 10) : chatId;
|
|
547
|
+
// NaN 체크
|
|
548
|
+
if (isNaN(numericChatId)) {
|
|
549
|
+
console.warn(`[Cron] Invalid chatId: ${chatId}`);
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
495
552
|
return jobs.filter((j) => j.chatId === numericChatId);
|
|
496
553
|
}
|
|
497
554
|
/**
|
|
@@ -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
|
}
|