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 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
- sonnet: { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
12
- opus: { id: "claude-opus-4-20250514", name: "Claude Opus 4" },
13
- haiku: { id: "claude-haiku-3-5-20241022", name: "Claude Haiku 3.5" },
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 model = MODELS[modelId].id;
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
- let response;
24
- try {
25
- response = await client.messages.create({
26
- model,
27
- max_tokens: 4096,
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
+ }
@@ -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
@@ -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,4 @@
1
+ // Memory module exports
2
+ export { embed, embedBatch, cosineSimilarity } from './embeddings.js';
3
+ export { search, invalidateCache } from './vectorStore.js';
4
+ export { indexFile, indexMainMemory, indexDailyMemories, reindexAll } from './indexer.js';
@@ -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
+ }
@@ -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
- // 최근 4개만 남기고 정리
118
- const removed = history.length - 4;
119
- history.splice(0, removed);
120
- await ctx.reply(`대화 정리 완료! ${removed}개 메시지 압축했어.`);
147
+ // 현재 토큰 계산
148
+ const currentTokens = estimateMessagesTokens(history);
149
+ await ctx.replyWithChatAction("typing");
150
+ await ctx.reply(`📊 현재: ${history.length}개 메시지, ~${currentTokens} 토큰\n요약 생성 중...`);
151
+ // 요약할 메시지와 유지할 최근 메시지 분리
152
+ const recentMessages = history.slice(-4);
153
+ const oldMessages = history.slice(0, -4);
154
+ // 요약 생성
155
+ const summary = await generateSummary(oldMessages);
156
+ // 히스토리 교체: 요약 + 최근 4개
157
+ history.splice(0, history.length);
158
+ history.push({
159
+ role: "user",
160
+ content: `[이전 대화 요약]\n${summary}`
161
+ });
162
+ history.push(...recentMessages);
163
+ // 새 토큰 수 계산
164
+ const newTokens = estimateMessagesTokens(history);
165
+ const savedPercent = Math.round((1 - newTokens / currentTokens) * 100);
166
+ await ctx.reply(`✨ 대화 정리 완료!\n\n` +
167
+ `📉 ${currentTokens} → ${newTokens} 토큰\n` +
168
+ `💾 약 ${savedPercent}% 절약 (${oldMessages.length}개 → 요약 1개)`);
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
- const systemPrompt = await buildSystemPrompt(modelId);
54
- const result = await chat(history, systemPrompt, modelId);
55
- history.push({ role: "assistant", content: result });
56
- // 히스토리 제한
57
- if (history.length > 20) {
58
- history.splice(0, history.length - 20);
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
- const response = await chat(history, systemPrompt, modelId);
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
- // 히스토리 제한 (최근 20개 메시지만 유지)
105
- if (history.length > 20) {
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 { loadRecentMemories, getWorkspacePath } from "../../workspace/index.js";
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
- const recentMemories = await loadRecentMemories(3);
58
- if (recentMemories.trim()) {
59
- parts.push("---");
60
- parts.push("# 최근 기억");
61
- parts.push(recentMemories);
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("---");
@@ -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.4.3",
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",
@@ -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를 체크해. 할 일이 있으면 알려주고, 없으면 조용히 있어.