companionbot 0.6.0 → 0.8.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/README.md CHANGED
@@ -29,12 +29,21 @@
29
29
  - **일회성 예약** - "내일 오전 9시에 알려줘"
30
30
  - **반복 작업** - "30분마다 주식 가격 확인해줘"
31
31
 
32
- ### 🤖 고급 기능 (v0.3.0)
32
+ ### 🤖 고급 기능 (v0.3.0+)
33
33
  - **서브 에이전트** - 복잡한 작업을 백그라운드에서 처리
34
34
  - **백그라운드 실행** - 긴 명령어를 백그라운드에서 실행하고 결과 확인
35
35
  - **파일 시스템** - 워크스페이스 내 파일 읽기/쓰기/편집
36
36
  - **일일 메모리** - 대화 내용 자동 저장
37
37
 
38
+ ### 🧠 시맨틱 메모리 (v0.6.0)
39
+ - **벡터 검색** - 임베딩 기반 관련 메모리 검색
40
+ - **자동 인덱싱** - 메모리 파일 자동 청크 분할 및 임베딩
41
+ - **유사도 매칭** - 의미 기반으로 관련 대화 기록 찾기
42
+
43
+ ### 🔧 시스템 (v0.6.0)
44
+ - **헬스 체크** - 봇 상태 모니터링 (uptime, 메시지 수, 오류 수)
45
+ - **업데이트 알림** - 새 버전 출시 시 자동 알림
46
+
38
47
  ## 설치
39
48
 
40
49
  ### 사전 준비
@@ -273,7 +282,22 @@ npm test # 테스트 실행
273
282
 
274
283
  ## 버전 히스토리
275
284
 
276
- ### v0.3.0 (현재)
285
+ ### v0.6.0 (현재)
286
+ - 🧠 시맨틱 메모리 검색 (임베딩 기반)
287
+ - 💚 헬스 체크 모니터링
288
+ - 🔄 자동 업데이트 알림
289
+
290
+ ### v0.5.0
291
+ - 🔒 보안 강화 (SSRF 방지, 경로 검증)
292
+ - ✅ 테스트 추가 (vitest)
293
+ - 📖 문서 개선
294
+
295
+ ### v0.4.0
296
+ - 🛡️ 보안 강화 (TOCTOU, 심볼릭 링크 검증)
297
+ - 🧹 세션 자동 정리
298
+ - 🔧 환경변수 기반 경로 설정
299
+
300
+ ### v0.3.0
277
301
  - 🔍 웹 검색 (Brave Search API)
278
302
  - 🕐 Cron 스케줄링 (한국어 지원)
279
303
  - 🤖 서브 에이전트 (백그라운드 작업)
package/dist/ai/claude.js CHANGED
@@ -1,5 +1,48 @@
1
- import Anthropic from "@anthropic-ai/sdk";
1
+ import Anthropic, { APIError } from "@anthropic-ai/sdk";
2
2
  import { tools, executeTool } from "../tools/index.js";
