companionbot 0.4.3 → 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/ai/claude.js +131 -19
- package/dist/cron/index.js +1 -1
- package/dist/cron/scheduler.js +31 -0
- 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/handlers/commands.js +52 -4
- package/dist/telegram/handlers/messages.js +68 -16
- package/dist/telegram/utils/prompt.js +74 -8
- package/dist/tools/index.js +64 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/tokens.js +30 -0
- package/package.json +2 -1
- package/templates/AGENTS.md +88 -0
package/dist/ai/claude.js
CHANGED
|
@@ -7,28 +7,59 @@ function getClient() {
|
|
|
7
7
|
}
|
|
8
8
|
return anthropic;
|
|
9
9
|
}
|
|
10
|
+
// 모델별 max_tokens 및 thinking budget 설정
|
|
11
|
+
// 참고: Claude API에서 thinking + output이 모델 한도 초과하면 안 됨
|
|
10
12
|
export const MODELS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
haiku: {
|
|
14
|
+
id: "claude-haiku-3-5-20241022",
|
|
15
|
+
name: "Claude Haiku 3.5",
|
|
16
|
+
maxTokens: 4096, // 빠른 응답
|
|
17
|
+
thinkingBudget: 0, // Haiku는 thinking 미지원
|
|
18
|
+
},
|
|
19
|
+
sonnet: {
|
|
20
|
+
id: "claude-sonnet-4-20250514",
|
|
21
|
+
name: "Claude Sonnet 4",
|
|
22
|
+
maxTokens: 8192, // 일반 작업
|
|
23
|
+
thinkingBudget: 10000, // 적당한 thinking
|
|
24
|
+
},
|
|
25
|
+
opus: {
|
|
26
|
+
id: "claude-opus-4-20250514",
|
|
27
|
+
name: "Claude Opus 4",
|
|
28
|
+
maxTokens: 16384, // 복잡한 작업
|
|
29
|
+
thinkingBudget: 32000, // 깊은 thinking
|
|
30
|
+
},
|
|
14
31
|
};
|
|
15
32
|
export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
16
33
|
const client = getClient();
|
|
17
|
-
const
|
|
34
|
+
const modelConfig = MODELS[modelId];
|
|
18
35
|
// 메시지를 API 형식으로 변환
|
|
19
36
|
const apiMessages = messages.map((m) => ({
|
|
20
37
|
role: m.role,
|
|
21
38
|
content: m.content,
|
|
22
39
|
}));
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
model,
|
|
27
|
-
max_tokens:
|
|
28
|
-
system: systemPrompt,
|
|
40
|
+
// API 요청 파라미터 빌드 (도구 루프에서도 동일하게 사용)
|
|
41
|
+
const buildRequestParams = () => {
|
|
42
|
+
const params = {
|
|
43
|
+
model: modelConfig.id,
|
|
44
|
+
max_tokens: modelConfig.maxTokens,
|
|
29
45
|
messages: apiMessages,
|
|
30
46
|
tools: tools,
|
|
31
|
-
}
|
|
47
|
+
};
|
|
48
|
+
if (systemPrompt) {
|
|
49
|
+
params.system = systemPrompt;
|
|
50
|
+
}
|
|
51
|
+
// thinking 활성화 (budget > 0인 경우)
|
|
52
|
+
if (modelConfig.thinkingBudget > 0) {
|
|
53
|
+
params.thinking = {
|
|
54
|
+
type: "enabled",
|
|
55
|
+
budget_tokens: modelConfig.thinkingBudget,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return params;
|
|
59
|
+
};
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await client.messages.create(buildRequestParams());
|
|
32
63
|
}
|
|
33
64
|
catch (error) {
|
|
34
65
|
if (error instanceof Anthropic.APIError) {
|
|
@@ -71,15 +102,9 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
71
102
|
role: "user",
|
|
72
103
|
content: toolResults,
|
|
73
104
|
});
|
|
74
|
-
// 다음 응답 요청
|
|
105
|
+
// 다음 응답 요청 (도구 루프에서도 thinking 유지)
|
|
75
106
|
try {
|
|
76
|
-
response = await client.messages.create(
|
|
77
|
-
model,
|
|
78
|
-
max_tokens: 4096,
|
|
79
|
-
system: systemPrompt,
|
|
80
|
-
messages: apiMessages,
|
|
81
|
-
tools: tools,
|
|
82
|
-
});
|
|
107
|
+
response = await client.messages.create(buildRequestParams());
|
|
83
108
|
}
|
|
84
109
|
catch (error) {
|
|
85
110
|
if (error instanceof Anthropic.APIError) {
|
|
@@ -102,3 +127,90 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
|
|
|
102
127
|
const textBlock = response.content.find((block) => block.type === "text");
|
|
103
128
|
return textBlock?.text ?? "응답을 생성하지 못했어. 다시 시도해줄래?";
|
|
104
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* 스마트 채팅 - 가능하면 스트리밍, 도구 필요하면 일반 호출
|
|
132
|
+
*
|
|
133
|
+
* 전략:
|
|
134
|
+
* - 먼저 스트리밍으로 시도
|
|
135
|
+
* - 도구 호출이 감지되면 (stop_reason === "tool_use") 기존 chat()으로 폴백
|
|
136
|
+
* - 스트리밍은 최종 텍스트 응답에만 사용
|
|
137
|
+
*/
|
|
138
|
+
export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
|
|
139
|
+
// 스트리밍 콜백이 없으면 그냥 일반 chat 사용
|
|
140
|
+
if (!onChunk) {
|
|
141
|
+
const text = await chat(messages, systemPrompt, modelId);
|
|
142
|
+
return { text, usedTools: false };
|
|
143
|
+
}
|
|
144
|
+
const client = getClient();
|
|
145
|
+
const modelConfig = MODELS[modelId];
|
|
146
|
+
// 메시지를 API 형식으로 변환
|
|
147
|
+
const apiMessages = messages.map((m) => ({
|
|
148
|
+
role: m.role,
|
|
149
|
+
content: m.content,
|
|
150
|
+
}));
|
|
151
|
+
// 스트리밍 요청 파라미터
|
|
152
|
+
const params = {
|
|
153
|
+
model: modelConfig.id,
|
|
154
|
+
max_tokens: modelConfig.maxTokens,
|
|
155
|
+
messages: apiMessages,
|
|
156
|
+
tools: tools,
|
|
157
|
+
stream: true,
|
|
158
|
+
};
|
|
159
|
+
if (systemPrompt) {
|
|
160
|
+
params.system = systemPrompt;
|
|
161
|
+
}
|
|
162
|
+
// Thinking은 스트리밍에서 복잡해지므로 일단 비활성화
|
|
163
|
+
// (도구 호출 폴백 시 chat()에서 thinking 사용됨)
|
|
164
|
+
let accumulated = "";
|
|
165
|
+
let stopReason = null;
|
|
166
|
+
try {
|
|
167
|
+
const stream = client.messages.stream(params);
|
|
168
|
+
// 스트리밍 이벤트 처리
|
|
169
|
+
stream.on("text", async (text) => {
|
|
170
|
+
accumulated += text;
|
|
171
|
+
try {
|
|
172
|
+
await onChunk(text, accumulated);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
// editMessageText 실패 등은 무시하고 계속
|
|
176
|
+
console.warn("[Stream] Chunk callback error (ignored):", err);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
// 스트림 완료 대기
|
|
180
|
+
const finalMessage = await stream.finalMessage();
|
|
181
|
+
stopReason = finalMessage.stop_reason;
|
|
182
|
+
// 도구 호출이 필요한 경우 - 일반 chat으로 폴백
|
|
183
|
+
if (stopReason === "tool_use") {
|
|
184
|
+
console.log("[Stream] Tool use detected, falling back to chat()");
|
|
185
|
+
const text = await chat(messages, systemPrompt, modelId);
|
|
186
|
+
return { text, usedTools: true };
|
|
187
|
+
}
|
|
188
|
+
// 성공적으로 스트리밍 완료
|
|
189
|
+
return { text: accumulated, usedTools: false };
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
// 스트리밍 에러 핸들링
|
|
193
|
+
if (error instanceof Anthropic.APIError) {
|
|
194
|
+
if (error.status === 429) {
|
|
195
|
+
throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
|
|
196
|
+
}
|
|
197
|
+
if (error.status >= 500) {
|
|
198
|
+
throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// 연결 끊김 등 스트리밍 에러 - 이미 받은 내용이 있으면 반환 시도
|
|
202
|
+
if (accumulated.length > 50) {
|
|
203
|
+
console.warn("[Stream] Connection error, returning partial response");
|
|
204
|
+
return { text: accumulated + "\n\n(연결이 끊겨서 응답이 잘렸을 수 있어)", usedTools: false };
|
|
205
|
+
}
|
|
206
|
+
// 응답이 거의 없으면 일반 chat으로 재시도
|
|
207
|
+
console.warn("[Stream] Connection error, retrying with chat()");
|
|
208
|
+
try {
|
|
209
|
+
const text = await chat(messages, systemPrompt, modelId);
|
|
210
|
+
return { text, usedTools: false };
|
|
211
|
+
}
|
|
212
|
+
catch (retryError) {
|
|
213
|
+
throw retryError;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
package/dist/cron/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export { isValidCronExpression, parseCronExpression, getNextCronRun, getNextRun,
|
|
|
8
8
|
// Storage functions
|
|
9
9
|
export { loadJobs, saveJobs, addJob, removeJob, updateJob, getDueJobs, markJobExecuted, getJobsByChat, getJob, calculateNextRun, } from "./store.js";
|
|
10
10
|
// Scheduler functions
|
|
11
|
-
export { CronScheduler, executeJob, setCronBot, startCronScheduler, stopCronScheduler, isCronSchedulerRunning, initCronSystem, restoreCronJobs, createCronJob, deleteCronJob, toggleCronJob, getCronJobs, getAllCronJobs, getActiveJobCount, } from "./scheduler.js";
|
|
11
|
+
export { CronScheduler, executeJob, setCronBot, startCronScheduler, stopCronScheduler, isCronSchedulerRunning, initCronSystem, restoreCronJobs, createCronJob, deleteCronJob, toggleCronJob, getCronJobs, getAllCronJobs, getActiveJobCount, ensureDefaultCronJobs, } from "./scheduler.js";
|
|
12
12
|
// Command handlers (for tools)
|
|
13
13
|
export { addCronJob, removeCronJob, setCronJobEnabled, listCronJobs, getCronStatus, } from "./commands.js";
|
|
14
14
|
// Aliases for backward compatibility
|
package/dist/cron/scheduler.js
CHANGED
|
@@ -324,3 +324,34 @@ export function getActiveJobCount() {
|
|
|
324
324
|
// For actual count, use getAllCronJobs and filter
|
|
325
325
|
return 0; // Placeholder - will be updated by scheduler
|
|
326
326
|
}
|
|
327
|
+
// ============================================================
|
|
328
|
+
// Default Cron Jobs
|
|
329
|
+
// ============================================================
|
|
330
|
+
const DEFAULT_CRON_JOBS = [
|
|
331
|
+
{
|
|
332
|
+
name: "daily_memory_save",
|
|
333
|
+
cronExpr: "0 12 * * *", // 매일 12시
|
|
334
|
+
command: "오늘 하루 동안 있었던 중요한 일들을 정리해서 MEMORY.md에 저장해줘. 새로운 정보, 대화 내용, 배운 것들 위주로.",
|
|
335
|
+
timezone: "Asia/Seoul",
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
/**
|
|
339
|
+
* Ensure default cron jobs exist for a chat
|
|
340
|
+
* Call this after onboarding or on /start
|
|
341
|
+
*/
|
|
342
|
+
export async function ensureDefaultCronJobs(chatId) {
|
|
343
|
+
const existingJobs = await getJobsByChat(chatId);
|
|
344
|
+
for (const defaultJob of DEFAULT_CRON_JOBS) {
|
|
345
|
+
const exists = existingJobs.some(job => job.name === defaultJob.name);
|
|
346
|
+
if (!exists) {
|
|
347
|
+
await createCronJob({
|
|
348
|
+
chatId,
|
|
349
|
+
name: defaultJob.name,
|
|
350
|
+
cronExpr: defaultJob.cronExpr,
|
|
351
|
+
command: defaultJob.command,
|
|
352
|
+
timezone: defaultJob.timezone,
|
|
353
|
+
});
|
|
354
|
+
console.log(`[Cron] Added default job: ${defaultJob.name} for chat ${chatId}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 로컬 임베딩 생성 모듈
|
|
3
|
+
* @xenova/transformers를 사용하여 텍스트 임베딩을 생성합니다.
|
|
4
|
+
*/
|
|
5
|
+
import { pipeline } from "@xenova/transformers";
|
|
6
|
+
// 싱글톤 파이프라인
|
|
7
|
+
let embeddingPipeline = null;
|
|
8
|
+
// 모델 로딩 중인지 추적
|
|
9
|
+
let isLoading = false;
|
|
10
|
+
let loadingPromise = null;
|
|
11
|
+
/**
|
|
12
|
+
* 임베딩 파이프라인을 초기화합니다.
|
|
13
|
+
* 작고 빠른 모델 사용 (384 차원)
|
|
14
|
+
*/
|
|
15
|
+
async function getEmbeddingPipeline() {
|
|
16
|
+
if (embeddingPipeline) {
|
|
17
|
+
return embeddingPipeline;
|
|
18
|
+
}
|
|
19
|
+
// 이미 로딩 중이면 기다림
|
|
20
|
+
if (isLoading && loadingPromise) {
|
|
21
|
+
return loadingPromise;
|
|
22
|
+
}
|
|
23
|
+
isLoading = true;
|
|
24
|
+
loadingPromise = pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2" // 384차원, 빠르고 가벼움
|
|
25
|
+
);
|
|
26
|
+
try {
|
|
27
|
+
embeddingPipeline = await loadingPromise;
|
|
28
|
+
return embeddingPipeline;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
isLoading = false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 텍스트를 임베딩 벡터로 변환합니다.
|
|
36
|
+
* @param text 변환할 텍스트
|
|
37
|
+
* @returns 384차원 임베딩 벡터
|
|
38
|
+
*/
|
|
39
|
+
export async function embed(text) {
|
|
40
|
+
const pipe = await getEmbeddingPipeline();
|
|
41
|
+
// 텍스트 정규화
|
|
42
|
+
const cleanText = text.trim().slice(0, 512); // 최대 512자
|
|
43
|
+
if (!cleanText) {
|
|
44
|
+
return new Array(384).fill(0);
|
|
45
|
+
}
|
|
46
|
+
const result = await pipe(cleanText, {
|
|
47
|
+
pooling: "mean",
|
|
48
|
+
normalize: true,
|
|
49
|
+
});
|
|
50
|
+
// Tensor를 배열로 변환
|
|
51
|
+
return Array.from(result.data);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 여러 텍스트를 배치로 임베딩합니다.
|
|
55
|
+
* @param texts 변환할 텍스트 배열
|
|
56
|
+
* @returns 임베딩 벡터 배열
|
|
57
|
+
*/
|
|
58
|
+
export async function embedBatch(texts) {
|
|
59
|
+
const results = [];
|
|
60
|
+
for (const text of texts) {
|
|
61
|
+
results.push(await embed(text));
|
|
62
|
+
}
|
|
63
|
+
return results;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 두 벡터 간의 코사인 유사도를 계산합니다.
|
|
67
|
+
*
|
|
68
|
+
* 최적화: embed()에서 normalize: true로 정규화된 벡터를 반환하므로,
|
|
69
|
+
* 정규화된 벡터의 경우 코사인 유사도 = 내적 (norm이 1이므로)
|
|
70
|
+
* normalized 파라미터가 true면 내적만 계산하여 성능 향상.
|
|
71
|
+
*/
|
|
72
|
+
export function cosineSimilarity(a, b, normalized = true) {
|
|
73
|
+
if (a.length !== b.length)
|
|
74
|
+
return 0;
|
|
75
|
+
let dotProduct = 0;
|
|
76
|
+
for (let i = 0; i < a.length; i++) {
|
|
77
|
+
dotProduct += a[i] * b[i];
|
|
78
|
+
}
|
|
79
|
+
// 정규화된 벡터면 내적 = 코사인 유사도
|
|
80
|
+
if (normalized) {
|
|
81
|
+
return dotProduct;
|
|
82
|
+
}
|
|
83
|
+
// 정규화되지 않은 벡터면 norm 계산 필요
|
|
84
|
+
let normA = 0;
|
|
85
|
+
let normB = 0;
|
|
86
|
+
for (let i = 0; i < a.length; i++) {
|
|
87
|
+
normA += a[i] * a[i];
|
|
88
|
+
normB += b[i] * b[i];
|
|
89
|
+
}
|
|
90
|
+
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
|
91
|
+
if (denominator === 0)
|
|
92
|
+
return 0;
|
|
93
|
+
return dotProduct / denominator;
|
|
94
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 메모리 인덱서 모듈
|
|
3
|
+
* 현재 구현은 vectorStore가 on-demand로 로드하므로 캐시 무효화만 수행
|
|
4
|
+
*/
|
|
5
|
+
import { invalidateCache } from './vectorStore.js';
|
|
6
|
+
// 단일 파일 인덱싱 (캐시 무효화)
|
|
7
|
+
export async function indexFile(_filePath, _source) {
|
|
8
|
+
// vectorStore가 on-demand로 로드하므로 캐시만 무효화
|
|
9
|
+
invalidateCache();
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
// MEMORY.md 인덱싱
|
|
13
|
+
export async function indexMainMemory() {
|
|
14
|
+
invalidateCache();
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
// 일일 메모리 파일들 인덱싱
|
|
18
|
+
export async function indexDailyMemories(_days = 30) {
|
|
19
|
+
invalidateCache();
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
// 전체 리인덱싱 (캐시 무효화 후 미리 로드)
|
|
23
|
+
export async function reindexAll() {
|
|
24
|
+
console.log('[Indexer] Invalidating cache for reindex...');
|
|
25
|
+
invalidateCache();
|
|
26
|
+
// 캐시 무효화 후 즉시 로드하여 청크 수 반환
|
|
27
|
+
// search를 임시로 호출하여 로드 트리거 (빈 쿼리로)
|
|
28
|
+
const { loadAllMemoryChunks } = await import('./vectorStore.js');
|
|
29
|
+
const chunks = await loadAllMemoryChunks();
|
|
30
|
+
// 소스별 집계
|
|
31
|
+
const sourceCounts = new Map();
|
|
32
|
+
for (const chunk of chunks) {
|
|
33
|
+
sourceCounts.set(chunk.source, (sourceCounts.get(chunk.source) || 0) + 1);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
total: chunks.length,
|
|
37
|
+
sources: Array.from(sourceCounts.keys())
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -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
|
}
|
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import { randomBytes } from "crypto";
|
|
2
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
|
+
}
|
|
3
30
|
// Reset 토큰 관리 (1분 만료)
|
|
4
31
|
const resetTokens = new Map();
|
|
5
32
|
function generateResetToken(chatId) {
|
|
@@ -29,6 +56,7 @@ import { isCalendarConfigured, hasCredentials, setCredentials, getAuthUrl, start
|
|
|
29
56
|
import { setBriefingConfig, getBriefingConfig, disableBriefing, } from "../../briefing/index.js";
|
|
30
57
|
import { setHeartbeatConfig, getHeartbeatConfig, disableHeartbeat, } from "../../heartbeat/index.js";
|
|
31
58
|
import { getWorkspace, invalidateWorkspaceCache, buildSystemPrompt, extractName, } from "../utils/index.js";
|
|
59
|
+
import { ensureDefaultCronJobs } from "../../cron/scheduler.js";
|
|
32
60
|
export function registerCommands(bot) {
|
|
33
61
|
// /start 명령어
|
|
34
62
|
bot.command("start", async (ctx) => {
|
|
@@ -67,6 +95,8 @@ export function registerCommands(bot) {
|
|
|
67
95
|
// 일반 모드
|
|
68
96
|
const workspace = await getWorkspace();
|
|
69
97
|
const name = extractName(workspace.identity) || "CompanionBot";
|
|
98
|
+
// 기본 cron jobs 설정 확인
|
|
99
|
+
await ensureDefaultCronJobs(chatId);
|
|
70
100
|
await ctx.reply(`안녕! ${name}이야.\n\n` +
|
|
71
101
|
`명령어:\n` +
|
|
72
102
|
`/clear - 대화 초기화\n` +
|
|
@@ -114,10 +144,28 @@ export function registerCommands(bot) {
|
|
|
114
144
|
await ctx.reply("아직 정리할 대화가 별로 없어!");
|
|
115
145
|
return;
|
|
116
146
|
}
|
|
117
|
-
//
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
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개)`);
|
|
121
169
|
});
|
|
122
170
|
// /memory 명령어 - 최근 기억 보기
|
|
123
171
|
bot.command("memory", async (ctx) => {
|
|
@@ -1,7 +1,54 @@
|
|
|
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
4
|
import { extractUrls, fetchWebContent, buildSystemPrompt, } from "../utils/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* 스트리밍 응답 전송 (Telegram 메시지 실시간 업데이트)
|
|
7
|
+
*/
|
|
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;
|
|
42
|
+
}
|
|
43
|
+
// 최종 메시지 업데이트 (커서 제거)
|
|
44
|
+
try {
|
|
45
|
+
await ctx.api.editMessageText(chatId, messageId, result.text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// 이미 동일 텍스트면 에러 발생 가능 - 무시
|
|
49
|
+
}
|
|
50
|
+
return result.text;
|
|
51
|
+
}
|
|
5
52
|
/**
|
|
6
53
|
* 메시지 핸들러들을 봇에 등록합니다.
|
|
7
54
|
*/
|
|
@@ -50,14 +97,19 @@ export function registerMessageHandlers(bot) {
|
|
|
50
97
|
},
|
|
51
98
|
];
|
|
52
99
|
history.push({ role: "user", content: imageContent });
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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);
|
|
107
|
+
}
|
|
108
|
+
catch (innerError) {
|
|
109
|
+
// 에러 시 방금 추가한 사용자 메시지 롤백 (히스토리 오염 방지)
|
|
110
|
+
history.pop();
|
|
111
|
+
throw innerError;
|
|
59
112
|
}
|
|
60
|
-
await ctx.reply(result);
|
|
61
113
|
}
|
|
62
114
|
catch (error) {
|
|
63
115
|
console.error("Photo error:", error);
|
|
@@ -98,16 +150,16 @@ export function registerMessageHandlers(bot) {
|
|
|
98
150
|
// 사용자 메시지 추가 (URL 내용 포함)
|
|
99
151
|
history.push({ role: "user", content: enrichedMessage });
|
|
100
152
|
try {
|
|
101
|
-
const systemPrompt = await buildSystemPrompt(modelId);
|
|
102
|
-
|
|
153
|
+
const systemPrompt = await buildSystemPrompt(modelId, history);
|
|
154
|
+
// 스트리밍 응답 사용 (실시간 업데이트)
|
|
155
|
+
const response = await sendStreamingResponse(ctx, history, systemPrompt, modelId);
|
|
103
156
|
history.push({ role: "assistant", content: response });
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
history.splice(0, history.length - 20);
|
|
107
|
-
}
|
|
108
|
-
await ctx.reply(response);
|
|
157
|
+
// 토큰 기반 히스토리 트리밍
|
|
158
|
+
trimHistoryByTokens(history);
|
|
109
159
|
}
|
|
110
160
|
catch (error) {
|
|
161
|
+
// 에러 시 방금 추가한 사용자 메시지 롤백 (히스토리 오염 방지)
|
|
162
|
+
history.pop();
|
|
111
163
|
console.error("Chat error:", error);
|
|
112
164
|
await ctx.reply("뭔가 잘못됐어. 다시 시도해줄래?");
|
|
113
165
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { MODELS } from "../../ai/claude.js";
|
|
2
|
-
import {
|
|
2
|
+
import { getWorkspacePath } from "../../workspace/index.js";
|
|
3
3
|
import { getToolsDescription } from "../../tools/index.js";
|
|
4
4
|
import { getWorkspace } from "./cache.js";
|
|
5
|
+
import { embed } from "../../memory/embeddings.js";
|
|
6
|
+
import { search } from "../../memory/vectorStore.js";
|
|
5
7
|
/**
|
|
6
8
|
* identity.md에서 이름을 추출합니다.
|
|
7
9
|
*/
|
|
@@ -17,16 +19,78 @@ export function extractName(identityContent) {
|
|
|
17
19
|
}
|
|
18
20
|
return null;
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* 최근 대화에서 검색 쿼리 컨텍스트를 추출합니다.
|
|
24
|
+
*/
|
|
25
|
+
function extractSearchContext(history) {
|
|
26
|
+
const recent = history.slice(-3);
|
|
27
|
+
return recent
|
|
28
|
+
.filter((m) => m.role === "user")
|
|
29
|
+
.map((m) => (typeof m.content === "string" ? m.content : ""))
|
|
30
|
+
.join(" ")
|
|
31
|
+
.slice(0, 500);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 대화 컨텍스트와 관련된 메모리를 검색합니다.
|
|
35
|
+
*/
|
|
36
|
+
async function getRelevantMemories(history) {
|
|
37
|
+
try {
|
|
38
|
+
const context = extractSearchContext(history);
|
|
39
|
+
if (!context.trim())
|
|
40
|
+
return "";
|
|
41
|
+
const queryEmbedding = await embed(context);
|
|
42
|
+
const results = await search(queryEmbedding, 3, 0.4); // 상위 3개, 유사도 0.4 이상
|
|
43
|
+
if (results.length === 0)
|
|
44
|
+
return "";
|
|
45
|
+
return ("\n\n## 관련 기억\n" +
|
|
46
|
+
results
|
|
47
|
+
.map((r) => `- (${r.source}): ${r.text.slice(0, 200)}${r.text.length > 200 ? "..." : ""}`)
|
|
48
|
+
.join("\n"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 현재 날짜/시간을 한국어 포맷으로 반환합니다.
|
|
56
|
+
*/
|
|
57
|
+
function getKoreanDateTime() {
|
|
58
|
+
const now = new Date();
|
|
59
|
+
const timezone = "Asia/Seoul";
|
|
60
|
+
const formatter = new Intl.DateTimeFormat("ko-KR", {
|
|
61
|
+
timeZone: timezone,
|
|
62
|
+
year: "numeric",
|
|
63
|
+
month: "long",
|
|
64
|
+
day: "numeric",
|
|
65
|
+
weekday: "short",
|
|
66
|
+
hour: "numeric",
|
|
67
|
+
minute: "2-digit",
|
|
68
|
+
hour12: true,
|
|
69
|
+
});
|
|
70
|
+
const formatted = formatter.format(now);
|
|
71
|
+
return {
|
|
72
|
+
formatted,
|
|
73
|
+
timezone: `${timezone} (GMT+9)`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
20
76
|
/**
|
|
21
77
|
* 시스템 프롬프트를 동적으로 생성합니다.
|
|
78
|
+
* @param modelId 사용할 모델 ID
|
|
79
|
+
* @param history 대화 히스토리 (관련 메모리 검색에 사용)
|
|
22
80
|
*/
|
|
23
|
-
export async function buildSystemPrompt(modelId) {
|
|
81
|
+
export async function buildSystemPrompt(modelId, history) {
|
|
24
82
|
const model = MODELS[modelId];
|
|
25
83
|
const workspace = await getWorkspace();
|
|
26
84
|
const parts = [];
|
|
27
85
|
// 기본 정보
|
|
28
86
|
parts.push(`You are a personal AI companion running on ${model.name}.`);
|
|
29
87
|
parts.push(`Workspace: ${getWorkspacePath()}`);
|
|
88
|
+
// 런타임 정보 (날짜/시간)
|
|
89
|
+
const dateTime = getKoreanDateTime();
|
|
90
|
+
parts.push(`Current time: ${dateTime.formatted}`);
|
|
91
|
+
parts.push(`Timezone: ${dateTime.timezone}`);
|
|
92
|
+
// 채널/플랫폼 정보
|
|
93
|
+
parts.push(`Runtime: channel=telegram | capabilities=markdown,inline_keyboard,reactions | version=0.4.x`);
|
|
30
94
|
// BOOTSTRAP 모드인 경우
|
|
31
95
|
if (workspace.bootstrap) {
|
|
32
96
|
parts.push("---");
|
|
@@ -53,12 +117,14 @@ export async function buildSystemPrompt(modelId) {
|
|
|
53
117
|
parts.push("---");
|
|
54
118
|
parts.push(workspace.agents);
|
|
55
119
|
}
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
120
|
+
// 관련 기억 로드 (대화 컨텍스트 기반)
|
|
121
|
+
if (history && history.length > 0) {
|
|
122
|
+
const relevantMemories = await getRelevantMemories(history);
|
|
123
|
+
if (relevantMemories) {
|
|
124
|
+
parts.push("---");
|
|
125
|
+
parts.push("# 관련 기억");
|
|
126
|
+
parts.push(relevantMemories);
|
|
127
|
+
}
|
|
62
128
|
}
|
|
63
129
|
if (workspace.memory) {
|
|
64
130
|
parts.push("---");
|
package/dist/tools/index.js
CHANGED
|
@@ -14,8 +14,11 @@ import { isCalendarConfigured, getEvents, addEvent, deleteEvent, formatEvent, pa
|
|
|
14
14
|
import { setHeartbeatConfig, getHeartbeatConfig, disableHeartbeat, runHeartbeatNow, } from "../heartbeat/index.js";
|
|
15
15
|
import { setBriefingConfig, getBriefingConfig, disableBriefing, sendBriefingNow, } from "../briefing/index.js";
|
|
16
16
|
import { spawnAgent, listAgents, cancelAgent, } from "../agents/index.js";
|
|
17
|
-
import { addCronJob, listCronJobs, removeCronJob, setCronJobEnabled, runCronJobNow, parseScheduleExpression, } from "../cron/index.js";
|
|
17
|
+
import { addCronJob, listCronJobs, removeCronJob, setCronJobEnabled, runCronJobNow, parseScheduleExpression, ensureDefaultCronJobs, } from "../cron/index.js";
|
|
18
18
|
import * as cheerio from "cheerio";
|
|
19
|
+
import { embed } from '../memory/embeddings.js';
|
|
20
|
+
import { search } from '../memory/vectorStore.js';
|
|
21
|
+
import { reindexAll } from '../memory/indexer.js';
|
|
19
22
|
const execAsync = promisify(exec);
|
|
20
23
|
// 메모리에 세션 저장
|
|
21
24
|
const sessions = new Map();
|
|
@@ -782,6 +785,38 @@ Examples:
|
|
|
782
785
|
required: ["id"],
|
|
783
786
|
},
|
|
784
787
|
},
|
|
788
|
+
// ============== 메모리 검색 ==============
|
|
789
|
+
{
|
|
790
|
+
name: "memory_search",
|
|
791
|
+
description: "Search through long-term memories using semantic similarity. Use this when the user asks about past conversations, events, or information that might be stored in memory.",
|
|
792
|
+
input_schema: {
|
|
793
|
+
type: "object",
|
|
794
|
+
properties: {
|
|
795
|
+
query: {
|
|
796
|
+
type: "string",
|
|
797
|
+
description: "Search query - what to look for in memories"
|
|
798
|
+
},
|
|
799
|
+
limit: {
|
|
800
|
+
type: "number",
|
|
801
|
+
description: "Maximum number of results (default: 5)"
|
|
802
|
+
},
|
|
803
|
+
minScore: {
|
|
804
|
+
type: "number",
|
|
805
|
+
description: "Minimum similarity score 0-1 (default: 0.3). Lower = more results but less relevant."
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
required: ["query"]
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
name: "memory_reindex",
|
|
813
|
+
description: "Reindex all memory files. Use when memories seem outdated or after major memory updates.",
|
|
814
|
+
input_schema: {
|
|
815
|
+
type: "object",
|
|
816
|
+
properties: {},
|
|
817
|
+
required: []
|
|
818
|
+
}
|
|
819
|
+
},
|
|
785
820
|
];
|
|
786
821
|
// Tool 실행 함수
|
|
787
822
|
export async function executeTool(name, input) {
|
|
@@ -1046,6 +1081,11 @@ ${"─".repeat(40)}`;
|
|
|
1046
1081
|
await saveWorkspaceFile("USER.md", user);
|
|
1047
1082
|
// BOOTSTRAP.md 삭제
|
|
1048
1083
|
await deleteBootstrap();
|
|
1084
|
+
// 기본 cron jobs 설정 (매일 12시 메모리 저장 등)
|
|
1085
|
+
const chatId = getCurrentChatId();
|
|
1086
|
+
if (chatId) {
|
|
1087
|
+
await ensureDefaultCronJobs(chatId);
|
|
1088
|
+
}
|
|
1049
1089
|
return "Persona saved! BOOTSTRAP mode complete. I'm ready to chat with my new identity.";
|
|
1050
1090
|
}
|
|
1051
1091
|
case "get_weather": {
|
|
@@ -1554,6 +1594,25 @@ Next run: ${nextRunStr}`;
|
|
|
1554
1594
|
return `Error: Cron job ${id} not found.`;
|
|
1555
1595
|
}
|
|
1556
1596
|
}
|
|
1597
|
+
// ============== 메모리 검색 ==============
|
|
1598
|
+
case "memory_search": {
|
|
1599
|
+
const query = input.query;
|
|
1600
|
+
const limit = input.limit || 5;
|
|
1601
|
+
const minScore = input.minScore || 0.3;
|
|
1602
|
+
const queryEmbedding = await embed(query);
|
|
1603
|
+
const results = await search(queryEmbedding, limit, minScore);
|
|
1604
|
+
if (results.length === 0) {
|
|
1605
|
+
return "관련 기억을 찾지 못했어.";
|
|
1606
|
+
}
|
|
1607
|
+
return results.map((r, i) => `[${i + 1}] (${r.source}, score: ${r.score.toFixed(2)})\n${r.text}`).join('\n\n---\n\n');
|
|
1608
|
+
}
|
|
1609
|
+
case "memory_reindex": {
|
|
1610
|
+
const result = await reindexAll();
|
|
1611
|
+
const sourceList = result.sources.length > 5
|
|
1612
|
+
? result.sources.slice(0, 5).join(', ') + ` 외 ${result.sources.length - 5}개`
|
|
1613
|
+
: result.sources.join(', ');
|
|
1614
|
+
return `리인덱싱 완료: 총 ${result.total}개 청크 (소스: ${sourceList || '없음'})`;
|
|
1615
|
+
}
|
|
1557
1616
|
default:
|
|
1558
1617
|
return `Error: Unknown tool: ${name}`;
|
|
1559
1618
|
}
|
|
@@ -1632,5 +1691,9 @@ export function getToolsDescription(modelId) {
|
|
|
1632
1691
|
- toggle_cron: cron job 활성화/비활성화 (id, enabled)
|
|
1633
1692
|
- run_cron: cron job 즉시 실행 (id) - 테스트/수동 트리거용
|
|
1634
1693
|
|
|
1694
|
+
## 메모리 검색
|
|
1695
|
+
- memory_search: 장기 기억에서 시맨틱 검색 (query, limit)
|
|
1696
|
+
- memory_reindex: 메모리 파일 재인덱싱
|
|
1697
|
+
|
|
1635
1698
|
허용된 경로: ${path.join(home, "Documents")}, ${path.join(home, "projects")}, 워크스페이스`;
|
|
1636
1699
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './tokens.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token estimation utilities
|
|
3
|
+
*
|
|
4
|
+
* Claude roughly uses:
|
|
5
|
+
* - English: ~4 chars per token
|
|
6
|
+
* - Korean: ~1.5 tokens per char (한글은 토큰이 더 많이 필요함)
|
|
7
|
+
*
|
|
8
|
+
* These are rough estimates for context management, not exact counts.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Estimate token count for a text string
|
|
12
|
+
* 한글은 보수적으로 1.5 토큰/글자로 계산 (실제보다 약간 높게)
|
|
13
|
+
*/
|
|
14
|
+
export function estimateTokens(text) {
|
|
15
|
+
// 자모음까지 포함하는 넓은 범위의 한글 매칭
|
|
16
|
+
const koreanChars = (text.match(/[\u3131-\uD79D]/g) || []).length;
|
|
17
|
+
const otherChars = text.length - koreanChars;
|
|
18
|
+
return Math.ceil(koreanChars * 1.5 + otherChars / 4);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Estimate token count for an array of messages
|
|
22
|
+
*/
|
|
23
|
+
export function estimateMessagesTokens(messages) {
|
|
24
|
+
return messages.reduce((sum, msg) => {
|
|
25
|
+
const content = typeof msg.content === 'string'
|
|
26
|
+
? msg.content
|
|
27
|
+
: JSON.stringify(msg.content);
|
|
28
|
+
return sum + estimateTokens(content) + 4; // 메시지 오버헤드
|
|
29
|
+
}, 0);
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "companionbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "AI 친구 텔레그램 봇 - Claude API 기반 개인화된 대화 상대",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telegram",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
47
47
|
"@grammyjs/ratelimiter": "^1.2.1",
|
|
48
|
+
"@xenova/transformers": "^2.17.2",
|
|
48
49
|
"cheerio": "^1.2.0",
|
|
49
50
|
"googleapis": "^171.4.0",
|
|
50
51
|
"grammy": "^1.31.0",
|
package/templates/AGENTS.md
CHANGED
|
@@ -53,6 +53,12 @@
|
|
|
53
53
|
|
|
54
54
|
다양한 도구를 사용할 수 있어. 시스템이 자동으로 제공하니까 목록 외울 필요 없어.
|
|
55
55
|
|
|
56
|
+
**언제 도구를 쓸지:**
|
|
57
|
+
- 단순 질문: 도구 없이 바로 답변
|
|
58
|
+
- 파일 작업: 확인 없이 바로 실행
|
|
59
|
+
- 외부 작업 (웹 검색 등): 필요시 바로 실행
|
|
60
|
+
- 위험한 작업 (삭제 등): 먼저 확인
|
|
61
|
+
|
|
56
62
|
**기본 원칙:**
|
|
57
63
|
- 대화가 먼저야 — 도구는 필요할 때만
|
|
58
64
|
- 도구 이름 모르면 그냥 하고 싶은 거 말해
|
|
@@ -65,6 +71,24 @@
|
|
|
65
71
|
- 날씨 조회
|
|
66
72
|
- 스케줄링 (cron)
|
|
67
73
|
|
|
74
|
+
## 도구 호출 스타일
|
|
75
|
+
|
|
76
|
+
기본: 단순한 도구 호출은 설명 없이 바로 실행해.
|
|
77
|
+
|
|
78
|
+
**설명 없이 바로 실행:**
|
|
79
|
+
- 파일 읽기/쓰기
|
|
80
|
+
- 웹 검색
|
|
81
|
+
- 날씨 조회
|
|
82
|
+
- 일정 확인
|
|
83
|
+
|
|
84
|
+
**간단히 설명 후 실행:**
|
|
85
|
+
- 여러 단계 작업
|
|
86
|
+
- 복잡한 문제 해결
|
|
87
|
+
- 민감한 작업 (삭제 등)
|
|
88
|
+
|
|
89
|
+
❌ 나쁜 예: "네, 파일을 읽어볼게요. read_file 도구를 사용하겠습니다."
|
|
90
|
+
✅ 좋은 예: (그냥 바로 read_file 호출)
|
|
91
|
+
|
|
68
92
|
## 💬 대화 스타일
|
|
69
93
|
|
|
70
94
|
- **메신저 환경**이야. 짧고 명확하게.
|
|
@@ -72,6 +96,70 @@
|
|
|
72
96
|
- 이모지 적절히 사용해도 됨.
|
|
73
97
|
- 사용자 스타일에 맞춰.
|
|
74
98
|
|
|
99
|
+
## 이모지 반응
|
|
100
|
+
|
|
101
|
+
Telegram에서 이모지 반응 사용 가능해. 텍스트 응답 대신 반응만으로 충분할 때 사용해.
|
|
102
|
+
|
|
103
|
+
**반응 사용 시:**
|
|
104
|
+
- 👍 간단한 확인/동의
|
|
105
|
+
- ❤️ 감사/공감
|
|
106
|
+
- 😂 재미있을 때
|
|
107
|
+
- 🔥 대단할 때
|
|
108
|
+
- 🤔 생각 중일 때
|
|
109
|
+
|
|
110
|
+
**주의:**
|
|
111
|
+
- 모든 메시지에 반응하지 마
|
|
112
|
+
- 반응 + 텍스트 둘 다 하지 마 (하나만)
|
|
113
|
+
- 5-10개 메시지당 1번 정도가 적당
|
|
114
|
+
|
|
115
|
+
## 그룹챗 행동
|
|
116
|
+
|
|
117
|
+
그룹챗에서는 더 조심해.
|
|
118
|
+
|
|
119
|
+
**말해야 할 때:**
|
|
120
|
+
- 직접 멘션되었을 때
|
|
121
|
+
- 내가 답할 수 있는 질문
|
|
122
|
+
- 유용한 정보 추가할 수 있을 때
|
|
123
|
+
|
|
124
|
+
**조용히 있어야 할 때:**
|
|
125
|
+
- 사람들끼리 대화 중
|
|
126
|
+
- 누군가 이미 답변함
|
|
127
|
+
- 단순 잡담
|
|
128
|
+
|
|
129
|
+
그룹에서는 참여하되, 지배하지 마.
|
|
130
|
+
|
|
131
|
+
## 플랫폼 포맷팅
|
|
132
|
+
|
|
133
|
+
### Telegram
|
|
134
|
+
- 마크다운 지원 (볼드, 이탤릭, 코드블록)
|
|
135
|
+
- 긴 메시지는 4096자 제한
|
|
136
|
+
- 인라인 키보드 버튼 사용 가능
|
|
137
|
+
- 이모지 자유롭게 사용 가능
|
|
138
|
+
|
|
139
|
+
### 응답 스타일
|
|
140
|
+
- 복잡한 정보는 bullet point로
|
|
141
|
+
- 코드는 백틱으로 감싸기
|
|
142
|
+
- 긴 응답은 섹션으로 나누기
|
|
143
|
+
|
|
144
|
+
## 침묵 규칙
|
|
145
|
+
|
|
146
|
+
모든 메시지에 응답할 필요 없어.
|
|
147
|
+
|
|
148
|
+
**침묵해도 되는 경우:**
|
|
149
|
+
- 하트비트 체크인데 할 일 없을 때 → `HEARTBEAT_OK`
|
|
150
|
+
- 사용자가 혼잣말하는 것 같을 때
|
|
151
|
+
- "ㅇㅇ", "ㅋㅋ" 같은 단순 반응
|
|
152
|
+
- 이미 충분히 답변한 후 "고마워" 같은 인사
|
|
153
|
+
|
|
154
|
+
**반드시 응답해야 하는 경우:**
|
|
155
|
+
- 직접적인 질문
|
|
156
|
+
- 도움 요청
|
|
157
|
+
- 명령/지시
|
|
158
|
+
- 중요한 정보 공유
|
|
159
|
+
|
|
160
|
+
**HEARTBEAT_OK:**
|
|
161
|
+
하트비트 메시지 받았는데 특별히 할 일 없으면 `HEARTBEAT_OK`만 응답.
|
|
162
|
+
|
|
75
163
|
## 💓 Heartbeat
|
|
76
164
|
|
|
77
165
|
주기적으로 HEARTBEAT.md를 체크해. 할 일이 있으면 알려주고, 없으면 조용히 있어.
|