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.
@@ -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
+ }
@@ -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, } from "../workspace/index.js";
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
- return [
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: "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.",
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: ["user_info", "preference", "event", "project", "other"],
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
- const parts = command.trim().split(/\s+/);
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
- const child = spawn("sh", ["-c", command], {
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 { stdout, stderr } = await execAsync(command, {
908
- cwd,
909
- timeout,
910
- env: safeEnv,
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
- await appendToMemory(`[${category}] ${content}`);
1018
- return `Memory saved: ${content.slice(0, 50)}...`;
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
- const parsedUrl = new URL(url);
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
  }
@@ -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, } from "./load.js";
6
+ export { loadWorkspace, loadBootstrap, saveWorkspaceFile, appendToMemory, loadRecentMemories, loadTodayAndYesterdayMemories,
7
+ // 메모리 정리 도구용
8
+ listDailyMemoryFiles, getDailyMemoryContent, appendToLongTermMemory, deleteDailyMemory, deleteOldDailyMemories, loadLongTermMemory, } from "./load.js";