3
+ import { sleep } from "../utils/time.js";
4
+ import { MAX_RETRIES, BASE_RETRY_DELAY_MS } from "../utils/constants.js";
5
+ async function withRetry(fn, retries = MAX_RETRIES) {
6
+ let lastError = null;
7
+ for (let attempt = 0; attempt < retries; attempt++) {
8
+ try {
9
+ return await fn();
10
+ }
11
+ catch (error) {
12
+ // APIError 타입 체크
13
+ if (error instanceof APIError) {
14
+ lastError = error;
15
+ // Rate limit (429)
16
+ if (error.status === 429) {
17
+ const retryAfter = error.headers?.["retry-after"];
18
+ const delay = retryAfter
19
+ ? parseInt(retryAfter) * 1000
20
+ : BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
21
+ console.log(`[RateLimit] 429 received, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
22
+ await sleep(delay);
23
+ continue;
24
+ }
25
+ // 서버 에러 (500+)
26
+ if (error.status >= 500) {
27
+ const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt);
28
+ console.log(`[ServerError] ${error.status}, waiting ${delay}ms (attempt ${attempt + 1}/${retries})`);
29
+ await sleep(delay);
30
+ continue;
31
+ }
32
+ }
33
+ // 일반 Error 처리
34
+ if (error instanceof Error) {
35
+ lastError = error;
36
+ }
37
+ else {
38
+ lastError = new Error(String(error));
39
+ }
40
+ // 다른 에러는 바로 throw
41
+ throw error;
42
+ }
43
+ }
44
+ throw lastError;
45
+ }
3
46
  let anthropic = null;
4
47
  function getClient() {
5
48
  if (!anthropic) {
@@ -58,20 +101,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
58
101
  return params;
59
102
  };
60
103
  let response;
61
- try {
62
- response = await client.messages.create(buildRequestParams());
63
- }
64
- catch (error) {
65
- if (error instanceof Anthropic.APIError) {
66
- if (error.status === 429) {
67
- throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
68
- }
69
- if (error.status >= 500) {
70
- throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
71
- }
72
- }
73
- throw error;
74
- }
104
+ response = await withRetry(() => client.messages.create(buildRequestParams()));
75
105
  // Tool use 루프 - Claude가 도구 사용을 멈출 때까지 반복 (최대 10회)
76
106
  const MAX_TOOL_ITERATIONS = 10;
77
107
  let iterations = 0;
@@ -103,20 +133,7 @@ export async function chat(messages, systemPrompt, modelId = "sonnet") {
103
133
  content: toolResults,
104
134
  });
105
135
  // 다음 응답 요청 (도구 루프에서도 thinking 유지)
106
- try {
107
- response = await client.messages.create(buildRequestParams());
108
- }
109
- catch (error) {
110
- if (error instanceof Anthropic.APIError) {
111
- if (error.status === 429) {
112
- throw new Error("API 요청이 너무 많아. 잠시 후 다시 시도해줘.");
113
- }
114
- if (error.status >= 500) {
115
- throw new Error("AI 서버에 문제가 생겼어. 잠시 후 다시 시도해줘.");
116
- }
117
- }
118
- throw error;
119
- }
136
+ response = await withRetry(() => client.messages.create(buildRequestParams()));
120
137
  }
121
138
  // 반복 횟수 초과 시 경고
122
139
  if (iterations >= MAX_TOOL_ITERATIONS) {
@@ -163,7 +180,9 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
163
180
  // (도구 호출 폴백 시 chat()에서 thinking 사용됨)
164
181
  let accumulated = "";
165
182
  let stopReason = null;
166
- try {
183
+ // 스트리밍에 withRetry 적용 - 실패 시 자동 재시도
184
+ return await withRetry(async () => {
185
+ accumulated = ""; // 재시도 시 초기화
167
186
  const stream = client.messages.stream(params);
168
187
  // 스트리밍 이벤트 처리
169
188
  stream.on("text", async (text) => {
@@ -187,30 +206,5 @@ export async function chatSmart(messages, systemPrompt, modelId, onChunk) {
187
206
  }
188
207
  // 성공적으로 스트리밍 완료
189
208
  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
- }
209
+ });
216
210
  }
@@ -1,4 +1,5 @@
1
- import { google } from "googleapis";
1
+ import { calendar } from "@googleapis/calendar";
2
+ import { OAuth2Client } from "google-auth-library";
2
3
  import * as fs from "fs/promises";
3
4
  import * as path from "path";
4
5
  import * as http from "http";
@@ -69,7 +70,7 @@ export async function getAuthUrl() {
69
70
  const creds = await loadCredentials();
70
71
  if (!creds)
71
72
  return null;
72
- const oauth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
73
+ const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
73
74
  return oauth2Client.generateAuthUrl({
74
75
  access_type: "offline",
75
76
  scope: SCOPES,
@@ -120,7 +121,7 @@ export async function exchangeCodeForToken(code) {
120
121
  const creds = await loadCredentials();
121
122
  if (!creds)
122
123
  return false;
123
- const oauth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
124
+ const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
124
125
  try {
125
126
  const { tokens } = await oauth2Client.getToken(code);
126
127
  if (tokens.access_token && tokens.refresh_token) {
@@ -145,7 +146,7 @@ async function getAuthClient() {
145
146
  if (!creds || !token) {
146
147
  throw new Error("Calendar not configured. Use /calendar_setup");
147
148
  }
148
- const oauth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, REDIRECT_URI);
149
+ const oauth2Client = new OAuth2Client(creds.client_id, creds.client_secret, REDIRECT_URI);
149
150
  oauth2Client.setCredentials({
150
151
  access_token: token.access_token,
151
152
  refresh_token: token.refresh_token,
@@ -169,17 +170,18 @@ async function getAuthClient() {
169
170
  // 캘린더 API 인스턴스
170
171
  async function getCalendar() {
171
172
  const auth = await getAuthClient();
172
- return google.calendar({ version: "v3", auth });
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ return calendar({ version: "v3", auth: auth });
173
175
  }
174
176
  // 오늘 일정 조회
175
177
  export async function getTodayEvents() {
176
- const calendar = await getCalendar();
178
+ const cal = await getCalendar();
177
179
  const now = new Date();
178
180
  const startOfDay = new Date(now);
179
181
  startOfDay.setHours(0, 0, 0, 0);
180
182
  const endOfDay = new Date(now);
181
183
  endOfDay.setHours(23, 59, 59, 999);
182
- const response = await calendar.events.list({
184
+ const response = await cal.events.list({
183
185
  calendarId: "primary",
184
186
  timeMin: startOfDay.toISOString(),
185
187
  timeMax: endOfDay.toISOString(),
@@ -190,8 +192,8 @@ export async function getTodayEvents() {
190
192
  }
191
193
  // 특정 기간 일정 조회
192
194
  export async function getEvents(startDate, endDate) {
193
- const calendar = await getCalendar();
194
- const response = await calendar.events.list({
195
+ const cal = await getCalendar();
196
+ const response = await cal.events.list({
195
197
  calendarId: "primary",
196
198
  timeMin: startDate.toISOString(),
197
199
  timeMax: endDate.toISOString(),
@@ -203,7 +205,7 @@ export async function getEvents(startDate, endDate) {
203
205
  }
204
206
  // 일정 추가
205
207
  export async function addEvent(summary, startTime, endTime, description) {
206
- const calendar = await getCalendar();
208
+ const cal = await getCalendar();
207
209
  // 종료 시간이 없으면 1시간 후
208
210
  const end = endTime || new Date(startTime.getTime() + 60 * 60 * 1000);
209
211
  const event = {
@@ -218,7 +220,7 @@ export async function addEvent(summary, startTime, endTime, description) {
218
220
  timeZone: "Asia/Seoul",
219
221
  },
220
222
  };
221
- const response = await calendar.events.insert({
223
+ const response = await cal.events.insert({
222
224
  calendarId: "primary",
223
225
  requestBody: event,
224
226
  });
@@ -227,8 +229,8 @@ export async function addEvent(summary, startTime, endTime, description) {
227
229
  // 일정 삭제
228
230
  export async function deleteEvent(eventId) {
229
231
  try {
230
- const calendar = await getCalendar();
231
- await calendar.events.delete({
232
+ const cal = await getCalendar();
233
+ await cal.events.delete({
232
234
  calendarId: "primary",
233
235
  eventId,
234
236
  });
package/dist/cli/main.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as readline from "readline";
2
+ import { checkbox, Separator } from "@inquirer/prompts";
2
3
  import { getSecret, setSecret } from "../config/secrets.js";
3
4
  import { isWorkspaceInitialized, initWorkspace, getWorkspacePath, } from "../workspace/index.js";
4
5
  import { createBot } from "../telegram/bot.js";
@@ -20,51 +21,167 @@ async function question(rl, prompt) {
20
21
  }
21
22
  async function interactiveSetup() {
22
23
  const rl = createPrompt();
23
- console.log("\n🤖 CompanionBot 첫 실행입니다!\n");
24
+ console.log(`
25
+ ╔═══════════════════════════════════════════════════════════════╗
26
+ ║ 🤖 CompanionBot 첫 실행 가이드 ║
27
+ ╚═══════════════════════════════════════════════════════════════╝
28
+
29
+ CompanionBot은 Telegram에서 동작하는 개인 AI 비서예요.
30
+
31
+ 💡 언제든지 'q'를 입력하면 설정을 취소할 수 있어요.
32
+ `);
24
33
  try {
34
+ // ===== STEP 1: 기능 선택 =====
35
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36
+ [STEP 1] 사용할 기능 선택
37
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38
+
39
+ ┌──────────────────────────────────────────────────────────────┐
40
+ │ [필수] 기본 기능 (자동 포함) │
41
+ │ ├─ 💬 AI 대화 자연스러운 한국어 대화 │
42
+ │ ├─ 📁 파일 관리 문서/코드 읽기·쓰기 │
43
+ │ ├─ ⏰ 리마인더 알림 설정 ("3시에 알려줘") │
44
+ │ └─ 🧠 메모리 대화 기억, 장기 기억 저장 │
45
+ └──────────────────────────────────────────────────────────────┘
46
+ `);
47
+ const features = {
48
+ webSearch: false,
49
+ calendar: false,
50
+ weather: false,
51
+ };
52
+ let selectedValues = [];
53
+ try {
54
+ selectedValues = await checkbox({
55
+ message: "추가 기능 선택 (Space=선택, Enter=확정)",
56
+ choices: [
57
+ { name: "🔍 웹 검색 - Brave API, 무료 2000/월", value: "webSearch" },
58
+ { name: "📅 캘린더 - Google Calendar 연동", value: "calendar" },
59
+ { name: "🌤️ 날씨 - OpenWeatherMap, 무료", value: "weather" },
60
+ new Separator(" ● 다음 단계로"),
61
+ ],
62
+ });
63
+ }
64
+ catch {
65
+ console.log("\n👋 설정을 취소했습니다.");
66
+ rl.close();
67
+ return false;
68
+ }
69
+ features.webSearch = selectedValues.includes("webSearch");
70
+ features.calendar = selectedValues.includes("calendar");
71
+ features.weather = selectedValues.includes("weather");
72
+ // 선택 요약
73
+ const selectedFeatures = [];
74
+ if (features.webSearch)
75
+ selectedFeatures.push("🔍 웹 검색");
76
+ if (features.calendar)
77
+ selectedFeatures.push("📅 캘린더");
78
+ if (features.weather)
79
+ selectedFeatures.push("🌤️ 날씨");
80
+ console.log(`
81
+ ✓ 선택됨: ${selectedFeatures.length > 0 ? selectedFeatures.join(", ") : "기본 기능만"}
82
+ `);
83
+ // ===== STEP 2: 필수 API 키 =====
84
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
85
+ [STEP 2] 필수 API 키 입력
86
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
87
+ `);
25
88
  // Telegram Bot Token
26
- console.log("[1/2] Telegram Bot Token");
27
- console.log(" @BotFather에서 봇 생성 후 토큰을 붙여넣으세요.");
28
- console.log(" (https://t.me/BotFather)\n");
89
+ console.log(` 📱 Telegram Bot Token
90
+
91
+ 1. Telegram에서 @BotFather 검색
92
+ 2. /newbot → 이름 입력 → 유저네임 입력 (_bot으로 끝나야 함)
93
+ 3. 토큰 복사 (예: 123456:ABC-DEF...)
94
+ 🔗 https://t.me/BotFather
95
+ `);
29
96
  const token = await question(rl, " Token: ");
30
- if (!token) {
31
- console.log("\n 토큰이 필요합니다.");
97
+ if (!token || token.toLowerCase() === "q") {
98
+ console.log("\n👋 설정을 취소했습니다.");
32
99
  rl.close();
33
100
  return false;
34
101
  }
35
102
  await setSecret("telegram-token", token);
36
103
  console.log(" ✓ 저장됨\n");
37
104
  // Anthropic API Key
38
- console.log("[2/2] Anthropic API Key");
39
- console.log(" console.anthropic.com에서 발급받으세요.");
40
- console.log(" (https://console.anthropic.com/settings/keys)\n");
105
+ console.log(` 🧠 Anthropic API Key
106
+
107
+ 1. https://console.anthropic.com 접속 (회원가입/로그인)
108
+ 2. Settings > API Keys > Create Key
109
+ 3. 키 복사 (sk-ant-...)
110
+ 🔗 https://console.anthropic.com/settings/keys
111
+ `);
41
112
  const apiKey = await question(rl, " API Key: ");
42
- if (!apiKey) {
43
- console.log("\n API 키가 필요합니다.");
113
+ if (!apiKey || apiKey.toLowerCase() === "q") {
114
+ console.log("\n👋 설정을 취소했습니다. (Telegram 토큰은 저장됨)");
44
115
  rl.close();
45
116
  return false;
46
117
  }
47
118
  await setSecret("anthropic-api-key", apiKey);
48
119
  console.log(" ✓ 저장됨\n");
49
- // 선택적 기능 설정
50
- const setupOptional = await question(rl, "[선택] 추가 기능을 설정하시겠습니까? (y/n): ");
51
- if (setupOptional.toLowerCase() === "y") {
52
- console.log("");
53
- // 날씨 기능
54
- const useWeather = await question(rl, " 날씨 기능을 사용하시겠습니까? (y/n): ");
55
- if (useWeather.toLowerCase() === "y") {
56
- console.log("\n OpenWeatherMap API Key가 필요합니다.");
57
- console.log(" (https://openweathermap.org)\n");
58
- const weatherKey = await question(rl, " API Key: ");
120
+ // ===== STEP 3: 선택 API 키 =====
121
+ if (features.webSearch || features.calendar || features.weather) {
122
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
123
+ [STEP 3] 선택한 기능 API 키 입력
124
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
125
+
126
+ Enter를 누르면 해당 기능을 건너뛸 수 있어요.
127
+ `);
128
+ // 웹 검색 API
129
+ if (features.webSearch) {
130
+ console.log(` 🔍 Brave Search API (무료 2000회/월)
131
+
132
+ 1. https://brave.com/search/api 접속
133
+ 2. Get Started > 가입 > API 키 생성
134
+ `);
135
+ const braveKey = await question(rl, " API Key (Enter=건너뛰기, q=취소): ");
136
+ if (braveKey.toLowerCase() === "q") {
137
+ console.log("\n👋 설정을 취소했습니다.");
138
+ rl.close();
139
+ return false;
140
+ }
141
+ if (braveKey) {
142
+ await setSecret("brave-api-key", braveKey);
143
+ console.log(" ✓ 저장됨\n");
144
+ }
145
+ else {
146
+ console.log(" → 건너뜀 (나중에: companionbot setup brave <KEY>)\n");
147
+ }
148
+ }
149
+ // 날씨 API
150
+ if (features.weather) {
151
+ console.log(` 🌤️ OpenWeatherMap API (무료)
152
+
153
+ 1. https://openweathermap.org 접속 > Sign Up
154
+ 2. API Keys 메뉴에서 키 확인/생성
155
+ `);
156
+ const weatherKey = await question(rl, " API Key (Enter=건너뛰기, q=취소): ");
157
+ if (weatherKey.toLowerCase() === "q") {
158
+ console.log("\n👋 설정을 취소했습니다.");
159
+ rl.close();
160
+ return false;
161
+ }
59
162
  if (weatherKey) {
60
163
  await setSecret("openweathermap-api-key", weatherKey);
61
164
  console.log(" ✓ 저장됨\n");
62
165
  }
63
166
  else {
64
- console.log(" → 건너뜀\n");
167
+ console.log(" → 건너뜀 (나중에: companionbot setup weather <KEY>)\n");
65
168
  }
66
169
  }
170
+ // 캘린더
171
+ if (features.calendar) {
172
+ console.log(` 📅 Google Calendar
173
+
174
+ 캘린더는 봇 실행 후 /calendar_setup 명령어로 설정합니다.
175
+ (OAuth 인증이 필요해서 브라우저가 열려요)
176
+ `);
177
+ await question(rl, " Enter를 눌러 계속...");
178
+ console.log("");
179
+ }
67
180
  }
181
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
182
+ ✅ 설정 완료!
183
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
184
+ `);
68
185
  rl.close();
69
186
  return true;
70
187
  }
@@ -93,15 +210,35 @@ async function main() {
93
210
  }
94
211
  // 3. 워크스페이스 초기화
95
212
  const workspaceReady = await isWorkspaceInitialized();
213
+ const workspacePath = getWorkspacePath();
96
214
  if (!workspaceReady) {
97
- console.log("📁 워크스페이스 생성 중...");
215
+ console.log(`
216
+ ╔═══════════════════════════════════════════════════════════════╗
217
+ ║ 📁 워크스페이스 생성 ║
218
+ ╚═══════════════════════════════════════════════════════════════╝
219
+ `);
98
220
  await initWorkspace();
99
- console.log(` ${getWorkspacePath()} 생성 완료\n`);
221
+ console.log(` 경로: ${workspacePath}
222
+
223
+ 생성된 파일들:
224
+ ├── IDENTITY.md ← 봇의 이름과 성격 설정
225
+ ├── SOUL.md ← 봇의 행동 원칙
226
+ ├── USER.md ← 당신에 대한 정보 (봇이 참고)
227
+ ├── AGENTS.md ← 봇 행동 가이드
228
+ ├── MEMORY.md ← 장기 기억 저장소
229
+ └── memory/ ← 일일 메모리 폴더
230
+
231
+ 💡 팁: IDENTITY.md와 USER.md를 편집해서 봇을 커스터마이즈하세요!
232
+ `);
100
233
  }
101
234
  // 4. 환경변수 설정
102
235
  process.env.ANTHROPIC_API_KEY = apiKey;
103
236
  // 5. 봇 시작
104
- console.log("🚀 봇을 시작합니다!\n");
237
+ console.log(`
238
+ ╔═══════════════════════════════════════════════════════════════╗
239
+ ║ 🚀 봇 시작! ║
240
+ ╚═══════════════════════════════════════════════════════════════╝
241
+ `);
105
242
  const bot = createBot(token);
106
243
  // Graceful shutdown
107
244
  async function shutdown() {
@@ -117,8 +254,22 @@ async function main() {
117
254
  process.once("SIGTERM", () => void shutdown());
118
255
  bot.start({
119
256
  onStart: (botInfo) => {
120
- console.log(`✓ @${botInfo.username} 시작됨`);
121
- console.log(` 텔레그램에서 대화를 시작하세요!\n`);
257
+ console.log(` ✓ @${botInfo.username} 연결됨!
258
+
259
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
260
+ 이제 Telegram에서 @${botInfo.username} 검색해서 대화해보세요!
261
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
262
+
263
+ 📱 명령어 목록:
264
+ /help - 도움말
265
+ /model - AI 모델 변경 (haiku/sonnet/opus)
266
+ /compact - 대화 요약 (토큰 절약)
267
+ /health - 봇 상태 확인
268
+ /calendar - 캘린더 연동 (Google)
269
+
270
+ ⌨️ Ctrl+C로 종료
271
+ 📂 워크스페이스: ${workspacePath}
272
+ `);
122
273
  },
123
274
  });
124
275
  }
@@ -131,50 +131,82 @@ export function parseCronExpression(expr) {
131
131
  }
132
132
  /**
133
133
  * Calculate the next run time for a cron expression
134
+ * Returns null if no valid next run time is found (safer than throwing)
134
135
  */
135
136
  export function getNextCronRun(expression, fromDate = new Date(), timezone) {
136
- const parsed = parseCronExpression(expression);
137
- const from = new Date(fromDate);
138
- // Start from the next minute
139
- from.setSeconds(0);
140
- from.setMilliseconds(0);
141
- from.setMinutes(from.getMinutes() + 1);
142
- // Search for the next matching time (max 2 years ahead)
143
- const maxIterations = 365 * 24 * 60 * 2; // 2 years in minutes
144
- for (let i = 0; i < maxIterations; i++) {
145
- const candidate = new Date(from.getTime() + i * 60000);
146
- const minute = candidate.getMinutes();
147
- const hour = candidate.getHours();
148
- const dayOfMonth = candidate.getDate();
149
- const month = candidate.getMonth() + 1;
150
- const dayOfWeek = candidate.getDay();
151
- if (parsed.minute.values.includes(minute) &&
152
- parsed.hour.values.includes(hour) &&
153
- parsed.month.values.includes(month) &&
154
- (parsed.dayOfMonth.values.includes(dayOfMonth) ||
155
- parsed.dayOfWeek.values.includes(dayOfWeek))) {
156
- return candidate;
137
+ // null/undefined/빈 문자열 처리
138
+ if (!expression || expression.trim() === "") {
139
+ console.warn("[Cron] Empty cron expression provided");
140
+ return null;
141
+ }
142
+ try {
143
+ const parsed = parseCronExpression(expression);
144
+ const from = new Date(fromDate);
145
+ // Invalid date 체크
146
+ if (isNaN(from.getTime())) {
147
+ console.warn("[Cron] Invalid fromDate provided");
148
+ return null;
149
+ }
150
+ // Start from the next minute
151
+ from.setSeconds(0);
152
+ from.setMilliseconds(0);
153
+ from.setMinutes(from.getMinutes() + 1);
154
+ // Search for the next matching time (max 2 years ahead)
155
+ const maxIterations = 365 * 24 * 60 * 2; // 2 years in minutes
156
+ for (let i = 0; i < maxIterations; i++) {
157
+ const candidate = new Date(from.getTime() + i * 60000);
158
+ const minute = candidate.getMinutes();
159
+ const hour = candidate.getHours();
160
+ const dayOfMonth = candidate.getDate();
161
+ const month = candidate.getMonth() + 1;
162
+ const dayOfWeek = candidate.getDay();
163
+ if (parsed.minute.values.includes(minute) &&
164
+ parsed.hour.values.includes(hour) &&
165
+ parsed.month.values.includes(month) &&
166
+ (parsed.dayOfMonth.values.includes(dayOfMonth) ||
167
+ parsed.dayOfWeek.values.includes(dayOfWeek))) {
168
+ return candidate;
169
+ }
157
170
  }
171
+ console.warn(`[Cron] Could not find next run time for: ${expression}`);
172
+ return null;
173
+ }
174
+ catch (error) {
175
+ console.error(`[Cron] Error parsing expression "${expression}":`, error);
176
+ return null;
158
177
  }
159
- throw new Error(`Could not find next run time for: ${expression}`);
160
178
  }
161
179
  /**
162
180
  * Get the next run time for any schedule type
181
+ * Returns NaN if schedule is invalid (check with isNaN())
163
182
  */
164
183
  export function getNextRun(schedule, now = new Date()) {
184
+ // null/undefined 처리
185
+ if (!schedule) {
186
+ console.warn("[Cron] No schedule provided to getNextRun");
187
+ return NaN;
188
+ }
165
189
  switch (schedule.kind) {
166
190
  case "at":
167
- return schedule.atMs;
191
+ return schedule.atMs ?? NaN;
168
192
  case "every": {
193
+ const everyMs = schedule.everyMs || schedule.intervalMs;
194
+ if (!everyMs || everyMs <= 0) {
195
+ console.warn("[Cron] Invalid everyMs in schedule");
196
+ return NaN;
197
+ }
169
198
  const startMs = schedule.startMs ?? now.getTime();
170
199
  const elapsed = now.getTime() - startMs;
171
- const intervals = Math.floor(elapsed / schedule.everyMs);
172
- return startMs + (intervals + 1) * schedule.everyMs;
200
+ const intervals = Math.floor(elapsed / everyMs);
201
+ return startMs + (intervals + 1) * everyMs;
202
+ }
203
+ case "cron": {
204
+ const nextRun = getNextCronRun(schedule.expression, now, schedule.timezone);
205
+ return nextRun ? nextRun.getTime() : NaN;
173
206
  }
174
- case "cron":
175
- return getNextCronRun(schedule.expression, now, schedule.timezone).getTime();
176
207
  default:
177
- throw new Error(`Unknown schedule kind: ${schedule.kind}`);
208
+ console.warn(`[Cron] Unknown schedule kind: ${schedule.kind}`);
209
+ return NaN;
178
210
  }
179
211
  }
180
212
  /**