This commit is contained in:
kim
2026-05-05 19:51:22 +09:00
parent 13f832f693
commit dac32dbcee
5 changed files with 697 additions and 227 deletions
@@ -251,12 +251,172 @@
"role": "assistant",
"content": "채팅창 오른쪽의 **마이크 아이콘**을 길게 누르면 음성 메시지가 전송됩니다. 텍스트로 변환해 입력하고 싶다면 채팅창을 클릭한 후 **Windows 키 + H**를 눌러 윈도우 음성 받아쓰기 기능을 이용하세요.",
"timestamp": 1777967988064
},
{
"role": "user",
"content": "로컬 llm 돌릴 수 있는 미니 pc 뭐가 있지?",
"timestamp": 1777973521090
},
{
"role": "assistant",
"content": "로컬 LLM 구동에는 통합 메모리 효율이 좋은 **Apple Mac Mini(M2/M4, 16GB 이상)**나, 내장 그래픽 성능이 뛰어난 **AMD Ryzen 7/9 기반 미니 PC(Minisforum UM780 XTX, Beelink SER8 등)**를 추천합니다. 더 높은 성능을 원하신다면 **RTX 4060 등 외장 GPU가 탑재된 게이밍 미니 PC**가 적합합니다.",
"timestamp": 1777973535248
},
{
"role": "user",
"content": "가격대는 어때?",
"timestamp": 1777973569559
},
{
"role": "assistant",
"content": "로컬 LLM 구동이 가능한 미니 PC의 가격대는 사양에 따라 다음과 같이 나뉩니다.\n\n* **가성비형 (60~90만 원대):** **Beelink SER8**이나 **Minisforum UM780 XTX** (Ryzen 7 8845HS/7840HS). RAM을 32GB~64GB로 업그레이드하면 7B~14B 모델을 쾌적하게 돌릴 수 있습니다.\n* **안정성 및 속도 중시 (80~130만 원대):** **Mac Mini M4 (16GB/24GB RAM)**. 통합 메모리 덕분에 속도가 빠르지만, 고사양 모델을 돌리려면 RAM 업그레이드 비용이 크게 상승합니다.\n* **고성능 GPU 탑재형 (150~250만 원대):** **ASUS ROG NUC**나 **Minisforum G7 Ti** (RTX 4060/4070 탑재). 외장 그래픽의 VRAM을 직접 활용하므로 가장 빠른 추론 속도를 보여줍니다.\n\n입문용으로는 **Ryzen 7 기반 미니 PC에 RAM을 별도로 추가(64GB)**하여 사용하는 것이 가장 가성비가 좋습니다.",
"timestamp": 1777973577255
},
{
"role": "user",
"content": "tts, stt mcp 서버가 있을까?",
"timestamp": 1777973768496
},
{
"role": "assistant",
"content": "네, TTS와 STT 기능을 제공하는 MCP 서버들이 이미 활발하게 개발되어 있습니다.\n\n* **mcp-server-whisper:** OpenAI의 Whisper(STT)와 TTS API를 모두 지원하는 대표적인 서버입니다. 음성 파일을 텍스트로 변환하거나, 텍스트를 고품질 음성으로 생성할 수 있습니다.\n* **mcp-tts (blacktop):** 텍스트를 음성으로 변환하는 데 특화된 서버로, macOS의 `say` 명령어나 외부 API를 활용합니다.\n* **sttai-mcp:** STT.ai 서비스를 연동하여 긴 오디오 파일의 전사(Transcribe) 및 요약을 지원합니다.\n\nClaude Desktop이나 Cursor 같은 MCP 클라이언트에 해당 서버들을 등록하면, AI가 직접 음성을 듣고 분석하거나 목소리로 답변하게 만들 수 있습니다.",
"timestamp": 1777973793498
},
{
"role": "user",
"content": "mcp-server-whisper 찾아 줘",
"timestamp": 1777973841019
},
{
"role": "assistant",
"content": "가장 널리 사용되는 **mcp-server-whisper** 프로젝트들입니다.\n\n1. **arcaputo3/mcp-server-whisper (추천):** OpenAI의 Whisper와 GPT-4o 모델을 사용하여 오디오 전사 및 처리를 지원하는 표준 MCP 서버입니다. [GitHub 링크](https://github.com/arcaputo3/mcp-server-whisper)\n2. **[REDACTED-HE]:** 로컬에서 더 빠른 속도로 구동되는 `Faster Whisper` 기반의 고성능 서버입니다. [GitHub 링크](https://github.[REDACTED-HE])\n\nClaude Desktop 등에서 사용하시려면 `npx` 또는 `pip`를 통해 설치하여 설정 파일에 등록하면 됩니다. 구체적인 설치 방법이 필요하시면 말씀해 주세요.",
"timestamp": 1777973856073
},
{
"role": "user",
"content": "모바일에서 텔리그램 stt 자동으로 해주지 않나?",
"timestamp": 1777973936759
},
{
"role": "assistant",
"content": "텔레그램의 공식 STT(음성 메시지 텍스트 변환) 기능은 **텔레그램 프리미엄(유료)** 구독자에게만 제공됩니다. 프리미엄 사용자는 음성 메시지 옆의 **'→A' 버튼**을 눌러 즉시 텍스트로 변환할 수 있지만, 일반 사용자는 이 기능을 사용할 수 없습니다.\n\n무료로 사용하고 싶다면 **'Voicy'**나 **'Transcriber'** 같은 텔레그램 봇을 채팅방에 초대하여 음성 메시지를 전달(Forward)하면 텍스트로 변환해 줍니다.",
"timestamp": 1777973945307
},
{
"role": "user",
"content": "어떻게 초대하지?",
"timestamp": 1777974006960
},
{
"role": "assistant",
"content": "텔레그램 봇을 초대하고 사용하는 방법은 매우 간단합니다.\n\n1. **봇 검색:** 텔레그램 상단 검색창에 `@Voicybot` 또는 `@TranscriberBot`을 입력하고 선택합니다.\n2. **시작:** 하단의 **[시작]** 또는 **[/start]** 버튼을 누릅니다.\n3. **사용 방법:**\n * **개인 채팅:** 변환하고 싶은 음성 메시지를 해당 봇 채팅방으로 **전달(Forward)**하면 즉시 텍스트로 답장이 옵니다.\n * **그룹 초대:** 그룹 채팅방 설정에서 **[구성원 추가]**를 눌러 봇 아이디를 검색해 초대하면, 그룹 내 모든 음성 메시지를 자동으로 변환해 줍니다.\n\n처음 실행 시 `/language` 명령어로 한국어를 설정하면 더 정확하게 변환됩니다.",
"timestamp": 1777974015503
},
{
"role": "user",
"content": "이메일 확인",
"timestamp": 1777975862542
},
{
"role": "assistant",
"content": "최근 도착한 이메일 4건입니다.\n\n1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 오전 1:02)\n2. **YouTube Premium:** PIP 모드 사용 업데이트 (4월 30일)\n3. **YouTube:** 결제 수단 업데이트 요청 (2025년 12월)\n4. **YouTube Premium:** 회원 가입 축하 (2025년 9월)\n\n특정 메일의 내용을 읽으시려면 번호나 제목을 말씀해 주세요.",
"timestamp": 1777975871630
},
{
"role": "user",
"content": "계정 모두",
"timestamp": 1777975893442
},
{
"role": "assistant",
"content": "현재 등록된 이메일 계정은 **`default`** 하나이며, 연결된 주소는 **`smallclaw.ai@gmail.com`**입니다. 다른 계정을 추가하거나 특정 계정의 설정을 확인하고 싶으시면 말씀해 주세요.",
"timestamp": 1777975897823
},
{
"role": "user",
"content": "다음 메일 없어?",
"timestamp": 1777975920265
},
{
"role": "assistant",
"content": "다음 계정(`daum`)에서 확인된 최근 메일 목록입니다.\n\n1. **Ollama:** Claude Desktop에서 Ollama 클라우드 모델 사용 가능 (오늘 오후 2:24)\n2. **eBay:** 수집품 최대 $120 할인 (오늘 오전 11:22)\n3. **Pixabay:** 가입 환영 및 시작하기 (오늘 오전 10:11)\n4. **Unsplash API:** homeclaw 관련 파트너십 (오늘 오전 3:00)\n5. **OpenRouter:** 요청별 모델 라우팅 안내 (어제 오후 11:07)\n6. **국세청:** 김상규연합치과 관련 안내 (어제 오후 4:56)\n7. **쿠팡:** 와우 멤버십 월회비 결제 완료 (어제 오전 9:51)\n\n내용 확인이 필요한 메일이 있으면 말씀해 주세요.",
"timestamp": 1777975929896
},
{
"role": "user",
"content": "applecherry",
"timestamp": 1777976053853
},
{
"role": "assistant",
"content": "아이디나 비밀번호, 혹은 특정 코드처럼 보이는 단어네요. 이 텍스트를 어디에 입력하거나 저장해 드릴까요? 아니면 관련해서 찾으시는 정보가 있는지 말씀해 주세요.",
"timestamp": 1777976058456
},
{
"role": "user",
"content": "이메일 알리아스",
"timestamp": 1777976080064
},
{
"role": "assistant",
"content": "`applecherry`는 현재 **기본(default) 이메일 계정의 별칭(Alias)**으로 등록되어 있습니다.\n\n이 계정으로 메일을 보내거나 조회할 때 `account=\"applecherry\"` 파라미터를 사용하면 됩니다. 현재 이 알리아스는 `smallclaw.ai@gmail.com` 주소와 연결되어 있습니다.",
"timestamp": 1777976085753
},
{
"role": "user",
"content": "이매일 다시 검색",
"timestamp": 1777977158608
},
{
"role": "assistant",
"content": "기본 계정과 다음(daum) 계정의 최신 메일 목록입니다.\n\n**[기본 계정]**\n1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 01:02)\n2. **YouTube Premium:** PIP 모드 업데이트 (4/30)\n3. **YouTube:** 결제 수단 업데이트 요청 (25년 12월)\n4. **YouTube Premium:** 회원 가입 축하 (25년 9월)\n\n**[다음 계정]**\n1. **Ollama:** Claude Desktop 내 클라우드 모델 사용 안내 (오늘 14:24)\n2. **eBay:** 수집품 할인 안내 (오늘 11:22)\n3. **Pixabay:** 가입 환영 인사 (오늘 10:11)\n4. **Unsplash API:** homeclaw 파트너십 관련 (오늘 03:00)\n5. **OpenRouter:** 모델 라우팅 안내 (어제 23:07)\n\n확인하고 싶으신 메일이 있다면 말씀해 주세요.",
"timestamp": 1777977184775
},
{
"role": "user",
"content": "evangel 계정은?",
"timestamp": 1777977213914
},
{
"role": "assistant",
"content": "`evangel` 계정의 받은편지함에 도착한 메일이 없습니다.",
"timestamp": 1777977265408
},
{
"role": "user",
"content": "매일 다시 리스트.",
"timestamp": 1777977698923
},
{
"role": "assistant",
"content": "계정별 최신 메일 리스트입니다.\n\n**[daum 계정]**\n1. **Ollama:** Claude Desktop 내 클라우드 모델 사용 안내 (오늘 14:24)\n2. **eBay:** 수집품 최대 $120 할인 (오늘 11:22)\n3. **Pixabay:** 가입 환영 인사 (오늘 10:11)\n4. **Unsplash API:** homeclaw 파트너십 관련 (오늘 03:00)\n5. **OpenRouter:** 요청별 모델 라우팅 안내 (어제 23:07)\n\n**[기본 계정]**\n1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 01:02)\n2. **YouTube Premium:** PIP 모드 업데이트 (4/30)\n3. **YouTube:** 결제 수단 업데이트 요청 (25년 12월)\n4. **YouTube Premium:** 회원 가입 축하 (25년 9월)\n\n**[evangel 계정]**\n- 도착한 메일이 없습니다.",
"timestamp": 1777977728186
},
{
"role": "user",
"content": "이메일 리스트",
"timestamp": 1777978124611
},
{
"role": "assistant",
"content": "계정별 최신 메일 목록입니다.\n\n**[applecherry]**\n1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 01:02)\n2. **YouTube Premium:** PIP 모드 업데이트 (4/30)\n3. **YouTube:** 결제 수단 업데이트 요청 (25년 12월)\n4. **YouTube Premium:** 회원 가입 축하 (25년 9월)\n\n**[daum]**\n1. **Ollama:** Claude Desktop 내 클라우드 모델 사용 안내 (오늘 14:24)\n2. **eBay:** 수집품 최대 $120 할인 (오늘 11:22)\n3. **Pixabay:** 가입 환영 인사 (오늘 10:11)\n4. **Unsplash API:** homeclaw 파트너십 관련 (오늘 03:00)\n5. **OpenRouter:** 모델 라우팅 안내 (어제 23:07)\n*(외 15건 더 있음)*\n\n**[evangel]**\n- 도착한 메일이 없습니다.\n\n상세 내용이 궁금한 메일의 번호나 제목을 말씀해 주세요.",
"timestamp": 1777978169224
},
{
"role": "user",
"content": "이매일 다시 리스트",
"timestamp": 1777978239015
},
{
"role": "assistant",
"content": "계정별 최신 메일 목록입니다.\n\n**[applecherry]**\n1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 01:02)\n2. **YouTube Premium:** PIP 모드 업데이트 (4/30)\n3. **YouTube:** 결제 수단 업데이트 요청 (25년 12월)\n4. **YouTube Premium:** 회원 가입 축하 (25년 9월)\n\n**[daum]**\n1. **Ollama:** Claude Desktop 내 클라우드 모델 사용 안내 (오늘 14:24)\n2. **eBay:** 수집품 최대 $120 할인 (오늘 11:22)\n3. **Pixabay:** 가입 환영 인사 (오늘 10:11)\n4. **Unsplash API:** homeclaw 파트너십 관련 (오늘 03:00)\n5. **OpenRouter:** 모델 라우팅 안내 (어제 23:07)\n\n**[evangel]**\n- 도착한 메일이 없습니다.",
"timestamp": 1777978250765
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777909116851,
"lastActiveAt": 1777967988064,
"lastActiveAt": 1777978250765,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 3195
"contextTokenEstimate": 4952
}
@@ -1,6 +1,5 @@
{
"email": {
"default": "applecherry",
"accounts": [
{
"id": "applecherry",
@@ -77,3 +77,109 @@
* [스타렉스 캠핑카 DIY 설계 도면 (구조변경 완료 사례)](htt
[16:59:43] **User**: telegram에서 음성입력하려면 어떻게 하지?
[16:59:48] **SmallClaw**: 채팅창 오른쪽의 **마이크 아이콘**을 길게 누르면 음성 메시지가 전송됩니다. 텍스트로 변환해 입력하고 싶다면 채팅창을 클릭한 후 **Windows 키 + H**를 눌러 윈도우 음성 받아쓰기 기능을 이용하세요.
[18:32:09] **User**: 로컬 llm 돌릴 수 있는 미니 pc 뭐가 있지?
[18:32:15] **SmallClaw**: 로컬 LLM 구동에는 통합 메모리 효율이 좋은 **Apple Mac Mini(M2/M4, 16GB 이상)**나, 내장 그래픽 성능이 뛰어난 **AMD Ryzen 7/9 기반 미니 PC(Minisforum UM780 XTX, Beelink SER8 등)**를 추천합니다. 더 높은 성능을 원하신다면 **RTX 4060 등 외장 GPU가 탑재된 게이밍 미니 PC**가 적합합니다.
[18:32:53] **User**: 가격대는 어때?
[18:32:57] **SmallClaw**: 로컬 LLM 구동이 가능한 미니 PC의 가격대는 사양에 따라 다음과 같이 나뉩니다.
* **가성비형 (60~90만 원대):** **Beelink SER8**이나 **Minisforum UM780 XTX** (Ryzen 7 8845HS/7840HS). RAM을 32GB~64GB로 업그레이드하면 7B~14B 모델을 쾌적하게 돌릴 수 있습니다.
* **안정성 및 속도 중시 (80~130만 원대):** **Mac Mini M4 (16GB/24GB RAM)**. 통합 메모리 덕분에 속도가 빠르지만, 고사양 모델을 돌리려면 RAM 업
[18:36:16] **User**: tts, stt mcp 서버가 있을까?
[18:36:33] **SmallClaw**: 네, TTS와 STT 기능을 제공하는 MCP 서버들이 이미 활발하게 개발되어 있습니다.
* **mcp-server-whisper:** OpenAI의 Whisper(STT)와 TTS API를 모두 지원하는 대표적인 서버입니다. 음성 파일을 텍스트로 변환하거나, 텍스트를 고품질 음성으로 생성할 수 있습니다.
* **mcp-tts (blacktop):** 텍스트를 음성으로 변환하는 데 특화된 서버로, macOS의 `say` 명령어나 외부 API를 활용합니다.
* **sttai-mcp:** STT.ai 서비스를 연동하여 긴 오
[18:37:29] **User**: mcp-server-whisper 찾아 줘
[18:37:36] **SmallClaw**: 가장 널리 사용되는 **mcp-server-whisper** 프로젝트들입니다.
1. **arcaputo3/mcp-server-whisper (추천):** OpenAI의 Whisper와 GPT-4o 모델을 사용하여 오디오 전사 및 처리를 지원하는 표준 MCP 서버입니다. [GitHub 링크](https://github.com/arcaputo3/mcp-server-whisper)
2. **BigUncle/Fast-Whisper-MCP-Server:** 로컬에서 더 빠른 속도로 구동되는 `Faster Whisper` 기반의 고성능
[18:39:02] **User**: 모바일에서 텔리그램 stt 자동으로 해주지 않나?
[18:39:05] **SmallClaw**: 텔레그램의 공식 STT(음성 메시지 텍스트 변환) 기능은 **텔레그램 프리미엄(유료)** 구독자에게만 제공됩니다. 프리미엄 사용자는 음성 메시지 옆의 **'→A' 버튼**을 눌러 즉시 텍스트로 변환할 수 있지만, 일반 사용자는 이 기능을 사용할 수 없습니다.
무료로 사용하고 싶다면 **'Voicy'**나 **'Transcriber'** 같은 텔레그램 봇을 채팅방에 초대하여 음성 메시지를 전달(Forward)하면 텍스트로 변환해 줍니다.
[18:40:12] **User**: 어떻게 초대하지?
[18:40:15] **SmallClaw**: 텔레그램 봇을 초대하고 사용하는 방법은 매우 간단합니다.
1. **봇 검색:** 텔레그램 상단 검색창에 `@Voicybot` 또는 `@TranscriberBot`을 입력하고 선택합니다.
2. **시작:** 하단의 **[시작]** 또는 **[/start]** 버튼을 누릅니다.
3. **사용 방법:**
* **개인 채팅:** 변환하고 싶은 음성 메시지를 해당 봇 채팅방으로 **전달(Forward)**하면 즉시 텍스트로 답장이 옵니다.
* **그룹 초대:** 그룹 채팅방 설정에서 **[구성원 추가]**를 눌러
[19:11:07] **User**: 이메일 확인
[19:11:11] **SmallClaw**: 최근 도착한 이메일 4건입니다.
1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 오전 1:02)
2. **YouTube Premium:** PIP 모드 사용 업데이트 (4월 30일)
3. **YouTube:** 결제 수단 업데이트 요청 (2025년 12월)
4. **YouTube Premium:** 회원 가입 축하 (2025년 9월)
특정 메일의 내용을 읽으시려면 번호나 제목을 말씀해 주세요.
[19:11:36] **User**: 계정 모두
[19:11:37] **SmallClaw**: 현재 등록된 이메일 계정은 **`default`** 하나이며, 연결된 주소는 **`smallclaw.ai@gmail.com`**입니다. 다른 계정을 추가하거나 특정 계정의 설정을 확인하고 싶으시면 말씀해 주세요.
[19:12:04] **User**: 다음 메일 없어?
[19:12:09] **SmallClaw**: 다음 계정(`daum`)에서 확인된 최근 메일 목록입니다.
1. **Ollama:** Claude Desktop에서 Ollama 클라우드 모델 사용 가능 (오늘 오후 2:24)
2. **eBay:** 수집품 최대 $120 할인 (오늘 오전 11:22)
3. **Pixabay:** 가입 환영 및 시작하기 (오늘 오전 10:11)
4. **Unsplash API:** homeclaw 관련 파트너십 (오늘 오전 3:00)
5. **OpenRouter:** 요청별 모델 라우팅 안내 (어제 오후 11:07)
6. **국세청:**
[19:14:16] **User**: applecherry
[19:14:18] **SmallClaw**: 아이디나 비밀번호, 혹은 특정 코드처럼 보이는 단어네요. 이 텍스트를 어디에 입력하거나 저장해 드릴까요? 아니면 관련해서 찾으시는 정보가 있는지 말씀해 주세요.
[19:14:43] **User**: 이메일 알리아스
[19:14:45] **SmallClaw**: `applecherry`는 현재 **기본(default) 이메일 계정의 별칭(Alias)**으로 등록되어 있습니다.
이 계정으로 메일을 보내거나 조회할 때 `account="applecherry"` 파라미터를 사용하면 됩니다. 현재 이 알리아스는 `smallclaw.ai@gmail.com` 주소와 연결되어 있습니다.
[19:32:44] **User**: 이매일 다시 검색
[19:33:04] **SmallClaw**: 기본 계정과 다음(daum) 계정의 최신 메일 목록입니다.
**[기본 계정]**
1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 01:02)
2. **YouTube Premium:** PIP 모드 업데이트 (4/30)
3. **YouTube:** 결제 수단 업데이트 요청 (25년 12월)
4. **YouTube Premium:** 회원 가입 축하 (25년 9월)
**[다음 계정]**
1. **Ollama:** Claude Desktop 내 클라우드 모델 사용 안내 (오늘 14:24)
2. **eBay:**
[19:33:38] **User**: evangel 계정은?
[19:34:25] **SmallClaw**: `evangel` 계정의 받은편지함에 도착한 메일이 없습니다.
[19:41:45] **User**: 매일 다시 리스트.
[19:42:08] **SmallClaw**: 계정별 최신 메일 리스트입니다.
**[daum 계정]**
1. **Ollama:** Claude Desktop 내 클라우드 모델 사용 안내 (오늘 14:24)
2. **eBay:** 수집품 최대 $120 할인 (오늘 11:22)
3. **Pixabay:** 가입 환영 인사 (오늘 10:11)
4. **Unsplash API:** homeclaw 파트너십 관련 (오늘 03:00)
5. **OpenRouter:** 요청별 모델 라우팅 안내 (어제 23:07)
**[기본 계정]**
1. **Ai2:** Peter Clark 임시 CE
[19:48:50] **User**: 이메일 리스트
[19:49:29] **SmallClaw**: 계정별 최신 메일 목록입니다.
**[applecherry]**
1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 01:02)
2. **YouTube Premium:** PIP 모드 업데이트 (4/30)
3. **YouTube:** 결제 수단 업데이트 요청 (25년 12월)
4. **YouTube Premium:** 회원 가입 축하 (25년 9월)
**[daum]**
1. **Ollama:** Claude Desktop 내 클라우드 모델 사용 안내 (오늘 14:24)
2. **eBay:** 수집품 최대 $12
[19:50:44] **User**: 이매일 다시 리스트
[19:50:50] **SmallClaw**: 계정별 최신 메일 목록입니다.
**[applecherry]**
1. **Ai2:** Peter Clark 임시 CEO와의 대담 (오늘 01:02)
2. **YouTube Premium:** PIP 모드 업데이트 (4/30)
3. **YouTube:** 결제 수단 업데이트 요청 (25년 12월)
4. **YouTube Premium:** 회원 가입 축하 (25년 9월)
**[daum]**
1. **Ollama:** Claude Desktop 내 클라우드 모델 사용 안내 (오늘 14:24)
2. **eBay:** 수집품 최대 $12
+12 -28
View File
@@ -1062,7 +1062,7 @@ const TOOL_BLOCKS: Record<string, string> = {
ollama_web: `OLLAMA WEB TOOLS: ollama_web_search(query, max_results?) → Ollama 모델이 DDG 검색 후 결과를 자연어로 요약 반환. ollama_web_fetch(url, instruction?) → URL 내용을 가져와 Ollama가 요약. instruction 예: "주요 수치만 뽑아줘". 일반 web_search/web_fetch보다 느리지만 모델이 결과를 직접 해석해서 반환.`,
email: `EMAIL TOOLS (멀티 계정 지원): 모든 도구에 account? 파라미터로 계정 ID 지정 가능. 미지정 시 default 계정 사용. 사용자별 설정에 등록된 계정만 접근 가능. email_list(folder?,limit?,account?) → 받은편지함 목록 (uid·제목·발신자·날짜·읽음여부). email_read(uid,folder?,account?) → 메일 전문 조회. email_send(to,subject,body,cc?,account?) → 메일 발송. email_search(query?,from?,subject?,since?,before?,folder?,limit?,account?) → 메일 검색. email_delete(uid,folder?,account?) → 메일 삭제. RULES: 이메일 관련 요청에는 항상 email_* 도구 사용 — web_search 사용 금지.`,
email: `EMAIL TOOLS (멀티 계정 지원): 모든 도구에 account? 파라미터로 계정 ID 지정 가능. 미지정 시 모든 계정 순회. 사용자별 설정에 등록된 계정만 접근 가능. email_list(folder?,limit?,account?) → 받은편지함 목록 (uid·제목·발신자·날짜·읽음여부). email_read(uid,folder?,account?) → 메일 전문 조회. email_send(to,subject,body,cc?,account?) → 메일 발송. email_search(query?,from?,subject?,since?,before?,folder?,limit?,account?) → 메일 검색. email_delete(uid,folder?,account?) → 메일 삭제. RULES: 이메일 관련 요청에는 항상 email_* 도구 사용 — web_search 사용 금지.`,
pdf: `PDF TOOL: pdf_read(path, page_from?, page_to?, max_chars?) → extract text from a PDF file in the workspace. path is relative to workspace or absolute. Use page_from/page_to to read specific page ranges. Default max_chars is 20000. If the PDF is scanned (image-only), it returns an error — use image_read with OCR instead.`,
@@ -1081,9 +1081,8 @@ function buildDynamicEmailBlock(workspacePath: string): string | null {
const data = JSON.parse(fs.readFileSync(perUserPath, 'utf-8'));
const accounts = data?.email?.accounts;
if (!Array.isArray(accounts) || accounts.length === 0) return null;
const defaultId = data?.email?.default || accounts[0].id;
const accountList = accounts.map((a: any) => `${a.id}${a.id === defaultId ? ' (기본)' : ''}`).join(', ');
return `EMAIL TOOLS (멀티 계정 지원): 모든 도구에 account? 파라미터로 계정 ID 지정 가능. 등록된 계정: ${accountList}. 미지정 시 기본 계정 사용. email_list(folder?,limit?,account?) → 받은편지함 목록. email_read(uid,folder?,account?) → 메일 전문 조회. email_send(to,subject,body,cc?,account?) → 메일 발송. email_search(query?,from?,subject?,since?,before?,folder?,limit?,account?) → 메일 검색. email_delete(uid,folder?,account?) → 메일 삭제. RULES: 이메일 관련 요청에는 항상 email_* 도구 사용 — web_search 사용 금지.`;
const accountList = accounts.map((a: any) => a.id).join(', ');
return `EMAIL TOOLS (멀티 계정 지원): 모든 도구에 account? 파라미터로 계정 ID 지정 가능. 등록된 계정: ${accountList}. 미지정 시 모든 계정 순회. email_list(folder?,limit?,account?) → 받은편지함 목록. email_read(uid,folder?,account?) → 메일 전문 조회. email_send(to,subject,body,cc?,account?) → 메일 발송. email_search(query?,from?,subject?,since?,before?,folder?,limit?,account?) → 메일 검색. email_delete(uid,folder?,account?) → 메일 삭제. RULES: 이메일 관련 요청에는 항상 email_* 도구 사용 — web_search 사용 금지.`;
} catch {
return null;
}
@@ -1965,7 +1964,7 @@ function buildTools() {
properties: {
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
limit: { type: 'number', description: '가져올 수 (기본 20, 최대 50)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
},
},
},
@@ -1980,7 +1979,7 @@ function buildTools() {
properties: {
uid: { type: 'number', description: '메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
},
},
},
@@ -1997,7 +1996,7 @@ function buildTools() {
subject: { type: 'string', description: '제목' },
body: { type: 'string', description: '본문 (plain text)' },
cc: { type: 'string', description: '참조 주소 (선택)' },
account: { type: 'string', description: '발신 계정 ID (미지정 시 default)' },
account: { type: 'string', description: '발신 계정 ID (미지정 시 첫 번째 계정)' },
},
},
},
@@ -2017,7 +2016,7 @@ function buildTools() {
before: { type: 'string', description: 'YYYY-MM-DD 이전' },
folder: { type: 'string', description: '폴더 (기본 INBOX)' },
limit: { type: 'number', description: '최대 결과 수 (기본 20)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
},
},
},
@@ -2032,7 +2031,7 @@ function buildTools() {
properties: {
uid: { type: 'number', description: '삭제할 메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
},
},
},
@@ -7674,18 +7673,18 @@ function userEmailConfigPath(username: string): string {
function userVaultDir(username: string): string {
return path.join(getUserWorkspace(username), '.smallclaw');
}
function loadUserEmailConfig(username: string): { default?: string; accounts: any[] } {
function loadUserEmailConfig(username: string): { accounts: any[] } {
const p = userEmailConfigPath(username);
if (!fs.existsSync(p)) return { accounts: [] };
try {
const d = JSON.parse(fs.readFileSync(p, 'utf-8'));
const e = d.email || {};
return { default: e.default, accounts: Array.isArray(e.accounts) ? e.accounts : [] };
return { accounts: Array.isArray(e.accounts) ? e.accounts : [] };
} catch {
return { accounts: [] };
}
}
function saveUserEmailConfig(username: string, cfg: { default?: string; accounts: any[] }): void {
function saveUserEmailConfig(username: string, cfg: { accounts: any[] }): void {
const p = userEmailConfigPath(username);
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
@@ -7712,7 +7711,7 @@ app.get('/api/users/:username/email/config', (req, res) => {
username: a.username,
password_set: v.has(`email.accounts.${a.id}.password`),
}));
res.json({ default: cfg.default || null, accounts });
res.json({ accounts });
});
app.put('/api/users/:username/email/accounts/:accountId', (req, res) => {
@@ -7741,7 +7740,6 @@ app.put('/api/users/:username/email/accounts/:accountId', (req, res) => {
};
const idx = cfg.accounts.findIndex((a: any) => a.id === accountId);
if (idx >= 0) cfg.accounts[idx] = updated; else cfg.accounts.push(updated);
if (!cfg.default) cfg.default = accountId;
saveUserEmailConfig(username, cfg);
if (typeof b.password === 'string' && b.password.length > 0) {
getVault(userVaultDir(username)).set(`email.accounts.${accountId}.password`, b.password, `email:upsert:${session.username}`);
@@ -7756,25 +7754,11 @@ app.delete('/api/users/:username/email/accounts/:accountId', (req, res) => {
if (!userExists(username)) return res.status(404).json({ error: 'User not found' });
const cfg = loadUserEmailConfig(username);
cfg.accounts = cfg.accounts.filter((a: any) => a.id !== accountId);
if (cfg.default === accountId) cfg.default = cfg.accounts[0]?.id;
saveUserEmailConfig(username, cfg);
try { getVault(userVaultDir(username)).delete(`email.accounts.${accountId}.password`, `email:delete:${session.username}`); } catch {}
res.json({ success: true });
});
app.put('/api/users/:username/email/default', (req, res) => {
const session = getSessionUser(req);
if (!session || session.role !== 'admin') return res.status(403).json({ error: 'Admin required' });
const { username } = req.params;
if (!userExists(username)) return res.status(404).json({ error: 'User not found' });
const accountId = String((req.body || {}).accountId || '');
const cfg = loadUserEmailConfig(username);
if (!cfg.accounts.find((a: any) => a.id === accountId)) return res.status(404).json({ error: 'Account not found' });
cfg.default = accountId;
saveUserEmailConfig(username, cfg);
res.json({ success: true });
});
app.post('/api/users/:username/email/accounts/:accountId/test', async (req, res) => {
const session = getSessionUser(req);
if (!session || session.role !== 'admin') return res.status(403).json({ error: 'Admin required' });
+417 -196
View File
@@ -19,65 +19,96 @@ interface EmailConfig {
password: string;
}
interface EmailAccount {
id: string;
config: EmailConfig;
}
function resolveConfigPath(workspacePath?: string): string | null {
const perUserPath = workspacePath
? path.join(workspacePath, '.smallclaw', 'email-config.json')
: null;
const globalPath = path.join(os.homedir(), '.smallclaw', 'config.json');
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
if (perUserPath && fs.existsSync(perUserPath)) return perUserPath;
if (fs.existsSync(projectCfg)) return projectCfg;
if (fs.existsSync(globalPath)) return globalPath;
return null;
}
function parseAccount(raw: any, cfgPath: string): EmailConfig | null {
if (!raw?.imap_host || !raw?.username) return null;
let password = raw.password || '';
if (password.startsWith('vault:')) {
try {
const vault = getVault(path.dirname(cfgPath));
const secret = vault.get(password.slice(6), 'email:resolve');
password = secret ? secret.expose() : '';
} catch {
password = '';
}
}
return {
imap_host: raw.imap_host,
imap_port: Number(raw.imap_port ?? 993),
imap_tls: raw.imap_tls !== false,
imap_reject_unauthorized: raw.imap_reject_unauthorized !== false,
smtp_host: raw.smtp_host || raw.imap_host.replace('imap.', 'smtp.'),
smtp_port: Number(raw.smtp_port ?? 587),
smtp_secure: raw.smtp_secure === true,
smtp_reject_unauthorized: raw.smtp_reject_unauthorized !== false,
username: raw.username,
password,
};
}
function getEmailConfig(accountId?: string, workspacePath?: string): EmailConfig | null {
try {
// Per-user config: look in the workspace directory first (e.g. <workspace>/.smallclaw/email-config.json)
const perUserPath = workspacePath
? path.join(workspacePath, '.smallclaw', 'email-config.json')
: null;
const globalPath = path.join(os.homedir(), '.smallclaw', 'config.json');
const projectCfg = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfgPath = perUserPath && fs.existsSync(perUserPath)
? perUserPath
: fs.existsSync(projectCfg)
? projectCfg
: globalPath;
if (!fs.existsSync(cfgPath)) return null;
const cfgPath = resolveConfigPath(workspacePath);
if (!cfgPath) return null;
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const emailCfg = data.email;
if (!emailCfg) return null;
let e: any;
if (Array.isArray(emailCfg.accounts)) {
const target = accountId || emailCfg.default;
e = target
? emailCfg.accounts.find((a: any) => a.id === target) ?? emailCfg.accounts[0]
: emailCfg.accounts[0];
if (accountId) {
e = emailCfg.accounts.find((a: any) => a.id === accountId);
}
if (!e) e = emailCfg.accounts[0];
} else {
e = emailCfg;
}
if (!e?.imap_host || !e?.username) return null;
// Resolve vault: references for password (vault is shared with the config dir)
let password = e.password || '';
if (password.startsWith('vault:')) {
try {
const vault = getVault(path.dirname(cfgPath));
const secret = vault.get(password.slice(6), 'email:resolve');
password = secret ? secret.expose() : '';
} catch {
password = '';
}
}
return {
imap_host: e.imap_host,
imap_port: Number(e.imap_port ?? 993),
imap_tls: e.imap_tls !== false,
imap_reject_unauthorized: e.imap_reject_unauthorized !== false,
smtp_host: e.smtp_host || e.imap_host.replace('imap.', 'smtp.'),
smtp_port: Number(e.smtp_port ?? 587),
smtp_secure: e.smtp_secure === true,
smtp_reject_unauthorized: e.smtp_reject_unauthorized !== false,
username: e.username,
password,
};
return parseAccount(e, cfgPath);
} catch {
return null;
}
}
function getAllEmailConfigs(workspacePath?: string): EmailAccount[] {
try {
const cfgPath = resolveConfigPath(workspacePath);
if (!cfgPath) return [];
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const emailCfg = data.email;
if (!emailCfg) return [];
if (Array.isArray(emailCfg.accounts)) {
const accounts: EmailAccount[] = [];
for (const a of emailCfg.accounts) {
const config = parseAccount(a, cfgPath);
if (config) accounts.push({ id: a.id, config });
}
return accounts;
}
const config = parseAccount(emailCfg, cfgPath);
const id = emailCfg.username ? emailCfg.username.split('@')[0] : 'default';
return config ? [{ id, config }] : [];
} catch {
return [];
}
}
// ── IMAP helper ───────────────────────────────────────────────────────────────
async function withImap<T>(fn: (client: any) => Promise<T>, accountId?: string, workspacePath?: string): Promise<T> {
@@ -124,18 +155,18 @@ function stripHtml(html: string): string {
export const emailListTool = {
name: 'email_list',
description: '받은편지함(또는 지정 폴더)의 최근 이메일 목록을 가져옵니다. uid, 제목, 발신자, 날짜, 읽음 여부를 반환합니다.',
description: '받은편지함(또는 지정 폴더)의 최근 이메일 목록을 가져옵니다. account 미지정 시 모든 계정을 순회합니다.',
schema: {
folder: '조회할 폴더 (기본: INBOX)',
limit: '가져올 메일 수 (기본: 20, 최대: 50)',
account: '계정 ID (미지정 시 default)',
account: '계정 ID (미지정 시 모든 계정 순회)',
},
jsonSchema: {
type: 'object',
properties: {
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
limit: { type: 'number', description: '가져올 수 (기본 20, 최대 50)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
},
additionalProperties: false,
},
@@ -145,45 +176,115 @@ export const emailListTool = {
const account = args?.account ? String(args.account) : undefined;
const workspacePath = args?._workspacePath || args?._workspace || undefined;
try {
const messages = await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: true });
const status = await client.status(folder, { messages: true });
const total = status.messages || 0;
if (total === 0) return [];
const seqFrom = Math.max(1, total - limit + 1);
const results: any[] = [];
for await (const msg of client.fetch(`${seqFrom}:*`, {
uid: true,
flags: true,
envelope: true,
})) {
const from = msg.envelope?.from?.[0];
results.push({
uid: msg.uid,
seq: msg.seq,
subject: msg.envelope?.subject || '(제목 없음)',
from: from ? `${from.name || ''} <${from.address}>`.trim() : '',
date: msg.envelope?.date
? new Date(msg.envelope.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
unread: !msg.flags?.has('\\Seen'),
});
}
return results.reverse();
}, account, workspacePath);
// Single account mode
if (account) {
const messages = await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: true });
const status = await client.status(folder, { messages: true });
const total = status.messages || 0;
if (total === 0) return [];
const seqFrom = Math.max(1, total - limit + 1);
const results: any[] = [];
for await (const msg of client.fetch(`${seqFrom}:*`, {
uid: true,
flags: true,
envelope: true,
})) {
const from = msg.envelope?.from?.[0];
results.push({
uid: msg.uid,
seq: msg.seq,
subject: msg.envelope?.subject || '(제목 없음)',
from: from ? `${from.name || ''} <${from.address}>`.trim() : '',
date: msg.envelope?.date
? new Date(msg.envelope.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
unread: !msg.flags?.has('\\Seen'),
account,
});
}
return results.reverse();
}, account, workspacePath);
if (messages.length === 0) {
return { success: true, stdout: `${folder}: 메일이 없습니다.`, data: [] };
if (messages.length === 0) {
return { success: true, stdout: `[${account}] ${folder}: 메일이 없습니다.`, data: [] };
}
const lines = [
`📬 [${account}] ${folder} — 최근 ${messages.length}개`,
'',
...messages.map((m: any, i: number) =>
`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
),
];
return { success: true, stdout: lines.join('\n'), data: messages };
}
// All accounts mode
const allAccounts = getAllEmailConfigs(workspacePath);
if (allAccounts.length === 0) {
return { success: false, error: '등록된 이메일 계정이 없습니다.' };
}
const allMessages: any[] = [];
const errors: string[] = [];
for (const acct of allAccounts) {
try {
const messages = await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: true });
const status = await client.status(folder, { messages: true });
const total = status.messages || 0;
if (total === 0) return [];
const perAcctLimit = Math.max(Math.ceil(limit / allAccounts.length), 5);
const seqFrom = Math.max(1, total - perAcctLimit + 1);
const results: any[] = [];
for await (const msg of client.fetch(`${seqFrom}:*`, {
uid: true,
flags: true,
envelope: true,
})) {
const from = msg.envelope?.from?.[0];
results.push({
uid: msg.uid,
seq: msg.seq,
subject: msg.envelope?.subject || '(제목 없음)',
from: from ? `${from.name || ''} <${from.address}>`.trim() : '',
date: msg.envelope?.date
? new Date(msg.envelope.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
unread: !msg.flags?.has('\\Seen'),
account: acct.id,
});
}
return results.reverse();
}, acct.id, workspacePath);
allMessages.push(...messages);
} catch (err: any) {
errors.push(`${acct.id}: ${err.message}`);
}
}
// Sort all messages by date descending
allMessages.sort((a, b) => {
const da = a.date ? new Date(a.date).getTime() : 0;
const db = b.date ? new Date(b.date).getTime() : 0;
return db - da;
});
const trimmed = allMessages.slice(0, limit);
if (trimmed.length === 0) {
const errInfo = errors.length ? `\n⚠️ 오류: ${errors.join('; ')}` : '';
return { success: true, stdout: `${folder}: 메일이 없습니다.${errInfo}`, data: [] };
}
const lines = [
`📬 ${folder} — 최근 ${messages.length}개`,
`📬 ${folder} — 전체 계정에서 ${trimmed.length}개`,
'',
...messages.map((m, i) =>
`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
...trimmed.map((m: any, i: number) =>
`${i + 1}. [${m.account}] [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
),
];
return { success: true, stdout: lines.join('\n'), data: messages };
if (errors.length) {
lines.push('', `⚠️ 일부 계정 오류: ${errors.join('; ')}`);
}
return { success: true, stdout: lines.join('\n'), data: trimmed };
} catch (err: any) {
return { success: false, error: err.message };
}
@@ -194,18 +295,18 @@ export const emailListTool = {
export const emailReadTool = {
name: 'email_read',
description: 'UID로 특정 이메일의 전문(발신자, 수신자, 제목, 본문)을 읽어옵니다. 첨부파일이 있으면 자동으로 workspace/attachments/uid-{uid}/ 에 저장하고 경로를 반환합니다.',
description: 'UID로 특정 이메일의 전문(발신자, 수신자, 제목, 본문)을 읽어옵니다. account 미지정 시 모든 계정에서 UID를 찾습니다. 첨부파일이 있으면 자동으로 workspace/attachments/uid-{uid}/ 에 저장하고 경로를 반환합니다.',
schema: {
uid: '읽을 메일의 UID (email_list에서 확인)',
folder: '폴더명 (기본: INBOX)',
account: '계정 ID (미지정 시 default)',
account: '계정 ID (미지정 시 모든 계정에서 검색)',
},
jsonSchema: {
type: 'object',
properties: {
uid: { type: 'number', description: '메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정 검색)' },
},
required: ['uid'],
additionalProperties: false,
@@ -215,80 +316,121 @@ export const emailReadTool = {
const folder = String(args?.folder || 'INBOX');
const account = args?.account ? String(args.account) : undefined;
const workspacePath = args?._workspacePath || args?._workspace || undefined;
const saveAttachments = true; // always save attachments when present
const saveAttachments = true;
if (!uid) return { success: false, error: 'uid가 필요합니다.' };
try {
const result = await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: false });
await client.messageFlagsAdd(uid.toString(), ['\\Seen'], { uid: true }).catch(() => {});
const { simpleParser } = await import('mailparser');
for await (const msg of client.fetch(uid.toString(), { uid: true, envelope: true, source: true }, { uid: true })) {
const parsed = await simpleParser(msg.source);
const body = parsed.text || (parsed.html ? stripHtml(parsed.html) : '(본문 없음)');
// Save attachments if requested
const savedPaths: string[] = [];
const savedRelPaths: string[] = [];
if (saveAttachments && parsed.attachments?.length) {
const injectedWorkspace = workspacePath || args?._workspacePath || args?._workspace;
let resolvedWorkspacePath: string;
if (injectedWorkspace) {
resolvedWorkspacePath = path.resolve(String(injectedWorkspace));
} else {
const cfgPath = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
resolvedWorkspacePath = path.resolve(cfg?.workspace?.path || path.join(process.cwd(), 'workspace'));
}
const relDir = path.join('attachments', `uid-${uid}`);
const saveDir = path.join(resolvedWorkspacePath, relDir);
fs.mkdirSync(saveDir, { recursive: true });
for (const att of parsed.attachments) {
const filename = att.filename || `attachment-${savedPaths.length + 1}`;
const safeName = filename.replace(/[/\\:*?"<>|]/g, '_');
const dest = path.join(saveDir, safeName);
fs.writeFileSync(dest, att.content);
savedPaths.push(dest);
savedRelPaths.push(path.join(relDir, safeName));
const readMail = async (acctId: string): Promise<{ result: any; account: string } | null> => {
try {
const result = await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: false });
await client.messageFlagsAdd(uid.toString(), ['\\Seen'], { uid: true }).catch(() => {});
const { simpleParser } = await import('mailparser');
for await (const msg of client.fetch(uid.toString(), { uid: true, envelope: true, source: true }, { uid: true })) {
const parsed = await simpleParser(msg.source);
const body = parsed.text || (parsed.html ? stripHtml(parsed.html) : '(본문 없음)');
const savedPaths: string[] = [];
const savedRelPaths: string[] = [];
if (saveAttachments && parsed.attachments?.length) {
const injectedWorkspace = workspacePath || args?._workspacePath || args?._workspace;
let resolvedWorkspacePath: string;
if (injectedWorkspace) {
resolvedWorkspacePath = path.resolve(String(injectedWorkspace));
} else {
const cfgPath = path.join(process.cwd(), '.smallclaw', 'config.json');
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
resolvedWorkspacePath = path.resolve(cfg?.workspace?.path || path.join(process.cwd(), 'workspace'));
}
const relDir = path.join('attachments', `uid-${uid}`);
const saveDir = path.join(resolvedWorkspacePath, relDir);
fs.mkdirSync(saveDir, { recursive: true });
for (const att of parsed.attachments) {
const filename = att.filename || `attachment-${savedPaths.length + 1}`;
const safeName = filename.replace(/[/\\:*?"<>|]/g, '_');
const dest = path.join(saveDir, safeName);
fs.writeFileSync(dest, att.content);
savedPaths.push(dest);
savedRelPaths.push(path.join(relDir, safeName));
}
}
return {
uid,
subject: parsed.subject || '(제목 없음)',
from: (Array.isArray(parsed.from) ? parsed.from[0] : parsed.from)?.text || '',
to: (Array.isArray(parsed.to) ? parsed.to[0] : parsed.to )?.text || '',
cc: (Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc )?.text || '',
date: parsed.date
? new Date(parsed.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
body: body.slice(0, 8000),
attachments: (parsed.attachments || []).map((a: any) => `${a.filename} (${a.size} bytes)`),
savedPaths,
savedRelPaths,
};
}
return {
uid,
subject: parsed.subject || '(제목 없음)',
from: (Array.isArray(parsed.from) ? parsed.from[0] : parsed.from)?.text || '',
to: (Array.isArray(parsed.to) ? parsed.to[0] : parsed.to )?.text || '',
cc: (Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc )?.text || '',
date: parsed.date
? new Date(parsed.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
body: body.slice(0, 8000),
attachments: (parsed.attachments || []).map((a: any) => `${a.filename} (${a.size} bytes)`),
savedPaths,
savedRelPaths,
};
}
return null;
}, acctId, workspacePath);
return result ? { result, account: acctId } : null;
} catch {
return null;
}, account, workspacePath);
}
};
if (!result) return { success: false, error: `UID ${uid} 메일을 찾을 수 없습니다.` };
try {
// If account specified, try only that account
if (account) {
const found = await readMail(account);
if (!found) return { success: false, error: `[${account}] UID ${uid} 메일을 찾을 수 없습니다.` };
const r = found.result;
const lines = [
`📧 메일 상세 (UID: ${uid}, 계정: ${account})`,
`제목: ${r.subject}`,
`발신: ${r.from}`,
`수신: ${r.to}`,
r.cc ? `참조: ${r.cc}` : '',
`날짜: ${r.date}`,
r.attachments.length ? `첨부파일: ${r.attachments.join(', ')}` : '',
r.savedRelPaths?.length
? `✅ 첨부파일 저장 완료 — pdf_read/read 등에서 아래 경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
: '',
'',
'─── 본문 ───',
r.body,
].filter((l: string) => l !== '');
return { success: true, stdout: lines.join('\n'), data: { ...r, account: found.account } };
}
const lines = [
`📧 메일 상세 (UID: ${uid})`,
`제목: ${result.subject}`,
`발신: ${result.from}`,
`수신: ${result.to}`,
result.cc ? `참조: ${result.cc}` : '',
`날짜: ${result.date}`,
result.attachments.length ? `첨부파일: ${result.attachments.join(', ')}` : '',
result.savedRelPaths?.length
? `✅ 첨부파일 저장 완료 — pdf_read/read 등에서 아래 경로를 그대로 사용하세요:\n${result.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
: '',
'',
'─── 본문 ───',
result.body,
].filter(l => l !== '');
return { success: true, stdout: lines.join('\n'), data: result };
// No account specified — try all accounts
const allAccounts = getAllEmailConfigs(workspacePath);
if (allAccounts.length === 0) {
return { success: false, error: '등록된 이메일 계정이 없습니다.' };
}
const errors: string[] = [];
for (const acct of allAccounts) {
const found = await readMail(acct.id);
if (found) {
const r = found.result;
const lines = [
`📧 메일 상세 (UID: ${uid}, 계정: ${found.account})`,
`제목: ${r.subject}`,
`발신: ${r.from}`,
`수신: ${r.to}`,
r.cc ? `참조: ${r.cc}` : '',
`날짜: ${r.date}`,
r.attachments.length ? `첨부파일: ${r.attachments.join(', ')}` : '',
r.savedRelPaths?.length
? `✅ 첨부파일 저장 완료 — pdf_read/read 등에서 아래 경로를 그대로 사용하세요:\n${r.savedRelPaths.map((p: string) => ` "${p}"`).join('\n')}`
: '',
'',
'─── 본문 ───',
r.body,
].filter((l: string) => l !== '');
return { success: true, stdout: lines.join('\n'), data: { ...r, account: found.account } };
}
errors.push(acct.id);
}
return { success: false, error: `UID ${uid} 메일을 찾을 수 없습니다. (검색한 계정: ${errors.join(', ')})` };
} catch (err: any) {
return { success: false, error: err.message };
}
@@ -299,13 +441,13 @@ export const emailReadTool = {
export const emailSendTool = {
name: 'email_send',
description: '이메일을 발송합니다. SMTP를 통해 to/subject/body를 지정해 발송하며 cc 추가도 가능합니다.',
description: '이메일을 발송합니다. SMTP를 통해 to/subject/body를 지정해 발송하며 cc 추가도 가능합니다. account 미지정 시 첫 번째 등록 계정으로 발송합니다.',
schema: {
to: '수신자 이메일 주소 (여러 명은 쉼표로 구분)',
subject: '제목',
body: '본문 (Plain text)',
cc: '참조 주소 (선택, 쉼표 구분)',
account: '계정 ID (미지정 시 default)',
account: '계정 ID (미지정 시 첫 번째 계정)',
},
jsonSchema: {
type: 'object',
@@ -314,7 +456,7 @@ export const emailSendTool = {
subject: { type: 'string', description: '제목' },
body: { type: 'string', description: '본문' },
cc: { type: 'string', description: '참조 (선택)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 첫 번째 계정)' },
},
required: ['to', 'subject', 'body'],
additionalProperties: false,
@@ -369,7 +511,7 @@ export const emailSendTool = {
export const emailSearchTool = {
name: 'email_search',
description: '이메일을 검색합니다. 키워드, 발신자, 제목, 날짜 범위로 검색 가능합니다.',
description: '이메일을 검색합니다. 키워드, 발신자, 제목, 날짜 범위로 검색 가능합니다. account 미지정 시 모든 계정을 순회합니다.',
schema: {
query: '검색어 (제목+본문 전문 검색)',
from: '발신자 이메일 주소로 필터',
@@ -378,7 +520,7 @@ export const emailSearchTool = {
before: '이 날짜 이전 (YYYY-MM-DD)',
folder: '검색할 폴더 (기본: INBOX)',
limit: '최대 결과 수 (기본: 20)',
account: '계정 ID (미지정 시 default)',
account: '계정 ID (미지정 시 모든 계정 순회)',
},
jsonSchema: {
type: 'object',
@@ -390,7 +532,7 @@ export const emailSearchTool = {
before: { type: 'string', description: 'YYYY-MM-DD 이전' },
folder: { type: 'string', description: '폴더 (기본 INBOX)' },
limit: { type: 'number', description: '최대 결과 수 (기본 20)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정)' },
},
additionalProperties: false,
},
@@ -404,20 +546,24 @@ export const emailSearchTool = {
return { success: false, error: 'query, from, subject, since, before 중 하나 이상이 필요합니다.' };
}
try {
const messages = await withImap(async (client) => {
const buildCriteria = () => {
const criteria: any[] = [];
if (args?.from) criteria.push({ from: String(args.from) });
if (args?.subject) criteria.push({ subject: String(args.subject) });
if (args?.query) criteria.push({ body: String(args.query) });
if (args?.since) criteria.push({ since: new Date(String(args.since)) });
if (args?.before) criteria.push({ before: new Date(String(args.before)) });
return criteria.length === 1 ? criteria[0] : { and: criteria };
};
const searchCriteria = buildCriteria();
const searchAccount = async (acctId: string): Promise<any[]> => {
return await withImap(async (client) => {
await client.mailboxOpen(folder, { readOnly: true });
const criteria: any[] = [];
if (args?.from) criteria.push({ from: String(args.from) });
if (args?.subject) criteria.push({ subject: String(args.subject) });
if (args?.query) criteria.push({ body: String(args.query) });
if (args?.since) criteria.push({ since: new Date(String(args.since)) });
if (args?.before) criteria.push({ before: new Date(String(args.before)) });
const searchCriteria = criteria.length === 1 ? criteria[0] : { and: criteria };
const uids: number[] = await client.search(searchCriteria, { uid: true });
const recentUids = uids.slice(-limit).reverse();
const perAcctLimit = account ? limit : Math.max(Math.ceil(limit / (getAllEmailConfigs(workspacePath).length || 1)), 5);
const recentUids = uids.slice(-perAcctLimit).reverse();
if (recentUids.length === 0) return [];
const results: any[] = [];
@@ -435,23 +581,69 @@ export const emailSearchTool = {
? new Date(msg.envelope.date).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })
: '',
unread: !msg.flags?.has('\\Seen'),
account: acctId,
});
}
return results;
}, account, workspacePath);
}, acctId, workspacePath);
};
if (messages.length === 0) {
return { success: true, stdout: '검색 결과가 없습니다.', data: [] };
try {
// Single account mode
if (account) {
const messages = await searchAccount(account);
if (messages.length === 0) {
return { success: true, stdout: '검색 결과가 없습니다.', data: [] };
}
const lines = [
`🔍 [${account}] 검색 결과 — ${messages.length}개`,
'',
...messages.map((m: any, i: number) =>
`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
),
];
return { success: true, stdout: lines.join('\n'), data: messages };
}
// All accounts mode
const allAccounts = getAllEmailConfigs(workspacePath);
if (allAccounts.length === 0) {
return { success: false, error: '등록된 이메일 계정이 없습니다.' };
}
const allMessages: any[] = [];
const errors: string[] = [];
for (const acct of allAccounts) {
try {
const messages = await searchAccount(acct.id);
allMessages.push(...messages);
} catch (err: any) {
errors.push(`${acct.id}: ${err.message}`);
}
}
// Sort by date descending
allMessages.sort((a, b) => {
const da = a.date ? new Date(a.date).getTime() : 0;
const db = b.date ? new Date(b.date).getTime() : 0;
return db - da;
});
const trimmed = allMessages.slice(0, limit);
if (trimmed.length === 0) {
const errInfo = errors.length ? `\n⚠️ 오류: ${errors.join('; ')}` : '';
return { success: true, stdout: `검색 결과가 없습니다.${errInfo}`, data: [] };
}
const lines = [
`🔍 검색 결과 — ${messages.length}개`,
`🔍 검색 결과 — 전체 계정에서 ${trimmed.length}개`,
'',
...messages.map((m, i) =>
`${i + 1}. [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
...trimmed.map((m: any, i: number) =>
`${i + 1}. [${m.account}] [UID:${m.uid}] ${m.unread ? '🔵 ' : ''}${m.subject}\n 발신: ${m.from}\n 날짜: ${m.date}`
),
];
return { success: true, stdout: lines.join('\n'), data: messages };
if (errors.length) {
lines.push('', `⚠️ 일부 계정 오류: ${errors.join('; ')}`);
}
return { success: true, stdout: lines.join('\n'), data: trimmed };
} catch (err: any) {
return { success: false, error: err.message };
}
@@ -462,18 +654,18 @@ export const emailSearchTool = {
export const emailDeleteTool = {
name: 'email_delete',
description: 'UID로 이메일을 삭제합니다. 가능한 경우 휴지통으로 이동하고, 불가능하면 \\Deleted 플래그를 설정합니다.',
description: 'UID로 이메일을 삭제합니다. account 미지정 시 모든 계정에서 UID를 찾아 삭제합니다. 가능한 경우 휴지통으로 이동하고, 불가능하면 \\Deleted 플래그를 설정합니다.',
schema: {
uid: '삭제할 메일의 UID',
folder: '폴더명 (기본: INBOX)',
account: '계정 ID (미지정 시 default)',
account: '계정 ID (미지정 시 모든 계정에서 검색)',
},
jsonSchema: {
type: 'object',
properties: {
uid: { type: 'number', description: '삭제할 메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
account: { type: 'string', description: '계정 ID (미지정 시 모든 계정 검색)' },
},
required: ['uid'],
additionalProperties: false,
@@ -485,29 +677,58 @@ export const emailDeleteTool = {
const workspacePath = args?._workspacePath || args?._workspace || undefined;
if (!uid) return { success: false, error: 'uid가 필요합니다.' };
const deleteFromAccount = async (acctId: string): Promise<boolean> => {
try {
await withImap(async (client) => {
await client.mailboxOpen(folder);
const mailboxes = await client.list();
const trash = mailboxes.find((m: any) =>
m.specialUse === '\\Trash' ||
/^(Trash|휴지통|Deleted Items|Deleted Messages)$/i.test(m.name)
);
if (trash) {
await client.messageMove(uid.toString(), trash.path, { uid: true });
} else {
await client.messageFlagsAdd(uid.toString(), ['\\Deleted'], { uid: true });
await client.mailboxClose();
}
}, acctId, workspacePath);
return true;
} catch {
return false;
}
};
try {
await withImap(async (client) => {
await client.mailboxOpen(folder);
// Single account mode
if (account) {
const ok = await deleteFromAccount(account);
if (!ok) return { success: false, error: `[${account}] UID ${uid} 삭제 실패` };
return {
success: true,
stdout: `🗑️ [${account}] UID ${uid} 메일이 삭제되었습니다.`,
data: { uid, folder, account },
};
}
const mailboxes = await client.list();
const trash = mailboxes.find((m: any) =>
m.specialUse === '\\Trash' ||
/^(Trash|휴지통|Deleted Items|Deleted Messages)$/i.test(m.name)
);
if (trash) {
await client.messageMove(uid.toString(), trash.path, { uid: true });
} else {
await client.messageFlagsAdd(uid.toString(), ['\\Deleted'], { uid: true });
await client.mailboxClose();
// All accounts mode — try each account
const allAccounts = getAllEmailConfigs(workspacePath);
if (allAccounts.length === 0) {
return { success: false, error: '등록된 이메일 계정이 없습니다.' };
}
for (const acct of allAccounts) {
const ok = await deleteFromAccount(acct.id);
if (ok) {
return {
success: true,
stdout: `🗑️ [${acct.id}] UID ${uid} 메일이 삭제되었습니다.`,
data: { uid, folder, account: acct.id },
};
}
}, account, workspacePath);
return {
success: true,
stdout: `🗑️ UID ${uid} 메일이 삭제되었습니다.`,
data: { uid, folder },
};
}
return { success: false, error: `UID ${uid} 메일을 찾을 수 없어 삭제하지 못했습니다.` };
} catch (err: any) {
return { success: false, error: `삭제 실패: ${err.message}` };
}