companionbot 0.8.1 → 0.9.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/dist/ai/claude.js CHANGED
@@ -75,6 +75,7 @@ export const MODELS = {
75
75
  export async function chat(messages, systemPrompt, modelId = "sonnet") {
76
76
  const client = getClient();
77
77
  const modelConfig = MODELS[modelId];
78
+ const toolsUsed = [];
78
79
  // 메시지를 API 형식으로 변환
79
80
  const apiMessages = messages.map((m) => ({
80
81
  role: m.role,
@@ -122,6 +123,12 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
122
123
  tool_use_id: toolUse.id,
123
124
  content: truncatedResult,
124
125
  });
126
+ // 도구 사용 기록 (히스토리 참조용)
127
+ toolsUsed.push({
128
+ name: toolUse.name,
129
+ input: JSON.stringify(toolUse.input).slice(0, 200),
130
+ output: truncatedResult.slice(0, 500),
131
+ });
125
132
  }
126
133
  // 어시스턴트 메시지와 도구 결과 추가
127
134
  apiMessages.push({
@@ -138,11 +145,14 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
138
145
  // 반복 횟수 초과 시 경고
139
146
  if (iterations >= MAX_TOOL_ITERATIONS) {
140
147
  console.warn(`[Warning] Tool use loop reached max iterations (${MAX_TOOL_ITERATIONS})`);
141
- return "도구 실행이 너무 많이 반복됐어. 다시 시도해줄래?";
148
+ return { text: "도구 실행이 너무 많이 반복됐어. 다시 시도해줄래?", toolsUsed };
142
149
  }
143
150
  // 최종 텍스트 응답 추출
144
151
  const textBlock = response.content.find((block) => block.type === "text");
145
- return textBlock?.text ?? "응답을 생성하지 못했어. 다시 시도해줄래?";
152
+ return {
153
+ text: textBlock?.text ?? "응답을 생성하지 못했어. 다시 시도해줄래?",
154
+ toolsUsed
155
+ };
146
156
  }
147
157
  /**
148
158
  * 스마트 채팅 - 가능하면 스트리밍, 도구 필요하면 일반 호출
@@ -151,12 +161,15 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
151
161
  * - 먼저 스트리밍으로 시도
152
162
  * - 도구 호출이 감지되면 (stop_reason === "tool_use") 기존 chat()으로 폴백
153
163
  * - 스트리밍은 최종 텍스트 응답에만 사용
164
+ *
165
+ * 주의: 스트리밍은 재시도하지 않음 (이미 전송된 청크를 되돌릴 수 없음)
166
+ * 스트리밍 중 에러 발생 시 적절한 에러 메시지를 반환하거나 예외를 전파함
154
167
  */
155
168
  export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
156
169
  // 스트리밍 콜백이 없으면 그냥 일반 chat 사용
157
170
  if (!onChunk) {
158
- const text = await chat(messages, systemPrompt, modelId);
159
- return { text, usedTools: false };
171
+ const result = await chat(messages, systemPrompt, modelId);
172
+ return { text: result.text, usedTools: result.toolsUsed.length > 0, toolsUsed: result.toolsUsed };
160
173
  }
161
174
  const client = getClient();
162
175
  const modelConfig = MODELS[modelId];
@@ -179,13 +192,12 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
179
192
  // Thinking은 스트리밍에서 복잡해지므로 일단 비활성화
180
193
  // (도구 호출 폴백 시 chat()에서 thinking 사용됨)
181
194
  let accumulated = "";
182
- let stopReason = null;
183
- // 스트리밍에 withRetry 적용 - 실패 시 자동 재시도
184
- return await withRetry(async () => {
185
- accumulated = ""; // 재시도 시 초기화
195
+ let streamingStarted = false;
196
+ try {
186
197
  const stream = client.messages.stream(params);
187
198
  // 스트리밍 이벤트 처리
188
199
  stream.on("text", async (text) => {
200
+ streamingStarted = true;
189
201
  accumulated += text;
190
202
  try {
191
203
  await onChunk(text, accumulated);
@@ -197,14 +209,43 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
197
209
  });
198
210
  // 스트림 완료 대기
199
211
  const finalMessage = await stream.finalMessage();
200
- stopReason = finalMessage.stop_reason;
212
+ const stopReason = finalMessage.stop_reason;
201
213
  // 도구 호출이 필요한 경우 - 일반 chat으로 폴백
214
+ // 주의: chat()은 내부에서 withRetry를 사용하므로 여기서 추가 재시도 불필요
202
215
  if (stopReason === "tool_use") {
203
216
  console.log("[Stream] Tool use detected, falling back to chat()");
204
- const text = await chat(messages, systemPrompt, modelId);
205
- return { text, usedTools: true };
217
+ const result = await chat(messages, systemPrompt, modelId);
218
+ return { text: result.text, usedTools: true, toolsUsed: result.toolsUsed };
206
219
  }
207
220
  // 성공적으로 스트리밍 완료
208
- return { text: accumulated, usedTools: false };
209
- });
221
+ return { text: accumulated, usedTools: false, toolsUsed: [] };
222
+ }
223
+ catch (error) {
224
+ // 스트리밍 시작 전 에러 (연결 실패 등) - 재시도 가능
225
+ if (!streamingStarted && error instanceof APIError) {
226
+ // Rate limit 또는 서버 에러는 withRetry로 재시도
227
+ if (error.status === 429 || error.status >= 500) {
228
+ console.log(`[Stream] Pre-stream error (${error.status}), retrying with withRetry...`);
229
+ return await withRetry(async () => {
230
+ // 재시도 시 일반 chat 사용 (스트리밍 대신)
231
+ const result = await chat(messages, systemPrompt, modelId);
232
+ return { text: result.text, usedTools: false, toolsUsed: result.toolsUsed };
233
+ });
234
+ }
235
+ }
236
+ // 스트리밍 중 에러 - 재시도 불가 (이미 청크가 전송됨)
237
+ if (streamingStarted) {
238
+ console.error("[Stream] Error during streaming (cannot retry):", error);
239
+ // 이미 일부 텍스트가 전송됐으므로, 에러 메시지를 추가하거나 부분 결과 반환
240
+ if (accumulated.length > 0) {
241
+ return {
242
+ text: accumulated + "\n\n(응답 생성 중 오류 발생)",
243
+ usedTools: false,
244
+ toolsUsed: []
245
+ };
246
+ }
247
+ }
248
+ // 그 외 에러는 전파
249
+ throw error;
250
+ }
210
251
  }
@@ -167,9 +167,9 @@ 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
- const response = await chat(messages, systemPrompt, "sonnet");
170
+ const result = await chat(messages, systemPrompt, "sonnet");
171
171
  // Send the response to the chat
172
- const trimmedResponse = response?.trim();
172
+ const trimmedResponse = result.text?.trim();
173
173
  if (trimmedResponse) {
174
174
  // Split long messages (Telegram limit is 4096 characters)
175
175
  const maxLength = TELEGRAM_SAFE_LIMIT;
@@ -141,9 +141,9 @@ ${context}
141
141
  ];
142
142
  let messageSent = false;
143
143
  try {
144
- const response = await chat(messages, systemPrompt, "haiku");
145
- if (!response.trim().includes("HEARTBEAT_OK")) {
146
- await botInstance.api.sendMessage(config.chatId, response);
144
+ const result = await chat(messages, systemPrompt, "haiku");
145
+ if (!result.text.trim().includes("HEARTBEAT_OK")) {
146
+ await botInstance.api.sendMessage(config.chatId, result.text);
147
147
  console.log(`[Heartbeat] Sent message to ${config.chatId}`);
148
148
  messageSent = true;
149
149
  // 타임스탬프는 메모리 캐시에만 저장 (파일 쓰기 안 함)
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * 간단한 벡터 저장소 모듈
3
3
  * 메모리 파일들을 로드하고 유사도 기반으로 검색합니다.
4
+ * 임베딩은 파일에 캐시되어 재시작 후에도 유지됩니다.
4
5
  */
5
6
  import * as fs from "fs/promises";
6
7
  import * as path from "path";
@@ -10,8 +11,61 @@ import { embed, embedBatch, cosineSimilarity } from "./embeddings.js";
10
11
  let cachedChunks = [];
11
12
  let cacheTimestamp = 0;
12
13
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
14
+ // 임베딩 영속 캐시 (hash → embedding)
15
+ let embeddingCache = new Map();
16
+ let embeddingCacheLoaded = false;
13
17
  // 로딩 중복 방지용 Promise
14
18
  let loadingPromise = null;
19
+ /**
20
+ * 간단한 해시 함수 (텍스트 변경 감지용)
21
+ */
22
+ function simpleHash(text) {
23
+ let hash = 0;
24
+ for (let i = 0; i < text.length; i++) {
25
+ const char = text.charCodeAt(i);
26
+ hash = ((hash << 5) - hash) + char;
27
+ hash = hash & hash; // 32bit 정수로 변환
28
+ }
29
+ return hash.toString(16);
30
+ }
31
+ /**
32
+ * 임베딩 캐시 파일 경로
33
+ */
34
+ function getEmbeddingCachePath() {
35
+ return path.join(getMemoryDirPath(), ".embedding-cache.json");
36
+ }
37
+ /**
38
+ * 임베딩 캐시를 파일에서 로드합니다.
39
+ */
40
+ async function loadEmbeddingCache() {
41
+ if (embeddingCacheLoaded)
42
+ return;
43
+ try {
44
+ const cachePath = getEmbeddingCachePath();
45
+ const data = await fs.readFile(cachePath, "utf-8");
46
+ const parsed = JSON.parse(data);
47
+ embeddingCache = new Map(Object.entries(parsed));
48
+ console.log(`[VectorStore] Loaded ${embeddingCache.size} cached embeddings`);
49
+ }
50
+ catch {
51
+ // 파일 없거나 파싱 실패 - 새로 시작
52
+ embeddingCache = new Map();
53
+ }
54
+ embeddingCacheLoaded = true;
55
+ }
56
+ /**
57
+ * 임베딩 캐시를 파일에 저장합니다.
58
+ */
59
+ async function saveEmbeddingCache() {
60
+ try {
61
+ const cachePath = getEmbeddingCachePath();
62
+ const obj = Object.fromEntries(embeddingCache);
63
+ await fs.writeFile(cachePath, JSON.stringify(obj), "utf-8");
64
+ }
65
+ catch (error) {
66
+ console.warn("[VectorStore] Failed to save embedding cache:", error);
67
+ }
68
+ }
15
69
  /**
16
70
  * 텍스트를 적절한 크기의 청크로 분할합니다.
17
71
  */
@@ -30,7 +84,11 @@ function splitIntoChunks(text, source) {
30
84
  for (const line of lines) {
31
85
  if (currentChunk.length + line.length > 500) {
32
86
  if (currentChunk.trim()) {
33
- chunks.push({ text: currentChunk.trim(), source });
87
+ chunks.push({
88
+ text: currentChunk.trim(),
89
+ source,
90
+ hash: simpleHash(currentChunk.trim())
91
+ });
34
92
  }
35
93
  currentChunk = line;
36
94
  }
@@ -39,11 +97,19 @@ function splitIntoChunks(text, source) {
39
97
  }
40
98
  }
41
99
  if (currentChunk.trim()) {
42
- chunks.push({ text: currentChunk.trim(), source });
100
+ chunks.push({
101
+ text: currentChunk.trim(),
102
+ source,
103
+ hash: simpleHash(currentChunk.trim())
104
+ });
43
105
  }
44
106
  }
45
107
  else {
46
- chunks.push({ text: trimmed, source });
108
+ chunks.push({
109
+ text: trimmed,
110
+ source,
111
+ hash: simpleHash(trimmed)
112
+ });
47
113
  }
48
114
  }
49
115
  return chunks;
@@ -52,12 +118,14 @@ function splitIntoChunks(text, source) {
52
118
  * 내부 로드 로직 - 실제 파일 로드 수행
53
119
  */
54
120
  async function doLoadAllMemoryChunks() {
121
+ // 임베딩 캐시 로드
122
+ await loadEmbeddingCache();
55
123
  const chunks = [];
56
124
  // 1. 일별 메모리 파일 (최근 30일)
57
125
  const memoryDir = getMemoryDirPath();
58
126
  try {
59
127
  const files = await fs.readdir(memoryDir);
60
- const mdFiles = files.filter(f => f.endsWith(".md")).sort().reverse().slice(0, 30);
128
+ const mdFiles = files.filter(f => f.endsWith(".md") && !f.startsWith(".")).sort().reverse().slice(0, 30);
61
129
  for (const file of mdFiles) {
62
130
  try {
63
131
  const content = await fs.readFile(path.join(memoryDir, file), "utf-8");
@@ -82,6 +150,15 @@ async function doLoadAllMemoryChunks() {
82
150
  catch {
83
151
  // 파일 없음 무시
84
152
  }
153
+ // 3. 캐시된 임베딩 복원
154
+ for (const chunk of chunks) {
155
+ if (chunk.hash) {
156
+ const cachedEmbedding = embeddingCache.get(chunk.hash);
157
+ if (cachedEmbedding) {
158
+ chunk.embedding = cachedEmbedding;
159
+ }
160
+ }
161
+ }
85
162
  return chunks;
86
163
  }
87
164
  /**
@@ -131,24 +208,35 @@ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
131
208
  // 임베딩이 없는 청크들을 배치로 처리
132
209
  const chunksNeedingEmbedding = chunks.filter(c => !c.embedding);
133
210
  if (chunksNeedingEmbedding.length > 0) {
211
+ console.log(`[VectorStore] Generating embeddings for ${chunksNeedingEmbedding.length} chunks`);
134
212
  try {
135
213
  const texts = chunksNeedingEmbedding.map(c => c.text);
136
214
  const embeddings = await embedBatch(texts);
137
- // 임베딩 할당
215
+ // 임베딩 할당 및 캐시 저장
138
216
  for (let i = 0; i < chunksNeedingEmbedding.length; i++) {
139
- chunksNeedingEmbedding[i].embedding = embeddings[i];
217
+ const chunk = chunksNeedingEmbedding[i];
218
+ chunk.embedding = embeddings[i];
219
+ if (chunk.hash) {
220
+ embeddingCache.set(chunk.hash, embeddings[i]);
221
+ }
140
222
  }
223
+ // 캐시 파일 저장 (비동기, 실패해도 무시)
224
+ saveEmbeddingCache().catch(() => { });
141
225
  }
142
226
  catch {
143
227
  // 배치 실패 시 개별 처리 폴백
144
228
  for (const chunk of chunksNeedingEmbedding) {
145
229
  try {
146
230
  chunk.embedding = await embed(chunk.text);
231
+ if (chunk.hash) {
232
+ embeddingCache.set(chunk.hash, chunk.embedding);
233
+ }
147
234
  }
148
235
  catch {
149
236
  // 개별 실패 무시
150
237
  }
151
238
  }
239
+ saveEmbeddingCache().catch(() => { });
152
240
  }
153
241
  }
154
242
  // 유사도 계산 및 필터링
@@ -172,12 +260,27 @@ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
172
260
  }
173
261
  /**
174
262
  * 캐시를 무효화합니다.
263
+ * 임베딩 캐시는 유지 (텍스트 해시 기반이므로)
175
264
  */
176
265
  export function invalidateCache() {
177
266
  cachedChunks = [];
178
267
  cacheTimestamp = 0;
179
268
  loadingPromise = null;
180
269
  }
270
+ /**
271
+ * 임베딩 캐시까지 완전 초기화합니다.
272
+ */
273
+ export async function clearAllCaches() {
274
+ invalidateCache();
275
+ embeddingCache.clear();
276
+ embeddingCacheLoaded = false;
277
+ try {
278
+ await fs.unlink(getEmbeddingCachePath());
279
+ }
280
+ catch {
281
+ // 파일 없으면 무시
282
+ }
283
+ }
181
284
  // 인메모리 저장소 (간단한 구현)
182
285
  let vectorStore = [];
183
286
  /**
@@ -1,9 +1,13 @@
1
1
  import { AsyncLocalStorage } from "async_hooks";
2
- import { estimateMessagesTokens } from "../utils/tokens.js";
2
+ import { estimateMessagesTokens, estimateTokens } from "../utils/tokens.js";
3
3
  // 세션 설정
4
4
  const MAX_SESSIONS = 100;
5
5
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24시간
6
- const MAX_HISTORY_TOKENS = 50000; // 시스템 프롬프트 + 응답 여유 남기고
6
+ // 토큰 한도 (개선됨)
7
+ const MAX_HISTORY_TOKENS = 40000; // 히스토리 한도
8
+ const SUMMARY_THRESHOLD_TOKENS = 25000; // 이 이상이면 요약 시작
9
+ const MIN_RECENT_MESSAGES = 6; // 최소 유지할 최근 메시지
10
+ const MAX_PINNED_TOKENS = 5000; // 핀 맥락 최대 토큰
7
11
  // 세션별 상태 저장
8
12
  const sessions = new Map();
9
13
  // AsyncLocalStorage for chatId context
@@ -11,18 +15,24 @@ const chatIdStorage = new AsyncLocalStorage();
11
15
  function getSession(chatId) {
12
16
  // chatId 유효성 검사
13
17
  if (chatId == null || isNaN(chatId)) {
14
- console.warn(`[Session] Invalid chatId: ${chatId}, using fallback session`);
15
- // 임시 세션 반환 (저장하지 않음)
18
+ console.error(`[Session] BUG: Invalid chatId: ${chatId} - history will NOT persist!`);
16
19
  return {
17
20
  history: [],
18
21
  model: "sonnet",
19
22
  lastAccessedAt: Date.now(),
23
+ pinnedContexts: [],
24
+ summaryChunks: [],
20
25
  };
21
26
  }
22
27
  const existing = sessions.get(chatId);
23
28
  const now = Date.now();
24
29
  if (existing) {
25
30
  existing.lastAccessedAt = now;
31
+ // 마이그레이션: 기존 세션에 새 필드 추가
32
+ if (!existing.pinnedContexts)
33
+ existing.pinnedContexts = [];
34
+ if (!existing.summaryChunks)
35
+ existing.summaryChunks = [];
26
36
  return existing;
27
37
  }
28
38
  // 새 세션 생성 전 정리
@@ -31,8 +41,11 @@ function getSession(chatId) {
31
41
  history: [],
32
42
  model: "sonnet",
33
43
  lastAccessedAt: now,
44
+ pinnedContexts: [],
45
+ summaryChunks: [],
34
46
  };
35
47
  sessions.set(chatId, session);
48
+ console.log(`[Session] Created new session for chatId=${chatId}, total sessions=${sessions.size}`);
36
49
  return session;
37
50
  }
38
51
  function cleanupSessions() {
@@ -55,24 +68,233 @@ function cleanupSessions() {
55
68
  }
56
69
  export function getHistory(chatId) {
57
70
  const session = getSession(chatId);
58
- // 참조 반환 (외부 수정 허용 - 의도적)
59
- // 필요시 [...session.history]로 복사본 반환 가능
60
- return session.history ?? [];
71
+ if (!session.history) {
72
+ session.history = [];
73
+ }
74
+ return session.history;
75
+ }
76
+ /**
77
+ * 핀된 맥락 가져오기
78
+ */
79
+ export function getPinnedContexts(chatId) {
80
+ return getSession(chatId).pinnedContexts;
81
+ }
82
+ /**
83
+ * 요약 청크 가져오기
84
+ */
85
+ export function getSummaryChunks(chatId) {
86
+ return getSession(chatId).summaryChunks;
87
+ }
88
+ /**
89
+ * 중요 맥락 핀하기
90
+ */
91
+ export function pinContext(chatId, text, source = "user") {
92
+ const session = getSession(chatId);
93
+ const currentTokens = session.pinnedContexts.reduce((sum, p) => sum + estimateTokens(p.text), 0);
94
+ const newTokens = estimateTokens(text);
95
+ // 토큰 한도 체크
96
+ if (currentTokens + newTokens > MAX_PINNED_TOKENS) {
97
+ // 오래된 자동 핀부터 제거
98
+ while (session.pinnedContexts.length > 0 &&
99
+ currentTokens + newTokens > MAX_PINNED_TOKENS) {
100
+ const autoIndex = session.pinnedContexts.findIndex((p) => p.source === "auto");
101
+ if (autoIndex >= 0) {
102
+ session.pinnedContexts.splice(autoIndex, 1);
103
+ }
104
+ else {
105
+ // 자동 핀 없으면 추가 불가
106
+ return false;
107
+ }
108
+ }
109
+ }
110
+ session.pinnedContexts.push({
111
+ text,
112
+ createdAt: Date.now(),
113
+ source,
114
+ });
115
+ console.log(`[Pin] chatId=${chatId} added pin (${source}): ${text.slice(0, 50)}...`);
116
+ return true;
117
+ }
118
+ /**
119
+ * 핀 제거
120
+ */
121
+ export function unpinContext(chatId, index) {
122
+ const session = getSession(chatId);
123
+ if (index >= 0 && index < session.pinnedContexts.length) {
124
+ session.pinnedContexts.splice(index, 1);
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ /**
130
+ * 모든 핀 제거
131
+ */
132
+ export function clearPins(chatId) {
133
+ getSession(chatId).pinnedContexts = [];
134
+ }
135
+ /**
136
+ * 요약 청크 추가
137
+ */
138
+ export function addSummaryChunk(chatId, chunk) {
139
+ const session = getSession(chatId);
140
+ session.summaryChunks.push(chunk);
141
+ // 오래된 요약은 병합 (최대 3개 유지)
142
+ while (session.summaryChunks.length > 3) {
143
+ const [first, second] = session.summaryChunks.splice(0, 2);
144
+ session.summaryChunks.unshift({
145
+ summary: `${first.summary}\n\n${second.summary}`,
146
+ messageCount: first.messageCount + second.messageCount,
147
+ startTime: first.startTime,
148
+ endTime: second.endTime,
149
+ });
150
+ }
61
151
  }
62
152
  /**
63
- * 히스토리를 토큰 기반으로 트리밍한다.
64
- * 최대 토큰 한도를 초과하면 가장 오래된 메시지부터 제거 (최소 2개는 유지).
153
+ * 개선된 히스토리 트리밍
154
+ *
155
+ * 전략:
156
+ * 1. 최근 N개 메시지는 반드시 유지
157
+ * 2. 토큰이 임계치 초과하면 오래된 메시지 제거 (요약 청크로 변환 가능)
158
+ * 3. 핀된 맥락은 별도로 보존됨 (여기서 처리 안 함)
65
159
  */
66
160
  export function trimHistoryByTokens(history) {
67
- // null/undefined/빈 배열 처리
68
161
  if (!history || history.length === 0) {
69
162
  return;
70
163
  }
71
- while (estimateMessagesTokens(history) > MAX_HISTORY_TOKENS && history.length > 2) {
164
+ const currentTokens = estimateMessagesTokens(history);
165
+ // 한도 이내면 패스
166
+ if (currentTokens <= MAX_HISTORY_TOKENS) {
167
+ return;
168
+ }
169
+ console.log(`[Trim] Starting trim: ${currentTokens} tokens, ${history.length} messages`);
170
+ // 최근 메시지는 반드시 유지
171
+ while (estimateMessagesTokens(history) > MAX_HISTORY_TOKENS && history.length > MIN_RECENT_MESSAGES) {
72
172
  history.shift();
73
173
  }
174
+ const afterTokens = estimateMessagesTokens(history);
175
+ console.log(`[Trim] After trim: ${afterTokens} tokens, ${history.length} messages`);
176
+ }
177
+ /**
178
+ * 스마트 트리밍 - 요약과 함께 수행
179
+ *
180
+ * @param chatId 채팅 ID
181
+ * @param summarizeFn 요약 함수 (외부 주입 - API 호출 필요)
182
+ * @returns 요약이 수행되었는지 여부
183
+ */
184
+ export async function smartTrimHistory(chatId, summarizeFn) {
185
+ const session = getSession(chatId);
186
+ const history = session.history;
187
+ if (!history || history.length === 0) {
188
+ return false;
189
+ }
190
+ const currentTokens = estimateMessagesTokens(history);
191
+ // 요약 임계치 이하면 패스
192
+ if (currentTokens <= SUMMARY_THRESHOLD_TOKENS) {
193
+ return false;
194
+ }
195
+ // 요약 함수가 없으면 기본 트리밍만
196
+ if (!summarizeFn) {
197
+ trimHistoryByTokens(history);
198
+ return false;
199
+ }
200
+ console.log(`[SmartTrim] chatId=${chatId} tokens=${currentTokens}, starting summarization...`);
201
+ // 오래된 메시지들 (최근 6개 제외)
202
+ const toSummarize = history.slice(0, -MIN_RECENT_MESSAGES);
203
+ const toKeep = history.slice(-MIN_RECENT_MESSAGES);
204
+ if (toSummarize.length < 4) {
205
+ // 요약할 게 별로 없으면 기본 트리밍
206
+ trimHistoryByTokens(history);
207
+ return false;
208
+ }
209
+ try {
210
+ const summary = await summarizeFn(toSummarize);
211
+ // 요약 청크 저장
212
+ addSummaryChunk(chatId, {
213
+ summary,
214
+ messageCount: toSummarize.length,
215
+ startTime: Date.now() - (toSummarize.length * 60000), // 대략적인 시간
216
+ endTime: Date.now(),
217
+ });
218
+ // 히스토리 교체: [요약 메시지] + [최근 메시지들]
219
+ history.splice(0, history.length);
220
+ history.push({
221
+ role: "user",
222
+ content: `[이전 대화 요약]\n${summary}`
223
+ });
224
+ history.push({
225
+ role: "assistant",
226
+ content: "네, 이전 대화 내용을 기억하고 있어요."
227
+ });
228
+ history.push(...toKeep);
229
+ const afterTokens = estimateMessagesTokens(history);
230
+ console.log(`[SmartTrim] chatId=${chatId} summarized: ${currentTokens} → ${afterTokens} tokens`);
231
+ return true;
232
+ }
233
+ catch (error) {
234
+ console.error(`[SmartTrim] Failed to summarize:`, error);
235
+ // 실패하면 기본 트리밍으로 폴백
236
+ trimHistoryByTokens(history);
237
+ return false;
238
+ }
239
+ }
240
+ /**
241
+ * 중요 맥락 자동 감지
242
+ *
243
+ * 패턴:
244
+ * - "기억해", "잊지 마", "remember"
245
+ * - 이름, 선호도, 중요 정보 언급
246
+ * - 명시적 핀 요청
247
+ */
248
+ export function detectImportantContext(message) {
249
+ const patterns = [
250
+ /기억해[줘요]?\s*[::]?\s*(.+)/i,
251
+ /잊지\s*마[줘요]?\s*[::]?\s*(.+)/i,
252
+ /remember\s*[::]?\s*(.+)/i,
253
+ /내\s*이름은?\s+(.+?)(?:이야|야|입니다|예요|요)?[.!]?\s*$/i,
254
+ /나는?\s+(.+?)(?:을|를)?\s*(?:좋아해|싫어해|선호해)/i,
255
+ ];
256
+ for (const pattern of patterns) {
257
+ const match = message.match(pattern);
258
+ if (match && match[1]) {
259
+ return match[1].trim();
260
+ }
261
+ }
262
+ return null;
263
+ }
264
+ /**
265
+ * 시스템 프롬프트용 맥락 문자열 생성
266
+ */
267
+ export function buildContextForPrompt(chatId) {
268
+ const session = getSession(chatId);
269
+ const parts = [];
270
+ // 핀된 맥락
271
+ if (session.pinnedContexts.length > 0) {
272
+ parts.push("## 📌 중요 맥락 (사용자가 기억해달라고 한 것들)");
273
+ session.pinnedContexts.forEach((p, i) => {
274
+ parts.push(`${i + 1}. ${p.text}`);
275
+ });
276
+ }
277
+ // 요약 청크 (있으면)
278
+ if (session.summaryChunks.length > 0) {
279
+ parts.push("\n## 📜 이전 대화 요약");
280
+ session.summaryChunks.forEach((chunk) => {
281
+ parts.push(`- ${chunk.summary}`);
282
+ });
283
+ }
284
+ return parts.join("\n");
74
285
  }
75
286
  export function clearHistory(chatId) {
287
+ const session = sessions.get(chatId);
288
+ if (session) {
289
+ session.history = [];
290
+ session.summaryChunks = [];
291
+ // 핀은 유지 (중요 맥락이므로)
292
+ }
293
+ }
294
+ /**
295
+ * 완전 초기화 (핀 포함)
296
+ */
297
+ export function clearSession(chatId) {
76
298
  sessions.delete(chatId);
77
299
  }
78
300
  export function getModel(chatId) {
@@ -81,27 +303,30 @@ export function getModel(chatId) {
81
303
  export function setModel(chatId, modelId) {
82
304
  getSession(chatId).model = modelId;
83
305
  }
84
- /**
85
- * Run a function with chatId context using AsyncLocalStorage.
86
- * All code inside the callback can access the chatId via getCurrentChatId().
87
- */
88
306
  export function runWithChatId(chatId, fn) {
89
307
  return chatIdStorage.run(chatId, fn);
90
308
  }
91
- /**
92
- * Get the current chatId from AsyncLocalStorage context.
93
- * Returns null if called outside of runWithChatId().
94
- */
95
309
  export function getCurrentChatId() {
96
310
  return chatIdStorage.getStore() ?? null;
97
311
  }
98
- // 세션 정리 (수동 호출용)
99
312
  export function cleanupExpiredSessions() {
100
313
  const before = sessions.size;
101
314
  cleanupSessions();
102
315
  return before - sessions.size;
103
316
  }
104
- // 현재 세션 수 조회
105
317
  export function getSessionCount() {
106
318
  return sessions.size;
107
319
  }
320
+ /**
321
+ * 세션 통계 (디버그용)
322
+ */
323
+ export function getSessionStats(chatId) {
324
+ const session = getSession(chatId);
325
+ return {
326
+ historyLength: session.history.length,
327
+ historyTokens: estimateMessagesTokens(session.history),
328
+ pinnedCount: session.pinnedContexts.length,
329
+ pinnedTokens: session.pinnedContexts.reduce((sum, p) => sum + estimateTokens(p.text), 0),
330
+ summaryCount: session.summaryChunks.length,
331
+ };
332
+ }