companionbot 0.12.1 → 0.13.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
@@ -1,4 +1,4 @@
1
- import Anthropic, { APIError } from "@anthropic-ai/sdk";
1
+ import Anthropic from "@anthropic-ai/sdk";
2
2
  import { tools, executeTool } from "../tools/index.js";
3
3
  import { MAX_TOOL_ITERATIONS, TOOL_INPUT_SUMMARY_LENGTH, TOOL_OUTPUT_SUMMARY_LENGTH, } from "../utils/constants.js";
4
4
  import { withRetry, withTimeout, } from "../utils/retry.js";
@@ -214,131 +214,15 @@ export async function chat(messages, systemPrompt, modelId = "sonnet", thinkingL
214
214
  };
215
215
  }
216
216
  /**
217
- * 스마트 채팅 - 가능하면 스트리밍, 도구 필요하면 일반 호출
217
+ * 스마트 채팅 - chat()의 단순 래퍼
218
218
  *
219
- * 전략:
220
- * - 먼저 스트리밍으로 시도
221
- * - 도구 호출이 감지되면 (stop_reason === "tool_use") 기존 chat()으로 폴백
222
- * - 스트리밍은 최종 텍스트 응답에만 사용
223
- *
224
- * 주의: 스트리밍은 재시도하지 않음 (이미 전송된 청크를 되돌릴 수 없음)
225
- * 스트리밍 중 에러 발생 시 적절한 에러 메시지를 반환하거나 예외를 전파함
219
+ * 도구 사용 여부를 별도로 반환하여 호출자가 구분할 수 있게 함
226
220
  */
227
- export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel = "medium", onChunk) {
228
- // 콜백 정규화
229
- const callbacks = typeof onChunk === 'function'
230
- ? { onChunk }
231
- : (onChunk ?? {});
232
- // 스트리밍 콜백이 없으면 그냥 일반 chat 사용
233
- if (!callbacks.onChunk) {
234
- const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
235
- return { text: result.text, usedTools: result.toolsUsed.length > 0, toolsUsed: result.toolsUsed };
236
- }
237
- const client = getClient();
238
- const modelConfig = MODELS[modelId];
239
- // 메시지를 API 형식으로 변환
240
- const apiMessages = messages.map((m) => ({
241
- role: m.role,
242
- content: m.content,
243
- }));
244
- // 입력 토큰 추정
245
- let inputTokens = 0;
246
- if (systemPrompt) {
247
- inputTokens += Math.ceil(systemPrompt.length / 3);
248
- }
249
- for (const msg of apiMessages) {
250
- const content = typeof msg.content === "string"
251
- ? msg.content
252
- : JSON.stringify(msg.content);
253
- inputTokens += Math.ceil(content.length / 3);
254
- }
255
- // 동적 토큰 budget 계산
256
- const { maxTokens, thinkingBudget } = calculateTokenBudgets(modelId, thinkingLevel, inputTokens);
257
- console.log(`[ChatSmart] model=${modelId}, thinking=${thinkingLevel}, input~${inputTokens}, maxTokens=${maxTokens}, budget=${thinkingBudget}`);
258
- // 스트리밍 요청 파라미터
259
- const params = {
260
- model: modelConfig.id,
261
- max_tokens: maxTokens,
262
- messages: apiMessages,
263
- tools: tools,
264
- stream: true,
221
+ export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel = "medium") {
222
+ const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
223
+ return {
224
+ text: result.text,
225
+ usedTools: result.toolsUsed.length > 0,
226
+ toolsUsed: result.toolsUsed
265
227
  };
266
- if (systemPrompt) {
267
- params.system = systemPrompt;
268
- }
269
- // Thinking 활성화 (스트리밍에서도 지원)
270
- if (thinkingBudget > 0) {
271
- params.thinking = {
272
- type: "enabled",
273
- budget_tokens: thinkingBudget,
274
- };
275
- }
276
- let accumulated = "";
277
- let streamingStarted = false;
278
- try {
279
- const stream = client.messages.stream(params);
280
- // 스트리밍 이벤트 처리
281
- stream.on("text", async (text) => {
282
- streamingStarted = true;
283
- accumulated += text;
284
- try {
285
- await callbacks.onChunk(text, accumulated);
286
- }
287
- catch (err) {
288
- // editMessageText 실패 등은 무시하고 계속
289
- console.warn("[Stream] Chunk callback error (ignored):", err);
290
- }
291
- });
292
- // 스트림 완료 대기
293
- const finalMessage = await stream.finalMessage();
294
- const stopReason = finalMessage.stop_reason;
295
- // 도구 호출이 필요한 경우 - 일반 chat으로 폴백
296
- // 주의: chat()은 내부에서 withRetry를 사용하므로 여기서 추가 재시도 불필요
297
- if (stopReason === "tool_use") {
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
- }
310
- const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
311
- return { text: result.text, usedTools: true, toolsUsed: result.toolsUsed };
312
- }
313
- // 성공적으로 스트리밍 완료
314
- return { text: accumulated, usedTools: false, toolsUsed: [] };
315
- }
316
- catch (error) {
317
- // 스트리밍 시작 전 에러 (연결 실패 등) - 재시도 가능
318
- if (!streamingStarted && error instanceof APIError) {
319
- // Rate limit 또는 서버 에러는 withRetry로 재시도
320
- if (error.status === 429 || error.status >= 500) {
321
- console.log(`[Stream] Pre-stream error (${error.status}), retrying with withRetry...`);
322
- return await withRetry(async () => {
323
- // 재시도 시 일반 chat 사용 (스트리밍 대신)
324
- const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
325
- return { text: result.text, usedTools: false, toolsUsed: result.toolsUsed };
326
- });
327
- }
328
- }
329
- // 스트리밍 중 에러 - 재시도 불가 (이미 청크가 전송됨)
330
- if (streamingStarted) {
331
- console.error("[Stream] Error during streaming (cannot retry):", error);
332
- // 이미 일부 텍스트가 전송됐으므로, 에러 메시지를 추가하거나 부분 결과 반환
333
- if (accumulated.length > 0) {
334
- return {
335
- text: accumulated + "\n\n(응답 생성 중 오류 발생)",
336
- usedTools: false,
337
- toolsUsed: []
338
- };
339
- }
340
- }
341
- // 그 외 에러는 전파
342
- throw error;
343
- }
344
228
  }
