companionbot 0.5.0 → 0.6.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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 간단한 벡터 저장소 모듈
3
+ * 메모리 파일들을 로드하고 유사도 기반으로 검색합니다.
4
+ */
5
+ import * as fs from "fs/promises";
6
+ import * as path from "path";
7
+ import { getMemoryDirPath, getWorkspaceFilePath } from "../workspace/paths.js";
8
+ import { embed, cosineSimilarity } from "./embeddings.js";
9
+ // 캐시된 청크들 (임베딩 포함)
10
+ let cachedChunks = [];
11
+ let cacheTimestamp = 0;
12
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
13
+ // 로딩 중복 방지용 Promise
14
+ let loadingPromise = null;
15
+ /**
16
+ * 텍스트를 적절한 크기의 청크로 분할합니다.
17
+ */
18
+ function splitIntoChunks(text, source) {
19
+ const chunks = [];
20
+ // ## 헤더로 분할 (메모리 파일 형식)
21
+ const sections = text.split(/(?=^## )/m);
22
+ for (const section of sections) {
23
+ const trimmed = section.trim();
24
+ if (!trimmed || trimmed.length < 20)
25
+ continue;
26
+ // 청크가 너무 길면 추가로 분할
27
+ if (trimmed.length > 500) {
28
+ const lines = trimmed.split("\n");
29
+ let currentChunk = "";
30
+ for (const line of lines) {
31
+ if (currentChunk.length + line.length > 500) {
32
+ if (currentChunk.trim()) {
33
+ chunks.push({ text: currentChunk.trim(), source });
34
+ }
35
+ currentChunk = line;
36
+ }
37
+ else {
38
+ currentChunk += "\n" + line;
39
+ }
40
+ }
41
+ if (currentChunk.trim()) {
42
+ chunks.push({ text: currentChunk.trim(), source });
43
+ }
44
+ }
45
+ else {
46
+ chunks.push({ text: trimmed, source });
47
+ }
48
+ }
49
+ return chunks;
50
+ }
51
+ /**
52
+ * 내부 로드 로직 - 실제 파일 로드 수행
53
+ */
54
+ async function doLoadAllMemoryChunks() {
55
+ const chunks = [];
56
+ // 1. 일별 메모리 파일 (최근 30일)
57
+ const memoryDir = getMemoryDirPath();
58
+ try {
59
+ const files = await fs.readdir(memoryDir);
60
+ const mdFiles = files.filter(f => f.endsWith(".md")).sort().reverse().slice(0, 30);
61
+ for (const file of mdFiles) {
62
+ try {
63
+ const content = await fs.readFile(path.join(memoryDir, file), "utf-8");
64
+ const fileChunks = splitIntoChunks(content, file.replace(".md", ""));
65
+ chunks.push(...fileChunks);
66
+ }
67
+ catch {
68
+ // 파일 읽기 실패 무시
69
+ }
70
+ }
71
+ }
72
+ catch {
73
+ // 디렉토리 없음 무시
74
+ }
75
+ // 2. MEMORY.md (장기 기억)
76
+ try {
77
+ const memoryMdPath = getWorkspaceFilePath("MEMORY.md");
78
+ const content = await fs.readFile(memoryMdPath, "utf-8");
79
+ const memoryChunks = splitIntoChunks(content, "MEMORY");
80
+ chunks.push(...memoryChunks);
81
+ }
82
+ catch {
83
+ // 파일 없음 무시
84
+ }
85
+ return chunks;
86
+ }
87
+ /**
88
+ * 모든 메모리 파일을 로드하고 청크로 분할합니다.
89
+ * 동시 요청 시 중복 로드를 방지합니다.
90
+ */
91
+ export async function loadAllMemoryChunks() {
92
+ const now = Date.now();
93
+ // 캐시가 유효하면 반환
94
+ if (cachedChunks.length > 0 && now - cacheTimestamp < CACHE_TTL_MS) {
95
+ return cachedChunks;
96
+ }
97
+ // 이미 로딩 중이면 해당 Promise 반환 (중복 로드 방지)
98
+ if (loadingPromise) {
99
+ return loadingPromise;
100
+ }
101
+ // 새로 로드
102
+ loadingPromise = doLoadAllMemoryChunks();
103
+ try {
104
+ const chunks = await loadingPromise;
105
+ // 캐시 업데이트 (임베딩은 아직 없음)
106
+ cachedChunks = chunks;
107
+ cacheTimestamp = Date.now();
108
+ return chunks;
109
+ }
110
+ finally {
111
+ loadingPromise = null;
112
+ }
113
+ }
114
+ /**
115
+ * 쿼리 임베딩으로 관련 메모리를 검색합니다.
116
+ * @param queryEmbedding 검색 쿼리의 임베딩 벡터
117
+ * @param topK 반환할 최대 결과 수
118
+ * @param minScore 최소 유사도 점수 (0-1)
119
+ */
120
+ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
121
+ const chunks = await loadAllMemoryChunks();
122
+ if (chunks.length === 0) {
123
+ return [];
124
+ }
125
+ // 각 청크에 대해 임베딩 생성 및 유사도 계산
126
+ const results = [];
127
+ for (const chunk of chunks) {
128
+ try {
129
+ // 캐시된 임베딩이 없으면 생성
130
+ if (!chunk.embedding) {
131
+ chunk.embedding = await embed(chunk.text);
132
+ }
133
+ const score = cosineSimilarity(queryEmbedding, chunk.embedding);
134
+ if (score >= minScore) {
135
+ results.push({
136
+ text: chunk.text,
137
+ source: chunk.source,
138
+ score,
139
+ });
140
+ }
141
+ }
142
+ catch {
143
+ // 임베딩 실패 무시
144
+ }
145
+ }
146
+ // 유사도 점수로 정렬하고 상위 K개 반환
147
+ return results
148
+ .sort((a, b) => b.score - a.score)
149
+ .slice(0, topK);
150
+ }
151
+ /**
152
+ * 캐시를 무효화합니다.
153
+ */
154
+ export function invalidateCache() {
155
+ cachedChunks = [];
156
+ cacheTimestamp = 0;
157
+ loadingPromise = null;
158
+ }
159
+ // 인메모리 저장소 (간단한 구현)
160
+ let vectorStore = [];
161
+ /**
162
+ * 엔트리들을 저장소에 추가/업데이트합니다.
163
+ */
164
+ export async function upsertEntries(entries) {
165
+ for (const entry of entries) {
166
+ const existingIndex = vectorStore.findIndex(e => e.id === entry.id);
167
+ if (existingIndex >= 0) {
168
+ vectorStore[existingIndex] = entry;
169
+ }
170
+ else {
171
+ vectorStore.push(entry);
172
+ }
173
+ }
174
+ // 캐시 무효화
175
+ invalidateCache();
176
+ }
177
+ /**
178
+ * 특정 소스의 모든 엔트리를 삭제합니다.
179
+ */
180
+ export async function deleteBySource(source) {
181
+ const before = vectorStore.length;
182
+ vectorStore = vectorStore.filter(e => e.source !== source);
183
+ const deleted = before - vectorStore.length;
184
+ if (deleted > 0) {
185
+ invalidateCache();
186
+ }
187
+ return deleted;
188
+ }
189
+ /**
190
+ * 저장소의 모든 엔트리를 반환합니다.
191
+ */
192
+ export function getAllEntries() {
193
+ return [...vectorStore];
194
+ }
@@ -1,7 +1,9 @@
1
1
  import { AsyncLocalStorage } from "async_hooks";
2
+ import { estimateMessagesTokens } from "../utils/tokens.js";
2
3
  // 세션 설정
3
4
  const MAX_SESSIONS = 100;
4
5
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24시간
6
+ const MAX_HISTORY_TOKENS = 50000; // 시스템 프롬프트 + 응답 여유 남기고
5
7
  // 세션별 상태 저장
6
8
  const sessions = new Map();
7
9
  // AsyncLocalStorage for chatId context
@@ -44,6 +46,15 @@ function cleanupSessions() {
44
46
  export function getHistory(chatId) {
45
47
  return getSession(chatId).history;
46
48
  }
49
+ /**
50
+ * 히스토리를 토큰 기반으로 트리밍한다.
51
+ * 최대 토큰 한도를 초과하면 가장 오래된 메시지부터 제거 (최소 2개는 유지).
52
+ */
53
+ export function trimHistoryByTokens(history) {
54
+ while (estimateMessagesTokens(history) > MAX_HISTORY_TOKENS && history.length > 2) {
55
+ history.shift();
56
+ }
57
+ }
47
58
  export function clearHistory(chatId) {
48
59
  sessions.delete(chatId);
49
60
  }
@@ -27,12 +27,12 @@ export function createBot(token) {
27
27
  // Cron 시스템 초기화
28
28
  setCronBot(bot);
29
29
  restoreCronJobs().catch((err) => console.error("Failed to restore cron jobs:", err));
30
- // Rate limiting - 1분에 20개 메시지
30
+ // Rate limiting - 1분에 10개 메시지
31
31
  bot.use(limit({
32
32
  timeFrame: 60000, // 1분
33
- limit: 20,
33
+ limit: 10,
34
34
  onLimitExceeded: async (ctx) => {
35
- await ctx.reply("⚠️ 너무 빠르게 메시지를 보내고 있어요. 30초 후 다시 시도해주세요.");
35
+ await ctx.reply("⚠️ 너무 빠르게 메시지를 보내고 있어요. 잠시 후 다시 시도해주세요.");
36
36
  },
37
37
  }));
38
38
  // 에러 핸들링
@@ -1,6 +1,32 @@
1
- import { InlineKeyboard } from "grammy";
2
1
  import { randomBytes } from "crypto";
3
2
  import { chat, MODELS } from "../../ai/claude.js";
3
+ import { estimateMessagesTokens } from "../../utils/tokens.js";
4
+ // 대화 요약 생성 함수
5
+ async function generateSummary(messages) {
6
+ const conversationText = messages.map(m => {
7
+ const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
8
+ return `${m.role === "user" ? "사용자" : "AI"}: ${content}`;
9
+ }).join("\n");
10
+ const summaryPrompt = [
11
+ {
12
+ role: "user",
13
+ content: `다음 대화를 핵심만 담아 간결하게 요약해줘. 중요한 결정사항, 사용자 정보, 맥락만 포함하고 3-5문장 이내로:
14
+
15
+ ${conversationText}
16
+
17
+ 요약:`
18
+ }
19
+ ];
20
+ try {
21
+ // haiku로 빠르게 요약 생성
22
+ const summary = await chat(summaryPrompt, undefined, "haiku");
23
+ return summary;
24
+ }
25
+ catch (error) {
26
+ console.error("Summary generation error:", error);
27
+ return "이전 대화 내용 (요약 생성 실패)";
28
+ }
29
+ }
4
30
  // Reset 토큰 관리 (1분 만료)
5
31
  const resetTokens = new Map();
6
32
  function generateResetToken(chatId) {
@@ -30,6 +56,7 @@ import { isCalendarConfigured, hasCredentials, setCredentials, getAuthUrl, start
30
56
  import { setBriefingConfig, getBriefingConfig, disableBriefing, } from "../../briefing/index.js";
31
57
  import { setHeartbeatConfig, getHeartbeatConfig, disableHeartbeat, } from "../../heartbeat/index.js";
32
58
  import { getWorkspace, invalidateWorkspaceCache, buildSystemPrompt, extractName, } from "../utils/index.js";
59
+ import { ensureDefaultCronJobs } from "../../cron/scheduler.js";
33
60
  export function registerCommands(bot) {
34
61
  // /start 명령어
35
62
  bot.command("start", async (ctx) => {
@@ -68,6 +95,8 @@ export function registerCommands(bot) {
68
95
  // 일반 모드
69
96
  const workspace = await getWorkspace();
70
97
  const name = extractName(workspace.identity) || "CompanionBot";
98
+ // 기본 cron jobs 설정 확인
99
+ await ensureDefaultCronJobs(chatId);
71
100
  await ctx.reply(`안녕! ${name}이야.\n\n` +
72
101
  `명령어:\n` +
73
102
  `/clear - 대화 초기화\n` +
@@ -75,48 +104,16 @@ export function registerCommands(bot) {
75
104
  `/reset - 페르소나 리셋`);
76
105
  }
77
106
  });
78
- // /reset 명령어 - 페르소나 리셋 (인라인 버튼 + 토큰 기반)
107
+ // /reset 명령어 - 페르소나 리셋 (토큰 기반)
79
108
  bot.command("reset", async (ctx) => {
80
109
  const chatId = ctx.chat.id;
81
110
  const token = generateResetToken(chatId);
82
- const keyboard = new InlineKeyboard()
83
- .text(" 예, 리셋합니다", `reset_confirm_${token}`)
84
- .text("❌ 취소", "reset_cancel");
85
- await ctx.reply("⚠️ 정말 페르소나를 리셋할까요?\n\n" +
86
- "모든 설정이 초기화되고 온보딩을 다시 진행합니다.\n" +
87
- "(1분 후 버튼 만료)", { reply_markup: keyboard });
88
- });
89
- // Reset 확인 버튼 콜백
90
- bot.callbackQuery(/^reset_confirm_([a-f0-9]+)$/, async (ctx) => {
91
- const chatId = ctx.chat.id;
92
- const token = ctx.match[1];
93
- if (!validateResetToken(chatId, token)) {
94
- await ctx.answerCallbackQuery({ text: "❌ 만료된 요청입니다. /reset 으로 다시 시도하세요." });
95
- await ctx.editMessageText("❌ 만료된 요청입니다.\n/reset 으로 다시 시도하세요.");
96
- return;
97
- }
98
- const { initWorkspace } = await import("../../workspace/index.js");
99
- const { rm } = await import("fs/promises");
100
- try {
101
- await ctx.answerCallbackQuery({ text: "리셋 중..." });
102
- await rm(getWorkspacePath(), { recursive: true, force: true });
103
- await initWorkspace();
104
- invalidateWorkspaceCache();
105
- clearHistory(chatId);
106
- await ctx.editMessageText("✓ 페르소나가 리셋되었습니다.\n\n" +
107
- "/start 를 눌러 온보딩을 시작하세요.");
108
- }
109
- catch (error) {
110
- console.error("Reset error:", error);
111
- await ctx.editMessageText("❌ 리셋 중 오류가 발생했습니다.");
112
- }
113
- });
114
- // Reset 취소 버튼 콜백
115
- bot.callbackQuery("reset_cancel", async (ctx) => {
116
- await ctx.answerCallbackQuery({ text: "취소됨" });
117
- await ctx.editMessageText("✓ 리셋이 취소되었습니다.");
111
+ await ctx.reply("⚠️ 정말 페르소나를 리셋할까요?\n" +
112
+ "모든 설정이 초기화되고 온보딩을 다시 진행합니다.\n\n" +
113
+ `확인하려면 /confirm_reset_${token} 을 입력하세요.\n` +
114
+ "(1분 만료)");
118
115
  });
119
- // /confirm_reset_<token> 패턴 매칭 (레거시 - 텍스트 입력 지원)
116
+ // /confirm_reset_<token> 패턴 매칭
120
117
  bot.hears(/^\/confirm_reset_([a-f0-9]+)$/, async (ctx) => {
121
118
  const chatId = ctx.chat.id;
122
119
  const token = ctx.match[1];
@@ -147,10 +144,28 @@ export function registerCommands(bot) {
147
144
  await ctx.reply("아직 정리할 대화가 별로 없어!");
148
145
  return;
149
146
  }
150
- // 최근 4개만 남기고 정리
151
- const removed = history.length - 4;
152
- history.splice(0, removed);
153
- await ctx.reply(`대화 정리 완료! ${removed}개 메시지 압축했어.`);
147
+ // 현재 토큰 계산
148
+ const currentTokens = estimateMessagesTokens(history);
149
+ await ctx.replyWithChatAction("typing");
150
+ await ctx.reply(`📊 현재: ${history.length}개 메시지, ~${currentTokens} 토큰\n요약 생성 중...`);
151
+ // 요약할 메시지와 유지할 최근 메시지 분리
152
+ const recentMessages = history.slice(-4);
153
+ const oldMessages = history.slice(0, -4);
154
+ // 요약 생성
155
+ const summary = await generateSummary(oldMessages);
156
+ // 히스토리 교체: 요약 + 최근 4개
157
+ history.splice(0, history.length);
158
+ history.push({
159
+ role: "user",
160
+ content: `[이전 대화 요약]\n${summary}`
161
+ });
162
+ history.push(...recentMessages);
163
+ // 새 토큰 수 계산
164
+ const newTokens = estimateMessagesTokens(history);
165
+ const savedPercent = Math.round((1 - newTokens / currentTokens) * 100);
166
+ await ctx.reply(`✨ 대화 정리 완료!\n\n` +
167
+ `📉 ${currentTokens} → ${newTokens} 토큰\n` +
168
+ `💾 약 ${savedPercent}% 절약 (${oldMessages.length}개 → 요약 1개)`);
154
169
  });
155
170
  // /memory 명령어 - 최근 기억 보기
156
171
  bot.command("memory", async (ctx) => {
@@ -1,69 +1,53 @@
1
- import { chat } from "../../ai/claude.js";
2
- import { getHistory, getModel, runWithChatId, } from "../../session/state.js";
1
+ import { chat, chatSmart } from "../../ai/claude.js";
2
+ import { getHistory, getModel, runWithChatId, trimHistoryByTokens, } from "../../session/state.js";
3
3
  import { updateLastMessageTime } from "../../heartbeat/index.js";
4
- import { extractUrls, fetchWebContent, buildSystemPrompt, detectSecrets, } from "../utils/index.js";
5
- // 채팅별 AbortController 관리 (race condition 방지)
6
- const chatAbortControllers = new Map();
4
+ import { extractUrls, fetchWebContent, buildSystemPrompt, } from "../utils/index.js";
7
5
  /**
8
- * 이전 요청을 취소하고 AbortController 생성
6
+ * 스트리밍 응답 전송 (Telegram 메시지 실시간 업데이트)
9
7
  */
10
- function getNewAbortController(chatId) {
11
- // 이전 요청 취소
12
- const previous = chatAbortControllers.get(chatId);
13
- if (previous) {
14
- previous.abort();
15
- }
16
- // controller 생성
17
- const controller = new AbortController();
18
- chatAbortControllers.set(chatId, controller);
19
- return controller;
20
- }
21
- /**
22
- * 완료된 요청의 controller 정리
23
- */
24
- function cleanupAbortController(chatId, controller) {
25
- // 현재 저장된 controller와 같은 경우에만 삭제 (새 요청이 없을 때)
26
- if (chatAbortControllers.get(chatId) === controller) {
27
- chatAbortControllers.delete(chatId);
28
- }
29
- }
30
- /**
31
- * 모든 진행 중인 요청 취소 (shutdown 시 사용)
32
- */
33
- export function abortAllChatRequests() {
34
- for (const [chatId, controller] of chatAbortControllers) {
35
- controller.abort();
36
- }
37
- chatAbortControllers.clear();
38
- }
39
- /**
40
- * API 키가 포함된 메시지를 처리합니다.
41
- * 메시지를 삭제하고 사용자에게 경고합니다.
42
- * @returns true if secret was detected and handled
43
- */
44
- async function handleSecretDetection(ctx, message) {
45
- const result = detectSecrets(message);
46
- if (!result.detected) {
47
- return false;
8
+ async function sendStreamingResponse(ctx, messages, systemPrompt, modelId) {
9
+ // 1. 먼저 "..." 플레이스홀더 메시지 전송
10
+ const placeholder = await ctx.reply("...");
11
+ const chatId = ctx.chat.id;
12
+ const messageId = placeholder.message_id;
13
+ let lastUpdate = Date.now();
14
+ const UPDATE_INTERVAL = 500; // 0.5초마다 업데이트 (Telegram rate limit 고려)
15
+ let lastText = "";
16
+ const result = await chatSmart(messages, systemPrompt, modelId, async (_chunk, accumulated) => {
17
+ const now = Date.now();
18
+ // 0.5초마다 또는 충분히 변경되었을 때 업데이트
19
+ if (now - lastUpdate > UPDATE_INTERVAL && accumulated !== lastText) {
20
+ try {
21
+ await ctx.api.editMessageText(chatId, messageId, accumulated + " ▌");
22
+ lastUpdate = now;
23
+ lastText = accumulated;
24
+ }
25
+ catch {
26
+ // rate limit 등 무시
27
+ }
28
+ }
29
+ });
30
+ // 도구를 사용한 경우 스트리밍이 안됐으므로 새 응답 전송
31
+ if (result.usedTools) {
32
+ // placeholder 메시지를 최종 결과로 교체
33
+ try {
34
+ await ctx.api.editMessageText(chatId, messageId, result.text);
35
+ }
36
+ catch {
37
+ // 실패시 새 메시지로 전송
38
+ await ctx.api.deleteMessage(chatId, messageId);
39
+ await ctx.reply(result.text);
40
+ }
41
+ return result.text;
48
42
  }
49
- // 원본 메시지 삭제 시도
43
+ // 최종 메시지 업데이트 (커서 제거)
50
44
  try {
51
- if (ctx.message?.message_id) {
52
- await ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id);
53
- }
45
+ await ctx.api.editMessageText(chatId, messageId, result.text);
54
46
  }
55
- catch (error) {
56
- // 삭제 실패해도 계속 진행 (권한 없을 있음)
57
- console.warn("Failed to delete message with secret:", error);
47
+ catch {
48
+ // 이미 동일 텍스트면 에러 발생 가능 - 무시
58
49
  }
59
- // 경고 메시지 전송
60
- const typeList = result.types.join(", ");
61
- await ctx.reply(`⚠️ **API 키 감지됨!**\n\n` +
62
- `방금 보낸 메시지에서 ${typeList} 키가 감지되어 삭제했어.\n\n` +
63
- `🔐 API 키는 채팅에 절대 입력하면 안 돼!\n` +
64
- `CLI에서 \`companionbot config\` 명령어로 안전하게 설정해줘.`, { parse_mode: "Markdown" });
65
- console.log(`[Security] Blocked API key exposure: ${typeList}`);
66
- return true;
50
+ return result.text;
67
51
  }
68
52
  /**
69
53
  * 메시지 핸들러들을 봇에 등록합니다.
@@ -72,13 +56,6 @@ export function registerMessageHandlers(bot) {
72
56
  // 사진 메시지 처리
73
57
  bot.on("message:photo", async (ctx) => {
74
58
  const chatId = ctx.chat.id;
75
- const caption = ctx.message.caption || "";
76
- // 캡션에서 API 키 감지
77
- if (caption && await handleSecretDetection(ctx, caption)) {
78
- return; // 메시지 삭제됨, 처리 중단
79
- }
80
- // 이전 요청 취소하고 새 controller 생성 (race condition 방지)
81
- const controller = getNewAbortController(chatId);
82
59
  await runWithChatId(chatId, async () => {
83
60
  const history = getHistory(chatId);
84
61
  const modelId = getModel(chatId);
@@ -103,7 +80,7 @@ export function registerMessageHandlers(bot) {
103
80
  const buffer = await response.arrayBuffer();
104
81
  const base64 = Buffer.from(buffer).toString("base64");
105
82
  // 캡션이 있으면 사용, 없으면 기본 질문
106
- const photoCaption = caption || "이 사진에 뭐가 있어?";
83
+ const caption = ctx.message.caption || "이 사진에 뭐가 있어?";
107
84
  // 이미지와 텍스트를 함께 전송
108
85
  const imageContent = [
109
86
  {
@@ -116,40 +93,28 @@ export function registerMessageHandlers(bot) {
116
93
  },
117
94
  {
118
95
  type: "text",
119
- text: photoCaption,
96
+ text: caption,
120
97
  },
121
98
  ];
122
99
  history.push({ role: "user", content: imageContent });
123
- const systemPrompt = await buildSystemPrompt(modelId);
124
- const result = await chat(history, systemPrompt, modelId, { signal: controller.signal });
125
- // abort된 경우 히스토리 롤백
126
- if (controller.signal.aborted) {
127
- history.pop();
128
- return;
100
+ try {
101
+ const systemPrompt = await buildSystemPrompt(modelId, history);
102
+ const result = await chat(history, systemPrompt, modelId);
103
+ history.push({ role: "assistant", content: result });
104
+ // 토큰 기반 히스토리 트리밍
105
+ trimHistoryByTokens(history);
106
+ await ctx.reply(result);
129
107
  }
130
- history.push({ role: "assistant", content: result });
131
- // 히스토리 제한
132
- if (history.length > 20) {
133
- history.splice(0, history.length - 20);
108
+ catch (innerError) {
109
+ // 에러 시 방금 추가한 사용자 메시지 롤백 (히스토리 오염 방지)
110
+ history.pop();
111
+ throw innerError;
134
112
  }
135
- await ctx.reply(result);
136
113
  }
137
114
  catch (error) {
138
- // abort로 인한 에러는 무시
139
- if (controller.signal.aborted) {
140
- console.log(`[Photo] Request aborted for chat ${chatId}`);
141
- // 유저 메시지 롤백 (이미 추가된 경우)
142
- if (history.length > 0 && history[history.length - 1].role === "user") {
143
- history.pop();
144
- }
145
- return;
146
- }
147
115
  console.error("Photo error:", error);
148
116
  await ctx.reply("사진 분석 중 오류가 발생했어.");
149
117
  }
150
- finally {
151
- cleanupAbortController(chatId, controller);
152
- }
153
118
  });
154
119
  });
155
120
  // 일반 메시지 처리
@@ -159,12 +124,6 @@ export function registerMessageHandlers(bot) {
159
124
  // 빈 메시지 무시
160
125
  if (!userMessage.trim())
161
126
  return;
162
- // API 키 감지 - 히스토리에 저장하지 않고 메시지 삭제
163
- if (await handleSecretDetection(ctx, userMessage)) {
164
- return; // 메시지 삭제됨, 처리 중단
165
- }
166
- // 이전 요청 취소하고 새 controller 생성 (race condition 방지)
167
- const controller = getNewAbortController(chatId);
168
127
  await runWithChatId(chatId, async () => {
169
128
  // Heartbeat 마지막 대화 시간 업데이트
170
129
  updateLastMessageTime(chatId);
@@ -191,36 +150,19 @@ export function registerMessageHandlers(bot) {
191
150
  // 사용자 메시지 추가 (URL 내용 포함)
192
151
  history.push({ role: "user", content: enrichedMessage });
193
152
  try {
194
- const systemPrompt = await buildSystemPrompt(modelId);
195
- const response = await chat(history, systemPrompt, modelId, { signal: controller.signal });
196
- // abort된 경우 히스토리 롤백
197
- if (controller.signal.aborted) {
198
- history.pop();
199
- return;
200
- }
153
+ const systemPrompt = await buildSystemPrompt(modelId, history);
154
+ // 스트리밍 응답 사용 (실시간 업데이트)
155
+ const response = await sendStreamingResponse(ctx, history, systemPrompt, modelId);
201
156
  history.push({ role: "assistant", content: response });
202
- // 히스토리 제한 (최근 20개 메시지만 유지)
203
- if (history.length > 20) {
204
- history.splice(0, history.length - 20);
205
- }
206
- await ctx.reply(response);
157
+ // 토큰 기반 히스토리 트리밍
158
+ trimHistoryByTokens(history);
207
159
  }
208
160
  catch (error) {
209
- // abort로 인한 에러는 무시
210
- if (controller.signal.aborted) {
211
- console.log(`[Chat] Request aborted for chat ${chatId}`);
212
- // 유저 메시지 롤백 (이미 추가된 경우)
213
- if (history.length > 0 && history[history.length - 1].role === "user") {
214
- history.pop();
215
- }
216
- return;
217
- }
161
+ // 에러 방금 추가한 사용자 메시지 롤백 (히스토리 오염 방지)
162
+ history.pop();
218
163
  console.error("Chat error:", error);
219
164
  await ctx.reply("뭔가 잘못됐어. 다시 시도해줄래?");
220
165
  }
221
- finally {
222
- cleanupAbortController(chatId, controller);
223
- }
224
166
  });
225
167
  });
226
168
  }
@@ -4,5 +4,3 @@ export { extractUrls, fetchWebContent, isSafeUrl } from "./url.js";
4
4
  export { buildSystemPrompt, extractName } from "./prompt.js";
5
5
  // Cache utilities
6
6
  export { getWorkspace, invalidateWorkspaceCache } from "./cache.js";
7
- // Secret detection utilities
8
- export { detectSecrets, containsSecret } from "./secrets.js";