companionbot 0.5.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/agents/index.js +1 -1
- package/dist/agents/manager.js +0 -30
- package/dist/ai/claude.js +151 -46
- package/dist/calendar/index.js +15 -13
- package/dist/cron/index.js +1 -1
- package/dist/cron/parser.js +58 -159
- package/dist/cron/scheduler.js +52 -16
- package/dist/cron/store.js +79 -22
- package/dist/health/index.js +66 -0
- package/dist/heartbeat/index.js +24 -7
- package/dist/memory/embeddings.js +113 -0
- package/dist/memory/index.js +4 -0
- package/dist/memory/indexer.js +39 -0
- package/dist/memory/vectorStore.js +216 -0
- package/dist/session/state.js +29 -1
- package/dist/telegram/bot.js +3 -3
- package/dist/telegram/handlers/commands.js +91 -49
- package/dist/telegram/handlers/messages.js +127 -118
- package/dist/telegram/utils/index.js +0 -2
- package/dist/telegram/utils/prompt.js +76 -13
- package/dist/telegram/utils/url.js +39 -8
- package/dist/tools/index.js +77 -353
- package/dist/updates/index.js +52 -0
- package/dist/utils/constants.js +44 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/time.js +51 -0
- package/dist/utils/tokens.js +30 -0
- package/dist/workspace/index.js +1 -3
- package/dist/workspace/load.js +7 -251
- package/package.json +4 -2
- package/templates/AGENTS.md +98 -77
- package/templates/MEMORY.md +4 -58
- package/dist/telegram/utils/secrets.js +0 -64
package/dist/cron/scheduler.js
CHANGED
|
@@ -7,7 +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 {
|
|
10
|
+
import { INTERVAL_1_MINUTE } from "../utils/time.js";
|
|
11
|
+
import { TELEGRAM_SAFE_LIMIT } from "../utils/constants.js";
|
|
11
12
|
// Scheduler state
|
|
12
13
|
let schedulerInterval = null;
|
|
13
14
|
let botInstance = null;
|
|
@@ -37,7 +38,7 @@ export class CronScheduler {
|
|
|
37
38
|
// Then check every minute
|
|
38
39
|
this.interval = setInterval(() => {
|
|
39
40
|
this.checkAndRun().catch((err) => console.error("[CronScheduler] Check failed:", err));
|
|
40
|
-
},
|
|
41
|
+
}, INTERVAL_1_MINUTE);
|
|
41
42
|
console.log("[CronScheduler] Started - checking every minute");
|
|
42
43
|
}
|
|
43
44
|
/**
|
|
@@ -146,8 +147,7 @@ async function executeSystemEvent(job, payload, bot) {
|
|
|
146
147
|
*/
|
|
147
148
|
async function executeAgentTurn(job, payload, bot) {
|
|
148
149
|
const { message: inputMessage, context } = payload;
|
|
149
|
-
|
|
150
|
-
const response = await runWithChatId(job.chatId, async () => {
|
|
150
|
+
try {
|
|
151
151
|
// Build a fresh conversation for this job (separate from main chat)
|
|
152
152
|
const messages = [
|
|
153
153
|
{
|
|
@@ -167,25 +167,26 @@ async function executeAgentTurn(job, payload, bot) {
|
|
|
167
167
|
- Run Count: ${(job.runCount || 0) + 1}
|
|
168
168
|
- This is a scheduled task, not a direct user message.`;
|
|
169
169
|
// Call Claude API
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (response && response.trim()) {
|
|
170
|
+
const response = await chat(messages, systemPrompt, "sonnet");
|
|
171
|
+
// Send the response to the chat
|
|
172
|
+
const trimmedResponse = response?.trim();
|
|
173
|
+
if (trimmedResponse) {
|
|
175
174
|
// Split long messages (Telegram limit is 4096 characters)
|
|
176
|
-
const maxLength =
|
|
177
|
-
if (
|
|
178
|
-
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, {
|
|
179
178
|
parse_mode: "Markdown",
|
|
180
179
|
});
|
|
181
180
|
}
|
|
182
181
|
else {
|
|
183
182
|
// Split into multiple messages
|
|
184
|
-
const chunks = splitMessage(
|
|
183
|
+
const chunks = splitMessage(trimmedResponse, maxLength);
|
|
185
184
|
for (const chunk of chunks) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
if (chunk) {
|
|
186
|
+
await bot.api.sendMessage(job.chatId, chunk, {
|
|
187
|
+
parse_mode: "Markdown",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
}
|
|
@@ -206,6 +207,10 @@ async function executeAgentTurn(job, payload, bot) {
|
|
|
206
207
|
* Split a long message into chunks
|
|
207
208
|
*/
|
|
208
209
|
function splitMessage(text, maxLength) {
|
|
210
|
+
// null/undefined/빈 문자열 처리
|
|
211
|
+
if (!text || text.trim().length === 0) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
209
214
|
const chunks = [];
|
|
210
215
|
let remaining = text;
|
|
211
216
|
while (remaining.length > 0) {
|
|
@@ -328,3 +333,34 @@ export function getActiveJobCount() {
|
|
|
328
333
|
// For actual count, use getAllCronJobs and filter
|
|
329
334
|
return 0; // Placeholder - will be updated by scheduler
|
|
330
335
|
}
|
|
336
|
+
// ============================================================
|
|
337
|
+
// Default Cron Jobs
|
|
338
|
+
// ============================================================
|
|
339
|
+
const DEFAULT_CRON_JOBS = [
|
|
340
|
+
{
|
|
341
|
+
name: "daily_memory_save",
|
|
342
|
+
cronExpr: "0 12 * * *", // 매일 12시
|
|
343
|
+
command: "오늘 하루 동안 있었던 중요한 일들을 정리해서 MEMORY.md에 저장해줘. 새로운 정보, 대화 내용, 배운 것들 위주로.",
|
|
344
|
+
timezone: "Asia/Seoul",
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
/**
|
|
348
|
+
* Ensure default cron jobs exist for a chat
|
|
349
|
+
* Call this after onboarding or on /start
|
|
350
|
+
*/
|
|
351
|
+
export async function ensureDefaultCronJobs(chatId) {
|
|
352
|
+
const existingJobs = await getJobsByChat(chatId);
|
|
353
|
+
for (const defaultJob of DEFAULT_CRON_JOBS) {
|
|
354
|
+
const exists = existingJobs.some(job => job.name === defaultJob.name);
|
|
355
|
+
if (!exists) {
|
|
356
|
+
await createCronJob({
|
|
357
|
+
chatId,
|
|
358
|
+
name: defaultJob.name,
|
|
359
|
+
cronExpr: defaultJob.cronExpr,
|
|
360
|
+
command: defaultJob.command,
|
|
361
|
+
timezone: defaultJob.timezone,
|
|
362
|
+
});
|
|
363
|
+
console.log(`[Cron] Added default job: ${defaultJob.name} for chat ${chatId}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
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,16 +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 {
|
|
7
|
+
import { checkForUpdates } from "../updates/index.js";
|
|
8
|
+
import { INTERVAL_30_MINUTES, INTERVAL_24_HOURS, hoursToMs } from "../utils/time.js";
|
|
8
9
|
// 활성 타이머
|
|
9
10
|
const activeTimers = new Map();
|
|
10
11
|
// 메모리 캐시: 타임스탬프는 메모리에만 유지하여 파일 쓰기 최소화
|
|
11
12
|
// lastCheckAt, lastMessageAt은 디버깅 용도라 매번 저장할 필요 없음
|
|
12
13
|
const timestampCache = new Map();
|
|
14
|
+
// 업데이트 체크 캐시 (하루에 한 번만)
|
|
15
|
+
let lastUpdateCheck = 0;
|
|
16
|
+
let cachedUpdateInfo = null;
|
|
17
|
+
const UPDATE_CHECK_INTERVAL = INTERVAL_24_HOURS;
|
|
13
18
|
// 봇 인스턴스
|
|
14
19
|
let botInstance = null;
|
|
15
20
|
// 기본 간격: 30분
|
|
16
|
-
const DEFAULT_INTERVAL_MS =
|
|
21
|
+
const DEFAULT_INTERVAL_MS = INTERVAL_30_MINUTES;
|
|
17
22
|
export function setHeartbeatBot(bot) {
|
|
18
23
|
botInstance = bot;
|
|
19
24
|
}
|
|
@@ -80,6 +85,21 @@ async function gatherContext() {
|
|
|
80
85
|
// 무시
|
|
81
86
|
}
|
|
82
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
|
+
}
|
|
83
103
|
return parts.join("\n");
|
|
84
104
|
}
|
|
85
105
|
// Heartbeat 실행 - 메시지를 보냈으면 true 반환
|
|
@@ -121,10 +141,7 @@ ${context}
|
|
|
121
141
|
];
|
|
122
142
|
let messageSent = false;
|
|
123
143
|
try {
|
|
124
|
-
|
|
125
|
-
const response = await runWithChatId(config.chatId, async () => {
|
|
126
|
-
return await chat(messages, systemPrompt, "haiku");
|
|
127
|
-
});
|
|
144
|
+
const response = await chat(messages, systemPrompt, "haiku");
|
|
128
145
|
if (!response.trim().includes("HEARTBEAT_OK")) {
|
|
129
146
|
await botInstance.api.sendMessage(config.chatId, response);
|
|
130
147
|
console.log(`[Heartbeat] Sent message to ${config.chatId}`);
|
|
@@ -261,7 +278,7 @@ export async function runHeartbeatNow(chatId) {
|
|
|
261
278
|
enabled: false,
|
|
262
279
|
intervalMs: DEFAULT_INTERVAL_MS,
|
|
263
280
|
lastCheckAt: Date.now(),
|
|
264
|
-
lastMessageAt: Date.now() - (8
|
|
281
|
+
lastMessageAt: Date.now() - hoursToMs(8), // 8시간 전으로 설정
|
|
265
282
|
};
|
|
266
283
|
return await executeHeartbeat(defaultConfig);
|
|
267
284
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 로컬 임베딩 생성 모듈
|
|
3
|
+
* @xenova/transformers를 사용하여 텍스트 임베딩을 생성합니다.
|
|
4
|
+
*/
|
|
5
|
+
import { pipeline } from "@xenova/transformers";
|
|
6
|
+
// 싱글톤 파이프라인
|
|
7
|
+
let embeddingPipeline = null;
|
|
8
|
+
// 모델 로딩 중인지 추적
|
|
9
|
+
let isLoading = false;
|
|
10
|
+
let loadingPromise = null;
|
|
11
|
+
/**
|
|
12
|
+
* 임베딩 파이프라인을 초기화합니다.
|
|
13
|
+
* 작고 빠른 모델 사용 (384 차원)
|
|
14
|
+
*/
|
|
15
|
+
async function getEmbeddingPipeline() {
|
|
16
|
+
if (embeddingPipeline) {
|
|
17
|
+
return embeddingPipeline;
|
|
18
|
+
}
|
|
19
|
+
// 이미 로딩 중이면 기다림
|
|
20
|
+
if (isLoading && loadingPromise) {
|
|
21
|
+
return loadingPromise;
|
|
22
|
+
}
|
|
23
|
+
isLoading = true;
|
|
24
|
+
loadingPromise = pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2" // 384차원, 빠르고 가벼움
|
|
25
|
+
);
|
|
26
|
+
try {
|
|
27
|
+
embeddingPipeline = await loadingPromise;
|
|
28
|
+
return embeddingPipeline;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
isLoading = false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 텍스트를 임베딩 벡터로 변환합니다.
|
|
36
|
+
* @param text 변환할 텍스트
|
|
37
|
+
* @returns 384차원 임베딩 벡터
|
|
38
|
+
*/
|
|
39
|
+
export async function embed(text) {
|
|
40
|
+
// null/undefined 처리
|
|
41
|
+
if (text == null) {
|
|
42
|
+
return new Array(384).fill(0);
|
|
43
|
+
}
|
|
44
|
+
const pipe = await getEmbeddingPipeline();
|
|
45
|
+
// 텍스트 정규화
|
|
46
|
+
const cleanText = text.trim().slice(0, 512); // 최대 512자
|
|
47
|
+
if (!cleanText) {
|
|
48
|
+
return new Array(384).fill(0);
|
|
49
|
+
}
|
|
50
|
+
const result = await pipe(cleanText, {
|
|
51
|
+
pooling: "mean",
|
|
52
|
+
normalize: true,
|
|
53
|
+
});
|
|
54
|
+
// Tensor를 배열로 변환
|
|
55
|
+
return Array.from(result.data);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 여러 텍스트를 배치로 임베딩합니다.
|
|
59
|
+
* 병렬로 처리하여 성능 향상 (모델 내부에서 순차 처리되더라도 Promise 오버헤드 감소)
|
|
60
|
+
* @param texts 변환할 텍스트 배열
|
|
61
|
+
* @returns 임베딩 벡터 배열
|
|
62
|
+
*/
|
|
63
|
+
export async function embedBatch(texts) {
|
|
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
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 두 벡터 간의 코사인 유사도를 계산합니다.
|
|
83
|
+
*
|
|
84
|
+
* 최적화: embed()에서 normalize: true로 정규화된 벡터를 반환하므로,
|
|
85
|
+
* 정규화된 벡터의 경우 코사인 유사도 = 내적 (norm이 1이므로)
|
|
86
|
+
* normalized 파라미터가 true면 내적만 계산하여 성능 향상.
|
|
87
|
+
*/
|
|
88
|
+
export function cosineSimilarity(a, b, normalized = true) {
|
|
89
|
+
// null/undefined 또는 빈 배열 처리
|
|
90
|
+
if (!a || !b || a.length === 0 || b.length === 0)
|
|
91
|
+
return 0;
|
|
92
|
+
if (a.length !== b.length)
|
|
93
|
+
return 0;
|
|
94
|
+
let dotProduct = 0;
|
|
95
|
+
for (let i = 0; i < a.length; i++) {
|
|
96
|
+
dotProduct += a[i] * b[i];
|
|
97
|
+
}
|
|
98
|
+
// 정규화된 벡터면 내적 = 코사인 유사도
|
|
99
|
+
if (normalized) {
|
|
100
|
+
return dotProduct;
|
|
101
|
+
}
|
|
102
|
+
// 정규화되지 않은 벡터면 norm 계산 필요
|
|
103
|
+
let normA = 0;
|
|
104
|
+
let normB = 0;
|
|
105
|
+
for (let i = 0; i < a.length; i++) {
|
|
106
|
+
normA += a[i] * a[i];
|
|
107
|
+
normB += b[i] * b[i];
|
|
108
|
+
}
|
|
109
|
+
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
|
110
|
+
if (denominator === 0)
|
|
111
|
+
return 0;
|
|
112
|
+
return dotProduct / denominator;
|
|
113
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 메모리 인덱서 모듈
|
|
3
|
+
* 현재 구현은 vectorStore가 on-demand로 로드하므로 캐시 무효화만 수행
|
|
4
|
+
*/
|
|
5
|
+
import { invalidateCache } from './vectorStore.js';
|
|
6
|
+
// 단일 파일 인덱싱 (캐시 무효화)
|
|
7
|
+
export async function indexFile(_filePath, _source) {
|
|
8
|
+
// vectorStore가 on-demand로 로드하므로 캐시만 무효화
|
|
9
|
+
invalidateCache();
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
// MEMORY.md 인덱싱
|
|
13
|
+
export async function indexMainMemory() {
|
|
14
|
+
invalidateCache();
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
// 일일 메모리 파일들 인덱싱
|
|
18
|
+
export async function indexDailyMemories(_days = 30) {
|
|
19
|
+
invalidateCache();
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
// 전체 리인덱싱 (캐시 무효화 후 미리 로드)
|
|
23
|
+
export async function reindexAll() {
|
|
24
|
+
console.log('[Indexer] Invalidating cache for reindex...');
|
|
25
|
+
invalidateCache();
|
|
26
|
+
// 캐시 무효화 후 즉시 로드하여 청크 수 반환
|
|
27
|
+
// search를 임시로 호출하여 로드 트리거 (빈 쿼리로)
|
|
28
|
+
const { loadAllMemoryChunks } = await import('./vectorStore.js');
|
|
29
|
+
const chunks = await loadAllMemoryChunks();
|
|
30
|
+
// 소스별 집계
|
|
31
|
+
const sourceCounts = new Map();
|
|
32
|
+
for (const chunk of chunks) {
|
|
33
|
+
sourceCounts.set(chunk.source, (sourceCounts.get(chunk.source) || 0) + 1);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
total: chunks.length,
|
|
37
|
+
sources: Array.from(sourceCounts.keys())
|
|
38
|
+
};
|
|
39
|
+
}
|