@@ -70,19 +70,6 @@ export const MEMORY = {
70
70
  // 텔레그램/UI 관련 설정
71
71
  // ============================================
72
72
  export const TELEGRAM = {
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
73
  /** 텔레그램 메시지 최대 길이 */
87
74
  MAX_MESSAGE_LENGTH: 4096,
88
75
  /** 최대 이미지 크기 (바이트) - 10MB */
@@ -91,60 +78,8 @@ export const TELEGRAM = {
91
78
  MAX_URL_FETCH: 3,
92
79
  /** 캘린더 미리보기 이벤트 수 */
93
80
  CALENDAR_PREVIEW_COUNT: 3,
94
- /** 스트리밍 UI 아이콘 */
95
- STREAM_ICONS: {
96
- THINKING: "💭",
97
- TYPING: "▌",
98
- TOOL: "🔧",
99
- DONE: "",
100
- },
101
81
  /** Typing indicator 자동 갱신 간격 (밀리초) - 텔레그램은 5초 후 만료 */
102
82
  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
- },
148
83
  };
149
84
  // ============================================
150
85
  // 보안/토큰 관련 설정
@@ -7,29 +7,6 @@ import { extractUrls, fetchWebContent, formatUrlContent, buildSystemPrompt, } fr
7
7
  import { estimateMessagesTokens } from "../../utils/tokens.js";
8
8
  import { TOKENS, TELEGRAM } from "../../config/constants.js";
9
9
  import { toUserFriendlyError } from "../../utils/retry.js";
10
- /**
11
- * 도구 이름을 친화적인 상태 메시지로 변환
12
- */
13
- function getToolStatusMessage(toolName) {
14
- const messages = TELEGRAM.TOOL_STATUS_MESSAGES;
15
- return messages[toolName] || { icon: "🔧", text: toolName, estimate: "" };
16
- }
17
- /**
18
- * 여러 도구의 상태를 친화적인 메시지로 포맷
19
- */
20
- function formatToolsStatus(toolNames) {
21
- if (toolNames.length === 0)
22
- return "";
23
- if (toolNames.length === 1) {
24
- const status = getToolStatusMessage(toolNames[0]);
25
- const estimateText = status.estimate ? ` (약 ${status.estimate})` : "";
26
- return `${status.icon} ${status.text}...${estimateText}`;
27
- }
28
- // 여러 도구: 아이콘만 모아서 표시 + 첫 번째 도구 텍스트
29
- const icons = toolNames.map(t => getToolStatusMessage(t).icon).join(" ");
30
- const firstStatus = getToolStatusMessage(toolNames[0]);
31
- return `${icons} ${firstStatus.text} 외 ${toolNames.length - 1}개...`;
32
- }
33
10
  /**
34
11
  * Typing indicator를 주기적으로 갱신하는 클래스
35
12
  * 텔레그램은 5초 후 typing 상태가 자동 해제되므로, 긴 작업 중 유지 필요
@@ -134,161 +111,12 @@ function splitLongMessage(text, maxLength = TELEGRAM.MAX_MESSAGE_LENGTH) {
134
111
  return parts;
135
112
  }
136
113
  /**
137
- * 스트리밍 응답 전송 (Telegram 메시지 실시간 업데이트)
138
- *
139
- * 개선사항:
140
- * - 적응형 업데이트 간격 (첫 응답 빠르게, 이후 안정화)
141
- * - 도구 사용 시 친화적인 상태 메시지 표시
142
- * - 긴 응답 자동 분할
143
- * - thinking 상태 표시
144
- * - 진행 단계 표시 (생각 중 → 도구 사용 → 응답 작성)
114
+ * 응답을 전송 ( 응답은 분할)
145
115
  */
146
- async function sendStreamingResponse(ctx, messages, systemPrompt, modelId, thinkingLevel) {
147
- const { STREAM_INTERVAL, STREAM_ICONS, MAX_MESSAGE_LENGTH } = TELEGRAM;
148
- // 1. thinking 표시로 시작 + 단계 안내
149
- const initialText = thinkingLevel !== "off"
150
- ? `${STREAM_ICONS.THINKING} 생각하는 중...`
151
- : `${STREAM_ICONS.THINKING} 응답 준비 중...`;
152
- const placeholder = await ctx.reply(initialText);
153
- const chatId = ctx.chat.id;
154
- const messageId = placeholder.message_id;
155
- let lastUpdate = 0; // 첫 업데이트는 즉시
156
- let updateCount = 0;
157
- let lastText = "";
158
- let currentPhase = "thinking";
159
- let toolStartTime = 0;
160
- let currentToolNames = [];
161
- // 적응형 간격 계산
162
- const getInterval = () => {
163
- if (updateCount === 0)
164
- return STREAM_INTERVAL.FIRST_MS;
165
- if (updateCount <= STREAM_INTERVAL.FAST_COUNT)
166
- return STREAM_INTERVAL.FAST_MS;
167
- return STREAM_INTERVAL.NORMAL_MS;
168
- };
169
- // 도구 실행 시간이 길어지면 추가 피드백
170
- let toolTimeoutId = null;
171
- const startToolTimeout = async () => {
172
- toolTimeoutId = setTimeout(async () => {
173
- if (currentPhase === "tools" && currentToolNames.length > 0) {
174
- const elapsed = Math.round((Date.now() - toolStartTime) / 1000);
175
- const statusMsg = formatToolsStatus(currentToolNames);
176
- try {
177
- await ctx.api.editMessageText(chatId, messageId, `${statusMsg}\n⏳ ${elapsed}초 경과... 조금만 기다려줘!`);
178
- }
179
- catch {
180
- // 무시
181
- }
182
- }
183
- }, 5000); // 5초 후 추가 피드백
184
- };
185
- const clearToolTimeout = () => {
186
- if (toolTimeoutId) {
187
- clearTimeout(toolTimeoutId);
188
- toolTimeoutId = null;
189
- }
190
- };
191
- try {
192
- const streamCallbacks = {
193
- onChunk: async (_chunk, accumulated) => {
194
- // 첫 청크 도착 시 thinking → generating 단계 전환
195
- if (currentPhase === "thinking" && accumulated.length > 0) {
196
- currentPhase = "generating";
197
- }
198
- const now = Date.now();
199
- const interval = getInterval();
200
- // 간격 체크 + 실제 변경 있을 때만 업데이트
201
- if (now - lastUpdate >= interval && accumulated !== lastText) {
202
- // 텔레그램 제한 초과 시 잘라서 표시
203
- const displayText = accumulated.length > MAX_MESSAGE_LENGTH - 10
204
- ? accumulated.slice(0, MAX_MESSAGE_LENGTH - 20) + "\n…(계속)"
205
- : accumulated;
206
- try {
207
- await ctx.api.editMessageText(chatId, messageId, displayText + " " + STREAM_ICONS.TYPING);
208
- lastUpdate = now;
209
- lastText = accumulated;
210
- updateCount++;
211
- }
212
- catch {
213
- // rate limit 등 무시 - 다음 chunk에서 재시도
214
- }
215
- }
216
- },
217
- onToolStart: async (toolNames) => {
218
- currentPhase = "tools";
219
- currentToolNames = toolNames;
220
- toolStartTime = Date.now();
221
- // 친화적인 도구 상태 메시지 생성
222
- const statusMsg = formatToolsStatus(toolNames);
223
- try {
224
- await ctx.api.editMessageText(chatId, messageId, statusMsg);
225
- }
226
- catch {
227
- // 무시
228
- }
229
- // 5초 후 추가 피드백 예약
230
- startToolTimeout();
231
- },
232
- };
233
- const result = await chatSmart(messages, systemPrompt, modelId, thinkingLevel, streamCallbacks);
234
- // 도구 타임아웃 정리
235
- clearToolTimeout();
236
- // 도구를 사용한 경우
237
- if (result.usedTools) {
238
- // 도구 사용 정보를 친화적으로 표시
239
- const toolSummary = result.toolsUsed.map(t => {
240
- const status = getToolStatusMessage(t.name);
241
- return `${status.icon} ${status.text}`;
242
- }).join("\n");
243
- const toolIndicator = `${toolSummary}\n\n`;
244
- const finalText = toolIndicator + result.text;
245
- // 긴 응답 분할 처리
246
- const parts = splitLongMessage(finalText);
247
- try {
248
- // 첫 번째 파트로 placeholder 교체
249
- await ctx.api.editMessageText(chatId, messageId, parts[0]);
250
- // 추가 파트가 있으면 새 메시지로 전송
251
- for (let i = 1; i < parts.length; i++) {
252
- await ctx.reply(parts[i]);
253
- }
254
- }
255
- catch {
256
- // 실패시 placeholder 삭제 후 새로 전송
257
- try {
258
- await ctx.api.deleteMessage(chatId, messageId);
259
- }
260
- catch { }
261
- for (const part of parts) {
262
- await ctx.reply(part);
263
- }
264
- }
265
- return result.text;
266
- }
267
- // 최종 메시지 업데이트 (커서 제거) + 긴 응답 분할
268
- const parts = splitLongMessage(result.text);
269
- try {
270
- await ctx.api.editMessageText(chatId, messageId, parts[0]);
271
- // 추가 파트 전송
272
- for (let i = 1; i < parts.length; i++) {
273
- await ctx.reply(parts[i]);
274
- }
275
- }
276
- catch {
277
- // 이미 동일 텍스트면 에러 발생 가능 - 무시
278
- }
279
- return result.text;
280
- }
281
- catch (error) {
282
- // 도구 타임아웃 정리
283
- clearToolTimeout();
284
- // 에러 발생 시 placeholder 삭제
285
- try {
286
- await ctx.api.deleteMessage(chatId, messageId);
287
- }
288
- catch {
289
- // 삭제 실패해도 계속 진행
290
- }
291
- throw error; // 에러 재전파
116
+ async function sendResponse(ctx, text) {
117
+ const parts = splitLongMessage(text);
118
+ for (const part of parts) {
119
+ await ctx.reply(part);
292
120
  }
293
121
  }
294
122
  /**
@@ -437,6 +265,9 @@ export function registerMessageHandlers(bot) {
437
265
  }
438
266
  // 히스토리에는 간략 버전 저장 + JSONL에 영구 저장
439
267
  addMessage(chatId, "user", messageForHistory);
268
+ // Typing indicator 시작 (긴 작업 동안 유지)
269
+ const typingIndicator = new TypingIndicator(ctx);
270
+ typingIndicator.start();
440
271
  try {
441
272
  const systemPrompt = await buildSystemPrompt(modelId, history);
442
273
  // API 호출용 메시지 준비 (URL 전체 내용 포함)
@@ -452,11 +283,13 @@ export function registerMessageHandlers(bot) {
452
283
  };
453
284
  }
454
285
  }
455
- // 스트리밍 응답 사용 (실시간 업데이트)
456
- const response = await sendStreamingResponse(ctx, messagesForApi, // URL 내용이 포함된 버전
457
- systemPrompt, modelId, thinkingLevel);
286
+ // AI 응답 생성 (typing indicator 동안)
287
+ const result = await chatSmart(messagesForApi, systemPrompt, modelId, thinkingLevel);
288
+ typingIndicator.stop();
289
+ // 응답 전송 (긴 응답은 분할)
290
+ await sendResponse(ctx, result.text);
458
291
  // 메모리 + JSONL에 영구 저장
459
- addMessage(chatId, "assistant", response);
292
+ addMessage(chatId, "assistant", result.text);
460
293
  // 스마트 트리밍 (요약 포함) - autoCompactIfNeeded 대체
461
294
  const summarizeFn = async (messages) => {
462
295
  const summaryPrompt = "다음 대화를 핵심만 3-4문장으로 요약해. 중요한 정보(이름, 선호도, 약속 등)는 반드시 포함:\n\n" +
@@ -473,6 +306,7 @@ export function registerMessageHandlers(bot) {
473
306
  }
474
307
  }
475
308
  catch (error) {
309
+ typingIndicator.stop();
476
310
  recordError();
477
311
  // 에러를 사용자 친화적 메시지로 변환
478
312
  const friendlyError = toUserFriendlyError(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "companionbot",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "AI 친구 텔레그램 봇 - Claude API 기반 개인화된 대화 상대",
5
5
  "keywords": [
6
6
  "telegram",