companionbot 0.8.0 → 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 stopReason = null;
183
- // 스트리밍에 withRetry 적용 - 실패 시 자동 재시도
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
  }
@@ -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 = 50000; // 시스템 프롬프트 + 응답 여유 남기고
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.warn(`[Session] Invalid chatId: ${chatId}, using fallback session`);
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
- // 필요시 [...session.history]로 복사본 반환 가능
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
- if (history.length <= 4) {
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
- const recentMessages = history.slice(-4);
154
- const oldMessages = history.slice(0, -4);
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.6; // 60%
8
+ const COMPACTION_THRESHOLD = 0.35; // 35% (35,000 토큰) - MAX_HISTORY_TOKENS(50k)보다 먼저 트리거되도록
9
9
  /**
10
10
  * 토큰 사용량이 임계치를 넘으면 자동으로 히스토리 압축
11
11
  * 실패해도 메시지 처리에 영향 없도록 에러를 조용히 처리
@@ -49,41 +49,53 @@ async function sendStreamingResponse(ctx, messages, systemPrompt, modelId) {
49
49
  let lastUpdate = Date.now();
50
50
  const UPDATE_INTERVAL = 500; // 0.5초마다 업데이트 (Telegram rate limit 고려)
51
51
  let lastText = "";
52
- const result = await chatSmart(messages, systemPrompt, modelId, async (_chunk, accumulated) => {
53
- const now = Date.now();
54
- // 0.5초마다 또는 충분히 변경되었을 때 업데이트
55
- if (now - lastUpdate > UPDATE_INTERVAL && accumulated !== lastText) {
52
+ try {
53
+ const result = await chatSmart(messages, systemPrompt, modelId, async (_chunk, accumulated) => {
54
+ const now = Date.now();
55
+ // 0.5초마다 또는 충분히 변경되었을 업데이트
56
+ if (now - lastUpdate > UPDATE_INTERVAL && accumulated !== lastText) {
57
+ try {
58
+ await ctx.api.editMessageText(chatId, messageId, accumulated + " ▌");
59
+ lastUpdate = now;
60
+ lastText = accumulated;
61
+ }
62
+ catch {
63
+ // rate limit 등 무시
64
+ }
65
+ }
66
+ });
67
+ // 도구를 사용한 경우 스트리밍이 안됐으므로 새 응답 전송
68
+ if (result.usedTools) {
69
+ // placeholder 메시지를 최종 결과로 교체
56
70
  try {
57
- await ctx.api.editMessageText(chatId, messageId, accumulated + " ▌");
58
- lastUpdate = now;
59
- lastText = accumulated;
71
+ await ctx.api.editMessageText(chatId, messageId, result.text);
60
72
  }
61
73
  catch {
62
- // rate limit 무시
74
+ // 실패시 메시지로 전송
75
+ await ctx.api.deleteMessage(chatId, messageId);
76
+ await ctx.reply(result.text);
63
77
  }
78
+ return result.text;
64
79
  }
65
- });
66
- // 도구를 사용한 경우 스트리밍이 안됐으므로 새 응답 전송
67
- if (result.usedTools) {
68
- // placeholder 메시지를 최종 결과로 교체
80
+ // 최종 메시지 업데이트 (커서 제거)
69
81
  try {
70
82
  await ctx.api.editMessageText(chatId, messageId, result.text);
71
83
  }
72
84
  catch {
73
- // 실패시 메시지로 전송
74
- await ctx.api.deleteMessage(chatId, messageId);
75
- await ctx.reply(result.text);
85
+ // 이미 동일 텍스트면 에러 발생 가능 - 무시
76
86
  }
77
87
  return result.text;
78
88
  }
79
- // 최종 메시지 업데이트 (커서 제거)
80
- try {
81
- await ctx.api.editMessageText(chatId, messageId, result.text);
82
- }
83
- catch {
84
- // 이미 동일 텍스트면 에러 발생 가능 - 무시
89
+ catch (error) {
90
+ // 에러 발생 시 placeholder 삭제
91
+ try {
92
+ await ctx.api.deleteMessage(chatId, messageId);
93
+ }
94
+ catch {
95
+ // 삭제 실패해도 계속 진행
96
+ }
97
+ throw error; // 에러 재전파
85
98
  }
86
- return result.text;
87
99
  }
88
100
  /**
89
101
  * 메시지 핸들러들을 봇에 등록합니다.
@@ -143,16 +155,31 @@ export function registerMessageHandlers(bot) {
143
155
  await ctx.reply(result);
144
156
  }
145
157
  catch (innerError) {
146
- // 에러 방금 추가한 사용자 메시지 롤백 (히스토리 오염 방지)
147
- history.pop();
148
- throw innerError;
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;
149
176
  }
150
177
  }
151
178
  catch (error) {
179
+ // 이미지 다운로드 등 history.push() 전 에러는 그냥 응답만
152
180
  recordError();
153
181
  const errorMsg = error instanceof Error ? error.message : String(error);
154
182
  console.error(`[Photo] chatId=${chatId} error:`, errorMsg);
155
- // 사용자 친화적 에러 메시지
156
183
  if (errorMsg.includes("rate limit") || errorMsg.includes("429")) {
157
184
  await ctx.reply("지금 요청이 많아서 사진을 분석할 수 없어. 잠시 후 다시 보내줄래?");
158
185
  }
@@ -210,25 +237,28 @@ export function registerMessageHandlers(bot) {
210
237
  await autoCompactIfNeeded(ctx, history);
211
238
  }
212
239
  catch (error) {
213
- // 에러 시 방금 추가한 사용자 메시지 롤백 (히스토리 오염 방지)
214
- history.pop();
215
240
  recordError();
216
241
  // 구체적인 에러 로깅
217
242
  const errorMsg = error instanceof Error ? error.message : String(error);
218
243
  console.error(`[Chat] chatId=${chatId} error:`, errorMsg);
219
- // 사용자 친화적 에러 메시지
244
+ // 에러 응답을 assistant로 기록 (사용자 메시지 보존 + role 교대 유지)
245
+ // 이렇게 하면 에러 발생해도 대화 컨텍스트 유지됨
246
+ let userErrorMsg;
220
247
  if (errorMsg.includes("rate limit") || errorMsg.includes("429")) {
221
- await ctx.reply("지금 요청이 많아서 잠깐 쉬어야 해. 30초 후에 다시 시도해줄래?");
248
+ userErrorMsg = "지금 요청이 많아서 잠깐 쉬어야 해. 30초 후에 다시 시도해줄래?";
222
249
  }
223
250
  else if (errorMsg.includes("timeout") || errorMsg.includes("ETIMEDOUT")) {
224
- await ctx.reply("응답이 너무 오래 걸려서 중단됐어. 다시 시도해줄래?");
251
+ userErrorMsg = "응답이 너무 오래 걸려서 중단됐어. 다시 시도해줄래?";
225
252
  }
226
- else if (errorMsg.includes("context") || errorMsg.includes("token")) {
227
- await ctx.reply("대화가 너무 길어졌어. /compact 로 정리하고 다시 시도해줘!");
253
+ else if (errorMsg.includes("context_length") || errorMsg.includes("too many tokens") || errorMsg.includes("maximum context")) {
254
+ userErrorMsg = "대화가 너무 길어졌어. /compact 로 정리하고 다시 시도해줘!";
228
255
  }
229
256
  else {
230
- await ctx.reply("메시지 처리 문제가 생겼어. 다시 시도해줄래?");
257
+ userErrorMsg = `문제가 생겼어: ${errorMsg.slice(0, 100)}`;
231
258
  }
259
+ // 에러 메시지를 assistant 응답으로 기록 (히스토리 컨텍스트 유지)
260
+ history.push({ role: "assistant", content: `[응답 실패] ${userErrorMsg}` });
261
+ await ctx.reply(userErrorMsg);
232
262
  }
233
263
  });
234
264
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "companionbot",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "AI 친구 텔레그램 봇 - Claude API 기반 개인화된 대화 상대",
5
5
  "keywords": [
6
6
  "telegram",