companionbot 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,7 @@
1
1
  import * as cheerio from "cheerio";
2
+ // URL 내용 캐시 (중복 fetch 방지)
3
+ const urlCache = new Map();
4
+ const URL_CACHE_TTL = 10 * 60 * 1000; // 10분
2
5
  /**
3
6
  * 텍스트에서 URL을 추출합니다.
4
7
  */
@@ -72,6 +75,7 @@ export function isSafeUrl(url) {
72
75
  }
73
76
  /**
74
77
  * 웹페이지 내용을 가져옵니다.
78
+ * 캐시 지원으로 중복 fetch 방지
75
79
  */
76
80
  export async function fetchWebContent(url) {
77
81
  // SSRF 방지
@@ -79,6 +83,12 @@ export async function fetchWebContent(url) {
79
83
  console.log(`[Security] Blocked unsafe URL: ${url}`);
80
84
  return null;
81
85
  }
86
+ // 캐시 확인
87
+ const cached = urlCache.get(url);
88
+ if (cached && Date.now() - cached.timestamp < URL_CACHE_TTL) {
89
+ console.log(`[URL] Cache hit: ${url}`);
90
+ return { title: cached.title, content: cached.content };
91
+ }
82
92
  try {
83
93
  // 10초 타임아웃
84
94
  const controller = new AbortController();
@@ -107,7 +117,15 @@ export async function fetchWebContent(url) {
107
117
  const content = mainContent
108
118
  .replace(/\s+/g, " ")
109
119
  .trim()
110
- .slice(0, 5000); // 5000자로 제한
120
+ .slice(0, 3000); // 5000 → 3000자로 제한 (토큰 절약)
121
+ // 캐시 저장
122
+ urlCache.set(url, { title, content, timestamp: Date.now() });
123
+ // 캐시 크기 제한 (최대 50개)
124
+ if (urlCache.size > 50) {
125
+ const oldestKey = urlCache.keys().next().value;
126
+ if (oldestKey)
127
+ urlCache.delete(oldestKey);
128
+ }
111
129
  return { title, content };
112
130
  }
113
131
  catch (error) {
@@ -115,3 +133,18 @@ export async function fetchWebContent(url) {
115
133
  return null;
116
134
  }
117
135
  }
136
+ /**
137
+ * URL 내용을 컨텍스트용 포맷으로 변환합니다.
138
+ * 히스토리에는 간략한 버전만, 현재 요청에는 전체 내용
139
+ */
140
+ export function formatUrlContent(url, content) {
141
+ const forHistory = `[링크: ${content.title}](${url})`;
142
+ const forContext = `\n---\n📎 ${url}\n📌 ${content.title}\n${content.content}\n---`;
143
+ return { forHistory, forContext };
144
+ }
145
+ /**
146
+ * 캐시를 무효화합니다.
147
+ */
148
+ export function clearUrlCache() {
149
+ urlCache.clear();
150
+ }
@@ -354,17 +354,35 @@ Guidelines:
354
354
  },
355
355
  {
356
356
  name: "save_memory",
357
- description: "Save important information about the user or conversation to long-term memory. Use this when you learn something new about the user that should be remembered.",
357
+ description: `Save important information to daily memory. This is automatically saved to memory/YYYY-MM-DD.md.
358
+
359
+ **WHEN TO USE (proactively, without being asked):**
360
+ - User shares personal info: name, birthday, family, job, location
361
+ - User expresses preferences: likes, dislikes, habits, routines
362
+ - User mentions plans: upcoming events, projects, goals
363
+ - User shares emotional moments: achievements, concerns, decisions
364
+ - Significant conversation outcomes: agreements, conclusions, learnings
365
+ - Technical context: project names, tech stack, environments
366
+
367
+ **WHEN NOT TO USE:**
368
+ - Trivial small talk
369
+ - Already recorded information
370
+ - Temporary/fleeting topics
371
+
372
+ **TIPS:**
373
+ - Be concise but include context (why it matters)
374
+ - Include date references for time-sensitive info
375
+ - Group related facts together`,
358
376
  input_schema: {
359
377
  type: "object",
360
378
  properties: {
361
379
  content: {
362
380
  type: "string",
363
- description: "The information to remember",
381
+ description: "The information to remember. Be specific and include context.",
364
382
  },
365
383
  category: {
366
384
  type: "string",
367
- enum: ["user_info", "preference", "event", "project", "other"],
385
+ enum: ["user_info", "preference", "event", "project", "decision", "emotion", "other"],
368
386
  description: "Category of the memory",
369
387
  },
370
388
  },
@@ -1,6 +1,43 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
3
  import { getWorkspacePath, getWorkspaceFilePath, getDailyMemoryPath } from "./paths.js";
4
+ // 파일 크기 제한 (문자 수)
5
+ const FILE_LIMITS = {
6
+ identity: 2000, // IDENTITY.md - 간결해야 함
7
+ soul: 4000, // SOUL.md - 성격/스타일
8
+ user: 3000, // USER.md - 사용자 정보
9
+ agents: 8000, // AGENTS.md - 가이드라인
10
+ tools: 3000, // TOOLS.md - 도구 노트
11
+ heartbeat: 2000, // HEARTBEAT.md - 체크리스트
12
+ memory: 6000, // MEMORY.md - 장기 기억
13
+ bootstrap: 2000, // BOOTSTRAP.md - 온보딩
14
+ };
15
+ // 전체 워크스페이스 최대 크기 (토큰 절약)
16
+ const TOTAL_WORKSPACE_LIMIT = 25000;
17
+ /**
18
+ * 파일을 읽고 크기 제한 적용
19
+ */
20
+ async function readFileWithLimit(filePath, limit) {
21
+ try {
22
+ const content = await fs.readFile(filePath, "utf-8");
23
+ if (content.length > limit) {
24
+ // 마지막 완전한 문단에서 자르기
25
+ let truncated = content.slice(0, limit);
26
+ const lastNewline = truncated.lastIndexOf("\n\n");
27
+ if (lastNewline > limit * 0.7) {
28
+ truncated = truncated.slice(0, lastNewline);
29
+ }
30
+ return {
31
+ content: truncated + "\n\n... (truncated)",
32
+ truncated: true
33
+ };
34
+ }
35
+ return { content, truncated: false };
36
+ }
37
+ catch {
38
+ return { content: null, truncated: false };
39
+ }
40
+ }
4
41
  async function readFileOrNull(filePath) {
5
42
  try {
6
43
  return await fs.readFile(filePath, "utf-8");
@@ -9,17 +46,84 @@ async function readFileOrNull(filePath) {
9
46
  return null;
10
47
  }
11
48
  }
49
+ /**
50
+ * 오늘과 어제의 daily memory를 로드합니다.
51
+ * 시스템 프롬프트에 직접 포함되어 최근 컨텍스트를 제공합니다.
52
+ */
53
+ async function loadRecentDailyMemory() {
54
+ const parts = [];
55
+ const today = new Date();
56
+ const yesterday = new Date(today);
57
+ yesterday.setDate(yesterday.getDate() - 1);
58
+ const DAILY_LIMIT = 2500; // 각 날짜별 최대 문자 수
59
+ for (const [label, date] of [["오늘", today], ["어제", yesterday]]) {
60
+ try {
61
+ const memoryPath = getDailyMemoryPath(date);
62
+ let content = await fs.readFile(memoryPath, "utf-8");
63
+ if (content.trim()) {
64
+ // 너무 길면 최근 부분만 유지 (## 타임스탬프 기준)
65
+ if (content.length > DAILY_LIMIT) {
66
+ // ## 로 시작하는 섹션들로 분할
67
+ const sections = content.split(/(?=^## )/m);
68
+ let trimmedContent = "";
69
+ // 뒤에서부터 추가 (최근 기록 우선)
70
+ for (let i = sections.length - 1; i >= 0; i--) {
71
+ if ((trimmedContent + sections[i]).length > DAILY_LIMIT)
72
+ break;
73
+ trimmedContent = sections[i] + trimmedContent;
74
+ }
75
+ content = "...(이전 기록 생략)...\n" + trimmedContent.trim();
76
+ }
77
+ parts.push(`### ${label} 기록 (${date.toISOString().split("T")[0]})\n${content.trim()}`);
78
+ }
79
+ }
80
+ catch {
81
+ // 파일 없음 무시
82
+ }
83
+ }
84
+ return parts.length > 0 ? parts.join("\n\n") : null;
85
+ }
12
86
  export async function loadWorkspace() {
13
87
  const workspacePath = getWorkspacePath();
14
- const [agents, bootstrap, identity, soul, user, memory] = await Promise.all([
15
- readFileOrNull(path.join(workspacePath, "AGENTS.md")),
16
- readFileOrNull(path.join(workspacePath, "BOOTSTRAP.md")),
17
- readFileOrNull(path.join(workspacePath, "IDENTITY.md")),
18
- readFileOrNull(path.join(workspacePath, "SOUL.md")),
19
- readFileOrNull(path.join(workspacePath, "USER.md")),
20
- readFileOrNull(path.join(workspacePath, "MEMORY.md")),
88
+ const truncatedFiles = [];
89
+ const results = await Promise.all([
90
+ readFileWithLimit(path.join(workspacePath, "AGENTS.md"), FILE_LIMITS.agents),
91
+ readFileOrNull(path.join(workspacePath, "BOOTSTRAP.md")), // bootstrap은 제한 없음 (임시)
92
+ readFileWithLimit(path.join(workspacePath, "IDENTITY.md"), FILE_LIMITS.identity),
93
+ readFileWithLimit(path.join(workspacePath, "SOUL.md"), FILE_LIMITS.soul),
94
+ readFileWithLimit(path.join(workspacePath, "USER.md"), FILE_LIMITS.user),
95
+ readFileWithLimit(path.join(workspacePath, "TOOLS.md"), FILE_LIMITS.tools),
96
+ readFileWithLimit(path.join(workspacePath, "HEARTBEAT.md"), FILE_LIMITS.heartbeat),
97
+ readFileWithLimit(path.join(workspacePath, "MEMORY.md"), FILE_LIMITS.memory),
98
+ loadRecentDailyMemory(), // 오늘/어제 daily memory
21
99
  ]);
22
- return { agents, bootstrap, identity, soul, user, memory };
100
+ const [agents, bootstrap, identity, soul, user, tools, heartbeat, memory, recentDaily] = results;
101
+ // 잘린 파일 추적
102
+ const fileNames = ["AGENTS.md", "BOOTSTRAP.md", "IDENTITY.md", "SOUL.md", "USER.md", "TOOLS.md", "HEARTBEAT.md", "MEMORY.md"];
103
+ results.slice(0, 8).forEach((r, i) => {
104
+ if (typeof r === "object" && r !== null && "truncated" in r && r.truncated) {
105
+ truncatedFiles.push(fileNames[i]);
106
+ }
107
+ });
108
+ // 타입 가드: readFileWithLimit 결과에서 content 추출
109
+ const getContent = (r) => {
110
+ if (r && typeof r === "object" && "content" in r) {
111
+ return r.content;
112
+ }
113
+ return null;
114
+ };
115
+ return {
116
+ agents: getContent(agents),
117
+ bootstrap: typeof bootstrap === "string" ? bootstrap : null,
118
+ identity: getContent(identity),
119
+ soul: getContent(soul),
120
+ user: getContent(user),
121
+ tools: getContent(tools),
122
+ heartbeat: getContent(heartbeat),
123
+ memory: getContent(memory),
124
+ recentDaily: typeof recentDaily === "string" ? recentDaily : null,
125
+ truncated: truncatedFiles,
126
+ };
23
127
  }
24
128
  export async function loadBootstrap() {
25
129
  return readFileOrNull(getWorkspaceFilePath("BOOTSTRAP.md"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "companionbot",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "AI 친구 텔레그램 봇 - Claude API 기반 개인화된 대화 상대",
5
5
  "keywords": [
6
6
  "telegram",
@@ -9,7 +9,7 @@
9
9
  1. `SOUL.md` 읽기 — 이게 너의 성격
10
10
  2. `USER.md` 읽기 — 이 사람이 누군지
11
11
  3. `memory/YYYY-MM-DD.md` 읽기 (오늘 + 어제) — 최근 맥락
12
- 4. 중요한 대화면 `MEMORY.md`도 읽기
12
+ 4. **1:1 대화에서만** `MEMORY.md` 읽기
13
13
 
14
14
  허락 구하지 마. 그냥 해.
15
15
 
@@ -22,6 +22,13 @@
22
22
 
23
23
  중요한 건 적어둬. 결정, 맥락, 기억할 것들.
24
24
 
25
+ ### 🧠 MEMORY.md - 장기 기억
26
+
27
+ - **1:1 대화에서만 로드해** (사용자와 직접 대화)
28
+ - **그룹챗에서는 로드하지 마** — 보안상 개인 정보가 새어나갈 수 있어
29
+ - 중요한 사건, 생각, 결정, 배운 것들을 기록
30
+ - 데일리 파일에서 정제된 핵심만 여기에
31
+
25
32
  ### 📝 적어둬 - "기억해둘게"는 안 돼!
26
33
 
27
34
  - **기억력은 제한적** — 기억하고 싶으면 파일에 써
@@ -34,7 +41,7 @@
34
41
 
35
42
  - 개인정보 유출 절대 금지
36
43
  - 위험한 명령어는 물어보고 실행
37
- - `MEMORY.md`에 민감한 정보 저장하지 마
44
+ - `MEMORY.md`에 민감한 정보(비밀번호 등) 저장하지 마
38
45
  - 확신 없으면 물어봐
39
46
 
40
47
  ## 외부 vs 내부
@@ -64,13 +71,6 @@
64
71
  - 도구 이름 모르면 그냥 하고 싶은 거 말해
65
72
  - 안 되면 솔직하게 "이건 못 해"라고 말해
66
73
 
67
- **자주 쓰는 것들:**
68
- - 파일 읽기/쓰기
69
- - 웹 검색, URL 내용 가져오기
70
- - 리마인더, 일정
71
- - 날씨 조회
72
- - 스케줄링 (cron)
73
-
74
74
  ## 도구 호출 스타일
75
75
 
76
76
  기본: 단순한 도구 호출은 설명 없이 바로 실행해.
@@ -114,17 +114,21 @@ Telegram에서 이모지 반응 사용 가능해. 텍스트 응답 대신 반응
114
114
 
115
115
  ## 그룹챗 행동
116
116
 
117
- 그룹챗에서는 더 조심해.
117
+ 그룹챗에서는 더 조심해. 너는 참여자지, 사용자의 대리인이 아니야.
118
118
 
119
119
  **말해야 할 때:**
120
120
  - 직접 멘션되었을 때
121
121
  - 내가 답할 수 있는 질문
122
122
  - 유용한 정보 추가할 수 있을 때
123
+ - 자연스럽게 재치있는 말이 맞을 때
123
124
 
124
125
  **조용히 있어야 할 때:**
125
126
  - 사람들끼리 대화 중
126
127
  - 누군가 이미 답변함
127
- - 단순 잡담
128
+ - "ㅇㅇ", "ㅋㅋ" 정도면 충분할 때
129
+ - 대화 흐름이 잘 가고 있을 때
130
+
131
+ **사람 규칙:** 사람들도 그룹챗에서 모든 메시지에 답하지 않아. 너도 마찬가지. 질 > 양.
128
132
 
129
133
  그룹에서는 참여하되, 지배하지 마.
130
134
 
@@ -157,18 +161,49 @@ Telegram에서 이모지 반응 사용 가능해. 텍스트 응답 대신 반응
157
161
  - 명령/지시
158
162
  - 중요한 정보 공유
159
163
 
160
- **HEARTBEAT_OK:**
161
- 하트비트 메시지 받았는데 특별히 할 일 없으면 `HEARTBEAT_OK`만 응답.
162
-
163
164
  ## 💓 Heartbeat
164
165
 
165
166
  주기적으로 HEARTBEAT.md를 체크해. 할 일이 있으면 알려주고, 없으면 조용히 있어.
166
167
 
168
+ **할 일 없으면:** `HEARTBEAT_OK`만 응답.
169
+
167
170
  **알림 보낼 때:**
168
171
  - 중요한 것만
169
172
  - 심야 (23:00-08:00)엔 급한 거 아니면 조용히
170
173
  - 최근 30분 내 체크했으면 스킵
171
174
 
175
+ **하트비트 중 할 수 있는 일:**
176
+ - 메모리 파일 정리
177
+ - 프로젝트 상태 확인
178
+ - 문서 업데이트
179
+ - MEMORY.md 리뷰 및 업데이트
180
+
181
+ ### Heartbeat vs Cron
182
+
183
+ **Heartbeat 쓸 때:**
184
+ - 여러 체크를 묶어서 (캘린더 + 리마인더 + 알림)
185
+ - 최근 대화 맥락이 필요할 때
186
+ - 타이밍이 정확하지 않아도 될 때 (~30분 간격)
187
+
188
+ **Cron 쓸 때:**
189
+ - 정확한 시간이 필요할 때 ("매주 월요일 9시")
190
+ - 메인 세션과 독립적으로 실행해야 할 때
191
+ - 단발성 리마인더 ("20분 후 알려줘")
192
+ - 결과를 바로 채널로 보내야 할 때
193
+
194
+ **팁:** 비슷한 주기적 체크는 HEARTBEAT.md에 묶어서. Cron은 정확한 스케줄과 단독 작업용.
195
+
196
+ ### 🔄 메모리 정리 (하트비트 중)
197
+
198
+ 주기적으로 (며칠에 한 번):
199
+
200
+ 1. 최근 `memory/YYYY-MM-DD.md` 파일들 읽기
201
+ 2. 장기적으로 중요한 사건, 교훈, 인사이트 찾기
202
+ 3. `MEMORY.md`에 정제해서 업데이트
203
+ 4. 더 이상 관련 없는 정보는 MEMORY.md에서 제거
204
+
205
+ 데일리 파일은 원본 기록, MEMORY.md는 정제된 지혜.
206
+
172
207
  ## 이 파일을 수정해도 돼
173
208
 
174
209
  이건 시작점이야. 너만의 규칙, 스타일, 컨벤션을 추가해.