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.
- package/dist/agents/index.js +1 -1
- package/dist/agents/manager.js +0 -30
- package/dist/ai/claude.js +132 -21
- package/dist/cron/index.js +1 -1
- package/dist/cron/parser.js +20 -153
- package/dist/cron/scheduler.js +34 -7
- package/dist/heartbeat/index.js +1 -5
- package/dist/memory/embeddings.js +94 -0
- package/dist/memory/index.js +4 -0
- package/dist/memory/indexer.js +39 -0
- package/dist/memory/vectorStore.js +194 -0
- package/dist/session/state.js +11 -0
- package/dist/telegram/bot.js +3 -3
- package/dist/telegram/handlers/commands.js +58 -43
- package/dist/telegram/handlers/messages.js +63 -121
- package/dist/telegram/utils/index.js +0 -2
- package/dist/telegram/utils/prompt.js +76 -13
- package/dist/tools/index.js +65 -343
- package/dist/utils/index.js +1 -0
- package/dist/utils/tokens.js +30 -0
- package/dist/workspace/index.js +1 -3
- package/dist/workspace/load.js +7 -251
- package/package.json +2 -1
- package/templates/AGENTS.md +98 -77
- package/templates/MEMORY.md +4 -58
- package/dist/telegram/utils/secrets.js +0 -64
|
@@ -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
|
+
}
|
package/dist/session/state.js
CHANGED
|
@@ -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
|
}
|
package/dist/telegram/bot.js
CHANGED
|
@@ -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분에
|
|
30
|
+
// Rate limiting - 1분에 10개 메시지
|
|
31
31
|
bot.use(limit({
|
|
32
32
|
timeFrame: 60000, // 1분
|
|
33
|
-
limit:
|
|
33
|
+
limit: 10,
|
|
34
34
|
onLimitExceeded: async (ctx) => {
|
|
35
|
-
await ctx.reply("⚠️ 너무 빠르게 메시지를 보내고 있어요.
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
await ctx.reply(
|
|
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,
|
|
5
|
-
// 채팅별 AbortController 관리 (race condition 방지)
|
|
6
|
-
const chatAbortControllers = new Map();
|
|
4
|
+
import { extractUrls, fetchWebContent, buildSystemPrompt, } from "../utils/index.js";
|
|
7
5
|
/**
|
|
8
|
-
*
|
|
6
|
+
* 스트리밍 응답 전송 (Telegram 메시지 실시간 업데이트)
|
|
9
7
|
*/
|
|
10
|
-
function
|
|
11
|
-
//
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
96
|
+
text: caption,
|
|
120
97
|
},
|
|
121
98
|
];
|
|
122
99
|
history.push({ role: "user", content: imageContent });
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
//
|
|
203
|
-
|
|
204
|
-
history.splice(0, history.length - 20);
|
|
205
|
-
}
|
|
206
|
-
await ctx.reply(response);
|
|
157
|
+
// 토큰 기반 히스토리 트리밍
|
|
158
|
+
trimHistoryByTokens(history);
|
|
207
159
|
}
|
|
208
160
|
catch (error) {
|
|
209
|
-
//
|
|
210
|
-
|
|
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";
|