companionbot 0.10.1 → 0.11.1

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
@@ -1,7 +1,7 @@
1
1
  import Anthropic, { APIError } from "@anthropic-ai/sdk";
2
2
  import { tools, executeTool } from "../tools/index.js";
3
3
  import { sleep } from "../utils/time.js";
4
- import { MAX_RETRIES, BASE_RETRY_DELAY_MS } from "../utils/constants.js";
4
+ import { MAX_RETRIES, BASE_RETRY_DELAY_MS, MAX_TOOL_ITERATIONS, TOOL_RESULT_MAX_LENGTH, TOOL_INPUT_SUMMARY_LENGTH, TOOL_OUTPUT_SUMMARY_LENGTH, } from "../utils/constants.js";
5
5
  async function withRetry(fn, retries = MAX_RETRIES) {
6
6
  let lastError = null;
7
7
  for (let attempt = 0; attempt < retries; attempt++) {
@@ -50,29 +50,70 @@ function getClient() {
50
50
  }
51
51
  return anthropic;
52
52
  }
53
- // 모델별 max_tokens thinking budget 설정
54
- // 참고: Claude API에서 thinking + output이 모델 한도 초과하면 안 됨
53
+ // Thinking 레벨별 설정 (비율 최대값)
54
+ export const THINKING_CONFIGS = {
55
+ off: { ratio: 0, maxBudget: 0 },
56
+ low: { ratio: 0.3, maxBudget: 5000 },
57
+ medium: { ratio: 0.5, maxBudget: 10000 },
58
+ high: { ratio: 0.7, maxBudget: 20000 },
59
+ };
60
+ // 모델별 설정
55
61
  export const MODELS = {
56
62
  haiku: {
57
63
  id: "claude-haiku-3-5-20241022",
58
64
  name: "Claude Haiku 3.5",
59
- maxTokens: 4096, // 빠른 응답
60
- thinkingBudget: 0, // Haiku는 thinking 미지원
65
+ contextWindow: 200000,
66
+ supportsThinking: false, // Haiku는 thinking 미지원
61
67
  },
62
68
  sonnet: {
63
69
  id: "claude-sonnet-4-20250514",
64
70
  name: "Claude Sonnet 4",
65
- maxTokens: 16000, // 일반 작업 (must be > thinkingBudget)
66
- thinkingBudget: 10000, // 적당한 thinking
71
+ contextWindow: 200000,
72
+ supportsThinking: true,
67
73
  },
68
74
  opus: {
69
75
  id: "claude-opus-4-20250514",
70
76
  name: "Claude Opus 4",
71
- maxTokens: 64000, // 복잡한 작업 (must be > thinkingBudget)
72
- thinkingBudget: 32000, // 깊은 thinking
77
+ contextWindow: 200000,
78
+ supportsThinking: true,
73
79
  },
74
80
  };
75
- export async function chat(messages, systemPrompt, modelId = "sonnet") {
81
+ // 동적 토큰 계산을 위한 설정
82
+ const MIN_OUTPUT_TOKENS = 4096; // 최소 출력 토큰
83
+ const OUTPUT_BUFFER_RATIO = 0.3; // 컨텍스트의 30%를 출력용으로 예약
84
+ /**
85
+ * 동적으로 max_tokens와 thinking budget 계산
86
+ *
87
+ * @param modelId 모델 ID
88
+ * @param thinkingLevel thinking 레벨
89
+ * @param inputTokens 현재 입력 토큰 수 (시스템 프롬프트 + 히스토리)
90
+ * @returns { maxTokens, thinkingBudget }
91
+ */
92
+ export function calculateTokenBudgets(modelId, thinkingLevel, inputTokens) {
93
+ const model = MODELS[modelId];
94
+ const thinkingConfig = THINKING_CONFIGS[thinkingLevel];
95
+ // Thinking 미지원 모델이거나 off인 경우
96
+ if (!model.supportsThinking || thinkingLevel === "off") {
97
+ // 간단히 고정 max_tokens 사용
98
+ return { maxTokens: 8192, thinkingBudget: 0 };
99
+ }
100
+ // 사용 가능한 출력 토큰 계산
101
+ // 컨텍스트 윈도우 - 입력 토큰 = 출력 가능 토큰
102
+ const availableOutputTokens = model.contextWindow - inputTokens;
103
+ // 최소 출력 토큰 보장
104
+ const maxTokens = Math.max(MIN_OUTPUT_TOKENS, Math.floor(availableOutputTokens * OUTPUT_BUFFER_RATIO));
105
+ // thinking budget 계산: min(레벨별 최대값, max_tokens * 비율)
106
+ // API 조건: max_tokens > budget_tokens 이므로 max_tokens - 1024 로 상한 설정
107
+ const calculatedBudget = Math.floor(maxTokens * thinkingConfig.ratio);
108
+ const thinkingBudget = Math.min(thinkingConfig.maxBudget, calculatedBudget, maxTokens - 1024 // max_tokens > budget_tokens 조건 충족
109
+ );
110
+ // budget이 1024 미만이면 thinking 비활성화 (의미 없음)
111
+ if (thinkingBudget < 1024) {
112
+ return { maxTokens, thinkingBudget: 0 };
113
+ }
114
+ return { maxTokens, thinkingBudget };
115
+ }
116
+ export async function chat(messages, systemPrompt, modelId = "sonnet", thinkingLevel = "medium") {
76
117
  const client = getClient();
77
118
  const modelConfig = MODELS[modelId];
78
119
  const toolsUsed = [];
@@ -81,11 +122,31 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
81
122
  role: m.role,
82
123
  content: m.content,
83
124
  }));
125
+ // 입력 토큰 추정 (대략적)
126
+ const estimateInputTokens = () => {
127
+ let total = 0;
128
+ // 시스템 프롬프트
129
+ if (systemPrompt) {
130
+ total += Math.ceil(systemPrompt.length / 3); // 대략 3자당 1토큰
131
+ }
132
+ // 메시지들
133
+ for (const msg of apiMessages) {
134
+ const content = typeof msg.content === "string"
135
+ ? msg.content
136
+ : JSON.stringify(msg.content);
137
+ total += Math.ceil(content.length / 3);
138
+ }
139
+ return total;
140
+ };
141
+ // 동적 토큰 budget 계산
142
+ const inputTokens = estimateInputTokens();
143
+ const { maxTokens, thinkingBudget } = calculateTokenBudgets(modelId, thinkingLevel, inputTokens);
144
+ console.log(`[Chat] model=${modelId}, thinking=${thinkingLevel}, input~${inputTokens}, maxTokens=${maxTokens}, budget=${thinkingBudget}`);
84
145
  // API 요청 파라미터 빌드 (도구 루프에서도 동일하게 사용)
