companionbot 0.8.1 → 0.9.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 +54 -13
- package/dist/cron/scheduler.js +2 -2
- package/dist/heartbeat/index.js +3 -3
- package/dist/memory/vectorStore.js +109 -6
- package/dist/session/state.js +246 -21
- package/dist/telegram/handlers/commands.js +114 -9
- package/dist/telegram/handlers/messages.js +94 -34
- package/dist/telegram/utils/index.js +1 -1
- package/dist/telegram/utils/prompt.js +221 -65
- package/dist/telegram/utils/url.js +34 -1
- package/dist/tools/index.js +21 -3
- package/dist/workspace/load.js +112 -8
- package/package.json +1 -1
- package/templates/AGENTS.md +49 -14
package/dist/ai/claude.js
CHANGED
|
@@ -75,6 +75,7 @@ export const MODELS = {
|
|
|
75
75
|
export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
76
76
|
const client = getClient();
|
|
77
77
|
const modelConfig = MODELS[modelId];
|
|
78
|
+
const toolsUsed = [];
|
|
78
79
|
// 메시지를 API 형식으로 변환
|
|
79
80
|
const apiMessages = messages.map((m) => ({
|
|
80
81
|
role: m.role,
|
|
@@ -122,6 +123,12 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
122
123
|
tool_use_id: toolUse.id,
|
|
123
124
|
content: truncatedResult,
|
|
124
125
|
});
|
|
126
|
+
// 도구 사용 기록 (히스토리 참조용)
|
|
127
|
+
toolsUsed.push({
|
|
128
|
+
name: toolUse.name,
|
|
129
|
+
input: JSON.stringify(toolUse.input).slice(0, 200),
|
|
130
|
+
output: truncatedResult.slice(0, 500),
|
|
131
|
+
});
|
|
125
132
|
}
|
|
126
133
|
// 어시스턴트 메시지와 도구 결과 추가
|
|
127
134
|
apiMessages.push({
|
|
@@ -138,11 +145,14 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
138
145
|
// 반복 횟수 초과 시 경고
|
|
139
146
|
if (iterations >= MAX_TOOL_ITERATIONS) {
|
|
140
147
|
console.warn(`[Warning] Tool use loop reached max iterations (${MAX_TOOL_ITERATIONS})`);
|
|
141
|
-
return "도구 실행이 너무 많이 반복됐어. 다시 시도해줄래?";
|
|
148
|
+
return { text: "도구 실행이 너무 많이 반복됐어. 다시 시도해줄래?", toolsUsed };
|
|
142
149
|
}
|
|
143
150
|
// 최종 텍스트 응답 추출
|
|
144
151
|
const textBlock = response.content.find((block) => block.type === "text");
|
|
145
|
-
return
|
|
152
|
+
return {
|
|
153
|
+
text: textBlock?.text ?? "응답을 생성하지 못했어. 다시 시도해줄래?",
|
|
154
|
+
toolsUsed
|
|
155
|
+
};
|
|
146
156
|
}
|
|
147
157
|
/**
|
|
148
158
|
* 스마트 채팅 - 가능하면 스트리밍, 도구 필요하면 일반 호출
|
|
@@ -151,12 +161,15 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
151
161
|
* - 먼저 스트리밍으로 시도
|
|
152
162
|
* - 도구 호출이 감지되면 (stop_reason === "tool_use") 기존 chat()으로 폴백
|
|
153
163
|
* - 스트리밍은 최종 텍스트 응답에만 사용
|
|
164
|
+
*
|
|
165
|
+
* 주의: 스트리밍은 재시도하지 않음 (이미 전송된 청크를 되돌릴 수 없음)
|
|
166
|
+
* 스트리밍 중 에러 발생 시 적절한 에러 메시지를 반환하거나 예외를 전파함
|
|
154
167
|
*/
|
|
155
168
|
export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
156
169
|
// 스트리밍 콜백이 없으면 그냥 일반 chat 사용
|
|
157
170
|
if (!onChunk) {
|
|
158
|
-
const
|
|
159
|
-
return { text, usedTools:
|
|
171
|
+
const result = await chat(messages, systemPrompt, modelId);
|
|
172
|
+
return { text: result.text, usedTools: result.toolsUsed.length > 0, toolsUsed: result.toolsUsed };
|
|
160
173
|
}
|
|
161
174
|
const client = getClient();
|
|
162
175
|
const modelConfig = MODELS[modelId];
|
|
@@ -179,13 +192,12 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
179
192
|
// Thinking은 스트리밍에서 복잡해지므로 일단 비활성화
|
|
180
193
|
// (도구 호출 폴백 시 chat()에서 thinking 사용됨)
|
|
181
194
|
let accumulated = "";
|
|
182
|
-
let
|
|
183
|
-
|
|
184
|
-
return await withRetry(async () => {
|
|
185
|
-
accumulated = ""; // 재시도 시 초기화
|
|
195
|
+
let streamingStarted = false;
|
|
196
|
+
try {
|
|
186
197
|
const stream = client.messages.stream(params);
|
|
187
198
|
// 스트리밍 이벤트 처리
|
|
188
199
|
stream.on("text", async (text) => {
|
|
200
|
+
streamingStarted = true;
|
|
189
201
|
accumulated += text;
|
|
190
202
|
try {
|
|
191
203
|
await onChunk(text, accumulated);
|
|
@@ -197,14 +209,43 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
|
197
209
|
});
|
|
198
210
|
// 스트림 완료 대기
|
|
199
211
|
const finalMessage = await stream.finalMessage();
|
|
200
|
-
stopReason = finalMessage.stop_reason;
|
|
212
|
+
const stopReason = finalMessage.stop_reason;
|
|
201
213
|
// 도구 호출이 필요한 경우 - 일반 chat으로 폴백
|
|
214
|
+
// 주의: chat()은 내부에서 withRetry를 사용하므로 여기서 추가 재시도 불필요
|
|
202
215
|
if (stopReason === "tool_use") {
|
|
203
216
|
console.log("[Stream] Tool use detected, falling back to chat()");
|
|
204
|
-
const
|
|
205
|
-
return { text, usedTools: true };
|
|
217
|
+
const result = await chat(messages, systemPrompt, modelId);
|
|
218
|
+
return { text: result.text, usedTools: true, toolsUsed: result.toolsUsed };
|
|
206
219
|
}
|
|
207
220
|
// 성공적으로 스트리밍 완료
|
|
208
|
-
return { text: accumulated, usedTools: false };
|
|
209
|
-
}
|
|
221
|
+
return { text: accumulated, usedTools: false, toolsUsed: [] };
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
// 스트리밍 시작 전 에러 (연결 실패 등) - 재시도 가능
|
|
225
|
+
if (!streamingStarted && error instanceof APIError) {
|
|
226
|
+
// Rate limit 또는 서버 에러는 withRetry로 재시도
|
|
227
|
+
if (error.status === 429 || error.status >= 500) {
|
|
228
|
+
console.log(`[Stream] Pre-stream error (${error.status}), retrying with withRetry...`);
|
|
229
|
+
return await withRetry(async () => {
|
|
230
|
+
// 재시도 시 일반 chat 사용 (스트리밍 대신)
|
|
231
|
+
const result = await chat(messages, systemPrompt, modelId);
|
|
232
|
+
return { text: result.text, usedTools: false, toolsUsed: result.toolsUsed };
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// 스트리밍 중 에러 - 재시도 불가 (이미 청크가 전송됨)
|
|
237
|
+
if (streamingStarted) {
|
|
238
|
+
console.error("[Stream] Error during streaming (cannot retry):", error);
|
|
239
|
+
// 이미 일부 텍스트가 전송됐으므로, 에러 메시지를 추가하거나 부분 결과 반환
|
|
240
|
+
if (accumulated.length > 0) {
|
|
241
|
+
return {
|
|
242
|
+
text: accumulated + "\n\n(응답 생성 중 오류 발생)",
|
|
243
|
+
usedTools: false,
|
|
244
|
+
toolsUsed: []
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// 그 외 에러는 전파
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
210
251
|
}
|
package/dist/cron/scheduler.js
CHANGED
|
@@ -167,9 +167,9 @@ async function executeAgentTurn(job, payload, bot) {
|
|
|
167
167
|
- Run Count: ${(job.runCount || 0) + 1}
|
|
168
168
|
- This is a scheduled task, not a direct user message.`;
|
|
169
169
|
// Call Claude API
|
|
170
|
-
const
|
|
170
|
+
const result = await chat(messages, systemPrompt, "sonnet");
|
|
171
171
|
// Send the response to the chat
|
|
172
|
-
const trimmedResponse =
|
|
172
|
+
const trimmedResponse = result.text?.trim();
|
|
173
173
|
if (trimmedResponse) {
|
|
174
174
|
// Split long messages (Telegram limit is 4096 characters)
|
|
175
175
|
const maxLength = TELEGRAM_SAFE_LIMIT;
|
package/dist/heartbeat/index.js
CHANGED
|
@@ -141,9 +141,9 @@ ${context}
|
|
|
141
141
|
];
|
|
142
142
|
let messageSent = false;
|
|
143
143
|
try {
|
|
144
|
-
const
|
|
145
|
-
if (!
|
|
146
|
-
await botInstance.api.sendMessage(config.chatId,
|
|
144
|
+
const result = await chat(messages, systemPrompt, "haiku");
|
|
145
|
+
if (!result.text.trim().includes("HEARTBEAT_OK")) {
|
|
146
|
+
await botInstance.api.sendMessage(config.chatId, result.text);
|
|
147
147
|
console.log(`[Heartbeat] Sent message to ${config.chatId}`);
|
|
148
148
|
messageSent = true;
|
|
149
149
|
// 타임스탬프는 메모리 캐시에만 저장 (파일 쓰기 안 함)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 간단한 벡터 저장소 모듈
|
|
3
3
|
* 메모리 파일들을 로드하고 유사도 기반으로 검색합니다.
|
|
4
|
+
* 임베딩은 파일에 캐시되어 재시작 후에도 유지됩니다.
|
|
4
5
|
*/
|
|
5
6
|
import * as fs from "fs/promises";
|
|
6
7
|
import * as path from "path";
|
|
@@ -10,8 +11,61 @@ import { embed, embedBatch, cosineSimilarity } from "./embeddings.js";
|
|
|
10
11
|
let cachedChunks = [];
|
|
11
12
|
let cacheTimestamp = 0;
|
|
12
13
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
|
|
14
|
+
// 임베딩 영속 캐시 (hash → embedding)
|
|
15
|
+
let embeddingCache = new Map();
|
|
16
|
+
let embeddingCacheLoaded = false;
|
|
13
17
|
// 로딩 중복 방지용 Promise
|
|
14
18
|
let loadingPromise = null;
|
|
19
|
+
/**
|
|
20
|
+
* 간단한 해시 함수 (텍스트 변경 감지용)
|
|
21
|
+
*/
|
|
22
|
+
function simpleHash(text) {
|
|
23
|
+
let hash = 0;
|
|
24
|
+
for (let i = 0; i < text.length; i++) {
|
|
25
|
+
const char = text.charCodeAt(i);
|
|
26
|
+
hash = ((hash << 5) - hash) + char;
|
|
27
|
+
hash = hash & hash; // 32bit 정수로 변환
|
|
28
|
+
}
|
|
29
|
+
return hash.toString(16);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 임베딩 캐시 파일 경로
|
|
33
|
+
*/
|
|
34
|
+
function getEmbeddingCachePath() {
|
|
35
|
+
return path.join(getMemoryDirPath(), ".embedding-cache.json");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 임베딩 캐시를 파일에서 로드합니다.
|
|
39
|
+
*/
|
|
40
|
+
async function loadEmbeddingCache() {
|
|
41
|
+
if (embeddingCacheLoaded)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
const cachePath = getEmbeddingCachePath();
|
|
45
|
+
const data = await fs.readFile(cachePath, "utf-8");
|
|
46
|
+
const parsed = JSON.parse(data);
|
|
47
|
+
embeddingCache = new Map(Object.entries(parsed));
|
|
48
|
+
console.log(`[VectorStore] Loaded ${embeddingCache.size} cached embeddings`);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// 파일 없거나 파싱 실패 - 새로 시작
|
|
52
|
+
embeddingCache = new Map();
|
|
53
|
+
}
|
|
54
|
+
embeddingCacheLoaded = true;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 임베딩 캐시를 파일에 저장합니다.
|
|
58
|
+
*/
|
|
59
|
+
async function saveEmbeddingCache() {
|
|
60
|
+
try {
|
|
61
|
+
const cachePath = getEmbeddingCachePath();
|
|
62
|
+
const obj = Object.fromEntries(embeddingCache);
|
|
63
|
+
await fs.writeFile(cachePath, JSON.stringify(obj), "utf-8");
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.warn("[VectorStore] Failed to save embedding cache:", error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
15
69
|
/**
|
|
16
70
|
* 텍스트를 적절한 크기의 청크로 분할합니다.
|
|
17
71
|
*/
|
|
@@ -30,7 +84,11 @@ function splitIntoChunks(text, source) {
|
|
|
30
84
|
for (const line of lines) {
|
|
31
85
|
if (currentChunk.length + line.length > 500) {
|
|
32
86
|
if (currentChunk.trim()) {
|
|
33
|
-
chunks.push({
|
|
87
|
+
chunks.push({
|
|
88
|
+
text: currentChunk.trim(),
|
|
89
|
+
source,
|
|
90
|
+
hash: simpleHash(currentChunk.trim())
|
|
91
|
+
});
|
|
34
92
|
}
|
|
35
93
|
currentChunk = line;
|
|
36
94
|
}
|
|
@@ -39,11 +97,19 @@ function splitIntoChunks(text, source) {
|
|
|
39
97
|
}
|
|
40
98
|
}
|
|
41
99
|
if (currentChunk.trim()) {
|
|
42
|
-
chunks.push({
|
|
100
|
+
chunks.push({
|
|
101
|
+
text: currentChunk.trim(),
|
|
102
|
+
source,
|
|
103
|
+
hash: simpleHash(currentChunk.trim())
|
|
104
|
+
});
|
|
43
105
|
}
|
|
44
106
|
}
|
|
45
107
|
else {
|
|
46
|
-
chunks.push({
|
|
108
|
+
chunks.push({
|
|
109
|
+
text: trimmed,
|
|
110
|
+
source,
|
|
111
|
+
hash: simpleHash(trimmed)
|
|
112
|
+
});
|
|
47
113
|
}
|
|
48
114
|
}
|
|
49
115
|
return chunks;
|
|
@@ -52,12 +118,14 @@ function splitIntoChunks(text, source) {
|
|
|
52
118
|
* 내부 로드 로직 - 실제 파일 로드 수행
|
|
53
119
|
*/
|
|
54
120
|
async function doLoadAllMemoryChunks() {
|
|
121
|
+
// 임베딩 캐시 로드
|
|
122
|
+
await loadEmbeddingCache();
|
|
55
123
|
const chunks = [];
|
|
56
124
|
// 1. 일별 메모리 파일 (최근 30일)
|
|
57
125
|
const memoryDir = getMemoryDirPath();
|
|
58
126
|
try {
|
|
59
127
|
const files = await fs.readdir(memoryDir);
|
|
60
|
-
const mdFiles = files.filter(f => f.endsWith(".md")).sort().reverse().slice(0, 30);
|
|
128
|
+
const mdFiles = files.filter(f => f.endsWith(".md") && !f.startsWith(".")).sort().reverse().slice(0, 30);
|
|
61
129
|
for (const file of mdFiles) {
|
|
62
130
|
try {
|
|
63
131
|
const content = await fs.readFile(path.join(memoryDir, file), "utf-8");
|
|
@@ -82,6 +150,15 @@ async function doLoadAllMemoryChunks() {
|
|
|
82
150
|
catch {
|
|
83
151
|
// 파일 없음 무시
|
|
84
152
|
}
|
|
153
|
+
// 3. 캐시된 임베딩 복원
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
if (chunk.hash) {
|
|
156
|
+
const cachedEmbedding = embeddingCache.get(chunk.hash);
|
|
157
|
+
if (cachedEmbedding) {
|
|
158
|
+
chunk.embedding = cachedEmbedding;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
85
162
|
return chunks;
|
|
86
163
|
}
|
|
87
164
|
/**
|
|
@@ -131,24 +208,35 @@ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
|
|
|
131
208
|
// 임베딩이 없는 청크들을 배치로 처리
|
|
132
209
|
const chunksNeedingEmbedding = chunks.filter(c => !c.embedding);
|
|
133
210
|
if (chunksNeedingEmbedding.length > 0) {
|
|
211
|
+
console.log(`[VectorStore] Generating embeddings for ${chunksNeedingEmbedding.length} chunks`);
|
|
134
212
|
try {
|
|
135
213
|
const texts = chunksNeedingEmbedding.map(c => c.text);
|
|
136
214
|
const embeddings = await embedBatch(texts);
|
|
137
|
-
// 임베딩 할당
|
|
215
|
+
// 임베딩 할당 및 캐시 저장
|
|
138
216
|
for (let i = 0; i < chunksNeedingEmbedding.length; i++) {
|
|
139
|
-
|
|
217
|
+
const chunk = chunksNeedingEmbedding[i];
|
|
218
|
+
chunk.embedding = embeddings[i];
|
|
219
|
+
if (chunk.hash) {
|
|
220
|
+
embeddingCache.set(chunk.hash, embeddings[i]);
|
|
221
|
+
}
|
|
140
222
|
}
|
|
223
|
+
// 캐시 파일 저장 (비동기, 실패해도 무시)
|
|
224
|
+
saveEmbeddingCache().catch(() => { });
|
|
141
225
|
}
|
|
142
226
|
catch {
|
|
143
227
|
// 배치 실패 시 개별 처리 폴백
|
|
144
228
|
for (const chunk of chunksNeedingEmbedding) {
|
|
145
229
|
try {
|
|
146
230
|
chunk.embedding = await embed(chunk.text);
|
|
231
|
+
if (chunk.hash) {
|
|
232
|
+
embeddingCache.set(chunk.hash, chunk.embedding);
|
|
233
|
+
}
|
|
147
234
|
}
|
|
148
235
|
catch {
|
|
149
236
|
// 개별 실패 무시
|
|
150
237
|
}
|
|
151
238
|
}
|
|
239
|
+
saveEmbeddingCache().catch(() => { });
|
|
152
240
|
}
|
|
153
241
|
}
|
|
154
242
|
// 유사도 계산 및 필터링
|
|
@@ -172,12 +260,27 @@ export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
|
|
|
172
260
|
}
|
|
173
261
|
/**
|
|
174
262
|
* 캐시를 무효화합니다.
|
|
263
|
+
* 임베딩 캐시는 유지 (텍스트 해시 기반이므로)
|
|
175
264
|
*/
|
|
176
265
|
export function invalidateCache() {
|
|
177
266
|
cachedChunks = [];
|
|
178
267
|
cacheTimestamp = 0;
|
|
179
268
|
loadingPromise = null;
|
|
180
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* 임베딩 캐시까지 완전 초기화합니다.
|
|
272
|
+
*/
|
|
273
|
+
export async function clearAllCaches() {
|
|
274
|
+
invalidateCache();
|
|
275
|
+
embeddingCache.clear();
|
|
276
|
+
embeddingCacheLoaded = false;
|
|
277
|
+
try {
|
|
278
|
+
await fs.unlink(getEmbeddingCachePath());
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// 파일 없으면 무시
|
|
282
|
+
}
|
|
283
|
+
}
|
|
181
284
|
// 인메모리 저장소 (간단한 구현)
|
|
182
285
|
let vectorStore = [];
|
|
183
286
|
/**
|
package/dist/session/state.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
-
import { estimateMessagesTokens } from "../utils/tokens.js";
|
|
2
|
+
import { estimateMessagesTokens, estimateTokens } from "../utils/tokens.js";
|
|
3
3
|
// 세션 설정
|
|
4
4
|
const MAX_SESSIONS = 100;
|
|
5
5
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24시간
|
|
6
|
-
|
|
6
|
+
// 토큰 한도 (개선됨)
|
|
7
|
+
const MAX_HISTORY_TOKENS = 40000; // 히스토리 한도
|
|
8
|
+
const SUMMARY_THRESHOLD_TOKENS = 25000; // 이 이상이면 요약 시작
|
|
9
|
+
const MIN_RECENT_MESSAGES = 6; // 최소 유지할 최근 메시지
|
|
10
|
+
const MAX_PINNED_TOKENS = 5000; // 핀 맥락 최대 토큰
|
|
7
11
|
// 세션별 상태 저장
|
|
8
12
|
const sessions = new Map();
|
|
9
13
|
// AsyncLocalStorage for chatId context
|
|
@@ -11,18 +15,24 @@ const chatIdStorage = new AsyncLocalStorage();
|
|
|
11
15
|
function getSession(chatId) {
|
|
12
16
|
// chatId 유효성 검사
|
|
13
17
|
if (chatId == null || isNaN(chatId)) {
|
|
14
|
-
console.
|
|
15
|
-
// 임시 세션 반환 (저장하지 않음)
|
|
18
|
+
console.error(`[Session] BUG: Invalid chatId: ${chatId} - history will NOT persist!`);
|
|
16
19
|
return {
|
|
17
20
|
history: [],
|
|
18
21
|
model: "sonnet",
|
|
19
22
|
lastAccessedAt: Date.now(),
|
|
23
|
+
pinnedContexts: [],
|
|
24
|
+
summaryChunks: [],
|
|
20
25
|
};
|
|
21
26
|
}
|
|
22
27
|
const existing = sessions.get(chatId);
|
|
23
28
|
const now = Date.now();
|
|
24
29
|
if (existing) {
|
|
25
30
|
existing.lastAccessedAt = now;
|
|
31
|
+
// 마이그레이션: 기존 세션에 새 필드 추가
|
|
32
|
+
if (!existing.pinnedContexts)
|
|
33
|
+
existing.pinnedContexts = [];
|
|
34
|
+
if (!existing.summaryChunks)
|
|
35
|
+
existing.summaryChunks = [];
|
|
26
36
|
return existing;
|
|
27
37
|
}
|
|
28
38
|
// 새 세션 생성 전 정리
|
|
@@ -31,8 +41,11 @@ function getSession(chatId) {
|
|
|
31
41
|
history: [],
|
|
32
42
|
model: "sonnet",
|
|
33
43
|
lastAccessedAt: now,
|
|
44
|
+
pinnedContexts: [],
|
|
45
|
+
summaryChunks: [],
|
|
34
46
|
};
|
|
35
47
|
sessions.set(chatId, session);
|
|
48
|
+
console.log(`[Session] Created new session for chatId=${chatId}, total sessions=${sessions.size}`);
|
|
36
49
|
return session;
|
|
37
50
|
}
|
|
38
51
|
function cleanupSessions() {
|
|
@@ -55,24 +68,233 @@ function cleanupSessions() {
|
|
|
55
68
|
}
|
|
56
69
|
export function getHistory(chatId) {
|
|
57
70
|
const session = getSession(chatId);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
if (!session.history) {
|
|
72
|
+
session.history = [];
|
|
73
|
+
}
|
|
74
|
+
return session.history;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 핀된 맥락 가져오기
|
|
78
|
+
*/
|
|
79
|
+
export function getPinnedContexts(chatId) {
|
|
80
|
+
return getSession(chatId).pinnedContexts;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 요약 청크 가져오기
|
|
84
|
+
*/
|
|
85
|
+
export function getSummaryChunks(chatId) {
|
|
86
|
+
return getSession(chatId).summaryChunks;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 중요 맥락 핀하기
|
|
90
|
+
*/
|
|
91
|
+
export function pinContext(chatId, text, source = "user") {
|
|
92
|
+
const session = getSession(chatId);
|
|
93
|
+
const currentTokens = session.pinnedContexts.reduce((sum, p) => sum + estimateTokens(p.text), 0);
|
|
94
|
+
const newTokens = estimateTokens(text);
|
|
95
|
+
// 토큰 한도 체크
|
|
96
|
+
if (currentTokens + newTokens > MAX_PINNED_TOKENS) {
|
|
97
|
+
// 오래된 자동 핀부터 제거
|
|
98
|
+
while (session.pinnedContexts.length > 0 &&
|
|
99
|
+
currentTokens + newTokens > MAX_PINNED_TOKENS) {
|
|
100
|
+
const autoIndex = session.pinnedContexts.findIndex((p) => p.source === "auto");
|
|
101
|
+
if (autoIndex >= 0) {
|
|
102
|
+
session.pinnedContexts.splice(autoIndex, 1);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// 자동 핀 없으면 추가 불가
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
session.pinnedContexts.push({
|
|
111
|
+
text,
|
|
112
|
+
createdAt: Date.now(),
|
|
113
|
+
source,
|
|
114
|
+
});
|
|
115
|
+
console.log(`[Pin] chatId=${chatId} added pin (${source}): ${text.slice(0, 50)}...`);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 핀 제거
|
|
120
|
+
*/
|
|
121
|
+
export function unpinContext(chatId, index) {
|
|
122
|
+
const session = getSession(chatId);
|
|
123
|
+
if (index >= 0 && index < session.pinnedContexts.length) {
|
|
124
|
+
session.pinnedContexts.splice(index, 1);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 모든 핀 제거
|
|
131
|
+
*/
|
|
132
|
+
export function clearPins(chatId) {
|
|
133
|
+
getSession(chatId).pinnedContexts = [];
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 요약 청크 추가
|
|
137
|
+
*/
|
|
138
|
+
export function addSummaryChunk(chatId, chunk) {
|
|
139
|
+
const session = getSession(chatId);
|
|
140
|
+
session.summaryChunks.push(chunk);
|
|
141
|
+
// 오래된 요약은 병합 (최대 3개 유지)
|
|
142
|
+
while (session.summaryChunks.length > 3) {
|
|
143
|
+
const [first, second] = session.summaryChunks.splice(0, 2);
|
|
144
|
+
session.summaryChunks.unshift({
|
|
145
|
+
summary: `${first.summary}\n\n${second.summary}`,
|
|
146
|
+
messageCount: first.messageCount + second.messageCount,
|
|
147
|
+
startTime: first.startTime,
|
|
148
|
+
endTime: second.endTime,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
61
151
|
}
|
|
62
152
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
153
|
+
* 개선된 히스토리 트리밍
|
|
154
|
+
*
|
|
155
|
+
* 전략:
|
|
156
|
+
* 1. 최근 N개 메시지는 반드시 유지
|
|
157
|
+
* 2. 토큰이 임계치 초과하면 오래된 메시지 제거 (요약 청크로 변환 가능)
|
|
158
|
+
* 3. 핀된 맥락은 별도로 보존됨 (여기서 처리 안 함)
|
|
65
159
|
*/
|
|
66
160
|
export function trimHistoryByTokens(history) {
|
|
67
|
-
// null/undefined/빈 배열 처리
|
|
68
161
|
if (!history || history.length === 0) {
|
|
69
162
|
return;
|
|
70
163
|
}
|
|
71
|
-
|
|
164
|
+
const currentTokens = estimateMessagesTokens(history);
|
|
165
|
+
// 한도 이내면 패스
|
|
166
|
+
if (currentTokens <= MAX_HISTORY_TOKENS) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
console.log(`[Trim] Starting trim: ${currentTokens} tokens, ${history.length} messages`);
|
|
170
|
+
// 최근 메시지는 반드시 유지
|
|
171
|
+
while (estimateMessagesTokens(history) > MAX_HISTORY_TOKENS && history.length > MIN_RECENT_MESSAGES) {
|
|
72
172
|
history.shift();
|
|
73
173
|
}
|
|
174
|
+
const afterTokens = estimateMessagesTokens(history);
|
|
175
|
+
console.log(`[Trim] After trim: ${afterTokens} tokens, ${history.length} messages`);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 스마트 트리밍 - 요약과 함께 수행
|
|
179
|
+
*
|
|
180
|
+
* @param chatId 채팅 ID
|
|
181
|
+
* @param summarizeFn 요약 함수 (외부 주입 - API 호출 필요)
|
|
182
|
+
* @returns 요약이 수행되었는지 여부
|
|
183
|
+
*/
|
|
184
|
+
export async function smartTrimHistory(chatId, summarizeFn) {
|
|
185
|
+
const session = getSession(chatId);
|
|
186
|
+
const history = session.history;
|
|
187
|
+
if (!history || history.length === 0) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const currentTokens = estimateMessagesTokens(history);
|
|
191
|
+
// 요약 임계치 이하면 패스
|
|
192
|
+
if (currentTokens <= SUMMARY_THRESHOLD_TOKENS) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
// 요약 함수가 없으면 기본 트리밍만
|
|
196
|
+
if (!summarizeFn) {
|
|
197
|
+
trimHistoryByTokens(history);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
console.log(`[SmartTrim] chatId=${chatId} tokens=${currentTokens}, starting summarization...`);
|
|
201
|
+
// 오래된 메시지들 (최근 6개 제외)
|
|
202
|
+
const toSummarize = history.slice(0, -MIN_RECENT_MESSAGES);
|
|
203
|
+
const toKeep = history.slice(-MIN_RECENT_MESSAGES);
|
|
204
|
+
if (toSummarize.length < 4) {
|
|
205
|
+
// 요약할 게 별로 없으면 기본 트리밍
|
|
206
|
+
trimHistoryByTokens(history);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const summary = await summarizeFn(toSummarize);
|
|
211
|
+
// 요약 청크 저장
|
|
212
|
+
addSummaryChunk(chatId, {
|
|
213
|
+
summary,
|
|
214
|
+
messageCount: toSummarize.length,
|
|
215
|
+
startTime: Date.now() - (toSummarize.length * 60000), // 대략적인 시간
|
|
216
|
+
endTime: Date.now(),
|
|
217
|
+
});
|
|
218
|
+
// 히스토리 교체: [요약 메시지] + [최근 메시지들]
|
|
219
|
+
history.splice(0, history.length);
|
|
220
|
+
history.push({
|
|
221
|
+
role: "user",
|
|
222
|
+
content: `[이전 대화 요약]\n${summary}`
|
|
223
|
+
});
|
|
224
|
+
history.push({
|
|
225
|
+
role: "assistant",
|
|
226
|
+
content: "네, 이전 대화 내용을 기억하고 있어요."
|
|
227
|
+
});
|
|
228
|
+
history.push(...toKeep);
|
|
229
|
+
const afterTokens = estimateMessagesTokens(history);
|
|
230
|
+
console.log(`[SmartTrim] chatId=${chatId} summarized: ${currentTokens} → ${afterTokens} tokens`);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
console.error(`[SmartTrim] Failed to summarize:`, error);
|
|
235
|
+
// 실패하면 기본 트리밍으로 폴백
|
|
236
|
+
trimHistoryByTokens(history);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* 중요 맥락 자동 감지
|
|
242
|
+
*
|
|
243
|
+
* 패턴:
|
|
244
|
+
* - "기억해", "잊지 마", "remember"
|
|
245
|
+
* - 이름, 선호도, 중요 정보 언급
|
|
246
|
+
* - 명시적 핀 요청
|
|
247
|
+
*/
|
|
248
|
+
export function detectImportantContext(message) {
|
|
249
|
+
const patterns = [
|
|
250
|
+
/기억해[줘요]?\s*[::]?\s*(.+)/i,
|
|
251
|
+
/잊지\s*마[줘요]?\s*[::]?\s*(.+)/i,
|
|
252
|
+
/remember\s*[::]?\s*(.+)/i,
|
|
253
|
+
/내\s*이름은?\s+(.+?)(?:이야|야|입니다|예요|요)?[.!]?\s*$/i,
|
|
254
|
+
/나는?\s+(.+?)(?:을|를)?\s*(?:좋아해|싫어해|선호해)/i,
|
|
255
|
+
];
|
|
256
|
+
for (const pattern of patterns) {
|
|
257
|
+
const match = message.match(pattern);
|
|
258
|
+
if (match && match[1]) {
|
|
259
|
+
return match[1].trim();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* 시스템 프롬프트용 맥락 문자열 생성
|
|
266
|
+
*/
|
|
267
|
+
export function buildContextForPrompt(chatId) {
|
|
268
|
+
const session = getSession(chatId);
|
|
269
|
+
const parts = [];
|
|
270
|
+
// 핀된 맥락
|
|
271
|
+
if (session.pinnedContexts.length > 0) {
|
|
272
|
+
parts.push("## 📌 중요 맥락 (사용자가 기억해달라고 한 것들)");
|
|
273
|
+
session.pinnedContexts.forEach((p, i) => {
|
|
274
|
+
parts.push(`${i + 1}. ${p.text}`);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// 요약 청크 (있으면)
|
|
278
|
+
if (session.summaryChunks.length > 0) {
|
|
279
|
+
parts.push("\n## 📜 이전 대화 요약");
|
|
280
|
+
session.summaryChunks.forEach((chunk) => {
|
|
281
|
+
parts.push(`- ${chunk.summary}`);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return parts.join("\n");
|
|
74
285
|
}
|
|
75
286
|
export function clearHistory(chatId) {
|
|
287
|
+
const session = sessions.get(chatId);
|
|
288
|
+
if (session) {
|
|
289
|
+
session.history = [];
|
|
290
|
+
session.summaryChunks = [];
|
|
291
|
+
// 핀은 유지 (중요 맥락이므로)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 완전 초기화 (핀 포함)
|
|
296
|
+
*/
|
|
297
|
+
export function clearSession(chatId) {
|
|
76
298
|
sessions.delete(chatId);
|
|
77
299
|
}
|
|
78
300
|
export function getModel(chatId) {
|
|
@@ -81,27 +303,30 @@ export function getModel(chatId) {
|
|
|
81
303
|
export function setModel(chatId, modelId) {
|
|
82
304
|
getSession(chatId).model = modelId;
|
|
83
305
|
}
|
|
84
|
-
/**
|
|
85
|
-
* Run a function with chatId context using AsyncLocalStorage.
|
|
86
|
-
* All code inside the callback can access the chatId via getCurrentChatId().
|
|
87
|
-
*/
|
|
88
306
|
export function runWithChatId(chatId, fn) {
|
|
89
307
|
return chatIdStorage.run(chatId, fn);
|
|
90
308
|
}
|
|
91
|
-
/**
|
|
92
|
-
* Get the current chatId from AsyncLocalStorage context.
|
|
93
|
-
* Returns null if called outside of runWithChatId().
|
|
94
|
-
*/
|
|
95
309
|
export function getCurrentChatId() {
|
|
96
310
|
return chatIdStorage.getStore() ?? null;
|
|
97
311
|
}
|
|
98
|
-
// 세션 정리 (수동 호출용)
|
|
99
312
|
export function cleanupExpiredSessions() {
|
|
100
313
|
const before = sessions.size;
|
|
101
314
|
cleanupSessions();
|
|
102
315
|
return before - sessions.size;
|
|
103
316
|
}
|
|
104
|
-
// 현재 세션 수 조회
|
|
105
317
|
export function getSessionCount() {
|
|
106
318
|
return sessions.size;
|
|
107
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* 세션 통계 (디버그용)
|
|
322
|
+
*/
|
|
323
|
+
export function getSessionStats(chatId) {
|
|
324
|
+
const session = getSession(chatId);
|
|
325
|
+
return {
|
|
326
|
+
historyLength: session.history.length,
|
|
327
|
+
historyTokens: estimateMessagesTokens(session.history),
|
|
328
|
+
pinnedCount: session.pinnedContexts.length,
|
|
329
|
+
pinnedTokens: session.pinnedContexts.reduce((sum, p) => sum + estimateTokens(p.text), 0),
|
|
330
|
+
summaryCount: session.summaryChunks.length,
|
|
331
|
+
};
|
|
332
|
+
}
|