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.
- package/dist/ai/claude.js +21 -10
- package/dist/cron/scheduler.js +2 -2
- package/dist/heartbeat/index.js +3 -3
- package/dist/memory/vectorStore.js +109 -6
- package/dist/session/state.js +240 -20
- package/dist/telegram/handlers/commands.js +93 -6
- package/dist/telegram/handlers/messages.js +64 -22
- package/dist/telegram/utils/index.js +1 -1
- package/dist/telegram/utils/prompt.js +221 -65
- package/dist/telegram/utils/url.js +34 -1
- package/dist/tools/index.js +21 -3
- package/dist/workspace/load.js +112 -8
- package/package.json +1 -1
- package/templates/AGENTS.md +49 -14
|
@@ -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,
|
|
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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -354,17 +354,35 @@ Guidelines:
|
|
|
354
354
|
},
|
|
355
355
|
{
|
|
356
356
|
name: "save_memory",
|
|
357
|
-
description:
|
|
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
|
},
|
package/dist/workspace/load.js
CHANGED
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
readFileOrNull(path.join(workspacePath, "
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
package/templates/AGENTS.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
1. `SOUL.md` 읽기 — 이게 너의 성격
|
|
10
10
|
2. `USER.md` 읽기 — 이 사람이 누군지
|
|
11
11
|
3. `memory/YYYY-MM-DD.md` 읽기 (오늘 + 어제) — 최근 맥락
|
|
12
|
-
4.
|
|
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
|
이건 시작점이야. 너만의 규칙, 스타일, 컨벤션을 추가해.
|