companionbot 0.8.1 → 0.8.2
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
|
@@ -151,6 +151,9 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
151
151
|
* - 먼저 스트리밍으로 시도
|
|
152
152
|
* - 도구 호출이 감지되면 (stop_reason === "tool_use") 기존 chat()으로 폴백
|
|
153
153
|
* - 스트리밍은 최종 텍스트 응답에만 사용
|
|
154
|
+
*
|
|
155
|
+
* 주의: 스트리밍은 재시도하지 않음 (이미 전송된 청크를 되돌릴 수 없음)
|
|
156
|
+
* 스트리밍 중 에러 발생 시 적절한 에러 메시지를 반환하거나 예외를 전파함
|
|
154
157
|
*/
|
|
155
158
|
export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
156
159
|
// 스트리밍 콜백이 없으면 그냥 일반 chat 사용
|
|
@@ -179,13 +182,12 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
179
182
|
// Thinking은 스트리밍에서 복잡해지므로 일단 비활성화
|
|
180
183
|
// (도구 호출 폴백 시 chat()에서 thinking 사용됨)
|
|
181
184
|
let accumulated = "";
|
|
182
|
-
let
|
|
183
|
-
|
|
184
|
-
return await withRetry(async () => {
|
|
185
|
-
accumulated = ""; // 재시도 시 초기화
|
|
185
|
+
let streamingStarted = false;
|
|
186
|
+
try {
|
|
186
187
|
const stream = client.messages.stream(params);
|
|
187
188
|
// 스트리밍 이벤트 처리
|
|
188
189
|
stream.on("text", async (text) => {
|
|
190
|
+
streamingStarted = true;
|
|
189
191
|
accumulated += text;
|
|
190
192
|
try {
|
|
191
193
|
await onChunk(text, accumulated);
|
|
@@ -197,8 +199,9 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
197
199
|
});
|
|
198
200
|
// 스트림 완료 대기
|
|
199
201
|
const finalMessage = await stream.finalMessage();
|
|
200
|
-
stopReason = finalMessage.stop_reason;
|
|
202
|
+
const stopReason = finalMessage.stop_reason;
|
|
201
203
|
// 도구 호출이 필요한 경우 - 일반 chat으로 폴백
|
|
204
|
+
// 주의: chat()은 내부에서 withRetry를 사용하므로 여기서 추가 재시도 불필요
|
|
202
205
|
if (stopReason === "tool_use") {
|
|
203
206
|
console.log("[Stream] Tool use detected, falling back to chat()");
|
|
204
207
|
const text = await chat(messages, systemPrompt, modelId);
|
|
@@ -206,5 +209,32 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
206
209
|
}
|
|
207
210
|
// 성공적으로 스트리밍 완료
|
|
208
211
|
return { text: accumulated, usedTools: false };
|
|
209
|
-
}
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
// 스트리밍 시작 전 에러 (연결 실패 등) - 재시도 가능
|
|
215
|
+
if (!streamingStarted && error instanceof APIError) {
|
|
216
|
+
// Rate limit 또는 서버 에러는 withRetry로 재시도
|
|
217
|
+
if (error.status === 429 || error.status >= 500) {
|
|
218
|
+
console.log(`[Stream] Pre-stream error (${error.status}), retrying with withRetry...`);
|
|
219
|
+
return await withRetry(async () => {
|
|
220
|
+
// 재시도 시 일반 chat 사용 (스트리밍 대신)
|
|
221
|
+
const text = await chat(messages, systemPrompt, modelId);
|
|
222
|
+
return { text, usedTools: false };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// 스트리밍 중 에러 - 재시도 불가 (이미 청크가 전송됨)
|
|
227
|
+
if (streamingStarted) {
|
|
228
|
+
console.error("[Stream] Error during streaming (cannot retry):", error);
|
|
229
|
+
// 이미 일부 텍스트가 전송됐으므로, 에러 메시지를 추가하거나 부분 결과 반환
|
|
230
|
+
if (accumulated.length > 0) {
|
|
231
|
+
return {
|
|
232
|
+
text: accumulated + "\n\n(응답 생성 중 오류 발생)",
|
|
233
|
+
usedTools: false
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// 그 외 에러는 전파
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
210
240
|
}
|
package/dist/session/state.js
CHANGED
|
@@ -3,7 +3,7 @@ import { estimateMessagesTokens } 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 =
|
|
6
|
+
const MAX_HISTORY_TOKENS = 40000; // 시스템 프롬프트(~10k) + 응답(~8k) 여유 남기고
|
|
7
7
|
// 세션별 상태 저장
|
|
8
8
|
const sessions = new Map();
|
|
9
9
|
// AsyncLocalStorage for chatId context
|
|
@@ -11,8 +11,8 @@ const chatIdStorage = new AsyncLocalStorage();
|
|
|
11
11
|
function getSession(chatId) {
|
|
12
12
|
// chatId 유효성 검사
|
|
13
13
|
if (chatId == null || isNaN(chatId)) {
|
|
14
|
-
console.
|
|
15
|
-
// 임시 세션 반환 (저장하지 않음)
|
|
14
|
+
console.error(`[Session] BUG: Invalid chatId: ${chatId} - history will NOT persist!`);
|
|
15
|
+
// 임시 세션 반환 (저장하지 않음) - 이건 버그 상황
|
|
16
16
|
return {
|
|
17
17
|
history: [],
|
|
18
18
|
model: "sonnet",
|
|
@@ -23,6 +23,7 @@ function getSession(chatId) {
|
|
|
23
23
|
const now = Date.now();
|
|
24
24
|
if (existing) {
|
|
25
25
|
existing.lastAccessedAt = now;
|
|
26
|
+
console.log(`[Session] Returning existing session for chatId=${chatId}, history length=${existing.history?.length ?? 0}`);
|
|
26
27
|
return existing;
|
|
27
28
|
}
|
|
28
29
|
// 새 세션 생성 전 정리
|
|
@@ -33,6 +34,7 @@ function getSession(chatId) {
|
|
|
33
34
|
lastAccessedAt: now,
|
|
34
35
|
};
|
|
35
36
|
sessions.set(chatId, session);
|
|
37
|
+
console.log(`[Session] Created new session for chatId=${chatId}, total sessions=${sessions.size}`);
|
|
36
38
|
return session;
|
|
37
39
|
}
|
|
38
40
|
function cleanupSessions() {
|
|
@@ -55,9 +57,12 @@ function cleanupSessions() {
|
|
|
55
57
|
}
|
|
56
58
|
export function getHistory(chatId) {
|
|
57
59
|
const session = getSession(chatId);
|
|
60
|
+
// history가 없으면 초기화하고 세션에 저장
|
|
61
|
+
if (!session.history) {
|
|
62
|
+
session.history = [];
|
|
63
|
+
}
|
|
58
64
|
// 참조 반환 (외부 수정 허용 - 의도적)
|
|
59
|
-
|
|
60
|
-
return session.history ?? [];
|
|
65
|
+
return session.history;
|
|
61
66
|
}
|
|
62
67
|
/**
|
|
63
68
|
* 히스토리를 토큰 기반으로 트리밍한다.
|
|
@@ -141,17 +141,35 @@ export function registerCommands(bot) {
|
|
|
141
141
|
bot.command("compact", async (ctx) => {
|
|
142
142
|
const chatId = ctx.chat.id;
|
|
143
143
|
const history = getHistory(chatId);
|
|
144
|
-
|
|
144
|
+
// 메시지가 1개 이하면 요약 불가
|
|
145
|
+
if (history.length <= 1) {
|
|
145
146
|
await ctx.reply("아직 정리할 대화가 별로 없어!");
|
|
146
147
|
return;
|
|
147
148
|
}
|
|
148
149
|
// 현재 토큰 수 계산
|
|
149
150
|
const currentTokens = estimateMessagesTokens(history);
|
|
151
|
+
// 메시지 개수가 적고 토큰도 적으면 스킵 (5000 토큰 = 약 한글 3000자)
|
|
152
|
+
// 단, 토큰이 많으면 메시지 개수와 관계없이 compact 허용
|
|
153
|
+
if (history.length <= 4 && currentTokens < 5000) {
|
|
154
|
+
await ctx.reply(`현재 ${history.length}개 메시지, ~${currentTokens} 토큰이라 충분히 짧아!`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
150
157
|
await ctx.replyWithChatAction("typing");
|
|
151
158
|
await ctx.reply(`📊 현재: ${history.length}개 메시지, ~${currentTokens} 토큰\n요약 생성 중...`);
|
|
152
159
|
// 요약할 메시지와 유지할 최근 메시지 분리
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
// 메시지가 4개 이하면 (토큰이 많아서 여기 온 경우) 전체 요약 후 마지막만 유지
|
|
161
|
+
let recentMessages;
|
|
162
|
+
let oldMessages;
|
|
163
|
+
if (history.length <= 4) {
|
|
164
|
+
// 토큰이 많아서 compact 진입한 경우: 전체 요약 → 마지막 1개만 유지
|
|
165
|
+
recentMessages = history.slice(-1);
|
|
166
|
+
oldMessages = history.slice(0, -1);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// 일반 경우: 마지막 4개 유지
|
|
170
|
+
recentMessages = history.slice(-4);
|
|
171
|
+
oldMessages = history.slice(0, -4);
|
|
172
|
+
}
|
|
155
173
|
// 요약 생성
|
|
156
174
|
const summary = await generateSummary(oldMessages);
|
|
157
175
|
// 히스토리 교체: 요약 + 최근 4개
|
|
@@ -5,7 +5,7 @@ import { updateLastMessageTime } from "../../heartbeat/index.js";
|
|
|
5
5
|
import { extractUrls, fetchWebContent, buildSystemPrompt, } from "../utils/index.js";
|
|
6
6
|
import { estimateMessagesTokens } from "../../utils/tokens.js";
|
|
7
7
|
const MAX_CONTEXT_TOKENS = 100000; // Claude 컨텍스트
|
|
8
|
-
const COMPACTION_THRESHOLD = 0.
|
|
8
|
+
const COMPACTION_THRESHOLD = 0.35; // 35% (35,000 토큰) - MAX_HISTORY_TOKENS(50k)보다 먼저 트리거되도록
|
|
9
9
|
/**
|
|
10
10
|
* 토큰 사용량이 임계치를 넘으면 자동으로 히스토리 압축
|
|
11
11
|
* 실패해도 메시지 처리에 영향 없도록 에러를 조용히 처리
|
|
@@ -155,16 +155,31 @@ export function registerMessageHandlers(bot) {
|
|
|
155
155
|
await ctx.reply(result);
|
|
156
156
|
}
|
|
157
157
|
catch (innerError) {
|
|
158
|
-
// 에러
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
// 에러 발생해도 사용자 메시지는 보존 (대화 컨텍스트 유지)
|
|
159
|
+
// 에러 응답을 assistant로 기록해서 role 교대 유지
|
|
160
|
+
const errorMsg = innerError instanceof Error ? innerError.message : String(innerError);
|
|
161
|
+
let userErrorMsg;
|
|
162
|
+
if (errorMsg.includes("rate limit") || errorMsg.includes("429")) {
|
|
163
|
+
userErrorMsg = "지금 요청이 많아서 사진을 분석할 수 없어. 잠시 후 다시 보내줄래?";
|
|
164
|
+
}
|
|
165
|
+
else if (errorMsg.includes("timeout")) {
|
|
166
|
+
userErrorMsg = "사진 분석이 너무 오래 걸렸어. 다시 보내줄래?";
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
userErrorMsg = "사진을 분석하다가 문제가 생겼어. 다시 보내줄래?";
|
|
170
|
+
}
|
|
171
|
+
history.push({ role: "assistant", content: `[응답 실패] ${userErrorMsg}` });
|
|
172
|
+
recordError();
|
|
173
|
+
console.error(`[Photo] chatId=${chatId} error:`, errorMsg);
|
|
174
|
+
await ctx.reply(userErrorMsg);
|
|
175
|
+
return;
|
|
161
176
|
}
|
|
162
177
|
}
|
|
163
178
|
catch (error) {
|
|
179
|
+
// 이미지 다운로드 등 history.push() 전 에러는 그냥 응답만
|
|
164
180
|
recordError();
|
|
165
181
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
166
182
|
console.error(`[Photo] chatId=${chatId} error:`, errorMsg);
|
|
167
|
-
// 사용자 친화적 에러 메시지
|
|
168
183
|
if (errorMsg.includes("rate limit") || errorMsg.includes("429")) {
|
|
169
184
|
await ctx.reply("지금 요청이 많아서 사진을 분석할 수 없어. 잠시 후 다시 보내줄래?");
|
|
170
185
|
}
|
|
@@ -222,25 +237,28 @@ export function registerMessageHandlers(bot) {
|
|
|
222
237
|
await autoCompactIfNeeded(ctx, history);
|
|
223
238
|
}
|
|
224
239
|
catch (error) {
|
|
225
|
-
// 에러 시 방금 추가한 사용자 메시지 롤백 (히스토리 오염 방지)
|
|
226
|
-
history.pop();
|
|
227
240
|
recordError();
|
|
228
241
|
// 구체적인 에러 로깅
|
|
229
242
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
230
243
|
console.error(`[Chat] chatId=${chatId} error:`, errorMsg);
|
|
231
|
-
// 사용자
|
|
244
|
+
// 에러 응답을 assistant로 기록 (사용자 메시지 보존 + role 교대 유지)
|
|
245
|
+
// 이렇게 하면 에러 발생해도 대화 컨텍스트 유지됨
|
|
246
|
+
let userErrorMsg;
|
|
232
247
|
if (errorMsg.includes("rate limit") || errorMsg.includes("429")) {
|
|
233
|
-
|
|
248
|
+
userErrorMsg = "지금 요청이 많아서 잠깐 쉬어야 해. 30초 후에 다시 시도해줄래?";
|
|
234
249
|
}
|
|
235
250
|
else if (errorMsg.includes("timeout") || errorMsg.includes("ETIMEDOUT")) {
|
|
236
|
-
|
|
251
|
+
userErrorMsg = "응답이 너무 오래 걸려서 중단됐어. 다시 시도해줄래?";
|
|
237
252
|
}
|
|
238
253
|
else if (errorMsg.includes("context_length") || errorMsg.includes("too many tokens") || errorMsg.includes("maximum context")) {
|
|
239
|
-
|
|
254
|
+
userErrorMsg = "대화가 너무 길어졌어. /compact 로 정리하고 다시 시도해줘!";
|
|
240
255
|
}
|
|
241
256
|
else {
|
|
242
|
-
|
|
257
|
+
userErrorMsg = `문제가 생겼어: ${errorMsg.slice(0, 100)}`;
|
|
243
258
|
}
|
|
259
|
+
// 에러 메시지를 assistant 응답으로 기록 (히스토리 컨텍스트 유지)
|
|
260
|
+
history.push({ role: "assistant", content: `[응답 실패] ${userErrorMsg}` });
|
|
261
|
+
await ctx.reply(userErrorMsg);
|
|
244
262
|
}
|
|
245
263
|
});
|
|
246
264
|
});
|