companionbot 0.4.2 → 0.5.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/agents/index.js +1 -1
- package/dist/agents/manager.js +30 -0
- package/dist/ai/claude.js +4 -3
- package/dist/cron/parser.js +153 -20
- package/dist/cron/scheduler.js +7 -3
- package/dist/heartbeat/index.js +5 -1
- package/dist/telegram/bot.js +3 -3
- package/dist/telegram/handlers/commands.js +39 -6
- package/dist/telegram/handlers/messages.js +115 -5
- package/dist/telegram/utils/index.js +2 -0
- package/dist/telegram/utils/prompt.js +10 -7
- package/dist/telegram/utils/secrets.js +64 -0
- package/dist/tools/index.js +382 -22
- package/dist/workspace/index.js +3 -1
- package/dist/workspace/load.js +251 -7
- package/package.json +1 -1
- package/templates/AGENTS.md +77 -10
- package/templates/MEMORY.md +58 -4
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API 키 및 시크릿 감지 유틸리티
|
|
3
|
+
* 사용자가 실수로 채팅에 API 키를 입력하는 것을 방지합니다.
|
|
4
|
+
*/
|
|
5
|
+
// API 키 패턴들 (정규식)
|
|
6
|
+
const API_KEY_PATTERNS = [
|
|
7
|
+
// OpenAI
|
|
8
|
+
{ name: "OpenAI", pattern: /sk-[a-zA-Z0-9]{20,}/ },
|
|
9
|
+
{ name: "OpenAI Project", pattern: /sk-proj-[a-zA-Z0-9_-]{20,}/ },
|
|
10
|
+
// Anthropic
|
|
11
|
+
{ name: "Anthropic", pattern: /sk-ant-[a-zA-Z0-9_-]{20,}/ },
|
|
12
|
+
{ name: "Anthropic API", pattern: /anthropic-[a-zA-Z0-9_-]{20,}/ },
|
|
13
|
+
// Bearer tokens (일반)
|
|
14
|
+
{ name: "Bearer Token", pattern: /Bearer\s+[a-zA-Z0-9_\-.]{20,}/ },
|
|
15
|
+
// GitHub
|
|
16
|
+
{ name: "GitHub", pattern: /gh[pousr]_[a-zA-Z0-9]{20,}/ },
|
|
17
|
+
// Slack
|
|
18
|
+
{ name: "Slack", pattern: /xox[bpras]-[a-zA-Z0-9\-]{20,}/ },
|
|
19
|
+
// Google
|
|
20
|
+
{ name: "Google API", pattern: /AIza[a-zA-Z0-9_-]{35}/ },
|
|
21
|
+
// AWS
|
|
22
|
+
{ name: "AWS Access Key", pattern: /AKIA[A-Z0-9]{16}/ },
|
|
23
|
+
{ name: "AWS Secret", pattern: /[a-zA-Z0-9/+=]{40}(?=\s|$)/ },
|
|
24
|
+
// Telegram Bot Token
|
|
25
|
+
{ name: "Telegram Bot", pattern: /\d{8,10}:[a-zA-Z0-9_-]{35}/ },
|
|
26
|
+
// Discord
|
|
27
|
+
{ name: "Discord", pattern: /[MN][a-zA-Z0-9]{23,}\.[a-zA-Z0-9_-]{6}\.[a-zA-Z0-9_-]{27,}/ },
|
|
28
|
+
// Stripe
|
|
29
|
+
{ name: "Stripe", pattern: /sk_live_[a-zA-Z0-9]{20,}/ },
|
|
30
|
+
{ name: "Stripe Test", pattern: /sk_test_[a-zA-Z0-9]{20,}/ },
|
|
31
|
+
// Twilio
|
|
32
|
+
{ name: "Twilio", pattern: /SK[a-f0-9]{32}/ },
|
|
33
|
+
// SendGrid
|
|
34
|
+
{ name: "SendGrid", pattern: /SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/ },
|
|
35
|
+
// Private keys
|
|
36
|
+
{ name: "Private Key", pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/ },
|
|
37
|
+
// Generic API key patterns (높은 엔트로피 문자열)
|
|
38
|
+
{ name: "API Key", pattern: /(?:api[_-]?key|apikey|secret[_-]?key|access[_-]?token)\s*[:=]\s*['"]?[a-zA-Z0-9_\-]{20,}['"]?/i },
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* 메시지에서 API 키나 시크릿을 감지합니다.
|
|
42
|
+
* @param message 검사할 메시지
|
|
43
|
+
* @returns 감지 결과
|
|
44
|
+
*/
|
|
45
|
+
export function detectSecrets(message) {
|
|
46
|
+
const detectedTypes = [];
|
|
47
|
+
for (const { name, pattern } of API_KEY_PATTERNS) {
|
|
48
|
+
if (pattern.test(message)) {
|
|
49
|
+
detectedTypes.push(name);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
detected: detectedTypes.length > 0,
|
|
54
|
+
types: [...new Set(detectedTypes)], // 중복 제거
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 메시지에 API 키가 포함되어 있는지 빠르게 확인합니다.
|
|
59
|
+
* @param message 검사할 메시지
|
|
60
|
+
* @returns API 키 포함 여부
|
|
61
|
+
*/
|
|
62
|
+
export function containsSecret(message) {
|
|
63
|
+
return API_KEY_PATTERNS.some(({ pattern }) => pattern.test(message));
|
|
64
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -7,7 +7,9 @@ import { randomUUID } from "crypto";
|
|
|
7
7
|
import { MODELS } from "../ai/claude.js";
|
|
8
8
|
import { getCurrentChatId, setModel, getModel } from "../session/state.js";
|
|
9
9
|
// Note: getCurrentChatId uses AsyncLocalStorage - must be called within runWithChatId context
|
|
10
|
-
import { getWorkspacePath, saveWorkspaceFile, appendToMemory, deleteBootstrap,
|
|
10
|
+
import { getWorkspacePath, saveWorkspaceFile, appendToMemory, deleteBootstrap,
|
|
11
|
+
// 메모리 정리 도구용
|
|
12
|
+
listDailyMemoryFiles, getDailyMemoryContent, appendToLongTermMemory, deleteOldDailyMemories, loadLongTermMemory, } from "../workspace/index.js";
|
|
11
13
|
import { getSecret } from "../config/secrets.js";
|
|
12
14
|
import { createReminder, deleteReminder, getReminders, parseTimeExpression, } from "../reminders/index.js";
|
|
13
15
|
import { isCalendarConfigured, getEvents, addEvent, deleteEvent, formatEvent, parseDateExpression, } from "../calendar/index.js";
|
|
@@ -16,6 +18,7 @@ import { setBriefingConfig, getBriefingConfig, disableBriefing, sendBriefingNow,
|
|
|
16
18
|
import { spawnAgent, listAgents, cancelAgent, } from "../agents/index.js";
|
|
17
19
|
import { addCronJob, listCronJobs, removeCronJob, setCronJobEnabled, runCronJobNow, parseScheduleExpression, } from "../cron/index.js";
|
|
18
20
|
import * as cheerio from "cheerio";
|
|
21
|
+
import * as dns from "dns/promises";
|
|
19
22
|
const execAsync = promisify(exec);
|
|
20
23
|
// 메모리에 세션 저장
|
|
21
24
|
const sessions = new Map();
|
|
@@ -49,13 +52,32 @@ function appendOutput(session, data) {
|
|
|
49
52
|
}
|
|
50
53
|
// 홈 디렉토리
|
|
51
54
|
const home = process.env.HOME || "";
|
|
52
|
-
// 허용된 디렉토리
|
|
55
|
+
// 허용된 디렉토리 설정
|
|
56
|
+
// - COMPANIONBOT_FULL_ACCESS=true: 홈 디렉토리 전체 접근 (위험한 파일 패턴은 여전히 차단)
|
|
57
|
+
// - COMPANIONBOT_ALLOWED_PATHS: 콜론(:)으로 구분된 추가 경로 (예: /tmp:/var/data)
|
|
58
|
+
// - 기본값: ~/Documents, ~/projects, 워크스페이스
|
|
53
59
|
function getAllowedPaths() {
|
|
54
|
-
|
|
60
|
+
// 전체 접근 모드
|
|
61
|
+
if (process.env.COMPANIONBOT_FULL_ACCESS === "true") {
|
|
62
|
+
return [home];
|
|
63
|
+
}
|
|
64
|
+
// 기본 경로
|
|
65
|
+
const paths = [
|
|
55
66
|
path.join(home, "Documents"),
|
|
56
67
|
path.join(home, "projects"),
|
|
57
|
-
getWorkspacePath(),
|
|
68
|
+
getWorkspacePath(),
|
|
58
69
|
];
|
|
70
|
+
// 환경변수로 추가 경로 설정
|
|
71
|
+
const extraPaths = process.env.COMPANIONBOT_ALLOWED_PATHS;
|
|
72
|
+
if (extraPaths) {
|
|
73
|
+
const extras = extraPaths.split(":").filter(p => p.trim());
|
|
74
|
+
for (const p of extras) {
|
|
75
|
+
// ~ 확장
|
|
76
|
+
const expanded = p.startsWith("~") ? path.join(home, p.slice(1)) : p;
|
|
77
|
+
paths.push(expanded);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return paths;
|
|
59
81
|
}
|
|
60
82
|
// 위험한 파일 패턴
|
|
61
83
|
// SSRF 방지: 사설 IP 체크
|
|
@@ -330,18 +352,34 @@ Guidelines:
|
|
|
330
352
|
},
|
|
331
353
|
{
|
|
332
354
|
name: "save_memory",
|
|
333
|
-
description:
|
|
355
|
+
description: `Save important information to daily memory. Use when you learn something about the user.
|
|
356
|
+
|
|
357
|
+
Features:
|
|
358
|
+
- Duplicate check: Won't save if similar content exists today (80% similarity threshold)
|
|
359
|
+
- Categories help organize memories for later retrieval
|
|
360
|
+
|
|
361
|
+
Guidelines:
|
|
362
|
+
- Use "preference" for likes/dislikes (음식, 음악, 스타일 등)
|
|
363
|
+
- Use "project" for work/projects (코드, 진행상황 등)
|
|
364
|
+
- Use "event" for dates/events (생일, 약속, 기념일)
|
|
365
|
+
- Use "decision" for decisions/agreements (결정, 약속)
|
|
366
|
+
- Use "user_info" for personal info (이름, 직업 등)
|
|
367
|
+
- Use force_save=true to bypass duplicate check if needed`,
|
|
334
368
|
input_schema: {
|
|
335
369
|
type: "object",
|
|
336
370
|
properties: {
|
|
337
371
|
content: {
|
|
338
372
|
type: "string",
|
|
339
|
-
description: "The information to remember",
|
|
373
|
+
description: "The information to remember. Be concise but specific.",
|
|
340
374
|
},
|
|
341
375
|
category: {
|
|
342
376
|
type: "string",
|
|
343
|
-
enum: ["
|
|
344
|
-
description: "Category of the memory",
|
|
377
|
+
enum: ["preference", "project", "event", "decision", "user_info", "other"],
|
|
378
|
+
description: "Category of the memory (affects emoji and organization)",
|
|
379
|
+
},
|
|
380
|
+
force_save: {
|
|
381
|
+
type: "boolean",
|
|
382
|
+
description: "Skip duplicate check and save anyway (default: false)",
|
|
345
383
|
},
|
|
346
384
|
},
|
|
347
385
|
required: ["content"],
|
|
@@ -763,6 +801,103 @@ Examples:
|
|
|
763
801
|
required: ["id"],
|
|
764
802
|
},
|
|
765
803
|
},
|
|
804
|
+
// ============== 메모리 정리 도구 ==============
|
|
805
|
+
{
|
|
806
|
+
name: "list_daily_memories",
|
|
807
|
+
description: `List all daily memory files with their dates and sizes. Use to see what memories exist before organizing or cleaning.
|
|
808
|
+
|
|
809
|
+
Use this when:
|
|
810
|
+
- User asks what memories exist
|
|
811
|
+
- Before organizing memories to see available files
|
|
812
|
+
- Before cleaning old memories to check what will be affected`,
|
|
813
|
+
input_schema: {
|
|
814
|
+
type: "object",
|
|
815
|
+
properties: {
|
|
816
|
+
max_age_days: {
|
|
817
|
+
type: "number",
|
|
818
|
+
description: "Only show files older than this many days (optional)",
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
required: [],
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
{
|
|
825
|
+
name: "get_daily_memory",
|
|
826
|
+
description: "Get the content of a specific daily memory file by date.",
|
|
827
|
+
input_schema: {
|
|
828
|
+
type: "object",
|
|
829
|
+
properties: {
|
|
830
|
+
date: {
|
|
831
|
+
type: "string",
|
|
832
|
+
description: "Date in YYYY-MM-DD format (e.g., '2025-02-07')",
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
required: ["date"],
|
|
836
|
+
},
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
name: "organize_memory",
|
|
840
|
+
description: `Move important content from daily memory files to long-term MEMORY.md.
|
|
841
|
+
|
|
842
|
+
Use during heartbeats or when user asks to organize memories:
|
|
843
|
+
1. Review recent daily files (call list_daily_memories first)
|
|
844
|
+
2. Identify significant events, decisions, lessons, or insights
|
|
845
|
+
3. Use this tool to save them to MEMORY.md with a section name
|
|
846
|
+
4. Optionally clear old daily files with clear_old_memory
|
|
847
|
+
|
|
848
|
+
Example flow:
|
|
849
|
+
1. list_daily_memories → see available files
|
|
850
|
+
2. get_daily_memory date="2025-02-05" → review content
|
|
851
|
+
3. organize_memory content="사용자는 오후에 집중력이 높다고 함" section="선호도"`,
|
|
852
|
+
input_schema: {
|
|
853
|
+
type: "object",
|
|
854
|
+
properties: {
|
|
855
|
+
content: {
|
|
856
|
+
type: "string",
|
|
857
|
+
description: "The important content to save to long-term memory",
|
|
858
|
+
},
|
|
859
|
+
section: {
|
|
860
|
+
type: "string",
|
|
861
|
+
description: "Optional section/category name (e.g., '선호도', '프로젝트', '결정사항')",
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
required: ["content"],
|
|
865
|
+
},
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
name: "clear_old_memory",
|
|
869
|
+
description: `Delete old daily memory files. Use after organizing to clean up storage.
|
|
870
|
+
|
|
871
|
+
Safety: Will not delete today's or yesterday's files by default.
|
|
872
|
+
Recommendation: Set older_than_days to at least 7 to keep a week of context.
|
|
873
|
+
|
|
874
|
+
Examples:
|
|
875
|
+
- "30일 지난 메모리 정리해줘" → older_than_days: 30
|
|
876
|
+
- "2주 이상 된 것만 삭제해" → older_than_days: 14`,
|
|
877
|
+
input_schema: {
|
|
878
|
+
type: "object",
|
|
879
|
+
properties: {
|
|
880
|
+
older_than_days: {
|
|
881
|
+
type: "number",
|
|
882
|
+
description: "Delete files older than this many days (minimum: 2, default: 30)",
|
|
883
|
+
},
|
|
884
|
+
dry_run: {
|
|
885
|
+
type: "boolean",
|
|
886
|
+
description: "If true, only show what would be deleted without actually deleting (default: true)",
|
|
887
|
+
},
|
|
888
|
+
},
|
|
889
|
+
required: [],
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
name: "get_long_term_memory",
|
|
894
|
+
description: "Read the current contents of MEMORY.md (long-term memory).",
|
|
895
|
+
input_schema: {
|
|
896
|
+
type: "object",
|
|
897
|
+
properties: {},
|
|
898
|
+
required: [],
|
|
899
|
+
},
|
|
900
|
+
},
|
|
766
901
|
];
|
|
767
902
|
// Tool 실행 함수
|
|
768
903
|
export async function executeTool(name, input) {
|
|
@@ -833,12 +968,50 @@ export async function executeTool(name, input) {
|
|
|
833
968
|
"which", "env", "printenv"
|
|
834
969
|
];
|
|
835
970
|
// 명령어 체이닝/치환/리디렉션 차단 (;, &&, ||, |, `, $(), ${}, 개행, >, <)
|
|
971
|
+
// Shell injection 방지: shell: false 사용하더라도 이중 방어
|
|
836
972
|
if (/[;&|`\n\r]|\$\(|\$\{|>>|>|</.test(command)) {
|
|
837
973
|
return `Error: Command chaining, substitution, and redirection not allowed.`;
|
|
838
974
|
}
|
|
839
|
-
//
|
|
840
|
-
|
|
975
|
+
// 인자 파싱 (쉘을 거치지 않으므로 직접 파싱)
|
|
976
|
+
// 간단한 공백 분리 (따옴표 처리 포함)
|
|
977
|
+
const parseArgs = (cmd) => {
|
|
978
|
+
const args = [];
|
|
979
|
+
let current = "";
|
|
980
|
+
let inQuote = null;
|
|
981
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
982
|
+
const char = cmd[i];
|
|
983
|
+
if (inQuote) {
|
|
984
|
+
if (char === inQuote) {
|
|
985
|
+
inQuote = null;
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
current += char;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
else if (char === '"' || char === "'") {
|
|
992
|
+
inQuote = char;
|
|
993
|
+
}
|
|
994
|
+
else if (char === " " || char === "\t") {
|
|
995
|
+
if (current) {
|
|
996
|
+
args.push(current);
|
|
997
|
+
current = "";
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
current += char;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (current) {
|
|
1005
|
+
args.push(current);
|
|
1006
|
+
}
|
|
1007
|
+
return args;
|
|
1008
|
+
};
|
|
1009
|
+
const parts = parseArgs(command.trim());
|
|
1010
|
+
if (parts.length === 0) {
|
|
1011
|
+
return `Error: Empty command.`;
|
|
1012
|
+
}
|
|
841
1013
|
const cmd = parts[0];
|
|
1014
|
+
const args = parts.slice(1);
|
|
842
1015
|
if (!ALLOWED_COMMANDS.includes(cmd)) {
|
|
843
1016
|
return `Error: Command '${cmd}' not in allowed list. Allowed: ${ALLOWED_COMMANDS.join(", ")}`;
|
|
844
1017
|
}
|
|
@@ -847,6 +1020,13 @@ export async function executeTool(name, input) {
|
|
|
847
1020
|
if (dangerousArgs.some(arg => parts.includes(arg))) {
|
|
848
1021
|
return `Error: Dangerous argument detected.`;
|
|
849
1022
|
}
|
|
1023
|
+
// 인자에 쉘 메타문자가 있으면 차단 (이중 방어)
|
|
1024
|
+
const shellMetaChars = /[;&|`$\\<>(){}[\]!#*?~]/;
|
|
1025
|
+
for (const arg of args) {
|
|
1026
|
+
if (shellMetaChars.test(arg)) {
|
|
1027
|
+
return `Error: Invalid characters in argument: ${arg}`;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
850
1030
|
// 환경 변수는 필요한 것만 화이트리스트로 전달 (민감 정보 노출 방지)
|
|
851
1031
|
const safeEnv = {
|
|
852
1032
|
PATH: process.env.PATH || "",
|
|
@@ -855,14 +1035,16 @@ export async function executeTool(name, input) {
|
|
|
855
1035
|
LANG: process.env.LANG || "en_US.UTF-8",
|
|
856
1036
|
TERM: process.env.TERM || "xterm",
|
|
857
1037
|
};
|
|
858
|
-
// Background 실행
|
|
1038
|
+
// Background 실행 - shell: false (spawn 직접 사용)
|
|
859
1039
|
if (background) {
|
|
860
1040
|
const sessionId = randomUUID().slice(0, 8);
|
|
861
|
-
|
|
1041
|
+
// shell: false - 쉘을 거치지 않고 직접 실행
|
|
1042
|
+
const child = spawn(cmd, args, {
|
|
862
1043
|
cwd,
|
|
863
1044
|
env: safeEnv,
|
|
864
1045
|
detached: true,
|
|
865
1046
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1047
|
+
shell: false, // 명시적으로 shell 비활성화
|
|
866
1048
|
});
|
|
867
1049
|
const session = {
|
|
868
1050
|
id: sessionId,
|
|
@@ -902,14 +1084,41 @@ CWD: ${cwd}
|
|
|
902
1084
|
|
|
903
1085
|
Use list_sessions to see all sessions, get_session_log to view output, kill_session to terminate.`;
|
|
904
1086
|
}
|
|
905
|
-
// Foreground 실행
|
|
1087
|
+
// Foreground 실행 - spawn with shell: false
|
|
906
1088
|
try {
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1089
|
+
const result = await new Promise((resolve, reject) => {
|
|
1090
|
+
const child = spawn(cmd, args, {
|
|
1091
|
+
cwd,
|
|
1092
|
+
env: safeEnv,
|
|
1093
|
+
shell: false, // shell 비활성화
|
|
1094
|
+
});
|
|
1095
|
+
let stdout = "";
|
|
1096
|
+
let stderr = "";
|
|
1097
|
+
child.stdout?.on("data", (data) => {
|
|
1098
|
+
stdout += data.toString();
|
|
1099
|
+
});
|
|
1100
|
+
child.stderr?.on("data", (data) => {
|
|
1101
|
+
stderr += data.toString();
|
|
1102
|
+
});
|
|
1103
|
+
const timeoutId = setTimeout(() => {
|
|
1104
|
+
child.kill("SIGTERM");
|
|
1105
|
+
reject(new Error(`Command timed out after ${timeout / 1000}s`));
|
|
1106
|
+
}, timeout);
|
|
1107
|
+
child.on("close", (code) => {
|
|
1108
|
+
clearTimeout(timeoutId);
|
|
1109
|
+
if (code === 0) {
|
|
1110
|
+
resolve({ stdout, stderr });
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
reject(new Error(`Command failed with exit code ${code}: ${stderr || stdout}`));
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
child.on("error", (err) => {
|
|
1117
|
+
clearTimeout(timeoutId);
|
|
1118
|
+
reject(err);
|
|
1119
|
+
});
|
|
911
1120
|
});
|
|
912
|
-
return stdout || stderr || "Command executed (no output)";
|
|
1121
|
+
return result.stdout || result.stderr || "Command executed (no output)";
|
|
913
1122
|
}
|
|
914
1123
|
catch (error) {
|
|
915
1124
|
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -1014,8 +1223,15 @@ ${"─".repeat(40)}`;
|
|
|
1014
1223
|
case "save_memory": {
|
|
1015
1224
|
const content = input.content;
|
|
1016
1225
|
const category = input.category || "other";
|
|
1017
|
-
|
|
1018
|
-
|
|
1226
|
+
const forceSave = input.force_save || false;
|
|
1227
|
+
const result = await appendToMemory(content, {
|
|
1228
|
+
category: category,
|
|
1229
|
+
skipDuplicateCheck: forceSave,
|
|
1230
|
+
});
|
|
1231
|
+
if (!result.success && result.isDuplicate) {
|
|
1232
|
+
return `Memory not saved (duplicate detected). Similar entry: "${result.existingEntry}..."`;
|
|
1233
|
+
}
|
|
1234
|
+
return `Memory saved [${category}]: ${content.slice(0, 50)}${content.length > 50 ? "..." : ""}`;
|
|
1019
1235
|
}
|
|
1020
1236
|
case "save_persona": {
|
|
1021
1237
|
const identity = input.identity;
|
|
@@ -1356,9 +1572,11 @@ ${"─".repeat(40)}`;
|
|
|
1356
1572
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
1357
1573
|
return "Error: URL must start with http:// or https://";
|
|
1358
1574
|
}
|
|
1359
|
-
// SSRF 방지: 사설 IP 차단
|
|
1575
|
+
// SSRF 방지: 사설 IP 차단 + DNS rebinding 방지
|
|
1576
|
+
let parsedUrl;
|
|
1360
1577
|
try {
|
|
1361
|
-
|
|
1578
|
+
parsedUrl = new URL(url);
|
|
1579
|
+
// 1차 검증: hostname이 직접 IP인 경우
|
|
1362
1580
|
if (isPrivateIP(parsedUrl.hostname)) {
|
|
1363
1581
|
return "Error: Access to private/internal addresses is not allowed.";
|
|
1364
1582
|
}
|
|
@@ -1366,12 +1584,52 @@ ${"─".repeat(40)}`;
|
|
|
1366
1584
|
catch {
|
|
1367
1585
|
return "Error: Invalid URL format.";
|
|
1368
1586
|
}
|
|
1587
|
+
// DNS rebinding 방지: 실제 IP 주소를 먼저 resolve하고 검증
|
|
1588
|
+
try {
|
|
1589
|
+
// IP 주소가 아닌 hostname만 DNS lookup 수행
|
|
1590
|
+
const isIPAddress = /^[\d.]+$/.test(parsedUrl.hostname) || parsedUrl.hostname.includes(":");
|
|
1591
|
+
if (!isIPAddress) {
|
|
1592
|
+
const addresses = await dns.lookup(parsedUrl.hostname, { all: true });
|
|
1593
|
+
for (const addr of addresses) {
|
|
1594
|
+
if (isPrivateIP(addr.address)) {
|
|
1595
|
+
return `Error: DNS resolves to private IP (${addr.address}). Access denied.`;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
catch (dnsError) {
|
|
1601
|
+
// DNS lookup 실패 - 도메인이 존재하지 않거나 resolve 불가
|
|
1602
|
+
return `Error: Could not resolve hostname: ${parsedUrl.hostname}`;
|
|
1603
|
+
}
|
|
1369
1604
|
try {
|
|
1605
|
+
// fetch 시 redirect 제한 (redirect를 통한 SSRF 방지)
|
|
1606
|
+
const controller = new AbortController();
|
|
1607
|
+
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
1370
1608
|
const response = await fetch(url, {
|
|
1371
1609
|
headers: {
|
|
1372
1610
|
"User-Agent": "Mozilla/5.0 (compatible; CompanionBot/1.0)",
|
|
1373
1611
|
},
|
|
1612
|
+
redirect: "manual", // redirect 직접 처리
|
|
1613
|
+
signal: controller.signal,
|
|
1374
1614
|
});
|
|
1615
|
+
clearTimeout(timeoutId);
|
|
1616
|
+
// Redirect 처리: 새 URL도 검증
|
|
1617
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1618
|
+
const redirectUrl = response.headers.get("location");
|
|
1619
|
+
if (redirectUrl) {
|
|
1620
|
+
try {
|
|
1621
|
+
const redirectParsed = new URL(redirectUrl, url);
|
|
1622
|
+
if (isPrivateIP(redirectParsed.hostname)) {
|
|
1623
|
+
return "Error: Redirect to private/internal address blocked.";
|
|
1624
|
+
}
|
|
1625
|
+
// 재귀 호출 대신 단일 redirect만 허용하고 에러 반환
|
|
1626
|
+
return `Error: URL redirects to ${redirectUrl}. Please use the final URL directly.`;
|
|
1627
|
+
}
|
|
1628
|
+
catch {
|
|
1629
|
+
return "Error: Invalid redirect URL.";
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1375
1633
|
if (!response.ok) {
|
|
1376
1634
|
return `Error: Failed to fetch URL (${response.status}: ${response.statusText})`;
|
|
1377
1635
|
}
|
|
@@ -1535,6 +1793,96 @@ Next run: ${nextRunStr}`;
|
|
|
1535
1793
|
return `Error: Cron job ${id} not found.`;
|
|
1536
1794
|
}
|
|
1537
1795
|
}
|
|
1796
|
+
// ============== 메모리 정리 도구 ==============
|
|
1797
|
+
case "list_daily_memories": {
|
|
1798
|
+
const maxAgeDays = input.max_age_days;
|
|
1799
|
+
const files = await listDailyMemoryFiles();
|
|
1800
|
+
if (files.length === 0) {
|
|
1801
|
+
return "No daily memory files found.";
|
|
1802
|
+
}
|
|
1803
|
+
// 필터링 (maxAgeDays가 지정된 경우)
|
|
1804
|
+
const filtered = maxAgeDays !== undefined
|
|
1805
|
+
? files.filter(f => f.ageInDays >= maxAgeDays)
|
|
1806
|
+
: files;
|
|
1807
|
+
if (filtered.length === 0) {
|
|
1808
|
+
return `No memory files older than ${maxAgeDays} days.`;
|
|
1809
|
+
}
|
|
1810
|
+
const lines = filtered.map(f => {
|
|
1811
|
+
const sizeKb = (f.sizeBytes / 1024).toFixed(1);
|
|
1812
|
+
const ageLabel = f.ageInDays === 0 ? "오늘"
|
|
1813
|
+
: f.ageInDays === 1 ? "어제"
|
|
1814
|
+
: `${f.ageInDays}일 전`;
|
|
1815
|
+
return `📅 ${f.date} (${ageLabel}) - ${sizeKb}KB`;
|
|
1816
|
+
});
|
|
1817
|
+
const totalSize = filtered.reduce((sum, f) => sum + f.sizeBytes, 0);
|
|
1818
|
+
const totalKb = (totalSize / 1024).toFixed(1);
|
|
1819
|
+
return `Daily memory files (${filtered.length} files, ${totalKb}KB total):\n\n${lines.join("\n")}`;
|
|
1820
|
+
}
|
|
1821
|
+
case "get_daily_memory": {
|
|
1822
|
+
const date = input.date;
|
|
1823
|
+
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
1824
|
+
return "Error: Date must be in YYYY-MM-DD format (e.g., '2025-02-07')";
|
|
1825
|
+
}
|
|
1826
|
+
const content = await getDailyMemoryContent(date);
|
|
1827
|
+
if (!content) {
|
|
1828
|
+
return `No memory file found for ${date}.`;
|
|
1829
|
+
}
|
|
1830
|
+
return `📅 ${date} 메모리:\n\n${content}`;
|
|
1831
|
+
}
|
|
1832
|
+
case "organize_memory": {
|
|
1833
|
+
const content = input.content;
|
|
1834
|
+
const section = input.section;
|
|
1835
|
+
if (!content || content.trim().length === 0) {
|
|
1836
|
+
return "Error: Content is required for organizing memory";
|
|
1837
|
+
}
|
|
1838
|
+
try {
|
|
1839
|
+
await appendToLongTermMemory(content.trim(), section);
|
|
1840
|
+
const sectionNote = section ? ` (섹션: ${section})` : "";
|
|
1841
|
+
return `✅ Long-term memory updated${sectionNote}.\n\nAdded:\n${content.slice(0, 200)}${content.length > 200 ? "..." : ""}`;
|
|
1842
|
+
}
|
|
1843
|
+
catch (error) {
|
|
1844
|
+
return `Error saving to long-term memory: ${error instanceof Error ? error.message : String(error)}`;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
case "clear_old_memory": {
|
|
1848
|
+
const olderThanDays = Math.max(2, input.older_than_days || 30);
|
|
1849
|
+
const dryRun = input.dry_run !== false; // default to true for safety
|
|
1850
|
+
const files = await listDailyMemoryFiles();
|
|
1851
|
+
const toDelete = files.filter(f => f.ageInDays >= olderThanDays);
|
|
1852
|
+
if (toDelete.length === 0) {
|
|
1853
|
+
return `No memory files older than ${olderThanDays} days to delete.`;
|
|
1854
|
+
}
|
|
1855
|
+
const totalSize = toDelete.reduce((sum, f) => sum + f.sizeBytes, 0);
|
|
1856
|
+
const totalKb = (totalSize / 1024).toFixed(1);
|
|
1857
|
+
if (dryRun) {
|
|
1858
|
+
const fileList = toDelete.slice(0, 10).map(f => ` - ${f.date}`).join("\n");
|
|
1859
|
+
const moreNote = toDelete.length > 10 ? `\n ... and ${toDelete.length - 10} more` : "";
|
|
1860
|
+
return `🔍 DRY RUN - Would delete ${toDelete.length} files (${totalKb}KB):\n${fileList}${moreNote}\n\nSet dry_run=false to actually delete.`;
|
|
1861
|
+
}
|
|
1862
|
+
// 실제 삭제
|
|
1863
|
+
const result = await deleteOldDailyMemories(olderThanDays);
|
|
1864
|
+
if (result.deleted.length === 0) {
|
|
1865
|
+
return "No files were deleted.";
|
|
1866
|
+
}
|
|
1867
|
+
const deletedList = result.deleted.slice(0, 5).join(", ");
|
|
1868
|
+
const moreNote = result.deleted.length > 5 ? ` and ${result.deleted.length - 5} more` : "";
|
|
1869
|
+
let response = `🗑️ Deleted ${result.deleted.length} memory files: ${deletedList}${moreNote}`;
|
|
1870
|
+
if (result.failed.length > 0) {
|
|
1871
|
+
response += `\n⚠️ Failed to delete: ${result.failed.join(", ")}`;
|
|
1872
|
+
}
|
|
1873
|
+
return response;
|
|
1874
|
+
}
|
|
1875
|
+
case "get_long_term_memory": {
|
|
1876
|
+
const content = await loadLongTermMemory();
|
|
1877
|
+
if (!content) {
|
|
1878
|
+
return "MEMORY.md does not exist yet. Use organize_memory to start building long-term memory.";
|
|
1879
|
+
}
|
|
1880
|
+
// 크기 제한 (너무 크면 요약 권장)
|
|
1881
|
+
if (content.length > 10000) {
|
|
1882
|
+
return `📚 MEMORY.md (${(content.length / 1024).toFixed(1)}KB - showing first 10KB):\n\n${content.slice(0, 10000)}\n\n... (truncated, full file is ${content.length} chars)`;
|
|
1883
|
+
}
|
|
1884
|
+
return `📚 MEMORY.md:\n\n${content}`;
|
|
1885
|
+
}
|
|
1538
1886
|
default:
|
|
1539
1887
|
return `Error: Unknown tool: ${name}`;
|
|
1540
1888
|
}
|
|
@@ -1613,5 +1961,17 @@ export function getToolsDescription(modelId) {
|
|
|
1613
1961
|
- toggle_cron: cron job 활성화/비활성화 (id, enabled)
|
|
1614
1962
|
- run_cron: cron job 즉시 실행 (id) - 테스트/수동 트리거용
|
|
1615
1963
|
|
|
1964
|
+
## 메모리 정리 (Long-term Memory Management)
|
|
1965
|
+
- list_daily_memories: 일일 메모리 파일 목록 조회
|
|
1966
|
+
- max_age_days: N일 이상된 것만 필터
|
|
1967
|
+
- get_daily_memory: 특정 날짜의 메모리 읽기 (date: "YYYY-MM-DD")
|
|
1968
|
+
- organize_memory: 중요한 내용을 MEMORY.md로 이동
|
|
1969
|
+
- content: 저장할 내용
|
|
1970
|
+
- section: 섹션명 (선택)
|
|
1971
|
+
- clear_old_memory: 오래된 일일 메모리 정리
|
|
1972
|
+
- older_than_days: N일 이상 (기본 30일, 최소 2일)
|
|
1973
|
+
- dry_run: true면 미리보기만 (기본 true)
|
|
1974
|
+
- get_long_term_memory: MEMORY.md 내용 읽기
|
|
1975
|
+
|
|
1616
1976
|
허용된 경로: ${path.join(home, "Documents")}, ${path.join(home, "projects")}, 워크스페이스`;
|
|
1617
1977
|
}
|
package/dist/workspace/index.js
CHANGED
|
@@ -3,4 +3,6 @@ export { getWorkspacePath, getWorkspaceFilePath, getMemoryDirPath, getDailyMemor
|
|
|
3
3
|
// 초기화 함수
|
|
4
4
|
export { isWorkspaceInitialized, initWorkspace, hasBootstrap, deleteBootstrap, } from "./init.js";
|
|
5
5
|
// 로드/저장 함수
|
|
6
|
-
export { loadWorkspace, loadBootstrap, saveWorkspaceFile, appendToMemory, loadRecentMemories,
|
|
6
|
+
export { loadWorkspace, loadBootstrap, saveWorkspaceFile, appendToMemory, loadRecentMemories, loadTodayAndYesterdayMemories,
|
|
7
|
+
// 메모리 정리 도구용
|
|
8
|
+
listDailyMemoryFiles, getDailyMemoryContent, appendToLongTermMemory, deleteDailyMemory, deleteOldDailyMemories, loadLongTermMemory, } from "./load.js";
|