companionbot 0.11.1 → 0.12.1
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/dist/ai/claude.js +79 -65
- package/dist/cli/main.js +16 -1
- package/dist/config/constants.js +93 -2
- package/dist/health/index.js +3 -1
- package/dist/memory/benchmark.js +114 -0
- package/dist/memory/embeddings.js +67 -5
- package/dist/memory/ftsIndex.js +288 -68
- package/dist/memory/ftsIndex.optimized.js +368 -0
- package/dist/memory/ftsIndex.original.js +148 -0
- package/dist/memory/hybridSearch.js +147 -41
- package/dist/memory/hybridSearch.optimized.js +209 -0
- package/dist/memory/hybridSearch.original.js +146 -0
- package/dist/memory/index.js +2 -2
- package/dist/memory/vectorStore.js +333 -160
- package/dist/memory/vectorStore.optimized.js +492 -0
- package/dist/memory/vectorStore.original.js +350 -0
- package/dist/telegram/bot.js +42 -12
- package/dist/telegram/handlers/commands.js +12 -0
- package/dist/telegram/handlers/messages.js +241 -65
- package/dist/telegram/utils/cache.js +35 -0
- package/dist/telegram/utils/index.js +1 -1
- package/dist/telegram/utils/prompt.js +126 -189
- package/dist/tools/compress.js +141 -0
- package/dist/tools/definitions.js +313 -0
- package/dist/tools/index.js +11 -2
- package/dist/tools/timeout.js +78 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/retry.js +288 -0
- package/dist/warmup.js +120 -0
- package/package.json +1 -1
- package/templates/AGENTS.md +27 -184
package/dist/ai/claude.js
CHANGED
|
@@ -1,48 +1,21 @@
|
|
|
1
1
|
import Anthropic, { APIError } from "@anthropic-ai/sdk";
|
|
2
2
|
import { tools, executeTool } from "../tools/index.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
? parseInt(retryAfter) * 1000
|
|
20
|
-
: BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
21
|
-
console.log(`[RateLimit] 429 received, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
|
|
22
|
-
await sleep(delay);
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
// 서버 에러 (500+)
|
|
26
|
-
if (error.status >= 500) {
|
|
27
|
-
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
28
|
-
console.log(`[ServerError] ${error.status}, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
|
|
29
|
-
await sleep(delay);
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
// 일반 Error 처리
|
|
34
|
-
if (error instanceof Error) {
|
|
35
|
-
lastError = error;
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
lastError = new Error(String(error));
|
|
39
|
-
}
|
|
40
|
-
// 다른 에러는 바로 throw
|
|
41
|
-
throw error;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
throw lastError;
|
|
45
|
-
}
|
|
3
|
+
import { MAX_TOOL_ITERATIONS, TOOL_INPUT_SUMMARY_LENGTH, TOOL_OUTPUT_SUMMARY_LENGTH, } from "../utils/constants.js";
|
|
4
|
+
import { withRetry, withTimeout, } from "../utils/retry.js";
|
|
5
|
+
import { getToolTimeout } from "../tools/timeout.js";
|
|
6
|
+
import { compressToolResult } from "../tools/compress.js";
|
|
7
|
+
// API 호출 타임아웃 (2분) - Claude의 긴 응답 시간 고려
|
|
8
|
+
const API_TIMEOUT_MS = 120000;
|
|
9
|
+
// 재시도 설정
|
|
10
|
+
const API_RETRY_OPTIONS = {
|
|
11
|
+
maxRetries: 3,
|
|
12
|
+
initialDelayMs: 1000,
|
|
13
|
+
maxDelayMs: 30000,
|
|
14
|
+
onRetry: (attempt, error, delay) => {
|
|
15
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
16
|
+
console.log(`[API Retry] Attempt ${attempt}, waiting ${delay}ms: ${errMsg.slice(0, 100)}`);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
46
19
|
let anthropic = null;
|
|
47
20
|
function getClient() {
|
|
48
21
|
if (!anthropic) {
|
|
@@ -163,31 +136,57 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", thinkingL
|
|
|
163
136
|
return params;
|
|
164
137
|
};
|
|
165
138
|
let response;
|
|
166
|
-
response = await withRetry(() => client.messages.create(buildRequestParams()));
|
|
139
|
+
response = await withRetry(() => withTimeout(() => client.messages.create(buildRequestParams()), API_TIMEOUT_MS, "API 응답 시간 초과"), API_RETRY_OPTIONS);
|
|
167
140
|
// Tool use 루프 - Claude가 도구 사용을 멈출 때까지 반복
|
|
168
141
|
let iterations = 0;
|
|
169
142
|
while (response.stop_reason === "tool_use" && iterations < MAX_TOOL_ITERATIONS) {
|
|
170
143
|
iterations++;
|
|
171
144
|
const toolUseBlocks = response.content.filter((block) => block.type === "tool_use");
|
|
172
|
-
// 도구 실행
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
145
|
+
// 도구 병렬 실행 (성능 최적화)
|
|
146
|
+
console.log(`[Tool] Executing ${toolUseBlocks.length} tool(s) in parallel`);
|
|
147
|
+
const toolExecutions = await Promise.all(toolUseBlocks.map(async (toolUse) => {
|
|
148
|
+
const startTime = Date.now();
|
|
149
|
+
console.log(`[Tool] ${toolUse.name}:`, JSON.stringify(toolUse.input).slice(0, 200));
|
|
150
|
+
try {
|
|
151
|
+
// 도구별 타임아웃 적용
|
|
152
|
+
const timeout = getToolTimeout(toolUse.name);
|
|
153
|
+
const result = await Promise.race([
|
|
154
|
+
executeTool(toolUse.name, toolUse.input),
|
|
155
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Tool ${toolUse.name} timed out after ${timeout}ms`)), timeout)),
|
|
156
|
+
]);
|
|
157
|
+
const elapsed = Date.now() - startTime;
|
|
158
|
+
console.log(`[Tool] ${toolUse.name} completed in ${elapsed}ms`);
|
|
159
|
+
// 스마트 결과 압축
|
|
160
|
+
const compressedResult = compressToolResult(toolUse.name, result);
|
|
161
|
+
return {
|
|
162
|
+
toolUse,
|
|
163
|
+
result: compressedResult,
|
|
164
|
+
success: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
const elapsed = Date.now() - startTime;
|
|
169
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
170
|
+
console.error(`[Tool] ${toolUse.name} failed after ${elapsed}ms:`, errorMsg);
|
|
171
|
+
return {
|
|
172
|
+
toolUse,
|
|
173
|
+
result: `Error: ${errorMsg}`,
|
|
174
|
+
success: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}));
|
|
178
|
+
// 결과 수집
|
|
179
|
+
const toolResults = toolExecutions.map((exec) => ({
|
|
180
|
+
type: "tool_result",
|
|
181
|
+
tool_use_id: exec.toolUse.id,
|
|
182
|
+
content: exec.result,
|
|
183
|
+
}));
|
|
184
|
+
// 도구 사용 기록
|
|
185
|
+
for (const exec of toolExecutions) {
|
|
187
186
|
toolsUsed.push({
|
|
188
|
-
name: toolUse.name,
|
|
189
|
-
input: JSON.stringify(toolUse.input).slice(0, TOOL_INPUT_SUMMARY_LENGTH),
|
|
190
|
-
output:
|
|
187
|
+
name: exec.toolUse.name,
|
|
188
|
+
input: JSON.stringify(exec.toolUse.input).slice(0, TOOL_INPUT_SUMMARY_LENGTH),
|
|
189
|
+
output: exec.result.slice(0, TOOL_OUTPUT_SUMMARY_LENGTH),
|
|
191
190
|
});
|
|
192
191
|
}
|
|
193
192
|
// 어시스턴트 메시지와 도구 결과 추가
|
|
@@ -200,7 +199,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", thinkingL
|
|
|
200
199
|
content: toolResults,
|
|
201
200
|
});
|
|
202
201
|
// 다음 응답 요청 (도구 루프에서도 thinking 유지)
|
|
203
|
-
response = await withRetry(() => client.messages.create(buildRequestParams()));
|
|
202
|
+
response = await withRetry(() => withTimeout(() => client.messages.create(buildRequestParams()), API_TIMEOUT_MS, "API 응답 시간 초과"), API_RETRY_OPTIONS);
|
|
204
203
|
}
|
|
205
204
|
// 반복 횟수 초과 시 경고
|
|
206
205
|
if (iterations >= MAX_TOOL_ITERATIONS) {
|
|
@@ -226,8 +225,12 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", thinkingL
|
|
|
226
225
|
* 스트리밍 중 에러 발생 시 적절한 에러 메시지를 반환하거나 예외를 전파함
|
|
227
226
|
*/
|
|
228
227
|
export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel = "medium", onChunk) {
|
|
228
|
+
// 콜백 정규화
|
|
229
|
+
const callbacks = typeof onChunk === 'function'
|
|
230
|
+
? { onChunk }
|
|
231
|
+
: (onChunk ?? {});
|
|
229
232
|
// 스트리밍 콜백이 없으면 그냥 일반 chat 사용
|
|
230
|
-
if (!onChunk) {
|
|
233
|
+
if (!callbacks.onChunk) {
|
|
231
234
|
const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
|
|
232
235
|
return { text: result.text, usedTools: result.toolsUsed.length > 0, toolsUsed: result.toolsUsed };
|
|
233
236
|
}
|
|
@@ -279,7 +282,7 @@ export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel =
|
|
|
279
282
|
streamingStarted = true;
|
|
280
283
|
accumulated += text;
|
|
281
284
|
try {
|
|
282
|
-
await onChunk(text, accumulated);
|
|
285
|
+
await callbacks.onChunk(text, accumulated);
|
|
283
286
|
}
|
|
284
287
|
catch (err) {
|
|
285
288
|
// editMessageText 실패 등은 무시하고 계속
|
|
@@ -293,6 +296,17 @@ export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel =
|
|
|
293
296
|
// 주의: chat()은 내부에서 withRetry를 사용하므로 여기서 추가 재시도 불필요
|
|
294
297
|
if (stopReason === "tool_use") {
|
|
295
298
|
console.log("[Stream] Tool use detected, falling back to chat()");
|
|
299
|
+
// 도구 이름 추출하여 콜백 호출
|
|
300
|
+
const toolUseBlocks = finalMessage.content.filter((block) => block.type === "tool_use");
|
|
301
|
+
const toolNames = toolUseBlocks.map(t => t.name);
|
|
302
|
+
if (callbacks.onToolStart && toolNames.length > 0) {
|
|
303
|
+
try {
|
|
304
|
+
await callbacks.onToolStart(toolNames);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
console.warn("[Stream] Tool start callback error (ignored):", err);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
296
310
|
const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
|
|
297
311
|
return { text: result.text, usedTools: true, toolsUsed: result.toolsUsed };
|
|
298
312
|
}
|
package/dist/cli/main.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createBot } from "../telegram/bot.js";
|
|
|
6
6
|
import { cleanupHeartbeats } from "../heartbeat/index.js";
|
|
7
7
|
import { cleanupBriefings } from "../briefing/index.js";
|
|
8
8
|
import { cleanupReminders } from "../reminders/index.js";
|
|
9
|
+
import { preloadEmbeddingModel, preloadVectorStore } from "../memory/index.js";
|
|
9
10
|
function createPrompt() {
|
|
10
11
|
return readline.createInterface({
|
|
11
12
|
input: process.stdin,
|
|
@@ -233,7 +234,21 @@ async function main() {
|
|
|
233
234
|
}
|
|
234
235
|
// 4. 환경변수 설정
|
|
235
236
|
process.env.ANTHROPIC_API_KEY = apiKey;
|
|
236
|
-
// 5.
|
|
237
|
+
// 5. 🚀 사전 로딩 (첫 응답 속도 개선)
|
|
238
|
+
console.log(`
|
|
239
|
+
╔═══════════════════════════════════════════════════════════════╗
|
|
240
|
+
║ ⏳ 시스템 사전 로딩... ║
|
|
241
|
+
╚═══════════════════════════════════════════════════════════════╝
|
|
242
|
+
`);
|
|
243
|
+
const preloadStart = Date.now();
|
|
244
|
+
// 임베딩 모델 + 벡터 저장소 병렬 로딩
|
|
245
|
+
await Promise.all([
|
|
246
|
+
preloadEmbeddingModel(),
|
|
247
|
+
preloadVectorStore(),
|
|
248
|
+
]);
|
|
249
|
+
console.log(` ✓ 사전 로딩 완료 (${Date.now() - preloadStart}ms)
|
|
250
|
+
`);
|
|
251
|
+
// 6. 봇 시작
|
|
237
252
|
console.log(`
|
|
238
253
|
╔═══════════════════════════════════════════════════════════════╗
|
|
239
254
|
║ 🚀 봇 시작! ║
|
package/dist/config/constants.js
CHANGED
|
@@ -70,14 +70,81 @@ export const MEMORY = {
|
|
|
70
70
|
// 텔레그램/UI 관련 설정
|
|
71
71
|
// ============================================
|
|
72
72
|
export const TELEGRAM = {
|
|
73
|
-
/** 스트리밍 업데이트 간격 (밀리초) */
|
|
74
|
-
STREAM_UPDATE_INTERVAL_MS: 500,
|
|
73
|
+
/** 스트리밍 업데이트 간격 - 적응형 (밀리초) */
|
|
74
|
+
STREAM_UPDATE_INTERVAL_MS: 500, // 레거시 호환용
|
|
75
|
+
/** 스트리밍 적응형 간격 설정 */
|
|
76
|
+
STREAM_INTERVAL: {
|
|
77
|
+
/** 첫 번째 업데이트 (즉시) */
|
|
78
|
+
FIRST_MS: 0,
|
|
79
|
+
/** 초기 빠른 업데이트 (처음 5회) */
|
|
80
|
+
FAST_MS: 200,
|
|
81
|
+
/** 이후 일반 간격 */
|
|
82
|
+
NORMAL_MS: 400,
|
|
83
|
+
/** 빠른 업데이트 횟수 */
|
|
84
|
+
FAST_COUNT: 5,
|
|
85
|
+
},
|
|
86
|
+
/** 텔레그램 메시지 최대 길이 */
|
|
87
|
+
MAX_MESSAGE_LENGTH: 4096,
|
|
75
88
|
/** 최대 이미지 크기 (바이트) - 10MB */
|
|
76
89
|
MAX_IMAGE_SIZE: 10 * 1024 * 1024,
|
|
77
90
|
/** URL 처리 최대 개수 */
|
|
78
91
|
MAX_URL_FETCH: 3,
|
|
79
92
|
/** 캘린더 미리보기 이벤트 수 */
|
|
80
93
|
CALENDAR_PREVIEW_COUNT: 3,
|
|
94
|
+
/** 스트리밍 UI 아이콘 */
|
|
95
|
+
STREAM_ICONS: {
|
|
96
|
+
THINKING: "💭",
|
|
97
|
+
TYPING: "▌",
|
|
98
|
+
TOOL: "🔧",
|
|
99
|
+
DONE: "",
|
|
100
|
+
},
|
|
101
|
+
/** Typing indicator 자동 갱신 간격 (밀리초) - 텔레그램은 5초 후 만료 */
|
|
102
|
+
TYPING_REFRESH_MS: 4000,
|
|
103
|
+
/** 도구별 친화적 상태 메시지 */
|
|
104
|
+
TOOL_STATUS_MESSAGES: {
|
|
105
|
+
// 검색/정보 조회
|
|
106
|
+
web_search: { icon: "🔍", text: "웹에서 검색하는 중", estimate: "5-10초" },
|
|
107
|
+
web_fetch: { icon: "📄", text: "웹페이지 읽는 중", estimate: "3-5초" },
|
|
108
|
+
get_weather: { icon: "🌤️", text: "날씨 확인 중", estimate: "2-3초" },
|
|
109
|
+
memory_search: { icon: "🧠", text: "기억 검색 중", estimate: "1-2초" },
|
|
110
|
+
memory_reindex: { icon: "🧠", text: "기억 재색인 중", estimate: "10-30초" },
|
|
111
|
+
// 파일 작업
|
|
112
|
+
read_file: { icon: "📖", text: "파일 읽는 중", estimate: "1초" },
|
|
113
|
+
write_file: { icon: "✍️", text: "파일 쓰는 중", estimate: "1초" },
|
|
114
|
+
edit_file: { icon: "✏️", text: "파일 수정 중", estimate: "1초" },
|
|
115
|
+
list_directory: { icon: "📁", text: "폴더 살펴보는 중", estimate: "1초" },
|
|
116
|
+
// 명령어 실행
|
|
117
|
+
run_command: { icon: "⚡", text: "명령어 실행 중", estimate: "변동" },
|
|
118
|
+
list_sessions: { icon: "📋", text: "세션 목록 확인 중", estimate: "1초" },
|
|
119
|
+
get_session_log: { icon: "📜", text: "로그 가져오는 중", estimate: "1초" },
|
|
120
|
+
kill_session: { icon: "🛑", text: "세션 종료 중", estimate: "1초" },
|
|
121
|
+
// 일정/리마인더
|
|
122
|
+
get_calendar_events: { icon: "📅", text: "일정 확인 중", estimate: "2-3초" },
|
|
123
|
+
add_calendar_event: { icon: "📅", text: "일정 추가 중", estimate: "2-3초" },
|
|
124
|
+
delete_calendar_event: { icon: "📅", text: "일정 삭제 중", estimate: "2초" },
|
|
125
|
+
set_reminder: { icon: "⏰", text: "알림 설정 중", estimate: "1초" },
|
|
126
|
+
list_reminders: { icon: "⏰", text: "알림 목록 확인 중", estimate: "1초" },
|
|
127
|
+
cancel_reminder: { icon: "⏰", text: "알림 취소 중", estimate: "1초" },
|
|
128
|
+
// 브리핑/하트비트
|
|
129
|
+
control_briefing: { icon: "☀️", text: "브리핑 설정 중", estimate: "1초" },
|
|
130
|
+
send_briefing_now: { icon: "☀️", text: "브리핑 준비 중", estimate: "5-10초" },
|
|
131
|
+
control_heartbeat: { icon: "💓", text: "하트비트 설정 중", estimate: "1초" },
|
|
132
|
+
run_heartbeat_check: { icon: "💓", text: "체크 실행 중", estimate: "3-5초" },
|
|
133
|
+
// 서브에이전트
|
|
134
|
+
spawn_agent: { icon: "🤖", text: "서브에이전트 생성 중", estimate: "2-3초" },
|
|
135
|
+
list_agents: { icon: "🤖", text: "에이전트 목록 확인 중", estimate: "1초" },
|
|
136
|
+
cancel_agent: { icon: "🤖", text: "에이전트 취소 중", estimate: "1초" },
|
|
137
|
+
// Cron
|
|
138
|
+
add_cron: { icon: "🕐", text: "예약 작업 추가 중", estimate: "1초" },
|
|
139
|
+
list_crons: { icon: "🕐", text: "예약 작업 확인 중", estimate: "1초" },
|
|
140
|
+
remove_cron: { icon: "🕐", text: "예약 작업 삭제 중", estimate: "1초" },
|
|
141
|
+
toggle_cron: { icon: "🕐", text: "예약 작업 설정 중", estimate: "1초" },
|
|
142
|
+
run_cron: { icon: "🕐", text: "예약 작업 실행 중", estimate: "변동" },
|
|
143
|
+
// 기타
|
|
144
|
+
change_model: { icon: "🔄", text: "모델 변경 중", estimate: "1초" },
|
|
145
|
+
save_memory: { icon: "💾", text: "기억 저장 중", estimate: "1초" },
|
|
146
|
+
save_persona: { icon: "✨", text: "페르소나 저장 중", estimate: "2초" },
|
|
147
|
+
},
|
|
81
148
|
};
|
|
82
149
|
// ============================================
|
|
83
150
|
// 보안/토큰 관련 설정
|
|
@@ -86,3 +153,27 @@ export const SECURITY = {
|
|
|
86
153
|
/** 리셋 토큰 만료 시간 (밀리초) - 1분 */
|
|
87
154
|
RESET_TOKEN_TTL_MS: 60000,
|
|
88
155
|
};
|
|
156
|
+
// ============================================
|
|
157
|
+
// API/네트워크 설정
|
|
158
|
+
// ============================================
|
|
159
|
+
export const API = {
|
|
160
|
+
/** Claude API 타임아웃 (밀리초) - 2분 */
|
|
161
|
+
TIMEOUT_MS: 120000,
|
|
162
|
+
/** 최대 재시도 횟수 */
|
|
163
|
+
MAX_RETRIES: 3,
|
|
164
|
+
/** 초기 재시도 대기 시간 (밀리초) */
|
|
165
|
+
INITIAL_RETRY_DELAY_MS: 1000,
|
|
166
|
+
/** 최대 재시도 대기 시간 (밀리초) */
|
|
167
|
+
MAX_RETRY_DELAY_MS: 30000,
|
|
168
|
+
/** 재시도 백오프 배수 */
|
|
169
|
+
BACKOFF_MULTIPLIER: 2,
|
|
170
|
+
};
|
|
171
|
+
// ============================================
|
|
172
|
+
// 메모리 검색 타임아웃 설정
|
|
173
|
+
// ============================================
|
|
174
|
+
export const SEARCH = {
|
|
175
|
+
/** 전체 검색 타임아웃 (밀리초) */
|
|
176
|
+
TIMEOUT_MS: 5000,
|
|
177
|
+
/** 임베딩 생성 타임아웃 (밀리초) */
|
|
178
|
+
EMBED_TIMEOUT_MS: 3000,
|
|
179
|
+
};
|
package/dist/health/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* 봇의 상태를 추적하고 모니터링합니다.
|
|
4
4
|
* @module health
|
|
5
5
|
*/
|
|
6
|
+
import { getWarmupStatus } from "../warmup.js";
|
|
6
7
|
let startTime = Date.now();
|
|
7
8
|
let lastActivity = Date.now();
|
|
8
9
|
let messageCount = 0;
|
|
@@ -36,7 +37,8 @@ export function getHealthStatus() {
|
|
|
36
37
|
lastActivity,
|
|
37
38
|
messageCount,
|
|
38
39
|
errorCount,
|
|
39
|
-
isHealthy
|
|
40
|
+
isHealthy,
|
|
41
|
+
warmup: getWarmupStatus(),
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 메모리 검색 벤치마크
|
|
3
|
+
*
|
|
4
|
+
* 사용법: npx tsx src/memory/benchmark.ts
|
|
5
|
+
*/
|
|
6
|
+
import { performance } from "perf_hooks";
|
|
7
|
+
// 현재 구현
|
|
8
|
+
import * as currentVector from "./vectorStore.js";
|
|
9
|
+
import * as currentFts from "./ftsIndex.js";
|
|
10
|
+
import * as currentHybrid from "./hybridSearch.js";
|
|
11
|
+
// 최적화 구현 (주석 해제하여 비교)
|
|
12
|
+
// import * as optimizedVector from "./vectorStore.optimized.js";
|
|
13
|
+
// import * as optimizedFts from "./ftsIndex.optimized.js";
|
|
14
|
+
// import * as optimizedHybrid from "./hybridSearch.optimized.js";
|
|
15
|
+
const TEST_QUERIES = [
|
|
16
|
+
"오늘 무슨 일이 있었어?",
|
|
17
|
+
"지난주 회의 내용",
|
|
18
|
+
"프로젝트 마감일",
|
|
19
|
+
"API 키 설정",
|
|
20
|
+
"에러 해결 방법",
|
|
21
|
+
"CompanionBot feature",
|
|
22
|
+
"일정 확인",
|
|
23
|
+
"메모리 검색 최적화",
|
|
24
|
+
"테스트 코드 작성",
|
|
25
|
+
"버그 수정",
|
|
26
|
+
];
|
|
27
|
+
async function benchmark(name, fn, iterations = 10) {
|
|
28
|
+
const times = [];
|
|
29
|
+
// 워밍업
|
|
30
|
+
await fn();
|
|
31
|
+
await fn();
|
|
32
|
+
for (let i = 0; i < iterations; i++) {
|
|
33
|
+
const start = performance.now();
|
|
34
|
+
await fn();
|
|
35
|
+
const end = performance.now();
|
|
36
|
+
times.push(end - start);
|
|
37
|
+
}
|
|
38
|
+
times.sort((a, b) => a - b);
|
|
39
|
+
const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
|
|
40
|
+
const p95Idx = Math.floor(times.length * 0.95);
|
|
41
|
+
return {
|
|
42
|
+
name,
|
|
43
|
+
avgMs: Math.round(avgMs * 100) / 100,
|
|
44
|
+
minMs: Math.round(times[0] * 100) / 100,
|
|
45
|
+
maxMs: Math.round(times[times.length - 1] * 100) / 100,
|
|
46
|
+
p95Ms: Math.round(times[p95Idx] * 100) / 100,
|
|
47
|
+
ops: Math.round(1000 / avgMs),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function printResult(result) {
|
|
51
|
+
console.log(`
|
|
52
|
+
${result.name}:
|
|
53
|
+
Average: ${result.avgMs}ms
|
|
54
|
+
Min: ${result.minMs}ms
|
|
55
|
+
Max: ${result.maxMs}ms
|
|
56
|
+
P95: ${result.p95Ms}ms
|
|
57
|
+
Ops/sec: ${result.ops}
|
|
58
|
+
`);
|
|
59
|
+
}
|
|
60
|
+
async function main() {
|
|
61
|
+
console.log("=".repeat(60));
|
|
62
|
+
console.log("Memory Search Benchmark");
|
|
63
|
+
console.log("=".repeat(60));
|
|
64
|
+
console.log(`\nTest queries: ${TEST_QUERIES.length}`);
|
|
65
|
+
console.log(`Iterations per query: 10\n`);
|
|
66
|
+
// 1. 벡터 검색 벤치마크
|
|
67
|
+
console.log("\n--- Vector Search ---");
|
|
68
|
+
for (const query of TEST_QUERIES.slice(0, 3)) {
|
|
69
|
+
const result = await benchmark(`Vector search: "${query.slice(0, 20)}..."`, async () => {
|
|
70
|
+
return currentHybrid.searchVector(query, 5, 0.3);
|
|
71
|
+
}, 10);
|
|
72
|
+
printResult(result);
|
|
73
|
+
}
|
|
74
|
+
// 2. FTS 검색 벤치마크
|
|
75
|
+
console.log("\n--- FTS Keyword Search ---");
|
|
76
|
+
for (const query of TEST_QUERIES.slice(0, 3)) {
|
|
77
|
+
const result = await benchmark(`FTS search: "${query.slice(0, 20)}..."`, async () => {
|
|
78
|
+
return currentFts.searchKeyword(query, 10);
|
|
79
|
+
}, 10);
|
|
80
|
+
printResult(result);
|
|
81
|
+
}
|
|
82
|
+
// 3. 하이브리드 검색 벤치마크
|
|
83
|
+
console.log("\n--- Hybrid Search ---");
|
|
84
|
+
for (const query of TEST_QUERIES.slice(0, 3)) {
|
|
85
|
+
const result = await benchmark(`Hybrid search: "${query.slice(0, 20)}..."`, async () => {
|
|
86
|
+
return currentHybrid.hybridSearch(query, { topK: 5 });
|
|
87
|
+
}, 10);
|
|
88
|
+
printResult(result);
|
|
89
|
+
}
|
|
90
|
+
// 4. 청크 로딩 벤치마크
|
|
91
|
+
console.log("\n--- Chunk Loading ---");
|
|
92
|
+
// 캐시 무효화 후 로딩 시간
|
|
93
|
+
currentVector.invalidateCache();
|
|
94
|
+
const loadResult = await benchmark("Load all chunks (cold)", async () => {
|
|
95
|
+
currentVector.invalidateCache();
|
|
96
|
+
return currentVector.loadAllMemoryChunks();
|
|
97
|
+
}, 5);
|
|
98
|
+
printResult(loadResult);
|
|
99
|
+
// 캐시된 로딩 시간
|
|
100
|
+
const cachedLoadResult = await benchmark("Load all chunks (cached)", async () => {
|
|
101
|
+
return currentVector.loadAllMemoryChunks();
|
|
102
|
+
}, 10);
|
|
103
|
+
printResult(cachedLoadResult);
|
|
104
|
+
// 5. 통계
|
|
105
|
+
console.log("\n--- Statistics ---");
|
|
106
|
+
const chunks = await currentVector.loadAllMemoryChunks();
|
|
107
|
+
const ftsCount = currentFts.getDocumentCount();
|
|
108
|
+
console.log(`Total chunks in vector store: ${chunks.length}`);
|
|
109
|
+
console.log(`Total documents in FTS: ${ftsCount}`);
|
|
110
|
+
console.log("\n" + "=".repeat(60));
|
|
111
|
+
console.log("Benchmark complete");
|
|
112
|
+
console.log("=".repeat(60));
|
|
113
|
+
}
|
|
114
|
+
main().catch(console.error);
|
|
@@ -8,6 +8,24 @@ let embeddingPipeline = null;
|
|
|
8
8
|
// 모델 로딩 중인지 추적
|
|
9
9
|
let isLoading = false;
|
|
10
10
|
let loadingPromise = null;
|
|
11
|
+
// ============== 쿼리 임베딩 LRU 캐시 ==============
|
|
12
|
+
// 같은 검색 쿼리가 반복될 때 임베딩 재계산 방지
|
|
13
|
+
const QUERY_CACHE_MAX_SIZE = 100;
|
|
14
|
+
const queryEmbeddingCache = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* LRU 방식으로 캐시 정리
|
|
17
|
+
*/
|
|
18
|
+
function pruneQueryCache() {
|
|
19
|
+
if (queryEmbeddingCache.size <= QUERY_CACHE_MAX_SIZE)
|
|
20
|
+
return;
|
|
21
|
+
// lastUsed 기준 정렬하여 오래된 것 삭제
|
|
22
|
+
const entries = [...queryEmbeddingCache.entries()];
|
|
23
|
+
entries.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
|
|
24
|
+
const toRemove = entries.slice(0, entries.length - QUERY_CACHE_MAX_SIZE);
|
|
25
|
+
for (const [key] of toRemove) {
|
|
26
|
+
queryEmbeddingCache.delete(key);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
11
29
|
/**
|
|
12
30
|
* 임베딩 파이프라인을 초기화합니다.
|
|
13
31
|
* 작고 빠른 모델 사용 (384 차원)
|
|
@@ -21,42 +39,73 @@ async function getEmbeddingPipeline() {
|
|
|
21
39
|
return loadingPromise;
|
|
22
40
|
}
|
|
23
41
|
isLoading = true;
|
|
42
|
+
console.log("[Embedding] Loading model...");
|
|
43
|
+
const startTime = Date.now();
|
|
24
44
|
loadingPromise = pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2" // 384차원, 빠르고 가벼움
|
|
25
45
|
);
|
|
26
46
|
try {
|
|
27
47
|
embeddingPipeline = await loadingPromise;
|
|
48
|
+
console.log(`[Embedding] Model loaded in ${Date.now() - startTime}ms`);
|
|
28
49
|
return embeddingPipeline;
|
|
29
50
|
}
|
|
30
51
|
finally {
|
|
31
52
|
isLoading = false;
|
|
32
53
|
}
|
|
33
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* 🚀 사전 로딩: 봇 시작 시 호출하여 첫 요청 지연 방지
|
|
57
|
+
*/
|
|
58
|
+
export async function preloadEmbeddingModel() {
|
|
59
|
+
try {
|
|
60
|
+
await getEmbeddingPipeline();
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
console.warn("[Embedding] Preload failed:", error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
34
66
|
/**
|
|
35
67
|
* 텍스트를 임베딩 벡터로 변환합니다.
|
|
68
|
+
* LRU 캐시로 반복 쿼리 성능 향상.
|
|
36
69
|
* @param text 변환할 텍스트
|
|
70
|
+
* @param useCache 캐시 사용 여부 (기본 true, 청크 임베딩 시 false 권장)
|
|
37
71
|
* @returns 384차원 임베딩 벡터
|
|
38
72
|
*/
|
|
39
|
-
export async function embed(text) {
|
|
73
|
+
export async function embed(text, useCache = true) {
|
|
40
74
|
// null/undefined 처리
|
|
41
75
|
if (text == null) {
|
|
42
76
|
return new Array(384).fill(0);
|
|
43
77
|
}
|
|
44
|
-
const pipe = await getEmbeddingPipeline();
|
|
45
78
|
// 텍스트 정규화
|
|
46
79
|
const cleanText = text.trim().slice(0, 512); // 최대 512자
|
|
47
80
|
if (!cleanText) {
|
|
48
81
|
return new Array(384).fill(0);
|
|
49
82
|
}
|
|
83
|
+
// 캐시 확인
|
|
84
|
+
if (useCache) {
|
|
85
|
+
const cached = queryEmbeddingCache.get(cleanText);
|
|
86
|
+
if (cached) {
|
|
87
|
+
cached.lastUsed = Date.now();
|
|
88
|
+
return cached.embedding;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const pipe = await getEmbeddingPipeline();
|
|
50
92
|
const result = await pipe(cleanText, {
|
|
51
93
|
pooling: "mean",
|
|
52
94
|
normalize: true,
|
|
53
95
|
});
|
|
54
96
|
// Tensor를 배열로 변환
|
|
55
|
-
|
|
97
|
+
const embedding = Array.from(result.data);
|
|
98
|
+
// 캐시 저장
|
|
99
|
+
if (useCache) {
|
|
100
|
+
queryEmbeddingCache.set(cleanText, { embedding, lastUsed: Date.now() });
|
|
101
|
+
pruneQueryCache();
|
|
102
|
+
}
|
|
103
|
+
return embedding;
|
|
56
104
|
}
|
|
57
105
|
/**
|
|
58
106
|
* 여러 텍스트를 배치로 임베딩합니다.
|
|
59
107
|
* 병렬로 처리하여 성능 향상 (모델 내부에서 순차 처리되더라도 Promise 오버헤드 감소)
|
|
108
|
+
* 청크용이므로 쿼리 캐시 사용 안 함 (vectorStore의 영속 캐시 사용).
|
|
60
109
|
* @param texts 변환할 텍스트 배열
|
|
61
110
|
* @returns 임베딩 벡터 배열
|
|
62
111
|
*/
|
|
@@ -65,19 +114,32 @@ export async function embedBatch(texts) {
|
|
|
65
114
|
if (!texts || texts.length === 0)
|
|
66
115
|
return [];
|
|
67
116
|
if (texts.length === 1)
|
|
68
|
-
return [await embed(texts[0])];
|
|
117
|
+
return [await embed(texts[0], false)];
|
|
69
118
|
// 동시성 제한 (메모리 보호)
|
|
70
119
|
const CONCURRENCY = 5;
|
|
71
120
|
const results = new Array(texts.length);
|
|
72
121
|
for (let i = 0; i < texts.length; i += CONCURRENCY) {
|
|
73
122
|
const batch = texts.slice(i, i + CONCURRENCY);
|
|
74
|
-
|
|
123
|
+
// 청크 임베딩은 캐시 사용 안 함 (useCache=false)
|
|
124
|
+
const batchResults = await Promise.all(batch.map(text => embed(text, false)));
|
|
75
125
|
for (let j = 0; j < batchResults.length; j++) {
|
|
76
126
|
results[i + j] = batchResults[j];
|
|
77
127
|
}
|
|
78
128
|
}
|
|
79
129
|
return results;
|
|
80
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* 쿼리 임베딩 캐시 통계를 반환합니다.
|
|
133
|
+
*/
|
|
134
|
+
export function getQueryCacheStats() {
|
|
135
|
+
return { size: queryEmbeddingCache.size, maxSize: QUERY_CACHE_MAX_SIZE };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 쿼리 임베딩 캐시를 초기화합니다.
|
|
139
|
+
*/
|
|
140
|
+
export function clearQueryCache() {
|
|
141
|
+
queryEmbeddingCache.clear();
|
|
142
|
+
}
|
|
81
143
|
/**
|
|
82
144
|
* 두 벡터 간의 코사인 유사도를 계산합니다.
|
|
83
145
|
*
|