85
146
  const buildRequestParams = () => {
86
147
  const params = {
87
148
  model: modelConfig.id,
88
- max_tokens: modelConfig.maxTokens,
149
+ max_tokens: maxTokens,
89
150
  messages: apiMessages,
90
151
  tools: tools,
91
152
  };
@@ -93,18 +154,17 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
93
154
  params.system = systemPrompt;
94
155
  }
95
156
  // thinking 활성화 (budget > 0인 경우)
96
- if (modelConfig.thinkingBudget > 0) {
157
+ if (thinkingBudget > 0) {
97
158
  params.thinking = {
98
159
  type: "enabled",
99
- budget_tokens: modelConfig.thinkingBudget,
160
+ budget_tokens: thinkingBudget,
100
161
  };
101
162
  }
102
163
  return params;
103
164
  };
104
165
  let response;
105
166
  response = await withRetry(() => client.messages.create(buildRequestParams()));
106
- // Tool use 루프 - Claude가 도구 사용을 멈출 때까지 반복 (최대 10회)
107
- const MAX_TOOL_ITERATIONS = 10;
167
+ // Tool use 루프 - Claude가 도구 사용을 멈출 때까지 반복
108
168
  let iterations = 0;
109
169
  while (response.stop_reason === "tool_use" && iterations < MAX_TOOL_ITERATIONS) {
110
170
  iterations++;
@@ -115,8 +175,8 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
115
175
  console.log(`[Tool] ${toolUse.name}:`, JSON.stringify(toolUse.input));
116
176
  const result = await executeTool(toolUse.name, toolUse.input);
117
177
  // 결과가 너무 길면 자르기
118
- const truncatedResult = result.length > 10000
119
- ? result.slice(0, 10000) + "\n... (truncated)"
178
+ const truncatedResult = result.length > TOOL_RESULT_MAX_LENGTH
179
+ ? result.slice(0, TOOL_RESULT_MAX_LENGTH) + "\n... (truncated)"
120
180
  : result;
121
181
  toolResults.push({
122
182
  type: "tool_result",
@@ -126,8 +186,8 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
126
186
  // 도구 사용 기록 (히스토리 참조용)
127
187
  toolsUsed.push({
128
188
  name: toolUse.name,
129
- input: JSON.stringify(toolUse.input).slice(0, 200),
130
- output: truncatedResult.slice(0, 500),
189
+ input: JSON.stringify(toolUse.input).slice(0, TOOL_INPUT_SUMMARY_LENGTH),
190
+ output: truncatedResult.slice(0, TOOL_OUTPUT_SUMMARY_LENGTH),
131
191
  });
132
192
  }
133
193
  // 어시스턴트 메시지와 도구 결과 추가
@@ -165,10 +225,10 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
165
225
  * 주의: 스트리밍은 재시도하지 않음 (이미 전송된 청크를 되돌릴 수 없음)
166
226
  * 스트리밍 중 에러 발생 시 적절한 에러 메시지를 반환하거나 예외를 전파함
167
227
  */
168
- export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
228
+ export async function chatSmart(messages, systemPrompt, modelId, thinkingLevel = "medium", onChunk) {
169
229
  // 스트리밍 콜백이 없으면 그냥 일반 chat 사용
170
230
  if (!onChunk) {
171
- const result = await chat(messages, systemPrompt, modelId);
231
+ const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
172
232
  return { text: result.text, usedTools: result.toolsUsed.length > 0, toolsUsed: result.toolsUsed };
173
233
  }
174
234
  const client = getClient();
@@ -178,10 +238,24 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
178
238
  role: m.role,
179
239
  content: m.content,
180
240
  }));
241
+ // 입력 토큰 추정
242
+ let inputTokens = 0;
243
+ if (systemPrompt) {
244
+ inputTokens += Math.ceil(systemPrompt.length / 3);
245
+ }
246
+ for (const msg of apiMessages) {
247
+ const content = typeof msg.content === "string"
248
+ ? msg.content
249
+ : JSON.stringify(msg.content);
250
+ inputTokens += Math.ceil(content.length / 3);
251
+ }
252
+ // 동적 토큰 budget 계산
253
+ const { maxTokens, thinkingBudget } = calculateTokenBudgets(modelId, thinkingLevel, inputTokens);
254
+ console.log(`[ChatSmart] model=${modelId}, thinking=${thinkingLevel}, input~${inputTokens}, maxTokens=${maxTokens}, budget=${thinkingBudget}`);
181
255
  // 스트리밍 요청 파라미터
182
256
  const params = {
183
257
  model: modelConfig.id,
184
- max_tokens: modelConfig.maxTokens,
258
+ max_tokens: maxTokens,
185
259
  messages: apiMessages,
186
260
  tools: tools,
187
261
  stream: true,
@@ -189,8 +263,13 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
189
263
  if (systemPrompt) {
190
264
  params.system = systemPrompt;
191
265
  }
192
- // Thinking 스트리밍에서 복잡해지므로 일단 비활성화
193
- // (도구 호출 폴백 시 chat()에서 thinking 사용됨)
266
+ // Thinking 활성화 (스트리밍에서도 지원)
267
+ if (thinkingBudget > 0) {
268
+ params.thinking = {
269
+ type: "enabled",
270
+ budget_tokens: thinkingBudget,
271
+ };
272
+ }
194
273
  let accumulated = "";
195
274
  let streamingStarted = false;
196
275
  try {
@@ -214,7 +293,7 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
214
293
  // 주의: chat()은 내부에서 withRetry를 사용하므로 여기서 추가 재시도 불필요
215
294
  if (stopReason === "tool_use") {
216
295
  console.log("[Stream] Tool use detected, falling back to chat()");
217
- const result = await chat(messages, systemPrompt, modelId);
296
+ const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
218
297
  return { text: result.text, usedTools: true, toolsUsed: result.toolsUsed };
219
298
  }
220
299
  // 성공적으로 스트리밍 완료
@@ -228,7 +307,7 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
228
307
  console.log(`[Stream] Pre-stream error (${error.status}), retrying with withRetry...`);
229
308
  return await withRetry(async () => {
230
309
  // 재시도 시 일반 chat 사용 (스트리밍 대신)
231
- const result = await chat(messages, systemPrompt, modelId);
310
+ const result = await chat(messages, systemPrompt, modelId, thinkingLevel);
232
311
  return { text: result.text, usedTools: false, toolsUsed: result.toolsUsed };
233
312
  });
234
313
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 전역 상수 설정
3
+ * 하드코딩된 매직 넘버들을 한 곳에서 관리
4
+ */
5
+ // ============================================
6
+ // 세션 관련 설정
7
+ // ============================================
8
+ export const SESSION = {
9
+ /** 최대 동시 세션 수 (LRU 정리) */
10
+ MAX_SESSIONS: 100,
11
+ /** 세션 TTL (밀리초) - 24시간 */
12
+ TTL_MS: 24 * 60 * 60 * 1000,
13
+ /** 메모리에 로드할 최대 히스토리 메시지 수 */
14
+ MAX_HISTORY_LOAD: 50,
15
+ };
16
+ // ============================================
17
+ // 토큰/컨텍스트 관련 설정
18
+ // ============================================
19
+ export const TOKENS = {
20
+ /** Claude 최대 컨텍스트 토큰 */
21
+ MAX_CONTEXT: 100000,
22
+ /** 히스토리 토큰 한도 */
23
+ MAX_HISTORY: 40000,
24
+ /** 이 이상이면 자동 요약 시작 */
25
+ SUMMARY_THRESHOLD: 25000,
26
+ /** 핀 맥락 최대 토큰 */
27
+ MAX_PINNED: 5000,
28
+ /** 자동 압축 시작 비율 (0.35 = 35%) */
29
+ COMPACTION_THRESHOLD: 0.35,
30
+ /** compact 스킵 기준 토큰 */
31
+ COMPACT_MIN_TOKENS: 5000,
32
+ };
33
+ // ============================================
34
+ // 메시지 관련 설정
35
+ // ============================================
36
+ export const MESSAGES = {
37
+ /** 트리밍 시 최소 유지할 최근 메시지 수 */
38
+ MIN_RECENT: 6,
39
+ /** compact 시 유지할 최근 메시지 수 */
40
+ KEEP_ON_COMPACT: 4,
41
+ /** 최대 요약 청크 수 */
42
+ MAX_SUMMARY_CHUNKS: 3,
43
+ /** 검색 기본 결과 수 */
44
+ SEARCH_LIMIT: 10,
45
+ /** 히스토리 로드 기본 limit */
46
+ HISTORY_LOAD_LIMIT: 100,
47
+ };
48
+ // ============================================
49
+ // 메모리/벡터 저장소 설정
50
+ // ============================================
51
+ export const MEMORY = {
52
+ /** 벡터 캐시 TTL (밀리초) - 5분 */
53
+ CACHE_TTL_MS: 5 * 60 * 1000,
54
+ /** 최소 청크 길이 (이하는 무시) */
55
+ MIN_CHUNK_LENGTH: 20,
56
+ /** 최대 청크 길이 (초과 시 분할) */
57
+ MAX_CHUNK_LENGTH: 500,
58
+ /** 로드할 최근 메모리 파일 일수 */
59
+ RECENT_DAYS: 30,
60
+ /** 벡터 검색 기본 topK */
61
+ SEARCH_TOP_K: 5,
62
+ /** 벡터 검색 최소 유사도 점수 */
63
+ MIN_SIMILARITY: 0.3,
64
+ /** /memory 명령어 표시 일수 */
65
+ DISPLAY_DAYS: 7,
66
+ /** /memory 최대 표시 길이 */
67
+ MAX_DISPLAY_LENGTH: 2000,
68
+ };
69
+ // ============================================
70
+ // 텔레그램/UI 관련 설정
71
+ // ============================================
72
+ export const TELEGRAM = {
73
+ /** 스트리밍 업데이트 간격 (밀리초) */
74
+ STREAM_UPDATE_INTERVAL_MS: 500,
75
+ /** 최대 이미지 크기 (바이트) - 10MB */
76
+ MAX_IMAGE_SIZE: 10 * 1024 * 1024,
77
+ /** URL 처리 최대 개수 */
78
+ MAX_URL_FETCH: 3,
79
+ /** 캘린더 미리보기 이벤트 수 */
80
+ CALENDAR_PREVIEW_COUNT: 3,
81
+ };
82
+ // ============================================
83
+ // 보안/토큰 관련 설정
84
+ // ============================================
85
+ export const SECURITY = {
86
+ /** 리셋 토큰 만료 시간 (밀리초) - 1분 */
87
+ RESET_TOKEN_TTL_MS: 60000,
88
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 설정 파일 로더
3
+ * config.json에서 사용자 설정을 읽음
4
+ */
5
+ import { readFileSync, existsSync } from "fs";
6
+ import { resolve, dirname } from "path";
7
+ import { fileURLToPath } from "url";
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const CONFIG_PATH = resolve(__dirname, "../../config.json");
10
+ const DEFAULT_CONFIG = {
11
+ thinking: "medium",
12
+ };
13
+ let cachedConfig = null;
14
+ export function loadConfig() {
15
+ if (cachedConfig)
16
+ return cachedConfig;
17
+ if (!existsSync(CONFIG_PATH)) {
18
+ console.log("[Config] config.json not found, using defaults");
19
+ cachedConfig = DEFAULT_CONFIG;
20
+ return cachedConfig;
21
+ }
22
+ try {
23
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
24
+ const parsed = JSON.parse(raw);
25
+ cachedConfig = {
26
+ thinking: parsed.thinking ?? DEFAULT_CONFIG.thinking,
27
+ };
28
+ console.log(`[Config] Loaded: thinking=${cachedConfig.thinking}`);
29
+ return cachedConfig;
30
+ }
31
+ catch (error) {
32
+ console.error("[Config] Failed to load config.json:", error);
33
+ cachedConfig = DEFAULT_CONFIG;
34
+ return cachedConfig;
35
+ }
36
+ }
37
+ export function getConfig() {
38
+ return cachedConfig ?? loadConfig();
39
+ }
@@ -7,6 +7,7 @@ import * as path from "path";
7
7
  import { invalidateCache, loadAllMemoryChunks } from "./vectorStore.js";
8
8
  import { indexTextBatch, clearIndex as clearFtsIndex, getDocumentCount } from "./ftsIndex.js";
9
9
  import { getMemoryDirPath, getWorkspaceFilePath } from "../workspace/paths.js";
10
+ import { MEMORY } from "../config/constants.js";
10
11
  /**
11
12
  * 텍스트를 청크로 분할합니다.
12
13
  */
@@ -17,14 +18,14 @@ function splitIntoChunks(text, source) {
17
18
  const sections = text.split(/(?=^## )/m);
18
19
  for (const section of sections) {
19
20
  const trimmed = section.trim();
20
- if (!trimmed || trimmed.length < 20)
21
+ if (!trimmed || trimmed.length < MEMORY.MIN_CHUNK_LENGTH)
21
22
  continue;
22
23
  // 청크가 너무 길면 추가로 분할
23
- if (trimmed.length > 500) {
24
+ if (trimmed.length > MEMORY.MAX_CHUNK_LENGTH) {
24
25
  const lines = trimmed.split("\n");
25
26
  let currentChunk = "";
26
27
  for (const line of lines) {
27
- if (currentChunk.length + line.length > 500) {
28
+ if (currentChunk.length + line.length > MEMORY.MAX_CHUNK_LENGTH) {
28
29
  if (currentChunk.trim()) {
29
30
  chunks.push({
30
31
  id: `${source}:${chunkIndex++}`,
@@ -88,7 +89,7 @@ export async function indexMainMemory() {
88
89
  /**
89
90
  * 일일 메모리 파일들 인덱싱
90
91
  */
91
- export async function indexDailyMemories(days = 30) {
92
+ export async function indexDailyMemories(days = MEMORY.RECENT_DAYS) {
92
93
  const memoryDir = getMemoryDirPath();
93
94
  let totalChunks = 0;
94
95
  try {
@@ -7,10 +7,10 @@ import * as fs from "fs/promises";
7
7
  import * as path from "path";
8
8
  import { getMemoryDirPath, getWorkspaceFilePath } from "../workspace/paths.js";
9
9
  import { embed, embedBatch, cosineSimilarity } from "./embeddings.js";
10
+ import { MEMORY } from "../config/constants.js";
10
11
  // 캐시된 청크들 (임베딩 포함)
11
12
  let cachedChunks = [];
12
13
  let cacheTimestamp = 0;
13
- const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
14
14
  // 임베딩 영속 캐시 (hash → embedding)
15
15
  let embeddingCache = new Map();
16
16
  let embeddingCacheLoaded = false;
@@ -75,14 +75,14 @@ function splitIntoChunks(text, source) {
75
75
  const sections = text.split(/(?=^## )/m);
76
76
  for (const section of sections) {
77
77
  const trimmed = section.trim();
78
- if (!trimmed || trimmed.length < 20)
78
+ if (!trimmed || trimmed.length < MEMORY.MIN_CHUNK_LENGTH)
79
79
  continue;
80
80
  // 청크가 너무 길면 추가로 분할
81
- if (trimmed.length > 500) {
81
+ if (trimmed.length > MEMORY.MAX_CHUNK_LENGTH) {
82
82
  const lines = trimmed.split("\n");
83
83
  let currentChunk = "";
84
84
  for (const line of lines) {
85
- if (currentChunk.length + line.length > 500) {
85
+ if (currentChunk.length + line.length > MEMORY.MAX_CHUNK_LENGTH) {
86
86
  if (currentChunk.trim()) {
87
87
  chunks.push({
88
88
  text: currentChunk.trim(),
@@ -121,11 +121,11 @@ async function doLoadAllMemoryChunks() {
121
121
  // 임베딩 캐시 로드
122
122
  await loadEmbeddingCache();
123
123
  const chunks = [];
124
- // 1. 일별 메모리 파일 (최근 30일)
124
+ // 1. 일별 메모리 파일
125
125
  const memoryDir = getMemoryDirPath();
126
126
  try {
127
127
  const files = await fs.readdir(memoryDir);
128
- const mdFiles = files.filter(f => f.endsWith(".md") && !f.startsWith(".")).sort().reverse().slice(0, 30);
128
+ const mdFiles = files.filter(f => f.endsWith(".md") && !f.startsWith(".")).sort().reverse().slice(0, MEMORY.RECENT_DAYS);
129
129
  for (const file of mdFiles) {
130
130
  try {
131
131
  const content = await fs.readFile(path.join(memoryDir, file), "utf-8");
@@ -168,7 +168,7 @@ async function doLoadAllMemoryChunks() {
168
168
  export async function loadAllMemoryChunks() {
169
169
  const now = Date.now();
170
170
  // 캐시가 유효하면 반환
171
- if (cachedChunks.length > 0 && now - cacheTimestamp < CACHE_TTL_MS) {
171
+ if (cachedChunks.length > 0 && now - cacheTimestamp < MEMORY.CACHE_TTL_MS) {
172
172
  return cachedChunks;
173
173
  }
174
174
  // 이미 로딩 중이면 해당 Promise 반환 (중복 로드 방지)
@@ -182,7 +182,7 @@ export async function loadAllMemoryChunks() {
182
182
  // 캐시 업데이트 (임베딩은 아직 없음)
183
183
  // 빈 결과도 캐시하되 TTL을 짧게 (1분)
184
184
  cachedChunks = chunks;
185
- cacheTimestamp = chunks.length > 0 ? Date.now() : Date.now() - CACHE_TTL_MS + 60000;
185
+ cacheTimestamp = chunks.length > 0 ? Date.now() : Date.now() - MEMORY.CACHE_TTL_MS + 60000;
186
186
  return chunks;
187
187
  }
188
188
  catch (error) {
@@ -200,7 +200,7 @@ export async function loadAllMemoryChunks() {
200
200
  * @param topK 반환할 최대 결과 수
201
201
  * @param minScore 최소 유사도 점수 (0-1)
202
202
  */
203
- export async function search(queryEmbedding, topK = 5, minScore = 0.3) {
203
+ export async function search(queryEmbedding, topK = MEMORY.SEARCH_TOP_K, minScore = MEMORY.MIN_SIMILARITY) {
204
204
  const chunks = await loadAllMemoryChunks();
205
205
  if (chunks.length === 0) {
206
206
  return [];
@@ -9,6 +9,7 @@ import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  import * as os from "os";
11
11
  import * as readline from "readline";
12
+ import { MESSAGES } from "../config/constants.js";
12
13
  // 저장 경로
13
14
  const SESSIONS_DIR = path.join(os.homedir(), ".companionbot", "sessions");
14
15
  /**
@@ -52,7 +53,7 @@ export function appendMessage(chatId, role, content) {
52
53
  * @param limit 최근 N개만 로드 (메모리 절약, 0 = 전부)
53
54
  * @returns 로드된 메시지 배열
54
55
  */
55
- export async function loadHistory(chatId, limit = 100) {
56
+ export async function loadHistory(chatId, limit = MESSAGES.HISTORY_LOAD_LIMIT) {
56
57
  const filePath = getSessionFilePath(chatId);
57
58
  if (!fs.existsSync(filePath)) {
58
59
  return [];
@@ -89,7 +90,7 @@ export async function loadHistory(chatId, limit = 100) {
89
90
  /**
90
91
  * 동기 버전 히스토리 로드 (초기화 시 사용)
91
92
  */
92
- export function loadHistorySync(chatId, limit = 100) {
93
+ export function loadHistorySync(chatId, limit = MESSAGES.HISTORY_LOAD_LIMIT) {
93
94
  const filePath = getSessionFilePath(chatId);
94
95
  if (!fs.existsSync(filePath)) {
95
96
  return [];
@@ -184,7 +185,7 @@ export function listSessionFiles() {
184
185
  * @param limit 최대 결과 수
185
186
  * @returns 매칭된 메시지들
186
187
  */
187
- export async function searchHistory(chatId, query, limit = 10) {
188
+ export async function searchHistory(chatId, query, limit = MESSAGES.SEARCH_LIMIT) {
188
189
  const all = await loadHistory(chatId, 0); // 전부 로드
189
190
  const lowerQuery = query.toLowerCase();
190
191
  const matches = all
@@ -1,27 +1,21 @@
1
1
  import { AsyncLocalStorage } from "async_hooks";
2
2
  import { estimateMessagesTokens, estimateTokens } from "../utils/tokens.js";
3
3
  import * as persistence from "./persistence.js";
4
- // 세션 설정
5
- const MAX_SESSIONS = 100;
6
- const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24시간
7
- // 토큰 한도 (개선됨)
8
- const MAX_HISTORY_TOKENS = 40000; // 히스토리 한도
9
- const SUMMARY_THRESHOLD_TOKENS = 25000; // 이 이상이면 요약 시작
10
- const MIN_RECENT_MESSAGES = 6; // 최소 유지할 최근 메시지
11
- const MAX_PINNED_TOKENS = 5000; // 핀 맥락 최대 토큰
12
- // 영구 저장 설정
13
- const MAX_HISTORY_LOAD = 50; // 메모리에 로드할 최대 메시지 수
4
+ import { SESSION, TOKENS, MESSAGES } from "../config/constants.js";
5
+ import { getConfig } from "../config/index.js";
14
6
  // 세션별 상태 저장
15
7
  const sessions = new Map();
16
8
  // AsyncLocalStorage for chatId context
17
9
  const chatIdStorage = new AsyncLocalStorage();
18
10
  function getSession(chatId) {
11
+ const config = getConfig();
19
12
  // chatId 유효성 검사
20
13
  if (chatId == null || isNaN(chatId)) {
21
14
  console.error(`[Session] BUG: Invalid chatId: ${chatId} - history will NOT persist!`);
22
15
  return {
23
16
  history: [],
24
17
  model: "sonnet",
18
+ thinkingLevel: config.thinking,
25
19
  lastAccessedAt: Date.now(),
26
20
  pinnedContexts: [],
27
21
  summaryChunks: [],
@@ -36,12 +30,14 @@ function getSession(chatId) {
36
30
  existing.pinnedContexts = [];
37
31
  if (!existing.summaryChunks)
38
32
  existing.summaryChunks = [];
33
+ if (!existing.thinkingLevel)
34
+ existing.thinkingLevel = config.thinking;
39
35
  return existing;
40
36
  }
41
37
  // 새 세션 생성 전 정리
42
38
  cleanupSessions();
43
39
  // 기존 JSONL 파일에서 히스토리 로드
44
- const persistedMessages = persistence.loadHistorySync(chatId, MAX_HISTORY_LOAD);
40
+ const persistedMessages = persistence.loadHistorySync(chatId, SESSION.MAX_HISTORY_LOAD);
45
41
  const history = persistedMessages.map(pm => ({
46
42
  role: pm.role,
47
43
  content: pm.content,
@@ -53,6 +49,7 @@ function getSession(chatId) {
53
49
  const session = {
54
50
  history,
55
51
  model: "sonnet",
52
+ thinkingLevel: config.thinking,
56
53
  lastAccessedAt: now,
57
54
  pinnedContexts: [],
58
55
  summaryChunks: [],
@@ -65,15 +62,15 @@ function cleanupSessions() {
65
62
  const now = Date.now();
66
63
  // 1. TTL 만료된 세션 삭제
67
64
  for (const [chatId, session] of sessions) {
68
- if (now - session.lastAccessedAt > SESSION_TTL_MS) {
65
+ if (now - session.lastAccessedAt > SESSION.TTL_MS) {
69
66
  sessions.delete(chatId);
70
67
  }
71
68
  }
72
69
  // 2. 최대 개수 초과 시 LRU 방식으로 삭제
73
- if (sessions.size >= MAX_SESSIONS) {
70
+ if (sessions.size >= SESSION.MAX_SESSIONS) {
74
71
  const entries = Array.from(sessions.entries());
75
72
  entries.sort((a, b) => a[1].lastAccessedAt - b[1].lastAccessedAt);
76
- const toRemove = entries.slice(0, sessions.size - MAX_SESSIONS + 1);
73
+ const toRemove = entries.slice(0, sessions.size - SESSION.MAX_SESSIONS + 1);
77
74
  for (const [chatId] of toRemove) {
78
75
  sessions.delete(chatId);
79
76
  }
@@ -123,10 +120,10 @@ export function pinContext(chatId, text, source = "user") {
123
120
  const currentTokens = session.pinnedContexts.reduce((sum, p) => sum + estimateTokens(p.text), 0);
124
121
  const newTokens = estimateTokens(text);
125
122
  // 토큰 한도 체크
126
- if (currentTokens + newTokens > MAX_PINNED_TOKENS) {
123
+ if (currentTokens + newTokens > TOKENS.MAX_PINNED) {
127
124
  // 오래된 자동 핀부터 제거
128
125
  while (session.pinnedContexts.length > 0 &&
129
- currentTokens + newTokens > MAX_PINNED_TOKENS) {
126
+ currentTokens + newTokens > TOKENS.MAX_PINNED) {
130
127
  const autoIndex = session.pinnedContexts.findIndex((p) => p.source === "auto");
131
128
  if (autoIndex >= 0) {
132
129
  session.pinnedContexts.splice(autoIndex, 1);
@@ -168,8 +165,8 @@ export function clearPins(chatId) {
168
165
  export function addSummaryChunk(chatId, chunk) {
169
166
  const session = getSession(chatId);
170
167
  session.summaryChunks.push(chunk);
171
- // 오래된 요약은 병합 (최대 3개 유지)
172
- while (session.summaryChunks.length > 3) {
168
+ // 오래된 요약은 병합
169
+ while (session.summaryChunks.length > MESSAGES.MAX_SUMMARY_CHUNKS) {
173
170
  const [first, second] = session.summaryChunks.splice(0, 2);
174
171
  session.summaryChunks.unshift({
175
172
  summary: `${first.summary}\n\n${second.summary}`,
@@ -193,12 +190,12 @@ export function trimHistoryByTokens(history) {
193
190
  }
194
191
  const currentTokens = estimateMessagesTokens(history);
195
192
  // 한도 이내면 패스
196
- if (currentTokens <= MAX_HISTORY_TOKENS) {
193
+ if (currentTokens <= TOKENS.MAX_HISTORY) {
197
194
  return;
198
195
  }
199
196
  console.log(`[Trim] Starting trim: ${currentTokens} tokens, ${history.length} messages`);
200
197
  // 최근 메시지는 반드시 유지
201
- while (estimateMessagesTokens(history) > MAX_HISTORY_TOKENS && history.length > MIN_RECENT_MESSAGES) {
198
+ while (estimateMessagesTokens(history) > TOKENS.MAX_HISTORY && history.length > MESSAGES.MIN_RECENT) {
202
199
  history.shift();
203
200
  }
204
201
  const afterTokens = estimateMessagesTokens(history);
@@ -219,7 +216,7 @@ export async function smartTrimHistory(chatId, summarizeFn) {
219
216
  }
220
217
  const currentTokens = estimateMessagesTokens(history);
221
218
  // 요약 임계치 이하면 패스
222
- if (currentTokens <= SUMMARY_THRESHOLD_TOKENS) {
219
+ if (currentTokens <= TOKENS.SUMMARY_THRESHOLD) {
223
220
  return false;
224
221
  }
225
222
  // 요약 함수가 없으면 기본 트리밍만
@@ -228,9 +225,9 @@ export async function smartTrimHistory(chatId, summarizeFn) {
228
225
  return false;
229
226
  }
230
227
  console.log(`[SmartTrim] chatId=${chatId} tokens=${currentTokens}, starting summarization...`);
231
- // 오래된 메시지들 (최근 6개 제외)
232
- const toSummarize = history.slice(0, -MIN_RECENT_MESSAGES);
233
- const toKeep = history.slice(-MIN_RECENT_MESSAGES);
228
+ // 오래된 메시지들 (최근 N개 제외)
229
+ const toSummarize = history.slice(0, -MESSAGES.MIN_RECENT);
230
+ const toKeep = history.slice(-MESSAGES.MIN_RECENT);
234
231
  if (toSummarize.length < 4) {
235
232
  // 요약할 게 별로 없으면 기본 트리밍
236
233
  trimHistoryByTokens(history);
@@ -334,6 +331,12 @@ export function getModel(chatId) {
334
331
  export function setModel(chatId, modelId) {
335
332
  getSession(chatId).model = modelId;
336
333
  }
334
+ export function getThinkingLevel(chatId) {
335
+ return getSession(chatId).thinkingLevel;
336
+ }
337
+ export function setThinkingLevel(chatId, level) {
338
+ getSession(chatId).thinkingLevel = level;
339
+ }
337
340
  export function runWithChatId(chatId, fn) {
338
341
  return chatIdStorage.run(chatId, fn);
339
342
  }
@@ -2,6 +2,7 @@ import { randomBytes } from "crypto";
2
2
  import { getHealthStatus, formatUptime } from "../../health/index.js";
3
3
  import { chat, MODELS } from "../../ai/claude.js";
4
4
  import { estimateMessagesTokens } from "../../utils/tokens.js";
5
+ import { TOKENS, MESSAGES, MEMORY, SECURITY, TELEGRAM } from "../../config/constants.js";
5
6
  // 대화 요약 생성 함수
6
7
  async function generateSummary(messages) {
7
8
  const conversationText = messages.map(m => {
@@ -32,7 +33,7 @@ ${conversationText}
32
33
  const resetTokens = new Map();
33
34
  function generateResetToken(chatId) {
34
35
  const token = randomBytes(8).toString("hex");
35
- const expiresAt = Date.now() + 60000; // 1분 후 만료
36
+ const expiresAt = Date.now() + SECURITY.RESET_TOKEN_TTL_MS;
36
37
  resetTokens.set(chatId, { token, expiresAt });
37
38
  return token;
38
39
  }
@@ -149,26 +150,26 @@ export function registerCommands(bot) {
149
150
  }
150
151
  // 현재 토큰 수 계산
151
152
  const currentTokens = estimateMessagesTokens(history);
152
- // 메시지 개수가 적고 토큰도 적으면 스킵 (5000 토큰 = 약 한글 3000자)
153
+ // 메시지 개수가 적고 토큰도 적으면 스킵
153
154
  // 단, 토큰이 많으면 메시지 개수와 관계없이 compact 허용
154
- if (history.length <= 4 && currentTokens < 5000) {
155
+ if (history.length <= MESSAGES.KEEP_ON_COMPACT && currentTokens < TOKENS.COMPACT_MIN_TOKENS) {
155
156
  await ctx.reply(`현재 ${history.length}개 메시지, ~${currentTokens} 토큰이라 충분히 짧아!`);
156
157
  return;
157
158
  }
158
159
  await ctx.replyWithChatAction("typing");
159
160
  await ctx.reply(`📊 현재: ${history.length}개 메시지, ~${currentTokens} 토큰\n요약 생성 중...`);
160
161
  // 요약할 메시지와 유지할 최근 메시지 분리
161
- // 메시지가 4개 이하면 (토큰이 많아서 여기 온 경우) 전체 요약 후 마지막만 유지
162
+ // 메시지가 적으면 (토큰이 많아서 여기 온 경우) 전체 요약 후 마지막만 유지
162
163
  let recentMessages;
163
164
  let oldMessages;
164
- if (history.length <= 4) {
165
+ if (history.length <= MESSAGES.KEEP_ON_COMPACT) {
165
166
  // 토큰이 많아서 compact 진입한 경우: 전체 요약 → 마지막 1개만 유지
166
167
  recentMessages = history.slice(-1);
167
168
  oldMessages = history.slice(0, -1);
168
169
  }
169
170
  else {
170
- // 일반 경우: 마지막 4개 유지
171
- recentMessages = history.slice(-4);
171
+ // 일반 경우: 마지막 N개 유지
172
+ recentMessages = history.slice(-MESSAGES.KEEP_ON_COMPACT);
172
173
  oldMessages = history.slice(0, -4);
173
174
  }
174
175
  // 요약 생성
@@ -189,16 +190,16 @@ export function registerCommands(bot) {
189
190
  });
190
191
  // /memory 명령어 - 최근 기억 보기
191
192
  bot.command("memory", async (ctx) => {
192
- const memories = await loadRecentMemories(7);
193
+ const memories = await loadRecentMemories(MEMORY.DISPLAY_DAYS);
193
194
  if (!memories.trim()) {
194
195
  await ctx.reply("아직 기억해둔 게 없어!");
195
196
  return;
196
197
  }
197
198
  // 너무 길면 자르기
198
- const truncated = memories.length > 2000
199
- ? memories.slice(0, 2000) + "\n\n... (더 있음)"
199
+ const truncated = memories.length > MEMORY.MAX_DISPLAY_LENGTH
200
+ ? memories.slice(0, MEMORY.MAX_DISPLAY_LENGTH) + "\n\n... (더 있음)"
200
201
  : memories;
201
- await ctx.reply(`📝 최근 일주일 기억:\n\n${truncated}`);
202
+ await ctx.reply(`📝 최근 ${MEMORY.DISPLAY_DAYS}일 기억:\n\n${truncated}`);
202
203
  });
203
204
  // /model 명령어 - 모델 변경
204
205
  bot.command("model", async (ctx) => {
@@ -449,7 +450,7 @@ export function registerCommands(bot) {
449
450
  try {
450
451
  const events = await getTodayEvents();
451
452
  const preview = events.length > 0
452
- ? events.slice(0, 3).map(formatEvent).join("\n")
453
+ ? events.slice(0, TELEGRAM.CALENDAR_PREVIEW_COUNT).map(formatEvent).join("\n")
453
454
  : "오늘 일정 없음";
454
455
  await ctx.reply(`📅 Google Calendar 연동됨!\n\n` +
455
456
  `오늘 일정:\n${preview}\n\n` +
@@ -1,12 +1,11 @@
1
1
  import { chat, chatSmart } from "../../ai/claude.js";
2
2
  import { recordActivity, recordError } from "../../health/index.js";
3
- import { getHistory, getModel, runWithChatId, trimHistoryByTokens, smartTrimHistory, detectImportantContext, pinContext, addMessage, } from "../../session/state.js";
3
+ import { getHistory, getModel, getThinkingLevel, runWithChatId, trimHistoryByTokens, smartTrimHistory, detectImportantContext, pinContext, addMessage, } from "../../session/state.js";
4
4
  import * as persistence from "../../session/persistence.js";
5
5
  import { updateLastMessageTime } from "../../heartbeat/index.js";
6
6
  import { extractUrls, fetchWebContent, formatUrlContent, buildSystemPrompt, } from "../utils/index.js";
7
7
  import { estimateMessagesTokens } from "../../utils/tokens.js";
8
- const MAX_CONTEXT_TOKENS = 100000; // Claude 컨텍스트
9
- const COMPACTION_THRESHOLD = 0.35; // 35% (35,000 토큰) - MAX_HISTORY_TOKENS(50k)보다 먼저 트리거되도록
8
+ import { TOKENS, TELEGRAM } from "../../config/constants.js";
10
9
  /**
11
10
  * 토큰 사용량이 임계치를 넘으면 자동으로 히스토리 압축
12
11
  * 실패해도 메시지 처리에 영향 없도록 에러를 조용히 처리
@@ -14,8 +13,8 @@ const COMPACTION_THRESHOLD = 0.35; // 35% (35,000 토큰) - MAX_HISTORY_TOKENS(5
14
13
  async function autoCompactIfNeeded(ctx, history) {
15
14
  try {
16
15
  const tokens = estimateMessagesTokens(history);
17
- const usage = tokens / MAX_CONTEXT_TOKENS;
18
- if (usage > COMPACTION_THRESHOLD && history.length > 6) {
16
+ const usage = tokens / TOKENS.MAX_CONTEXT;
17
+ if (usage > TOKENS.COMPACTION_THRESHOLD && history.length > 6) {
19
18
  // 자동 compaction 실행
20
19
  console.log(`[AutoCompact] chatId=${ctx.chat?.id} usage=${(usage * 100).toFixed(1)}% - compacting...`);
21
20
  // 앞부분 요약 생성 (최근 4개 메시지 제외)
@@ -42,16 +41,16 @@ async function autoCompactIfNeeded(ctx, history) {
42
41
  /**
43
42
  * 스트리밍 응답 전송 (Telegram 메시지 실시간 업데이트)
44
43
  */
45
- async function sendStreamingResponse(ctx, messages, systemPrompt, modelId) {
44
+ async function sendStreamingResponse(ctx, messages, systemPrompt, modelId, thinkingLevel) {
46
45
  // 1. 먼저 "..." 플레이스홀더 메시지 전송
47
46
  const placeholder = await ctx.reply("...");
48
47
  const chatId = ctx.chat.id;
49
48
  const messageId = placeholder.message_id;
50
49
  let lastUpdate = Date.now();
51
- const UPDATE_INTERVAL = 500; // 0.5초마다 업데이트 (Telegram rate limit 고려)
50
+ const UPDATE_INTERVAL = TELEGRAM.STREAM_UPDATE_INTERVAL_MS;
52
51
  let lastText = "";
53
52
  try {
54
- const result = await chatSmart(messages, systemPrompt, modelId, async (_chunk, accumulated) => {
53
+ const result = await chatSmart(messages, systemPrompt, modelId, thinkingLevel, async (_chunk, accumulated) => {
55
54
  const now = Date.now();
56
55
  // 0.5초마다 또는 충분히 변경되었을 때 업데이트
57
56
  if (now - lastUpdate > UPDATE_INTERVAL && accumulated !== lastText) {
@@ -109,6 +108,7 @@ export function registerMessageHandlers(bot) {
109
108
  recordActivity();
110
109
  const history = getHistory(chatId);
111
110
  const modelId = getModel(chatId);
111
+ const thinkingLevel = getThinkingLevel(chatId);
112
112
  await ctx.replyWithChatAction("typing");
113
113
  try {
114
114
  // 가장 큰 사진 선택 (마지막이 가장 큼)
@@ -118,10 +118,10 @@ export function registerMessageHandlers(bot) {
118
118
  await ctx.reply("사진을 가져올 수 없어.");
119
119
  return;
120
120
  }
121
- // 파일 크기 제한 (10MB)
122
- const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
123
- if (file.file_size && file.file_size > MAX_IMAGE_SIZE) {
124
- await ctx.reply("사진이 너무 커. 10MB 이하로 보내줄래?");
121
+ // 파일 크기 제한
122
+ if (file.file_size && file.file_size > TELEGRAM.MAX_IMAGE_SIZE) {
123
+ const maxMb = Math.floor(TELEGRAM.MAX_IMAGE_SIZE / (1024 * 1024));
124
+ await ctx.reply(`사진이 너무 커. ${maxMb}MB 이하로 보내줄래?`);
125
125
  return;
126
126
  }
127
127
  // 파일 다운로드
@@ -152,7 +152,7 @@ export function registerMessageHandlers(bot) {
152
152
  persistence.appendMessage(chatId, "user", `[이미지] ${caption}`);
153
153
  try {
154
154
  const systemPrompt = await buildSystemPrompt(modelId, history);
155
- const result = await chat(history, systemPrompt, modelId);
155
+ const result = await chat(history, systemPrompt, modelId, thinkingLevel);
156
156
  // 도구 사용 정보를 포함한 응답 기록
157
157
  let assistantContent = result.text;
158
158
  if (result.toolsUsed.length > 0) {
@@ -221,6 +221,7 @@ export function registerMessageHandlers(bot) {
221
221
  updateLastMessageTime(chatId);
222
222
  const history = getHistory(chatId);
223
223
  const modelId = getModel(chatId);
224
+ const thinkingLevel = getThinkingLevel(chatId);
224
225
  // 중요 맥락 자동 감지 및 핀
225
226
  const importantContext = detectImportantContext(userMessage);
226
227
  if (importantContext) {
@@ -233,7 +234,7 @@ export function registerMessageHandlers(bot) {
233
234
  let messageForHistory = userMessage;
234
235
  let urlContextForApi = ""; // 현재 요청에만 주입될 URL 내용
235
236
  if (urls.length > 0) {
236
- const urlsToFetch = urls.slice(0, 3); // 최대 3개 URL
237
+ const urlsToFetch = urls.slice(0, TELEGRAM.MAX_URL_FETCH);
237
238
  const contents = await Promise.all(urlsToFetch.map((url) => fetchWebContent(url)));
238
239
  const urlRefs = [];
239
240
  for (let i = 0; i < contents.length; i++) {
@@ -268,7 +269,7 @@ export function registerMessageHandlers(bot) {
268
269
  }
269
270
  // 스트리밍 응답 사용 (실시간 업데이트)
270
271
  const response = await sendStreamingResponse(ctx, messagesForApi, // URL 내용이 포함된 버전
271
- systemPrompt, modelId);
272
+ systemPrompt, modelId, thinkingLevel);
272
273
  // 메모리 + JSONL에 영구 저장
273
274
  addMessage(chatId, "assistant", response);
274
275
  // 스마트 트리밍 (요약 포함) - autoCompactIfNeeded 대체
@@ -5,6 +5,7 @@ import { getWorkspace } from "./cache.js";
5
5
  import { embed } from "../../memory/embeddings.js";
6
6
  import { search } from "../../memory/vectorStore.js";
7
7
  import { buildContextForPrompt, getCurrentChatId } from "../../session/state.js";
8
+ import { SEARCH_CONTEXT_LENGTH, PROMPT_MEMORY_SEARCH_LIMIT, PROMPT_MEMORY_MIN_SCORE, MEMORY_PREVIEW_LENGTH, } from "../../utils/constants.js";
8
9
  import * as os from "os";
9
10
  function getRuntimeInfo(modelId) {
10
11
  const model = MODELS[modelId];
@@ -61,7 +62,7 @@ function extractSearchContext(history) {
61
62
  .filter((m) => m.role === "user")
62
63
  .map((m) => (typeof m.content === "string" ? m.content : ""))
63
64
  .join(" ")
64
- .slice(0, 500);
65
+ .slice(0, SEARCH_CONTEXT_LENGTH);
65
66
  }
66
67
  async function getRelevantMemories(history) {
67
68
  try {
@@ -69,11 +70,11 @@ async function getRelevantMemories(history) {
69
70
  if (!context.trim())
70
71
  return "";
71
72
  const queryEmbedding = await embed(context);
72
- const results = await search(queryEmbedding, 3, 0.4);
73
+ const results = await search(queryEmbedding, PROMPT_MEMORY_SEARCH_LIMIT, PROMPT_MEMORY_MIN_SCORE);
73
74
  if (results.length === 0)
74
75
  return "";
75
76
  return results
76
- .map((r) => `- (${r.source}): ${r.text.slice(0, 200)}${r.text.length > 200 ? "..." : ""}`)
77
+ .map((r) => `- (${r.source}): ${r.text.slice(0, MEMORY_PREVIEW_LENGTH)}${r.text.length > MEMORY_PREVIEW_LENGTH ? "..." : ""}`)
77
78
  .join("\n");
78
79
  }
79
80
  catch {
@@ -42,3 +42,23 @@ export const MAX_SEARCH_RESULTS = 20;
42
42
  // ============== Memory ==============
43
43
  export const DEFAULT_MEMORY_SEARCH_LIMIT = 5;
44
44
  export const DEFAULT_MEMORY_MIN_SCORE = 0.3;
45
+ /** Memory preview length in search results */
46
+ export const MEMORY_PREVIEW_LENGTH = 200;
47
+ /** Context extraction length for memory search */
48
+ export const SEARCH_CONTEXT_LENGTH = 500;
49
+ /** Memory search limit for prompt context (fewer, more relevant) */
50
+ export const PROMPT_MEMORY_SEARCH_LIMIT = 3;
51
+ /** Minimum score for prompt memory search (stricter) */
52
+ export const PROMPT_MEMORY_MIN_SCORE = 0.4;
53
+ // ============== Token Estimation ==============
54
+ /** Tokens per Korean character (보수적 추정) */
55
+ export const TOKENS_PER_KOREAN_CHAR = 1.5;
56
+ /** Characters per token for non-Korean text */
57
+ export const CHARS_PER_TOKEN_OTHER = 4;
58
+ /** Per-message token overhead */
59
+ export const MESSAGE_TOKEN_OVERHEAD = 4;
60
+ // ============== Tool Usage Logging ==============
61
+ /** Max length for tool input summary in history */
62
+ export const TOOL_INPUT_SUMMARY_LENGTH = 200;
63
+ /** Max length for tool output summary in history */
64
+ export const TOOL_OUTPUT_SUMMARY_LENGTH = 500;
@@ -7,15 +7,16 @@
7
7
  *
8
8
  * These are rough estimates for context management, not exact counts.
9
9
  */
10
+ import { TOKENS_PER_KOREAN_CHAR, CHARS_PER_TOKEN_OTHER, MESSAGE_TOKEN_OVERHEAD, } from "./constants.js";
10
11
  /**
11
12
  * Estimate token count for a text string
12
- * 한글은 보수적으로 1.5 토큰/글자로 계산 (실제보다 약간 높게)
13
+ * 한글은 보수적으로 계산 (실제보다 약간 높게)
13
14
  */
14
15
  export function estimateTokens(text) {
15
16
  // 자모음까지 포함하는 넓은 범위의 한글 매칭
16
17
  const koreanChars = (text.match(/[\u3131-\uD79D]/g) || []).length;
17
18
  const otherChars = text.length - koreanChars;
18
- return Math.ceil(koreanChars * 1.5 + otherChars / 4);
19
+ return Math.ceil(koreanChars * TOKENS_PER_KOREAN_CHAR + otherChars / CHARS_PER_TOKEN_OTHER);
19
20
  }
20
21
  /**
21
22
  * Estimate token count for an array of messages
@@ -25,6 +26,6 @@ export function estimateMessagesTokens(messages) {
25
26
  const content = typeof msg.content === 'string'
26
27
  ? msg.content
27
28
  : JSON.stringify(msg.content);
28
- return sum + estimateTokens(content) + 4; // 메시지 오버헤드
29
+ return sum + estimateTokens(content) + MESSAGE_TOKEN_OVERHEAD;
29
30
  }, 0);
30
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "companionbot",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "AI 친구 텔레그램 봇 - Claude API 기반 개인화된 대화 상대",
5
5
  "keywords": [
6
6
  "telegram",