This commit is contained in:
kim
2026-05-05 00:51:37 +09:00
parent 9714d4b50c
commit 233b67b9a3
35 changed files with 3496 additions and 138 deletions
+3 -1
View File
@@ -69,7 +69,9 @@
"Bash(namei *)",
"Bash(snap list *)",
"Bash(pkill *)",
"Bash(grep -rn \"getUserWorkspace\\\\|userWorkspace\\\\|users/\\\\${username}\" /home/kim/homeclaw/src/config/config.ts)"
"Bash(grep -rn \"getUserWorkspace\\\\|userWorkspace\\\\|users/\\\\${username}\" /home/kim/homeclaw/src/config/config.ts)",
"Bash(grep *)",
"Bash(dd if=/home/kim/homeclaw/.smallclaw/users/cherry/workspace/.smallclaw/vault/vault.enc bs=1 skip=620 count=80)"
]
},
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
@@ -0,0 +1,52 @@
{
"id": "24f63dff-68ab-4755-8a9a-5ff65d78e801",
"username": "jasmine",
"history": [
{
"role": "user",
"content": "러아ㅣㄴ머리ㅏㅇㄴㅁ",
"timestamp": 1777897920008
},
{
"role": "assistant",
"content": "무슨 말씀이신지 잘 모르겠어요. 도움이 필요하시면 구체적으로 말씀해 주세요!",
"timestamp": 1777897947372
},
{
"role": "user",
"content": "채팅 기록이 없어졌네...",
"timestamp": 1777897967482
},
{
"role": "assistant",
"content": "채팅 기록이 보이지 않아 당황스러우시겠어요. 시스템 오류나 세션 초기화 때문일 수 있는데, 제가 도와드릴 수 있는 부분이 있는지 확인해 보겠습니다. 어떤 내용을 찾고 계셨나요?",
"timestamp": 1777897986884
},
{
"role": "user",
"content": "머야...",
"timestamp": 1777898191647
},
{
"role": "assistant",
"content": "당황하지 마세요! 제가 여기 있습니다. 혹시 이전에 하던 작업이나 찾으시던 정보가 있다면 말씀해 주세요. 바로 도와드릴게요.",
"timestamp": 1777898201276
},
{
"role": "user",
"content": "ㄹㅇㄴㅁㄹㄴㅁ",
"timestamp": 1777898279759
},
{
"role": "assistant",
"content": "키보드를 마구 치신 것 같네요! 혹시 장난치시는 건가요, 아니면 정말로 도움이 필요하신 상황인가요? 말씀해 주시면 바로 도와드릴게요.",
"timestamp": 1777898285821
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777897894237,
"lastActiveAt": 1777898285821,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 141
}
@@ -0,0 +1,42 @@
{
"id": "259cc369-c453-4dfd-b2eb-4acc70ea6509",
"username": "jasmine",
"history": [
{
"role": "user",
"content": "안녕?",
"timestamp": 1777897581467
},
{
"role": "assistant",
"content": "반가워요! 현숙님, 무엇을 도와드릴까요?",
"timestamp": 1777897587506
},
{
"role": "user",
"content": "안정적인 주식투자 방법 알려줘.",
"timestamp": 1777897745475
},
{
"role": "assistant",
"content": "안정적인 주식 투자를 위해서는 **'잃지 않는 원칙'**이 가장 중요합니다.\n\n1. **지수 추종 ETF 활용**: 개별 종목의 위험을 피하기 위해 S&P 500(VOO, SPY)이나 나스닥 100(QQQ) 같은 시장 전체에 투자하는 ETF를 추천합니다.\n2. **적립식 분할 매수**: 한 번에 몰빵하지 않고 매달 일정 금액을 기계적으로 매수하여 평균 단가를 낮추는 전략이 안전합니다.\n3. **우량주와 배당주**: 각 산업의 1등 기업이나 꾸준히 배당을 주는 기업에 투자해 하락장에서도 현금 흐름과 방어력을 확보하세요.\n4. **자산 배분**: 주식 외에 채권, 현금, 금 등을 섞어 시장 변동성에 대비하는 것이 장기 수익의 핵심입니다.\n\n처음 시작하신다면 미국 S&P 500 지수 ETF에 소액으로 적립식 투자를 시작해 보시는 것을 권해 드립니다.",
"timestamp": 1777897764155
},
{
"role": "user",
"content": "혹시 내가 매일 계정이 있나?",
"timestamp": 1777897803002
},
{
"role": "assistant",
"content": "현재 등록된 이메일 계정은 다음과 같습니다:\n\n1. **applecherry** (kim@applecherry.net)\n2. **evangel** (kim@evangel.xyz)\n3. **daum** (kimsgms@hanmail.net)\n\n이 중 확인하고 싶은 계정이 있으신가요? 최근 메일 목록을 불러올 수도 있습니다.",
"timestamp": 1777897813274
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777897562454,
"lastActiveAt": 1777897813274,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 229
}
@@ -0,0 +1,17 @@
{
"id": "26d81daa-b76a-430d-bc5d-341d3239f28e",
"username": "jasmine",
"history": [
{
"role": "user",
"content": "who am i?",
"timestamp": 1777894094001
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777894079403,
"lastActiveAt": 1777894094003,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 9
}
@@ -101,12 +101,32 @@
"role": "assistant",
"content": "Kim님입니다. Windows 11 환경에서 TypeScript와 Node.js를 사용하시는 개발자이시죠. 무엇을 도와드릴까요?",
"timestamp": 1777882835629
},
{
"role": "user",
"content": "지난 대화가 어디론가 가고 없네?",
"timestamp": 1777885362548
},
{
"role": "assistant",
"content": "이전 대화 내용이 보이지 않아 당황스러우시겠네요. 제가 기억하는 Kim님의 정보(Windows 11, TypeScript/Node.js 스택 등)는 그대로 유지되고 있으니, 필요한 작업이 있다면 바로 말씀해 주세요. 어떤 대화 내용을 찾으시나요?",
"timestamp": 1777885373148
},
{
"role": "user",
"content": "내가 누구지?",
"timestamp": 1777885408255
},
{
"role": "assistant",
"content": "Kim님입니다. Windows 11 환경에서 TypeScript와 Node.js를 사용하시는 개발자이시죠. 무엇을 도와드릴까요?",
"timestamp": 1777885415792
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777881930425,
"lastActiveAt": 1777882835630,
"lastActiveAt": 1777885415792,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 385
"contextTokenEstimate": 478
}
@@ -31,12 +31,42 @@
"role": "assistant",
"content": "jasmine 님입니다. 방금 성함을 현숙에서 jasmine으로 변경해 드렸습니다.",
"timestamp": 1777884996890
},
{
"role": "user",
"content": "투자 연금에 대하여 간략히 알려줘.",
"timestamp": 1777885186227
},
{
"role": "assistant",
"content": "투자 연금은 노후 자금 마련을 위해 주식, 채권 등 수익성 자산에 투자하는 연금 상품입니다. 대표적으로 **연금저축펀드**와 **IRP(개인형 퇴직연금)**가 있으며, 납입 시 세액공제 혜택을 받고 운용 수익에 대해서는 인출 시까지 과세가 이연되는 장점이 있습니다.",
"timestamp": 1777885191972
},
{
"role": "user",
"content": "수익율은 어때?",
"timestamp": 1777885214338
},
{
"role": "assistant",
"content": "투자 연금의 수익률은 선택하는 투자 상품(펀드, ETF 등)과 시장 상황에 따라 천차만별입니다. 원금 보장형 상품은 연 3~4% 수준의 낮은 수익률을 보이지만, 주식형 상품은 시장 상승기에 따라 연 10% 이상의 높은 수익률을 기대할 수 있는 반면 손실 위험도 존재합니다. 구체적인 최근 시장 수익률 데이터를 확인해 드릴까요?",
"timestamp": 1777885227515
},
{
"role": "user",
"content": "응",
"timestamp": 1777885244547
},
{
"role": "assistant",
"content": "2024년 상반기 기준으로 증권사 연금저축펀드나 IRP에서 미국 지수(S&P500, 나스닥100)에 투자했을 경우 **약 15~23%**의 높은 수익률을 기록했습니다. 반면, 은행이나 보험사의 원리금 보장형 상품은 **연 3~4%** 수준으로 안정적이지만 상대적으로 낮습니다.\n\n특히 AI와 반도체 테마 ETF는 **30% 이상** 급등하기도 했으나, 시장 상황에 따라 변동성이 크다는 점을 유의해야 합니다. 관심 있는 특정 종목이나 투자 성향이 있으신가요?",
"timestamp": 1777885288654
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777884936156,
"lastActiveAt": 1777884996890,
"lastActiveAt": 1777885288654,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 88
"contextTokenEstimate": 303
}
@@ -0,0 +1,72 @@
{
"id": "71525932-b8b1-4b30-aa46-2a7f6ce094df",
"username": "jasmine",
"history": [
{
"role": "user",
"content": "내 매일 계정이 있나?",
"timestamp": 1777900663724
},
{
"role": "assistant",
"content": "네, 현재 이메일 계정이 연결되어 있으며 최근 YouTube Premium에서 온 메일을 포함한 수신함 목록을 확인했습니다. 어떤 작업을 도와드릴까요?",
"timestamp": 1777900676175
},
{
"role": "user",
"content": "리스트",
"timestamp": 1777900685321
},
{
"role": "assistant",
"content": "최근 수신된 이메일 3건입니다.\n\n1. **YouTube의 PIP 모드 사용에 관한 업데이트** (4/30)\n2. **YouTube Premium 결제 수단 업데이트 안내** (2025/12/2)\n3. **YouTube Premium 가입 축하 메일** (2025/9/3)\n\n더 자세히 보고 싶은 메일이 있으신가요?",
"timestamp": 1777900698649
},
{
"role": "user",
"content": "메일 계정 정보 보여줘",
"timestamp": 1777900834491
},
{
"role": "assistant",
"content": "현재 연결된 이메일 계정 정보입니다.\n\n* **이메일 주소:** kim@applecherry.net\n* **사용자 이름:** 김 (현숙)\n* **최근 활동:** YouTube Premium 관련 안내 메일 수신 (4/30)\n\n추가로 확인이 필요한 정보가 있으신가요?",
"timestamp": 1777900912072
},
{
"role": "user",
"content": "이매일 확인해줘",
"timestamp": 1777901248792
},
{
"role": "assistant",
"content": "이메일 계정 설정이 되어 있지 않은 것 같습니다. `config.json` 파일의 `email` 섹션에 계정 정보를 입력해 주시면 바로 확인해 드릴 수 있습니다. 어떤 정보를 입력해야 하는지 안내해 드릴까요?",
"timestamp": 1777901270056
},
{
"role": "user",
"content": "이매일 체크",
"timestamp": 1777908938203
},
{
"role": "assistant",
"content": "이메일 설정이 되어 있지 않아 목록을 가져올 수 없습니다. `config.json` 파일에 계정 정보를 입력해 주시면 바로 확인해 드릴 수 있습니다. 설정 방법을 안내해 드릴까요?",
"timestamp": 1777908946623
},
{
"role": "user",
"content": "응",
"timestamp": 1777908960438
},
{
"role": "assistant",
"content": "`config.json` 파일의 `email` 섹션에 아래 정보를 입력해 주세요.\n\n1. **imap_host**: `imap.gmail.com` (Gmail 기준)\n2. **smtp_host**: `smtp.gmail.com`\n3. **user**: 이메일 주소\n4. **password**: 앱 비밀번호 (2단계 인증 후 생성)\n\n설정 파일을 직접 열어드릴까요? 혹은 제가 대신 작성해 드릴까요?",
"timestamp": 1777908966371
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777900652300,
"lastActiveAt": 1777908966372,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 334
}
@@ -0,0 +1,11 @@
{
"id": "85c9c5f3-eb99-4622-a10b-f0a945c26890",
"username": "jasmine",
"history": [],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777898412470,
"lastActiveAt": 1777898412470,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 0
}
@@ -0,0 +1,11 @@
{
"id": "9ee49bcb-44bb-44f7-85e7-33d621bd2f3f",
"username": "jasmine",
"history": [],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777898397875,
"lastActiveAt": 1777898397875,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 0
}
@@ -0,0 +1,52 @@
{
"id": "a2bde524-c660-44de-818a-65e11407c8bc",
"username": "jasmine",
"history": [
{
"role": "user",
"content": "하이",
"timestamp": 1777898422553
},
{
"role": "assistant",
"content": "반가워요, 현숙님! 무엇을 도와드릴까요? 무엇이든 말씀만 하세요. 🦞",
"timestamp": 1777898427843
},
{
"role": "user",
"content": "주식 투자 잘 할 수 있을까?",
"timestamp": 1777898440992
},
{
"role": "assistant",
"content": "주식 투자는 철저한 분석과 원칙만 있다면 충분히 잘하실 수 있어요! 원하시는 기업의 실적 분석이나 최근 시장 트렌드 조사가 필요하시면 제가 바로 도와드릴까요? 🦞",
"timestamp": 1777898448473
},
{
"role": "user",
"content": "삼성전자 어때?",
"timestamp": 1777898461329
},
{
"role": "assistant",
"content": "삼성전자는 현재 AI 반도체(HBM4)와 2나노 공정 기술 확보를 통해 재도약을 준비하는 중요한 시점에 있습니다. 2026년에는 실적이 50~60조 원대까지 회복될 것이라는 긍정적 전망이 많지만, 기술 격차 유지 여부가 주가의 핵심 변수가 될 것으로 보입니다. 🦞",
"timestamp": 1777898484780
},
{
"role": "user",
"content": "내 매일 계정이 있나?",
"timestamp": 1777900621341
},
{
"role": "assistant",
"content": "네, 현재 연결된 이메일 계정이 있습니다. 최근 YouTube Premium 관련 안내 메일들이 수신된 것을 확인했습니다. 🦞",
"timestamp": 1777900640331
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777898412544,
"lastActiveAt": 1777900640331,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 163
}
@@ -0,0 +1,22 @@
{
"id": "d6bca97c-85bf-4682-8952-8a713b4775c2",
"username": "jasmine",
"history": [
{
"role": "user",
"content": "이매일 확인",
"timestamp": 1777896616186
},
{
"role": "assistant",
"content": "최근 수신된 이메일 3건입니다. YouTube Premium 관련 안내와 결제 수단 업데이트 요청 메일이 있습니다. 구체적으로 읽어보고 싶은 메일이 있으신가요?",
"timestamp": 1777896632741
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777896578106,
"lastActiveAt": 1777896632741,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 40
}
@@ -0,0 +1,52 @@
{
"id": "da89daae-a7c5-468d-bf82-4c492685b0d3",
"username": "jasmine",
"history": [
{
"role": "user",
"content": "who am i?",
"timestamp": 1777894501326
},
{
"role": "assistant",
"content": "You are jasmine. You're working on a Windows 11 machine with a TypeScript and Node.js stack.",
"timestamp": 1777894516376
},
{
"role": "user",
"content": "what have i done last time?",
"timestamp": 1777894534533
},
{
"role": "assistant",
"content": "Last time, you updated your name to **jasmine** and we discussed **investment pensions**, specifically looking into the returns of pension savings funds and IRPs for the first half of 2024. You also asked about your identity and missing conversation history.",
"timestamp": 1777894553548
},
{
"role": "user",
"content": "can you show me the chat?",
"timestamp": 1777894580489
},
{
"role": "assistant",
"content": "Here is the chat history from today, including your name changes and our discussion on investment pensions:\n\n- **17:10**: You introduced yourself as **Kim**.\n- **17:11**: You changed your name to **현숙**.\n- **17:20**: You changed it back to **Kim**.\n- **17:56**: You updated your name to **jasmine**.\n- **18:00**: We discussed **investment pensions** (pension savings funds and IRPs).\n- **18:01**: I shared that US index-linked pensions (S&P500, Nasdaq) saw **15-23% returns** in early 2024, while AI/Semiconductor ETFs rose over **30%**.\n- **18:02**: You noticed the previous chat history was missing.",
"timestamp": 1777894631776
},
{
"role": "user",
"content": "hey who am i?",
"timestamp": 1777894733048
},
{
"role": "assistant",
"content": "You are jasmine. You're a developer working with TypeScript and Node.js on Windows 11.",
"timestamp": 1777894739050
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777894078436,
"lastActiveAt": 1777894739050,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 369
}
@@ -0,0 +1,22 @@
{
"id": "e8d92840-c725-49f0-b748-c5fcf6c28ffd",
"username": "jasmine",
"history": [
{
"role": "user",
"content": "연금보험에 대하여 알려줘.",
"timestamp": 1777896514832
},
{
"role": "assistant",
"content": "연금보험은 노후 생활 자금 마련을 위해 일정 기간 보험료를 납입하고, 정해진 시점부터 연금을 수령하는 상품입니다. 크게 세제 혜택이 있는 '연금저축보험'과 비과세 혜택이 있는 '일반 연금보험'으로 나뉩니다. 구체적인 상품 비교나 추천이 필요하시면 말씀해 주세요.",
"timestamp": 1777896532565
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/jasmine/workspace",
"createdAt": 1777895941662,
"lastActiveAt": 1777896532566,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 58
}
@@ -28,3 +28,97 @@
[17:56:23] **SmallClaw**: 네, 이름을 jasmine으로 변경했습니다. 이제 jasmine 님이라고 불러드릴게요.
[17:56:35] **User**: 내가 누구지?
[17:56:36] **SmallClaw**: jasmine 님입니다. 방금 성함을 현숙에서 jasmine으로 변경해 드렸습니다.
[17:59:49] **User**: 투자 연금에 대하여 간략히 알려줘.
[17:59:51] **SmallClaw**: 투자 연금은 노후 자금 마련을 위해 주식, 채권 등 수익성 자산에 투자하는 연금 상품입니다. 대표적으로 **연금저축펀드**와 **IRP(개인형 퇴직연금)**가 있으며, 납입 시 세액공제 혜택을 받고 운용 수익에 대해서는 인출 시까지 과세가 이연되는 장점이 있습니다.
[18:00:25] **User**: 수익율은 어때?
[18:00:27] **SmallClaw**: 투자 연금의 수익률은 선택하는 투자 상품(펀드, ETF 등)과 시장 상황에 따라 천차만별입니다. 원금 보장형 상품은 연 3~4% 수준의 낮은 수익률을 보이지만, 주식형 상품은 시장 상승기에 따라 연 10% 이상의 높은 수익률을 기대할 수 있는 반면 손실 위험도 존재합니다. 구체적인 최근 시장 수익률 데이터를 확인해 드릴까요?
[18:01:13] **User**: 응
[18:01:28] **SmallClaw**: 2024년 상반기 기준으로 증권사 연금저축펀드나 IRP에서 미국 지수(S&P500, 나스닥100)에 투자했을 경우 **약 15~23%**의 높은 수익률을 기록했습니다. 반면, 은행이나 보험사의 원리금 보장형 상품은 **연 3~4%** 수준으로 안정적이지만 상대적으로 낮습니다.
특히 AI와 반도체 테마 ETF는 **30% 이상** 급등하기도 했으나, 시장 상황에 따라 변동성이 크다는 점을 유의해야 합니다. 관심 있는 특정 종목이나 투자 성향이 있으신가요?
[18:02:50] **User**: 지난 대화가 어디론가 가고 없네?
[18:02:53] **SmallClaw**: 이전 대화 내용이 보이지 않아 당황스러우시겠네요. 제가 기억하는 Kim님의 정보(Windows 11, TypeScript/Node.js 스택 등)는 그대로 유지되고 있으니, 필요한 작업이 있다면 바로 말씀해 주세요. 어떤 대화 내용을 찾으시나요?
[18:03:34] **User**: 내가 누구지?
[18:03:35] **SmallClaw**: Kim님입니다. Windows 11 환경에서 TypeScript와 Node.js를 사용하시는 개발자이시죠. 무엇을 도와드릴까요?
[20:35:10] **User**: who am i?
[20:35:16] **SmallClaw**: You are jasmine. You're working on a Windows 11 machine with a TypeScript and Node.js stack.
[20:35:37] **User**: what have i done last time?
[20:35:53] **SmallClaw**: Last time, you updated your name to **jasmine** and we discussed **investment pensions**, specifically looking into the returns of pension savings funds and IRPs for the first half of 2024. You also asked about your identity and missing conversation history.
[20:36:25] **User**: can you show me the chat?
[20:37:11] **SmallClaw**: Here is the chat history from today, including your name changes and our discussion on investment pensions:
- **17:10**: You introduced yourself as **Kim**.
- **17:11**: You changed your name to **현숙**.
- **17:20**: You changed it back to **Kim**.
- **17:56**: You updated your name to **jasmine**.
[20:38:57] **User**: hey who am i?
[20:38:59] **SmallClaw**: You are jasmine. You're a developer working with TypeScript and Node.js on Windows 11.
[21:08:49] **User**: 연금보험에 대하여 알려줘.
[21:08:52] **SmallClaw**: 연금보험은 노후 생활 자금 마련을 위해 일정 기간 보험료를 납입하고, 정해진 시점부터 연금을 수령하는 상품입니다. 크게 세제 혜택이 있는 '연금저축보험'과 비과세 혜택이 있는 '일반 연금보험'으로 나뉩니다. 구체적인 상품 비교나 추천이 필요하시면 말씀해 주세요.
[21:10:28] **User**: 이매일 확인
[21:10:32] **SmallClaw**: 최근 수신된 이메일 3건입니다. YouTube Premium 관련 안내와 결제 수단 업데이트 요청 메일이 있습니다. 구체적으로 읽어보고 싶은 메일이 있으신가요?
[21:26:24] **User**: 안녕?
[21:26:27] **SmallClaw**: 반가워요! 현숙님, 무엇을 도와드릴까요?
[21:29:08] **User**: 안정적인 주식투자 방법 알려줘.
[21:29:24] **SmallClaw**: 안정적인 주식 투자를 위해서는 **'잃지 않는 원칙'**이 가장 중요합니다.
1. **지수 추종 ETF 활용**: 개별 종목의 위험을 피하기 위해 S&P 500(VOO, SPY)이나 나스닥 100(QQQ) 같은 시장 전체에 투자하는 ETF를 추천합니다.
2. **적립식 분할 매수**: 한 번에 몰빵하지 않고 매달 일정 금액을 기계적으로 매수하여 평균 단가를 낮추는 전략이 안전합니다.
3. **우량주와 배당주**: 각 산업의 1등 기업이나 꾸준히 배당을 주는 기업에 투자해 하락장에서도 현금 흐름과 방어력을 확보하세요.
4.
[21:30:10] **User**: 혹시 내가 매일 계정이 있나?
[21:30:13] **SmallClaw**: 현재 등록된 이메일 계정은 다음과 같습니다:
1. **applecherry** (kim@applecherry.net)
2. **evangel** (kim@evangel.xyz)
3. **daum** (kimsgms@hanmail.net)
이 중 확인하고 싶은 계정이 있으신가요? 최근 메일 목록을 불러올 수도 있습니다.
[21:32:24] **User**: 러아ㅣㄴ머리ㅏㅇㄴㅁ
[21:32:27] **SmallClaw**: 무슨 말씀이신지 잘 모르겠어요. 도움이 필요하시면 구체적으로 말씀해 주세요!
[21:33:04] **User**: 채팅 기록이 없어졌네...
[21:33:06] **SmallClaw**: 채팅 기록이 보이지 않아 당황스러우시겠어요. 시스템 오류나 세션 초기화 때문일 수 있는데, 제가 도와드릴 수 있는 부분이 있는지 확인해 보겠습니다. 어떤 내용을 찾고 계셨나요?
[21:36:39] **User**: 머야...
[21:36:41] **SmallClaw**: 당황하지 마세요! 제가 여기 있습니다. 혹시 이전에 하던 작업이나 찾으시던 정보가 있다면 말씀해 주세요. 바로 도와드릴게요.
[21:38:03] **User**: ㄹㅇㄴㅁㄹㄴㅁ
[21:38:05] **SmallClaw**: 키보드를 마구 치신 것 같네요! 혹시 장난치시는 건가요, 아니면 정말로 도움이 필요하신 상황인가요? 말씀해 주시면 바로 도와드릴게요.
[21:40:26] **User**: 하이
[21:40:27] **SmallClaw**: 반가워요, 현숙님! 무엇을 도와드릴까요? 무엇이든 말씀만 하세요. 🦞
[21:40:45] **User**: 주식 투자 잘 할 수 있을까?
[21:40:48] **SmallClaw**: 주식 투자는 철저한 분석과 원칙만 있다면 충분히 잘하실 수 있어요! 원하시는 기업의 실적 분석이나 최근 시장 트렌드 조사가 필요하시면 제가 바로 도와드릴까요? 🦞
[21:41:07] **User**: 삼성전자 어때?
[21:41:24] **SmallClaw**: 삼성전자는 현재 AI 반도체(HBM4)와 2나노 공정 기술 확보를 통해 재도약을 준비하는 중요한 시점에 있습니다. 2026년에는 실적이 50~60조 원대까지 회복될 것이라는 긍정적 전망이 많지만, 기술 격차 유지 여부가 주가의 핵심 변수가 될 것으로 보입니다. 🦞
[22:17:15] **User**: 내 매일 계정이 있나?
[22:17:20] **SmallClaw**: 네, 현재 연결된 이메일 계정이 있습니다. 최근 YouTube Premium 관련 안내 메일들이 수신된 것을 확인했습니다. 🦞
[22:17:49] **User**: 내 매일 계정이 있나?
[22:17:56] **SmallClaw**: 네, 현재 이메일 계정이 연결되어 있으며 최근 YouTube Premium에서 온 메일을 포함한 수신함 목록을 확인했습니다. 어떤 작업을 도와드릴까요?
[22:18:14] **User**: 리스트
[22:18:18] **SmallClaw**: 최근 수신된 이메일 3건입니다.
1. **YouTube의 PIP 모드 사용에 관한 업데이트** (4/30)
2. **YouTube Premium 결제 수단 업데이트 안내** (2025/12/2)
3. **YouTube Premium 가입 축하 메일** (2025/9/3)
더 자세히 보고 싶은 메일이 있으신가요?
[22:20:53] **User**: 메일 계정 정보 보여줘
[22:21:52] **SmallClaw**: 현재 연결된 이메일 계정 정보입니다.
* **이메일 주소:** kim@applecherry.net
* **사용자 이름:** 김 (현숙)
* **최근 활동:** YouTube Premium 관련 안내 메일 수신 (4/30)
추가로 확인이 필요한 정보가 있으신가요?
[22:27:45] **User**: 이매일 확인해줘
[22:27:50] **SmallClaw**: 이메일 계정 설정이 되어 있지 않은 것 같습니다. `config.json` 파일의 `email` 섹션에 계정 정보를 입력해 주시면 바로 확인해 드릴 수 있습니다. 어떤 정보를 입력해야 하는지 안내해 드릴까요?
[00:35:43] **User**: 이매일 체크
[00:35:46] **SmallClaw**: 이메일 설정이 되어 있지 않아 목록을 가져올 수 없습니다. `config.json` 파일에 계정 정보를 입력해 주시면 바로 확인해 드릴 수 있습니다. 설정 방법을 안내해 드릴까요?
[00:36:04] **User**: 응
[00:36:06] **SmallClaw**: `config.json` 파일의 `email` 섹션에 아래 정보를 입력해 주세요.
1. **imap_host**: `imap.gmail.com` (Gmail 기준)
2. **smtp_host**: `smtp.gmail.com`
3. **user**: 이메일 주소
4. **password**: 앱 비밀번호 (2단계 인증 후 생성)
설정 파일을 직접 열어드릴까요? 혹은 제가 대신 작성해 드릴까요?
@@ -0,0 +1,97 @@
{
"id": "04fa1a98-55c7-43c6-bfc9-d9d2eb8d0680",
"username": "papa",
"history": [
{
"role": "user",
"content": "머야",
"timestamp": 1777898596987
},
{
"role": "assistant",
"content": "무슨 일이신가요? 도와드릴 일이 있다면 말씀해 주세요. 무엇을 도와드릴까요?",
"timestamp": 1777898609876
},
{
"role": "user",
"content": "채팅기록이 없어졌네..",
"timestamp": 1777898623643
},
{
"role": "user",
"content": "어딧지?",
"timestamp": 1777898679569
},
{
"role": "assistant",
"content": "현재 시스템 설정상 채팅 기록 폴더가 보이지 않네요. 혹시 브라우저나 특정 앱의 캐시 문제일 수 있습니다. `USER.md`에는 'Papa'라는 이름과 최근 작업 내역만 남아있는데, 혹시 최근에 앱을 업데이트하거나 초기화하신 적이 있나요?",
"timestamp": 1777898693065
},
{
"role": "user",
"content": "매일 모두 확인",
"timestamp": 1777900717061
},
{
"role": "assistant",
"content": "현재 서울은 14.6°C로 맑은 날씨이며, 최근 이메일 3건(YouTube 관련)을 확인했습니다. 진행 중인 작업은 없습니다. 매일 아침 이 내용을 자동으로 브리핑해 드릴까요?",
"timestamp": 1777900729115
},
{
"role": "user",
"content": "이매일 확인해줘",
"timestamp": 1777901282517
},
{
"role": "assistant",
"content": "최근 수신된 이메일 3건은 모두 YouTube 관련 안내입니다. PIP 모드 업데이트(4월 30일), 결제 수단 업데이트 요청, 그리고 프리미엄 가입 축하 메일이 있습니다. 상세 내용을 확인하고 싶은 메일이 있으신가요?",
"timestamp": 1777901301266
},
{
"role": "user",
"content": "모두 리스트",
"timestamp": 1777901314143
},
{
"role": "assistant",
"content": "최근 수신된 이메일 3건의 목록입니다:\n\n1. **YouTube의 PIP 모드 사용에 관한 업데이트** (4월 30일)\n2. **YouTube Premium 결제 수단 업데이트 요청** (25년 12월 2일)\n3. **YouTube Premium 가입 축하 메일** (25년 9월 3일)\n\n더 이전의 메일이나 특정 발신자의 메일을 찾으시겠어요?",
"timestamp": 1777901341081
},
{
"role": "user",
"content": "매일 계정이 몇개가 있지?",
"timestamp": 1777901379158
},
{
"role": "assistant",
"content": "현재 시스템에 연결된 이메일 계정은 **1개(default)**입니다. 추가로 연결된 다른 계정 정보는 확인되지 않습니다.",
"timestamp": 1777901400020
},
{
"role": "user",
"content": "이메일 모두 리스트",
"timestamp": 1777901798708
},
{
"role": "assistant",
"content": "현재 수신함에 있는 이메일 3건의 전체 목록입니다.\n\n1. **YouTube의 PIP 모드 사용에 관한 업데이트** (2026.04.30)\n2. **YouTube Premium 결제 수단 업데이트 요청** (2025.12.02)\n3. **YouTube Premium 가입 축하 메일** (2025.09.03)\n\n더 상세한 내용이 궁금하시거나 다른 폴더를 확인해 드릴까요?",
"timestamp": 1777901827537
},
{
"role": "user",
"content": "이메일 모두 리스트",
"timestamp": 1777901861803
},
{
"role": "assistant",
"content": "현재 수신함(INBOX)에 있는 이메일 3건의 전체 목록입니다.\n\n1. **YouTube의 PIP 모드 사용에 관한 업데이트** (2026.04.30)\n2. **YouTube Premium 결제 수단 업데이트 요청** (2025.12.02)\n3. **YouTube Premium 가입 축하 메일** (2025.09.03)\n\n더 이전의 메일을 확인하시려면 말씀해 주세요.",
"timestamp": 1777901879799
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777898556036,
"lastActiveAt": 1777901879799,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 434
}
@@ -0,0 +1,72 @@
{
"id": "45e5482e-675e-4caf-b18d-7fca87887b0f",
"username": "papa",
"history": [
{
"role": "user",
"content": "메일 체크",
"timestamp": 1777909125768
},
{
"role": "assistant",
"content": "최근 도착한 메일 3건입니다. YouTube Premium 관련 안내와 결제 수단 업데이트 요청이 와 있습니다. 더 자세히 읽어볼 메일이 있을까요?",
"timestamp": 1777909152319
},
{
"role": "user",
"content": "모든 메일 리스트",
"timestamp": 1777909167485
},
{
"role": "assistant",
"content": "최근 수신된 메일 3건의 목록입니다. YouTube Premium 안내 및 결제 수단 업데이트 관련 메일들이 확인됩니다. 더 이전의 메일을 확인하시겠습니까?",
"timestamp": 1777909183967
},
{
"role": "user",
"content": "메일 리스트 보여줘",
"timestamp": 1777909208742
},
{
"role": "assistant",
"content": "최근 수신된 메일 3건의 목록입니다. YouTube Premium 안내 및 결제 수단 업데이트 관련 메일들이 확인됩니다. 더 자세히 확인하고 싶은 메일이 있으신가요?",
"timestamp": 1777909231244
},
{
"role": "user",
"content": "모든 메일 다 리스트.",
"timestamp": 1777909296199
},
{
"role": "assistant",
"content": "최근 수신된 메일 3건의 목록입니다. YouTube Premium 안내 및 결제 수단 업데이트 관련 메일들이 확인됩니다. 더 이전의 메일을 확인하시겠습니까?",
"timestamp": 1777909309863
},
{
"role": "user",
"content": "메일 계정이 모두 몇 개지?",
"timestamp": 1777909329327
},
{
"role": "assistant",
"content": "현재 등록된 메일 계정은 applecherry(기본), evangel, daum 총 3개입니다. 어떤 계정의 메일을 확인해 드릴까요?",
"timestamp": 1777909343705
},
{
"role": "user",
"content": "모두 다 리스트",
"timestamp": 1777909357179
},
{
"role": "assistant",
"content": "모든 계정의 메일 목록입니다.\n\n**applecherry**\n1. YouTube PIP 모드 업데이트 (4/30)\n2. YouTube Premium 결제 수단 업데이트 요청 (25/12/2)\n3. YouTube Premium 가입 축하 (25/9/3)\n\n**daum**\n1. OpenRouter Team: per-request model routing (5/4)\n2. Unsplash Team: A special invitation (5/4)\n3. 국세청: 김상규연합치과 안내 (5/4)\n4. KICC: 쿠팡 신용카드 결제 내역 (5/4)\n5. 쿠팡: 와우 멤버십 월회비 결제 (5/4)\n6. Pexels: Find free content (5/4)\n7. Elicit: Search over 125M papers (5/3)\n8. Unsplash: Beautiful photos (5/3)\n9. Google Play: 주문 영수증 (5/3)\n10. eBay: $5 coupon for Sang-Gyoo (5/2)\n\n**evangel** 계정에는 메일이 없습니다. 더 자세히 읽어볼 메일이 있을까요?",
"timestamp": 1777909390918
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777909116851,
"lastActiveAt": 1777909390918,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 375
}
@@ -0,0 +1,52 @@
{
"id": "70563dc2-2b77-4255-b462-dd60d434e0ac",
"username": "papa",
"history": [
{
"role": "user",
"content": "이메일 모두 리스트",
"timestamp": 1777901893790
},
{
"role": "assistant",
"content": "최근 수신된 이메일 3건입니다. 더 이전 메일이나 특정 발신자의 메일을 찾으시려면 말씀해 주세요.",
"timestamp": 1777901909604
},
{
"role": "user",
"content": "이메일 계정이 몇개가 있지?",
"timestamp": 1777901953939
},
{
"role": "assistant",
"content": "현재 `applecherry` (기본), `evangel`, `daum` 총 3개의 이메일 계정이 등록되어 있습니다. 어떤 계정의 메일을 확인해 드릴까요?",
"timestamp": 1777901970695
},
{
"role": "user",
"content": "모두다",
"timestamp": 1777901977935
},
{
"role": "assistant",
"content": "등록된 3개 계정의 최근 메일 목록입니다.\n\n**evangel** (최근 5건)\n- Unsplash Team: A special invitation (5/4)\n- 국세청: 김상규연합치과 관련 (5/4)\n- 한국정보통신: 쿠팡 결제 내역 (5/4)\n- 쿠팡: 와우 멤버십 결제 (5/4)\n- Pexels: Find the content you need (5/4)\n\n**applecherry** (최근 3건)\n- YouTube Premium: PIP 모드 업데이트 (4/30)\n- YouTube: 결제 수단 업데이트 안내 (25/12/2)\n- YouTube Premium: 가입 축하 (25/9/3)\n\n**daum** 계정에는 수신된 메일이 없습니다. 더 자세히 확인하고 싶은 메일이 있나요?",
"timestamp": 1777901990391
},
{
"role": "user",
"content": "이메일 체크",
"timestamp": 1777909042596
},
{
"role": "assistant",
"content": "각 계정의 최신 메일 목록입니다. `evangel` 계정에 OpenRouter의 새로운 메일이 추가되었습니다.\n\n**evangel**\n- OpenRouter Team: per-request model routing (방금 전)\n- Unsplash Team: A special invitation (어제)\n- 국세청: 김상규연합치과 관련 (어제)\n\n**applecherry**\n- YouTube Premium: PIP 모드 업데이트 (4/30)\n- YouTube: 결제 수단 업데이트 안내 (25/12/2)\n\n**daum**\n- 수신된 메일이 없습니다.\n\n내용을 확인하고 싶은 메일이 있으면 말씀해 주세요.",
"timestamp": 1777909085963
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777901889260,
"lastActiveAt": 1777909085964,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 307
}
@@ -0,0 +1,42 @@
{
"id": "74b65ff1-5aaa-4a87-88ef-7e7eff824e95",
"username": "papa",
"history": [
{
"role": "user",
"content": "하이",
"timestamp": 1777898307724
},
{
"role": "assistant",
"content": "반가워요! 무엇을 도와드릴까요?",
"timestamp": 1777898315882
},
{
"role": "user",
"content": "나는 누구야?",
"timestamp": 1777898324462
},
{
"role": "assistant",
"content": "기억하고 있는 이름은 **Papa**님입니다. 윈도우 11 환경에서 TypeScript와 Node.js를 주로 사용하시는 개발자시네요!",
"timestamp": 1777898340668
},
{
"role": "user",
"content": "너는 누구지?",
"timestamp": 1777898348099
},
{
"role": "assistant",
"content": "저는 **SmallClaw(클로)**입니다. Papa님의 PC에서 파일 관리, 브라우저 자동화, 코딩 등을 돕는 로컬 AI 어시스턴트예요. 무엇을 도와드릴까요?",
"timestamp": 1777898354401
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777898301373,
"lastActiveAt": 1777898354401,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 94
}
@@ -0,0 +1,11 @@
{
"id": "7eb22428-6137-4416-a384-4b3d918ed632",
"username": "papa",
"history": [],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777898298611,
"lastActiveAt": 1777898298611,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 0
}
@@ -0,0 +1,32 @@
{
"id": "aaf87ad9-08d2-4276-bf7e-04824ea702ab",
"username": "papa",
"history": [
{
"role": "user",
"content": "hi who am i?",
"timestamp": 1777894684434
},
{
"role": "assistant",
"content": "How can I help you today?",
"timestamp": 1777894698895
},
{
"role": "user",
"content": "do you know my name?",
"timestamp": 1777894772519
},
{
"role": "assistant",
"content": "You're Papa. How can I help you today?",
"timestamp": 1777894799464
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777894664561,
"lastActiveAt": 1777894799464,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 53
}
@@ -121,12 +121,22 @@
"role": "assistant",
"content": "마크다운(.md) 파일도 다운로드 링크 생성이 가능합니다. 현재 워크스페이스에 있는 파일들의 링크입니다.\n\n1. [magnesium_membrane_GBR_2022.md](sandbox:/magnesium_membrane_GBR_2022.md)\n2. [magnesium_membrane_review_2023.md](sandbox:/magnesium_membrane_review_2023.md)\n\n클릭하시면 해당 파일을 다운로드하거나 브라우저에서 바로 확인하실 수 있습니다.",
"timestamp": 1777822749940
},
{
"role": "user",
"content": "ㅀㅇㄴㄹㅇㄴㄹㅇㅁㄴㄹ",
"timestamp": 1777898269609
},
{
"role": "assistant",
"content": "오타가 입력된 것 같습니다. 도움이 필요하신 작업이 있다면 말씀해 주세요!",
"timestamp": 1777898288106
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777819988760,
"lastActiveAt": 1777822749941,
"lastActiveAt": 1777898288106,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 1389
"contextTokenEstimate": 1417
}
@@ -0,0 +1,11 @@
{
"id": "da89daae-a7c5-468d-bf82-4c492685b0d3",
"username": "papa",
"history": [],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777894663993,
"lastActiveAt": 1777894663993,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 0
}
@@ -0,0 +1,32 @@
{
"id": "deb64f03-9c97-4cac-8d53-bb07ff797d0e",
"username": "papa",
"history": [
{
"role": "user",
"content": "안녕?",
"timestamp": 1777897620046
},
{
"role": "assistant",
"content": "반가워요! 무엇을 도와드릴까요? 무엇이든 말씀만 하세요. 🦞",
"timestamp": 1777897625665
},
{
"role": "user",
"content": "최신 유리치은조직 이식술에 관한 논문 3편 찾아줘.",
"timestamp": 1777897663489
},
{
"role": "assistant",
"content": "최신 유리치은조직 이식술(FGG)에 관한 최신 논문 3편을 찾아 요약해 드립니다.\n\n1. **확장 메쉬 유리치은조직 이식술 (2024)**: 기존 FGG의 한계를 극복하기 위해 '메쉬(Mesh)' 기법을 도입하여 이식편의 길이를 확장하고, 넓은 부위에 각화점막을 효과적으로 형성하는 새로운 접근법을 제시했습니다.\n2. **유리치은조직 이식술의 변형 기법 체계적 고찰 (2024)**: 전통적인 FGG의 심미적 단점(비대함 등)을 보완하기 위해 제안된 부분 상피화 FGG(PE-FGG) 등 다양한 최신 변형 술식들의 임상 결과와 효용성을 분석했습니다.\n3. **혁신적인 마이크로 봉합술 'Fishnet Suture' (2025)**: 이식편의 안정성과 혈관 재형성을 극대화하기 위해 그물망 형태의 연속 인터로킹 봉합법을 도입하여, 하악 전치부 및 임플란트 주위 조직 재건에서 우수한 결과를 보고했습니다.\n\n더 자세한 내용이나 특정 논문의 전문이 필요하시면 말씀해 주세요. 🦞",
"timestamp": 1777897686440
}
],
"workspace": "/home/kim/homeclaw/.smallclaw/users/papa/workspace",
"createdAt": 1777897486889,
"lastActiveAt": 1777897686440,
"pendingMemoryFlush": false,
"pendingCompaction": false,
"contextTokenEstimate": 184
}
@@ -0,0 +1,46 @@
{
"email": {
"default": "applecherry",
"accounts": [
{
"id": "applecherry",
"imap_host": "121.183.65.201",
"imap_port": 993,
"imap_tls": true,
"imap_reject_unauthorized": false,
"smtp_host": "121.183.65.201",
"smtp_port": 587,
"smtp_secure": false,
"smtp_reject_unauthorized": false,
"username": "kim@applecherry.net",
"password": "vault:email.accounts.applecherry.password"
},
{
"id": "evangel",
"imap_host": "mail.evangel.xyz",
"imap_port": 993,
"imap_tls": true,
"imap_reject_unauthorized": false,
"smtp_host": "mail.evangel.xyz",
"smtp_port": 587,
"smtp_secure": false,
"smtp_reject_unauthorized": false,
"username": "kim@evangel.xyz",
"password": "vault:email.accounts.evangel.password"
},
{
"id": "daum",
"imap_host": "imap.daum.net",
"imap_port": 993,
"imap_tls": true,
"imap_reject_unauthorized": true,
"smtp_host": "smtp.daum.net",
"smtp_port": 465,
"smtp_secure": true,
"smtp_reject_unauthorized": true,
"username": "kimsgms@hanmail.net",
"password": "vault:email.accounts.daum.password"
}
]
}
}
@@ -170,3 +170,112 @@ TS 한국교통안전공단 승인용 도면에는 반드시 **mm 단위의 치
[17:05:16] **SmallClaw**: Understood. I'm here if you need anything later.
[17:55:08] **User**: 내가 누구지?
[17:55:10] **SmallClaw**: 당신은 Windows 11 환경에서 TypeScript와 Node.js를 사용하는 개발자이며, 성함은 **Papa**입니다. 제가 기억해 두었으니 걱정 마세요. 무엇을 도와드릴까요?
[20:38:16] **User**: hi who am i?
[20:38:18] **SmallClaw**: How can I help you today?
[20:39:58] **User**: do you know my name?
[20:39:59] **SmallClaw**: You're Papa. How can I help you today?
[21:27:03] **User**: 안녕?
[21:27:05] **SmallClaw**: 반가워요! 무엇을 도와드릴까요? 무엇이든 말씀만 하세요. 🦞
[21:27:49] **User**: 최신 유리치은조직 이식술에 관한 논문 3편 찾아줘.
[21:28:06] **SmallClaw**: 최신 유리치은조직 이식술(FGG)에 관한 최신 논문 3편을 찾아 요약해 드립니다.
1. **확장 메쉬 유리치은조직 이식술 (2024)**: 기존 FGG의 한계를 극복하기 위해 '메쉬(Mesh)' 기법을 도입하여 이식편의 길이를 확장하고, 넓은 부위에 각화점막을 효과적으로 형성하는 새로운 접근법을 제시했습니다.
2. **유리치은조직 이식술의 변형 기법 체계적 고찰 (2024)**: 전통적인 FGG의 심미적 단점(비대함 등)을 보완하기 위해 제안된 부분 상피화 FGG(PE-FGG) 등 다양한 최신 변형 술식들의 임상 결과와 효용성
[21:38:05] **User**: ㅀㅇㄴㄹㅇㄴㄹㅇㅁㄴㄹ
[21:38:08] **SmallClaw**: 오타가 입력된 것 같습니다. 도움이 필요하신 작업이 있다면 말씀해 주세요!
[21:38:33] **User**: 하이
[21:38:35] **SmallClaw**: 반가워요! 무엇을 도와드릴까요?
[21:38:55] **User**: 나는 누구야?
[21:39:00] **SmallClaw**: 기억하고 있는 이름은 **Papa**님입니다. 윈도우 11 환경에서 TypeScript와 Node.js를 주로 사용하시는 개발자시네요!
[21:39:12] **User**: 너는 누구지?
[21:39:14] **SmallClaw**: 저는 **SmallClaw(클로)**입니다. Papa님의 PC에서 파일 관리, 브라우저 자동화, 코딩 등을 돕는 로컬 AI 어시스턴트예요. 무엇을 도와드릴까요?
[21:43:27] **User**: 머야
[21:43:29] **SmallClaw**: 무슨 일이신가요? 도와드릴 일이 있다면 말씀해 주세요. 무엇을 도와드릴까요?
[21:43:46] **User**: 채팅기록이 없어졌네..
[21:44:46] **User**: 어딧지?
[21:44:53] **SmallClaw**: 현재 시스템 설정상 채팅 기록 폴더가 보이지 않네요. 혹시 브라우저나 특정 앱의 캐시 문제일 수 있습니다. `USER.md`에는 'Papa'라는 이름과 최근 작업 내역만 남아있는데, 혹시 최근에 앱을 업데이트하거나 초기화하신 적이 있나요?
[22:18:41] **User**: 매일 모두 확인
[22:18:49] **SmallClaw**: 현재 서울은 14.6°C로 맑은 날씨이며, 최근 이메일 3건(YouTube 관련)을 확인했습니다. 진행 중인 작업은 없습니다. 매일 아침 이 내용을 자동으로 브리핑해 드릴까요?
[22:28:16] **User**: 이매일 확인해줘
[22:28:21] **SmallClaw**: 최근 수신된 이메일 3건은 모두 YouTube 관련 안내입니다. PIP 모드 업데이트(4월 30일), 결제 수단 업데이트 요청, 그리고 프리미엄 가입 축하 메일이 있습니다. 상세 내용을 확인하고 싶은 메일이 있으신가요?
[22:28:56] **User**: 모두 리스트
[22:29:01] **SmallClaw**: 최근 수신된 이메일 3건의 목록입니다:
1. **YouTube의 PIP 모드 사용에 관한 업데이트** (4월 30일)
2. **YouTube Premium 결제 수단 업데이트 요청** (25년 12월 2일)
3. **YouTube Premium 가입 축하 메일** (25년 9월 3일)
더 이전의 메일이나 특정 발신자의 메일을 찾으시겠어요?
[22:29:55] **User**: 매일 계정이 몇개가 있지?
[22:30:00] **SmallClaw**: 현재 시스템에 연결된 이메일 계정은 **1개(default)**입니다. 추가로 연결된 다른 계정 정보는 확인되지 않습니다.
[22:36:49] **User**: 이메일 모두 리스트
[22:37:07] **SmallClaw**: 현재 수신함에 있는 이메일 3건의 전체 목록입니다.
1. **YouTube의 PIP 모드 사용에 관한 업데이트** (2026.04.30)
2. **YouTube Premium 결제 수단 업데이트 요청** (2025.12.02)
3. **YouTube Premium 가입 축하 메일** (2025.09.03)
더 상세한 내용이 궁금하시거나 다른 폴더를 확인해 드릴까요?
[22:37:54] **User**: 이메일 모두 리스트
[22:37:59] **SmallClaw**: 현재 수신함(INBOX)에 있는 이메일 3건의 전체 목록입니다.
1. **YouTube의 PIP 모드 사용에 관한 업데이트** (2026.04.30)
2. **YouTube Premium 결제 수단 업데이트 요청** (2025.12.02)
3. **YouTube Premium 가입 축하 메일** (2025.09.03)
더 이전의 메일을 확인하시려면 말씀해 주세요.
[22:38:25] **User**: 이메일 모두 리스트
[22:38:29] **SmallClaw**: 최근 수신된 이메일 3건입니다. 더 이전 메일이나 특정 발신자의 메일을 찾으시려면 말씀해 주세요.
[22:39:28] **User**: 이메일 계정이 몇개가 있지?
[22:39:30] **SmallClaw**: 현재 `applecherry` (기본), `evangel`, `daum` 총 3개의 이메일 계정이 등록되어 있습니다. 어떤 계정의 메일을 확인해 드릴까요?
[22:39:43] **User**: 모두다
[22:39:50] **SmallClaw**: 등록된 3개 계정의 최근 메일 목록입니다.
**evangel** (최근 5건)
- Unsplash Team: A special invitation (5/4)
- 국세청: 김상규연합치과 관련 (5/4)
- 한국정보통신: 쿠팡 결제 내역 (5/4)
- 쿠팡: 와우 멤버십 결제 (5/4)
- Pexels: Find the content you need (5/4)
**applecherry** (최근 3건)
- YouTube Premium: PIP 모드 업데이트 (4/30)
- YouTube: 결제 수단 업데이트 안내 (25/12/2)
[00:38:00] **User**: 이메일 체크
[00:38:05] **SmallClaw**: 각 계정의 최신 메일 목록입니다. `evangel` 계정에 OpenRouter의 새로운 메일이 추가되었습니다.
**evangel**
- OpenRouter Team: per-request model routing (방금 전)
- Unsplash Team: A special invitation (어제)
- 국세청: 김상규연합치과 관련 (어제)
**applecherry**
- YouTube Premium: PIP 모드 업데이트 (4/30)
- YouTube: 결제 수단 업데이트 안내 (25/12/2)
**daum**
- 수신된 메
[00:39:07] **User**: 메일 체크
[00:39:12] **SmallClaw**: 최근 도착한 메일 3건입니다. YouTube Premium 관련 안내와 결제 수단 업데이트 요청이 와 있습니다. 더 자세히 읽어볼 메일이 있을까요?
[00:39:39] **User**: 모든 메일 리스트
[00:39:43] **SmallClaw**: 최근 수신된 메일 3건의 목록입니다. YouTube Premium 안내 및 결제 수단 업데이트 관련 메일들이 확인됩니다. 더 이전의 메일을 확인하시겠습니까?
[00:40:26] **User**: 메일 리스트 보여줘
[00:40:31] **SmallClaw**: 최근 수신된 메일 3건의 목록입니다. YouTube Premium 안내 및 결제 수단 업데이트 관련 메일들이 확인됩니다. 더 자세히 확인하고 싶은 메일이 있으신가요?
[00:41:45] **User**: 모든 메일 다 리스트.
[00:41:49] **SmallClaw**: 최근 수신된 메일 3건의 목록입니다. YouTube Premium 안내 및 결제 수단 업데이트 관련 메일들이 확인됩니다. 더 이전의 메일을 확인하시겠습니까?
[00:42:21] **User**: 메일 계정이 모두 몇 개지?
[00:42:23] **SmallClaw**: 현재 등록된 메일 계정은 applecherry(기본), evangel, daum 총 3개입니다. 어떤 계정의 메일을 확인해 드릴까요?
[00:43:03] **User**: 모두 다 리스트
[00:43:10] **SmallClaw**: 모든 계정의 메일 목록입니다.
**applecherry**
1. YouTube PIP 모드 업데이트 (4/30)
2. YouTube Premium 결제 수단 업데이트 요청 (25/12/2)
3. YouTube Premium 가입 축하 (25/9/3)
**daum**
1. OpenRouter Team: per-request model routing (5/4)
2. Unsplash Team: A special invitation (5/4)
3. 국세청: 김상규연합치과 안내 (5/4)
4. KICC: 쿠팡 신용카드 결제 내역 (5/4
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "smallclaw",
"version": "1.1.0",
"version": "2.4.0",
"description": "Local AI agent framework powered by Ollama - OpenClaw alternative",
"main": "dist/index.js",
"bin": {
+1 -1
View File
@@ -1 +1 @@
claude --resume a7fdc7e1-3943-4bdc-b176-5381ec6c51ce
claude --resume 12962d51-aea1-47d5-8f3f-4d3c1ed4c04f
+148
View File
@@ -0,0 +1,148 @@
/**
* Copy non-auth secrets from the global vault (./.smallclaw/vault)
* to per-user vaults (./.smallclaw/users/<u>/workspace/.smallclaw/vault).
*
* Skips gateway.auth.* keys — those are gateway-scope, not user-scope.
* Idempotent: re-running overwrites existing entries with the same plaintext.
*
* Usage:
* npx tsx scripts/migrate-vault-to-users.ts # all users, all non-auth keys
* npx tsx scripts/migrate-vault-to-users.ts --dry-run # preview only
* npx tsx scripts/migrate-vault-to-users.ts --user papa # one user
* npx tsx scripts/migrate-vault-to-users.ts --user papa,jasmine # multiple
* npx tsx scripts/migrate-vault-to-users.ts --keys email.* # glob filter (multiple --keys allowed)
* npx tsx scripts/migrate-vault-to-users.ts --user jasmine --delete-keys email.*
* # remove from one user
*/
import path from 'path';
import fs from 'fs';
import { getVault } from '../src/security/vault';
const REPO_ROOT = path.resolve(__dirname, '..');
const GLOBAL_DIR = path.join(REPO_ROOT, '.smallclaw');
const USERS_DIR = path.join(GLOBAL_DIR, 'users');
const SKIP_PREFIXES = ['gateway.auth.'];
// ── arg parsing ──────────────────────────────────────────────────────────────
interface Args {
dryRun: boolean;
users: string[] | null; // null = all users on disk
keyPatterns: string[]; // empty = all non-skip keys
deletePatterns: string[]; // if non-empty: delete instead of copy
}
function parseArgs(argv: string[]): Args {
const args: Args = { dryRun: false, users: null, keyPatterns: [], deletePatterns: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--dry-run') args.dryRun = true;
else if (a === '--user' || a === '--users') {
args.users = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
}
else if (a === '--keys') args.keyPatterns.push(argv[++i] || '');
else if (a === '--delete-keys') args.deletePatterns.push(argv[++i] || '');
else if (a.startsWith('--')) throw new Error(`Unknown flag: ${a}`);
}
return args;
}
function globToRegex(glob: string): RegExp {
const esc = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${esc}$`);
}
function matchAny(key: string, patterns: string[]): boolean {
if (patterns.length === 0) return true; // no filter = match all
return patterns.some((p) => globToRegex(p).test(key));
}
// ── helpers ──────────────────────────────────────────────────────────────────
function listUsersOnDisk(): string[] {
if (!fs.existsSync(USERS_DIR)) return [];
return fs.readdirSync(USERS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
}
function userVaultDir(user: string): string {
return path.join(USERS_DIR, user, 'workspace', '.smallclaw');
}
// ── main ─────────────────────────────────────────────────────────────────────
function main() {
const args = parseArgs(process.argv.slice(2));
const globalVault = getVault(GLOBAL_DIR);
const allUsers = listUsersOnDisk();
const users = args.users ?? allUsers;
for (const u of users) {
if (!allUsers.includes(u)) console.warn(`! warning: user "${u}" has no directory under ${USERS_DIR}`);
}
const isDelete = args.deletePatterns.length > 0;
const mode = args.dryRun ? 'DRY RUN' : 'APPLY';
console.log(`Global vault : ${GLOBAL_DIR}/vault`);
console.log(`Users : ${users.join(', ') || '(none)'}`);
console.log(`Mode : ${mode}`);
if (isDelete) {
console.log(`Action : DELETE keys matching: ${args.deletePatterns.join(', ')}`);
} else {
const allKeys = globalVault.keys();
const transferKeys = allKeys
.filter((k) => !SKIP_PREFIXES.some((p) => k.startsWith(p)))
.filter((k) => matchAny(k, args.keyPatterns));
console.log(`Keys to copy : ${transferKeys.length}${args.keyPatterns.length ? ` (filter: ${args.keyPatterns.join(', ')})` : ''}`);
if (transferKeys.length) console.log(` ${transferKeys.join(', ')}`);
runCopy(globalVault, users, transferKeys, args.dryRun);
return;
}
console.log('');
runDelete(users, args.deletePatterns, args.dryRun);
}
function runCopy(globalVault: ReturnType<typeof getVault>, users: string[], keys: string[], dryRun: boolean) {
console.log('');
for (const user of users) {
const dir = userVaultDir(user);
console.log(`→ ${user} (${dir}/vault)`);
if (dryRun) { console.log(` [dry-run] would copy ${keys.length} keys`); continue; }
fs.mkdirSync(dir, { recursive: true });
const userVault = getVault(dir);
let copied = 0, missed = 0;
for (const k of keys) {
const sec = globalVault.get(k, 'migrate:user-seed');
if (!sec) { missed++; continue; }
userVault.set(k, sec.expose(), 'migrate:user-seed');
copied++;
}
console.log(` copied=${copied} missed=${missed}`);
}
console.log('\nDone.');
}
function runDelete(users: string[], patterns: string[], dryRun: boolean) {
for (const user of users) {
const dir = userVaultDir(user);
if (!fs.existsSync(path.join(dir, 'vault'))) {
console.log(`→ ${user} (no vault — skipping)`);
continue;
}
const userVault = getVault(dir);
const matching = userVault.keys().filter((k) => matchAny(k, patterns));
console.log(`→ ${user} (${dir}/vault) — ${matching.length} match`);
for (const k of matching) {
console.log(` ${dryRun ? '[dry-run] would delete' : 'delete'}: ${k}`);
if (!dryRun) userVault.delete(k, 'migrate:user-prune');
}
}
console.log('\nDone.');
}
main();
+4 -2
View File
@@ -627,7 +627,8 @@ export class Reactor {
params: options.serverToolCall.params || {},
stepNum: 1,
});
const toolResult = await this.registry.execute(mapped, options.serverToolCall.params || {});
const serverParams = { ...options.serverToolCall.params, _workspacePath: workspacePath };
const toolResult = await this.registry.execute(mapped, serverParams);
const resultText = toolResult.success
? (toolResult.stdout || JSON.stringify(toolResult.data || {}))
: `ERROR: ${toolResult.error}`;
@@ -717,7 +718,8 @@ export class Reactor {
continue;
}
const toolResult = await this.registry.execute(mappedName, params);
const nativeParams = { ...params, _workspacePath: workspacePath };
const toolResult = await this.registry.execute(mappedName, nativeParams);
const fullResultText = toolResult.success
? (toolResult.stdout || JSON.stringify(toolResult.data || {}))
: `ERROR: ${toolResult.error}`;
+212 -40
View File
@@ -1060,7 +1060,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? 파라미터 추가 가능 (예: "applecherry", "evangel", "daum"). 미지정 시 default 계정 사용. 등록된 계정: applecherry(kim@applecherry.net), evangel(kim@evangel.xyz), daum(kimsgms@hanmail.net). 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 지정 가능. 미지정 시 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 사용 금지.`,
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.`,
@@ -1071,6 +1071,22 @@ const TOOL_BLOCKS: Record<string, string> = {
sqlite: `SQLITE TOOL: sqlite_query(db_path, query, write?, max_rows?) → query a SQLite .db file in the workspace. SELECT queries allowed by default. Set write=true for INSERT/UPDATE/DELETE/CREATE/DROP. Returns formatted table. db_path is relative to workspace or absolute (must be inside workspace).`,
};
// Build a dynamic email tool block that lists the user's own accounts only
function buildDynamicEmailBlock(workspacePath: string): string | null {
try {
const perUserPath = path.join(workspacePath, '.smallclaw', 'email-config.json');
if (!fs.existsSync(perUserPath)) return 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 사용 금지.`;
} catch {
return null;
}
}
// Read memory categories for a specific file (for category-aware injection)
function readMemoryCategories(workspacePath: string, file: 'user' | 'soul'): string[] {
const filename = file === 'user' ? 'USER.md' : 'SOUL.md';
@@ -1182,6 +1198,13 @@ async function buildPersonalityContext(
// Build tool blocks for detected categories
const toolBlockParts: string[] = [];
for (const cat of cats) {
if (cat === 'email') {
const dynamicEmail = buildDynamicEmailBlock(workspacePath);
if (dynamicEmail) {
toolBlockParts.push(dynamicEmail);
continue;
}
}
if (TOOL_BLOCKS[cat]) toolBlockParts.push(TOOL_BLOCKS[cat]);
}
@@ -1934,13 +1957,13 @@ function buildTools() {
type: 'function',
function: {
name: 'email_list',
description: '받은편지함(또는 지정 폴더)의 최근 이메일 목록 조회. uid·제목·발신자·날짜·읽음여부 반환. 이메일 목록이 필요할 때 사용. 등록된 계정: applecherry(kim@applecherry.net, 기본), evangel(kim@evangel.xyz), daum(kimsgms@hanmail.net).',
description: '받은편지함(또는 지정 폴더)의 최근 이메일 목록 조회. uid·제목·발신자·날짜·읽음여부 반환.',
parameters: {
type: 'object', required: [],
properties: {
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
limit: { type: 'number', description: '가져올 수 (기본 20, 최대 50)' },
account: { type: 'string', description: '계정 ID: "applecherry"·"evangel"·"daum" (기본: applecherry)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
},
},
@@ -1955,7 +1978,7 @@ function buildTools() {
properties: {
uid: { type: 'number', description: '메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID: "applecherry"·"evangel"·"daum" (기본: applecherry)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
},
},
@@ -1972,7 +1995,7 @@ function buildTools() {
subject: { type: 'string', description: '제목' },
body: { type: 'string', description: '본문 (plain text)' },
cc: { type: 'string', description: '참조 주소 (선택)' },
account: { type: 'string', description: '발신 계정 ID: "applecherry"·"evangel"·"daum" (기본: applecherry)' },
account: { type: 'string', description: '발신 계정 ID (미지정 시 default)' },
},
},
},
@@ -1992,7 +2015,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: "applecherry"·"evangel"·"daum" (기본: applecherry)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
},
},
@@ -2007,7 +2030,7 @@ function buildTools() {
properties: {
uid: { type: 'number', description: '삭제할 메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID: "applecherry"·"evangel"·"daum" (기본: applecherry)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
},
},
@@ -4111,18 +4134,20 @@ async function handleChat(
type: secondaryClass.operation as FileOpType,
reason: `secondary classifier: ${secondaryClass.reason || 'runtime classification'} (confidence ${secondaryClass.confidence.toFixed(2)})`,
};
sendSSE('orchestration', {
trigger: 'file_op_classifier',
mode: 'router',
route: secondaryClass.operation === 'BROWSER_OP'
? 'browser_ops'
: secondaryClass.operation === 'DESKTOP_OP'
? 'desktop_ops'
: (secondaryClass.operation === 'CHAT' ? 'chat' : 'file_ops'),
reason: secondaryClass.reason || 'secondary runtime classification',
operation: secondaryClass.operation,
confidence: secondaryClass.confidence,
});
if (secondaryClass.operation !== 'CHAT') {
sendSSE('orchestration', {
trigger: 'file_op_classifier',
mode: 'router',
route: secondaryClass.operation === 'BROWSER_OP'
? 'browser_ops'
: secondaryClass.operation === 'DESKTOP_OP'
? 'desktop_ops'
: 'file_ops',
reason: secondaryClass.reason || 'secondary runtime classification',
operation: secondaryClass.operation,
confidence: secondaryClass.confidence,
});
}
} else {
// Secondary classifier unavailable — degrade to local classifier rather than
// collapsing to CHAT. Falling back to CHAT silently strips all file-op gating
@@ -7296,9 +7321,15 @@ function saveUserList(users: string[]): void {
vault.set('gateway.auth.user_list', JSON.stringify(users), 'auth:saveUserList');
}
function extractAuthToken(req: express.Request | http.IncomingMessage): string | null {
const h = (req.headers['authorization'] || req.headers['Authorization']) as string | undefined;
if (h && /^Bearer\s+/i.test(h)) return h.replace(/^Bearer\s+/i, '').trim() || null;
const cookies = parseCookies(req as any);
return cookies[AUTH_COOKIE] || null;
}
function getSessionUser(req: express.Request): SessionInfo | null {
const cookies = parseCookies(req);
const token = cookies[AUTH_COOKIE];
const token = extractAuthToken(req);
if (!token) return null;
return activeSessions.get(token) ?? null;
}
@@ -7378,7 +7409,7 @@ app.post('/api/auth/setup', (req, res) => {
const sessionToken = crypto.randomBytes(32).toString('hex');
activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() });
res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`);
return res.json({ success: true, username, role: 'admin' });
return res.json({ success: true, username, role: 'admin', token: sessionToken });
}
// Legacy migration with username provided
@@ -7408,7 +7439,7 @@ app.post('/api/auth/setup', (req, res) => {
const sessionToken = crypto.randomBytes(32).toString('hex');
activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() });
res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`);
return res.json({ success: true, username, role: 'admin' });
return res.json({ success: true, username, role: 'admin', token: sessionToken });
}
// Fresh install: first user setup
@@ -7429,7 +7460,7 @@ app.post('/api/auth/setup', (req, res) => {
const sessionToken = crypto.randomBytes(32).toString('hex');
activeSessions.set(sessionToken, { username, role: 'admin', createdAt: Date.now() });
res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`);
res.json({ success: true, username, role: 'admin' });
res.json({ success: true, username, role: 'admin', token: sessionToken });
});
app.post('/api/auth/login', (req, res) => {
@@ -7451,7 +7482,7 @@ app.post('/api/auth/login', (req, res) => {
const sessionToken = crypto.randomBytes(32).toString('hex');
activeSessions.set(sessionToken, { username: 'legacy', role: 'admin', createdAt: Date.now() });
res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`);
return res.json({ success: true, username: 'legacy', role: 'admin', needsMigration: true });
return res.json({ success: true, username: 'legacy', role: 'admin', needsMigration: true, token: sessionToken });
}
// Multi-user mode
@@ -7468,12 +7499,11 @@ app.post('/api/auth/login', (req, res) => {
const sessionToken = crypto.randomBytes(32).toString('hex');
activeSessions.set(sessionToken, { username, role, createdAt: Date.now() });
res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${sessionToken}; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=604800`);
res.json({ success: true, username, role });
res.json({ success: true, username, role, token: sessionToken });
});
app.post('/api/auth/logout', (req, res) => {
const cookies = parseCookies(req);
const token = cookies[AUTH_COOKIE];
const token = extractAuthToken(req);
if (token) activeSessions.delete(token);
res.setHeader('Set-Cookie', `${AUTH_COOKIE}=; HttpOnly; Path=/; SameSite=Lax${req.secure ? '; Secure' : ''}; Max-Age=0`);
res.json({ success: true });
@@ -7504,8 +7534,7 @@ app.use((req, _res, next) => {
// Allow auth endpoints and login page without authentication
if (req.path.startsWith('/api/auth/') || req.path === '/login.html') return next();
const cookies = parseCookies(req);
const token = cookies[AUTH_COOKIE];
const token = extractAuthToken(req);
const session = token ? activeSessions.get(token) : undefined;
if (token && activeSessions.has(token)) {
if (session) {
@@ -7579,6 +7608,146 @@ app.delete('/api/users/:username', (req, res) => {
res.json({ success: true });
});
// ─── Per-user Email Accounts ──────────────────────────────────────────────────
// Stored at <user-workspace>/.smallclaw/email-config.json with passwords as
// vault:email.accounts.<id>.password — resolved at runtime against the
// per-user vault. Admin-only endpoints.
function userEmailConfigPath(username: string): string {
return path.join(getUserWorkspace(username), '.smallclaw', 'email-config.json');
}
function userVaultDir(username: string): string {
return path.join(getUserWorkspace(username), '.smallclaw');
}
function loadUserEmailConfig(username: string): { default?: 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 : [] };
} catch {
return { accounts: [] };
}
}
function saveUserEmailConfig(username: string, cfg: { default?: string; accounts: any[] }): void {
const p = userEmailConfigPath(username);
fs.mkdirSync(path.dirname(p), { recursive: true });
const tmp = `${p}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ email: cfg }, null, 2), { mode: 0o600 });
fs.renameSync(tmp, p);
}
function userExists(username: string): boolean {
return !!getUserField(username, 'password_hash');
}
app.get('/api/users/:username/email/config', (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 cfg = loadUserEmailConfig(username);
const v = getVault(userVaultDir(username));
const accounts = cfg.accounts.map((a: any) => ({
id: a.id,
imap_host: a.imap_host, imap_port: a.imap_port, imap_tls: a.imap_tls !== false,
imap_reject_unauthorized: a.imap_reject_unauthorized !== false,
smtp_host: a.smtp_host, smtp_port: a.smtp_port, smtp_secure: a.smtp_secure === true,
smtp_reject_unauthorized: a.smtp_reject_unauthorized !== false,
username: a.username,
password_set: v.has(`email.accounts.${a.id}.password`),
}));
res.json({ default: cfg.default || null, accounts });
});
app.put('/api/users/:username/email/accounts/:accountId', (req, res) => {
const session = getSessionUser(req);
if (!session || session.role !== 'admin') return res.status(403).json({ error: 'Admin required' });
const { username, accountId } = req.params;
if (!userExists(username)) return res.status(404).json({ error: 'User not found' });
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(accountId)) return res.status(400).json({ error: 'Invalid account id (1-32 chars: letters, digits, dash, underscore)' });
const b = req.body || {};
for (const k of ['imap_host', 'smtp_host', 'username']) {
if (!b[k] || typeof b[k] !== 'string') return res.status(400).json({ error: `${k} required` });
}
const cfg = loadUserEmailConfig(username);
const updated: any = {
id: accountId,
imap_host: b.imap_host,
imap_port: Number(b.imap_port) || 993,
imap_tls: b.imap_tls !== false,
imap_reject_unauthorized: b.imap_reject_unauthorized !== false,
smtp_host: b.smtp_host,
smtp_port: Number(b.smtp_port) || 587,
smtp_secure: b.smtp_secure === true,
smtp_reject_unauthorized: b.smtp_reject_unauthorized !== false,
username: b.username,
password: `vault:email.accounts.${accountId}.password`,
};
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}`);
}
res.json({ success: true });
});
app.delete('/api/users/:username/email/accounts/:accountId', (req, res) => {
const session = getSessionUser(req);
if (!session || session.role !== 'admin') return res.status(403).json({ error: 'Admin required' });
const { username, accountId } = req.params;
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' });
const { username, accountId } = req.params;
if (!userExists(username)) return res.status(404).json({ error: 'User not found' });
const cfg = loadUserEmailConfig(username);
const a = cfg.accounts.find((x: any) => x.id === accountId);
if (!a) return res.status(404).json({ error: 'Account not found' });
const sec = getVault(userVaultDir(username)).get(`email.accounts.${accountId}.password`, `email:test:${session.username}`);
if (!sec) return res.status(400).json({ error: 'Password not stored in vault' });
try {
const { ImapFlow } = await import('imapflow');
const client = new ImapFlow({
host: a.imap_host,
port: Number(a.imap_port) || 993,
secure: a.imap_tls !== false,
auth: { user: a.username, pass: sec.expose() },
tls: { rejectUnauthorized: a.imap_reject_unauthorized !== false },
logger: false as any,
});
await client.connect();
await client.logout();
res.json({ success: true });
} catch (e: any) {
res.json({ success: false, error: e?.message || String(e) });
}
});
app.get('/api/admin/context-viewer', async (req, res) => {
const session = getSessionUser(req);
if (!session || session.role !== 'admin') return res.status(403).json({ error: 'Admin required' });
@@ -9683,7 +9852,7 @@ app.post('/api/settings/search', (req, res) => {
// GET /api/credentials/status — list which vault keys are currently stored (names only, no values)
app.get('/api/credentials/status', (_req, res) => {
try {
const vault = getVault();
const vault = getVault(CONFIG_DIR_PATH);
res.json({ success: true, keys: vault.keys() });
} catch (err: any) {
res.json({ success: false, keys: [], error: err.message });
@@ -9909,11 +10078,7 @@ app.get('/api/system-stats', async (req, res) => {
gpuStats = { available: true, gpu_util_percent: 0, vram_used_percent: 0, vram_used_gb: 0, vram_total_gb: 0, name: gpuInfo.name ?? 'Apple Silicon' };
}
const callerSession = getSessionUser(req);
const isAdmin = callerSession?.role === 'admin';
const onlineUsers = isAdmin
? [...new Set([...activeSessions.values()].map(s => s.username))]
: undefined;
const onlineUsers = [...new Set([...activeSessions.values()].map(s => s.username))];
res.json({
system: {
@@ -9927,7 +10092,7 @@ app.get('/api/system-stats', async (req, res) => {
gateway_process: { rss_mb: rss / (1024 * 1024) },
active_provider: (getConfig().getConfig() as any).llm?.provider || 'ollama',
active_model: (() => { const c = getConfig().getConfig() as any; const p = c.llm?.provider || 'ollama'; return c.llm?.providers?.[p]?.model || c.models?.primary || 'unknown'; })(),
...(onlineUsers !== undefined ? { online_users: onlineUsers } : {}),
online_users: onlineUsers,
timestamp: new Date().toISOString(),
});
});
@@ -10485,9 +10650,16 @@ wss.on('error', (err: any) => {
process.exit(1);
});
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
// Authenticate WS via session cookie
const cookies = parseCookies(req as any);
const token = cookies[AUTH_COOKIE];
// Authenticate WS via query token first, then cookie fallback
let token: string | null = null;
try {
const u = new URL(req.url || '/', `http://${req.headers.host || 'x'}`);
token = u.searchParams.get('token');
} catch {}
if (!token) {
const cookies = parseCookies(req as any);
token = cookies[AUTH_COOKIE] || null;
}
const authEnabled = getAuthConfig().enabled;
const session = authEnabled && token ? activeSessions.get(token) : undefined;
const user = authEnabled ? (session ? { username: session.username, role: session.role, workspace: getUserWorkspace(session.username) } : null) : null;
+20 -9
View File
@@ -195,8 +195,13 @@ export class SecretVault {
// ── Persistence ───────────────────────────────────────────────────────────
private persist(): void {
fs.promises.writeFile(this.vaultPath, JSON.stringify(this.data, null, 2), { mode: 0o600 })
.catch((err) => console.warn('[vault] Async write failed:', err));
// Sync + atomic-rename. set()/delete() are synchronous from the caller's
// POV; using fs.promises.writeFile races when calls land in quick
// succession (truncate of write A interleaves with bytes of write B,
// producing a corrupt file — observed in vault migration script).
const tmp = `${this.vaultPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 });
fs.renameSync(tmp, this.vaultPath);
}
// ── Audit ─────────────────────────────────────────────────────────────────
@@ -296,14 +301,20 @@ export class SecretVault {
}
}
// ─── Singleton ────────────────────────────────────────────────────────────────
// ─── Per-configDir cache ──────────────────────────────────────────────────────
// Each configDir gets its own SecretVault instance. This lets per-user
// workspaces hold their own vaults (e.g. <workspace>/.smallclaw/vault) while
// the project-level vault stays separate.
let _vault: SecretVault | null = null;
const _vaults = new Map<string, SecretVault>();
export function getVault(configDir?: string): SecretVault {
if (!_vault) {
if (!configDir) throw new Error('[Vault] configDir required for first initialisation');
_vault = new SecretVault(configDir);
export function getVault(configDir: string): SecretVault {
if (!configDir) throw new Error('[Vault] configDir required');
const key = path.resolve(configDir);
let v = _vaults.get(key);
if (!v) {
v = new SecretVault(key);
_vaults.set(key, v);
}
return _vault;
return v;
}
+39 -27
View File
@@ -19,12 +19,19 @@ interface EmailConfig {
password: string;
}
function getEmailConfig(accountId?: string): EmailConfig | null {
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 = fs.existsSync(projectCfg)
? projectCfg
: path.join(os.homedir(), '.smallclaw', 'config.json');
const cfgPath = perUserPath && fs.existsSync(perUserPath)
? perUserPath
: fs.existsSync(projectCfg)
? projectCfg
: globalPath;
if (!fs.existsSync(cfgPath)) return null;
const data = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const emailCfg = data.email;
@@ -42,7 +49,7 @@ function getEmailConfig(accountId?: string): EmailConfig | null {
if (!e?.imap_host || !e?.username) return null;
// Resolve vault: references for password
// Resolve vault: references for password (vault is shared with the config dir)
let password = e.password || '';
if (password.startsWith('vault:')) {
try {
@@ -73,8 +80,8 @@ function getEmailConfig(accountId?: string): EmailConfig | null {
// ── IMAP helper ───────────────────────────────────────────────────────────────
async function withImap<T>(fn: (client: any) => Promise<T>, accountId?: string): Promise<T> {
const cfg = getEmailConfig(accountId);
async function withImap<T>(fn: (client: any) => Promise<T>, accountId?: string, workspacePath?: string): Promise<T> {
const cfg = getEmailConfig(accountId, workspacePath);
if (!cfg) throw new Error('이메일 설정이 없습니다. config.json의 email 섹션을 확인하세요.');
const { ImapFlow } = await import('imapflow');
const client = new ImapFlow({
@@ -121,14 +128,14 @@ export const emailListTool = {
schema: {
folder: '조회할 폴더 (기본: INBOX)',
limit: '가져올 메일 수 (기본: 20, 최대: 50)',
account: '계정 ID (예: applecherry, evangel. 기본: default 계정)',
account: '계정 ID (미지정 시 default)',
},
jsonSchema: {
type: 'object',
properties: {
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
limit: { type: 'number', description: '가져올 수 (기본 20, 최대 50)' },
account: { type: 'string', description: '계정 ID (예: applecherry, evangel)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
additionalProperties: false,
},
@@ -136,6 +143,7 @@ export const emailListTool = {
const folder = String(args?.folder || 'INBOX');
const limit = Math.min(Number(args?.limit) || 20, 50);
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 });
@@ -162,7 +170,7 @@ export const emailListTool = {
});
}
return results.reverse();
}, account);
}, account, workspacePath);
if (messages.length === 0) {
return { success: true, stdout: `${folder}: 메일이 없습니다.`, data: [] };
@@ -190,14 +198,14 @@ export const emailReadTool = {
schema: {
uid: '읽을 메일의 UID (email_list에서 확인)',
folder: '폴더명 (기본: INBOX)',
account: '계정 ID (예: applecherry, evangel, daum. 기본: default 계정)',
account: '계정 ID (미지정 시 default)',
},
jsonSchema: {
type: 'object',
properties: {
uid: { type: 'number', description: '메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID (예: applecherry, evangel, daum)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
required: ['uid'],
additionalProperties: false,
@@ -206,6 +214,7 @@ export const emailReadTool = {
const uid = Number(args?.uid);
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
if (!uid) return { success: false, error: 'uid가 필요합니다.' };
try {
@@ -221,17 +230,17 @@ export const emailReadTool = {
const savedPaths: string[] = [];
const savedRelPaths: string[] = [];
if (saveAttachments && parsed.attachments?.length) {
const injectedWorkspace = args?._workspace;
let workspacePath: string;
const injectedWorkspace = workspacePath || args?._workspacePath || args?._workspace;
let resolvedWorkspacePath: string;
if (injectedWorkspace) {
workspacePath = path.resolve(String(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'));
workspacePath = path.resolve(cfg?.workspace?.path || path.join(process.cwd(), 'workspace'));
resolvedWorkspacePath = path.resolve(cfg?.workspace?.path || path.join(process.cwd(), 'workspace'));
}
const relDir = path.join('attachments', `uid-${uid}`);
const saveDir = path.join(workspacePath, relDir);
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}`;
@@ -259,7 +268,7 @@ export const emailReadTool = {
};
}
return null;
}, account);
}, account, workspacePath);
if (!result) return { success: false, error: `UID ${uid} 메일을 찾을 수 없습니다.` };
@@ -296,7 +305,7 @@ export const emailSendTool = {
subject: '제목',
body: '본문 (Plain text)',
cc: '참조 주소 (선택, 쉼표 구분)',
account: '계정 ID (예: applecherry, evangel. 기본: default 계정)',
account: '계정 ID (미지정 시 default)',
},
jsonSchema: {
type: 'object',
@@ -305,7 +314,7 @@ export const emailSendTool = {
subject: { type: 'string', description: '제목' },
body: { type: 'string', description: '본문' },
cc: { type: 'string', description: '참조 (선택)' },
account: { type: 'string', description: '계정 ID (예: applecherry, evangel)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
required: ['to', 'subject', 'body'],
additionalProperties: false,
@@ -321,7 +330,8 @@ export const emailSendTool = {
if (!subject) return { success: false, error: 'subject(제목)가 필요합니다.' };
if (!body) return { success: false, error: 'body(본문)가 필요합니다.' };
const cfg = getEmailConfig(account);
const workspacePath = args?._workspacePath || args?._workspace || undefined;
const cfg = getEmailConfig(account, workspacePath);
if (!cfg) return { success: false, error: '이메일 설정이 없습니다. config.json의 email 섹션을 확인하세요.' };
try {
@@ -368,7 +378,7 @@ export const emailSearchTool = {
before: '이 날짜 이전 (YYYY-MM-DD)',
folder: '검색할 폴더 (기본: INBOX)',
limit: '최대 결과 수 (기본: 20)',
account: '계정 ID (예: applecherry, evangel. 기본: default 계정)',
account: '계정 ID (미지정 시 default)',
},
jsonSchema: {
type: 'object',
@@ -380,7 +390,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 (예: applecherry, evangel)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
additionalProperties: false,
},
@@ -388,6 +398,7 @@ export const emailSearchTool = {
const folder = String(args?.folder || 'INBOX');
const limit = Math.min(Number(args?.limit) || 20, 50);
const account = args?.account ? String(args.account) : undefined;
const workspacePath = args?._workspacePath || args?._workspace || undefined;
if (!args?.query && !args?.from && !args?.subject && !args?.since && !args?.before) {
return { success: false, error: 'query, from, subject, since, before 중 하나 이상이 필요합니다.' };
@@ -427,7 +438,7 @@ export const emailSearchTool = {
});
}
return results;
}, account);
}, account, workspacePath);
if (messages.length === 0) {
return { success: true, stdout: '검색 결과가 없습니다.', data: [] };
@@ -455,14 +466,14 @@ export const emailDeleteTool = {
schema: {
uid: '삭제할 메일의 UID',
folder: '폴더명 (기본: INBOX)',
account: '계정 ID (예: applecherry, evangel. 기본: default 계정)',
account: '계정 ID (미지정 시 default)',
},
jsonSchema: {
type: 'object',
properties: {
uid: { type: 'number', description: '삭제할 메일 UID' },
folder: { type: 'string', description: '폴더명 (기본: INBOX)' },
account: { type: 'string', description: '계정 ID (예: applecherry, evangel)' },
account: { type: 'string', description: '계정 ID (미지정 시 default)' },
},
required: ['uid'],
additionalProperties: false,
@@ -471,6 +482,7 @@ export const emailDeleteTool = {
const uid = Number(args?.uid);
const folder = String(args?.folder || 'INBOX');
const account = args?.account ? String(args.account) : undefined;
const workspacePath = args?._workspacePath || args?._workspace || undefined;
if (!uid) return { success: false, error: 'uid가 필요합니다.' };
try {
@@ -489,7 +501,7 @@ export const emailDeleteTool = {
await client.messageFlagsAdd(uid.toString(), ['\\Deleted'], { uid: true });
await client.mailboxClose();
}
}, account);
}, account, workspacePath);
return {
success: true,
+248 -51
View File
@@ -207,6 +207,12 @@
overflow-x: hidden;
}
/* Collapsed (minimized to right rail) — grid columns are set via JS so the
mobile single-column media query keeps working. */
body.right-collapsed .right-panel > *:not(#right-panel-rail) { display: none !important; }
body.right-collapsed #right-panel-rail { display: flex !important; }
#right-panel-rail { display: none; }
aside {
grid-area: sidebar;
display: flex;
@@ -2569,13 +2575,23 @@
<div class="right-panel" id="right-panel" style="position:relative">
<!-- Resize handle on left edge -->
<div id="right-panel-resize-handle" style="position:absolute;left:0;top:0;bottom:0;width:5px;cursor:col-resize;z-index:200;background:transparent;transition:background 0.15s ease" title="드래그하여 크기 조절"></div>
<!-- Collapsed rail (visible when right panel is minimized) -->
<div id="right-panel-rail" onclick="toggleRightPanel()" title="컨텍스트 패널 펼치기" style="flex-direction:column;align-items:center;justify-content:flex-start;gap:14px;padding:10px 0;cursor:pointer;background:var(--panel);height:100%">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="color:var(--muted)"><polyline points="15 18 9 12 15 6"/></svg>
<div style="writing-mode:vertical-rl;font-size:10px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:0.12em">컨텍스트</div>
</div>
<!-- Right panel topbar with canvas toggle -->
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 12px;border-bottom:1px solid var(--line);flex-shrink:0;background:var(--panel);min-height:38px">
<span style="font-size:10px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:0.06em">컨텍스트</span>
<button id="canvas-toggle-btn" onclick="toggleCanvas()" title="캔버스 전환" style="display:flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:5px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif;transition:all 0.15s ease">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="M8 9l-4 3 4 3"/><path d="M16 9l4 3-4 3"/><path d="M12 6v12"/></svg>
Canvas
</button>
<div style="display:flex;align-items:center;gap:6px">
<button id="canvas-toggle-btn" onclick="toggleCanvas()" title="캔버스 전환" style="display:flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:5px 10px;font-size:11px;font-weight:700;cursor:pointer;font-family:'Manrope',sans-serif;transition:all 0.15s ease">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="M8 9l-4 3 4 3"/><path d="M16 9l4 3-4 3"/><path d="M12 6v12"/></svg>
Canvas
</button>
<button onclick="toggleRightPanel()" title="컨텍스트 패널 최소화" style="display:flex;align-items:center;justify-content:center;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;width:28px;height:28px;cursor:pointer">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<!-- Canvas Overlay -->
@@ -3796,9 +3812,15 @@ let pollInterval = null;
let currentMode = 'chat';
let sidebarTab = 'jobs';
let useAgentMode = false;
const CHAT_SESSIONS_KEY = 'cherryclaw_chat_sessions_v1';
const AGENT_SESSION_KEY = 'cherryclaw_agent_session_id';
const CHAT_SESSIONS_KEY_BASE = 'cherryclaw_chat_sessions_v1';
const AGENT_SESSION_KEY_BASE = 'cherryclaw_agent_session_id';
const THEME_KEY = 'cherryclaw_theme';
const TOKEN_KEY = 'smallclaw_token';
function getAuthToken() { try { return sessionStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; } }
function setAuthToken(t) { try { t ? sessionStorage.setItem(TOKEN_KEY, t) : sessionStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders(extra) { const t = getAuthToken(); const h = { ...(extra||{}) }; if (t) h['Authorization'] = 'Bearer ' + t; return h; }
function chatSessionsKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${CHAT_SESSIONS_KEY_BASE}__${u}`; }
function agentSessionKey() { const u = (currentUser && currentUser.username) || '__anon__'; return `${AGENT_SESSION_KEY_BASE}__${u}`; }
function generateSessionId() {
return (crypto?.randomUUID?.() || ('sess_' + Math.random().toString(36).slice(2)));
}
@@ -3811,7 +3833,7 @@ function getCookie(name) {
function setAgentSessionId(id) {
const next = String(id || '').trim() || generateSessionId();
agentSessionId = next;
localStorage.setItem(AGENT_SESSION_KEY, next);
localStorage.setItem(agentSessionKey(), next);
return next;
}
let agentSessionId = '';
@@ -3976,7 +3998,7 @@ function updateQueuedPromptUI() {
}
function saveChatSessions() {
localStorage.setItem(CHAT_SESSIONS_KEY, JSON.stringify(chatSessions));
localStorage.setItem(chatSessionsKey(), JSON.stringify(chatSessions));
}
function makeSessionTitle(history) {
@@ -3993,8 +4015,22 @@ function makeSessionTitle(history) {
}
function loadChatSessions() {
const key = chatSessionsKey();
try {
chatSessions = JSON.parse(localStorage.getItem(CHAT_SESSIONS_KEY) || '[]');
const raw = localStorage.getItem(key);
if (raw) {
chatSessions = JSON.parse(raw);
} else {
// Migrate from old non-namespaced key once, then delete it so other users can't see it.
const oldRaw = localStorage.getItem(CHAT_SESSIONS_KEY_BASE);
if (oldRaw) {
chatSessions = JSON.parse(oldRaw);
localStorage.setItem(key, oldRaw);
localStorage.removeItem(CHAT_SESSIONS_KEY_BASE);
} else {
chatSessions = [];
}
}
} catch {
chatSessions = [];
}
@@ -4565,10 +4601,7 @@ async function openInFileLocation(targetPath) {
try {
const res = await fetch('/api/open-path', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ path: p }),
});
const data = await res.json().catch(() => ({}));
@@ -4993,11 +5026,10 @@ async function sendChat(queuedMessage = null) {
const res = await fetch('/api/chat', {
method: 'POST',
credentials: 'same-origin',
headers: {
headers: authHeaders({
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
},
}),
signal: currentAbortController.signal,
body: JSON.stringify({ message: fullMessage, history: historyForAPI, useTools: useAgentMode, sessionId: agentSessionId, pinnedMessages: confirmedPins.length > 0 ? confirmedPins : undefined })
});
@@ -5516,7 +5548,7 @@ function openProcessLogFile() {
// Call server endpoint to open the process log file
if (activeTaskId) {
fetch(`/api/open-file?task=${encodeURIComponent(activeTaskId)}&type=process`, {
credentials: 'same-origin'
headers: authHeaders()
})
.catch(err => console.error('Unable to open process log:', err));
}
@@ -5626,7 +5658,9 @@ document.getElementById('chat-input').addEventListener('input', function() {
// ---- WebSocket ----
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws`);
const token = getAuthToken();
const qs = token ? `?token=${encodeURIComponent(token)}` : '';
ws = new WebSocket(`${proto}://${API.replace(/^https?:\/\//, '')}/ws${qs}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'jobs_update') {
@@ -5716,7 +5750,7 @@ function connectWS() {
(async () => {
try {
const data = await fetch(`/api/bg-tasks/${msg.taskId}`, {
credentials: 'same-origin'
headers: authHeaders()
}).then(r => r.json());
const task = data?.task || data;
if (task && task.sessionId && task.sessionId.startsWith('cron_')) {
@@ -5794,9 +5828,9 @@ function connectWS() {
// ---- API ----
async function api(path, opts = {}) {
const { headers: optsHeaders, credentials: _discard, ...restOpts } = opts;
const headers = { 'Content-Type': 'application/json', ...optsHeaders };
const r = await fetch(API + path, { credentials: 'same-origin', headers, ...restOpts });
if (r.status === 401) { window.location.href = '/login.html'; throw new Error('Authentication required'); }
const headers = authHeaders({ 'Content-Type': 'application/json', ...optsHeaders });
const r = await fetch(API + path, { headers, ...restOpts });
if (r.status === 401) { setAuthToken(''); window.location.href = '/login.html'; throw new Error('Authentication required'); }
const ct = String(r.headers.get('content-type') || '').toLowerCase();
const bodyText = await r.text();
@@ -5825,8 +5859,8 @@ const LAST_USER_KEY = 'cherryclaw_last_user';
function clearLocalUserState(reason) {
try {
localStorage.removeItem(CHAT_SESSIONS_KEY);
localStorage.removeItem(AGENT_SESSION_KEY);
localStorage.removeItem(chatSessionsKey());
localStorage.removeItem(agentSessionKey());
} catch {}
try {
chatSessions = [];
@@ -5843,7 +5877,7 @@ function clearLocalUserState(reason) {
async function checkAuth() {
try {
const res = await fetch('/api/auth/status');
const res = await fetch('/api/auth/status', { headers: authHeaders() });
const data = await res.json();
if (data.enabled && !data.authenticated && !data.hasPassword) {
window.location.href = '/login.html';
@@ -5856,6 +5890,10 @@ async function checkAuth() {
if (data.authenticated && data.username) {
currentUser = { username: data.username, role: data.role };
// Reload chat sessions under the correct user-scoped key now that we know who is logged in.
// This also migrates any data from the old non-namespaced key on first load after server restart.
if (typeof loadChatSessions === 'function') loadChatSessions();
// Multi-user safety: if a different user was logged in on this browser
// before, wipe the cached session list & active sessionId so the
// incoming user does not reuse the previous user's chat IDs.
@@ -5880,12 +5918,13 @@ async function checkAuth() {
async function logout() {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' });
await fetch('/api/auth/logout', { method: 'POST', headers: authHeaders() });
} catch (e) { /* ignore network errors */ }
// Wipe cached chat list / sessionId so a different user logging in next
// does not inherit any of this user's session IDs.
clearLocalUserState('logout');
try { localStorage.removeItem(LAST_USER_KEY); } catch {}
setAuthToken('');
window.location.href = '/login.html';
}
@@ -6566,11 +6605,15 @@ async function loadUsersTab() {
return;
}
container.innerHTML = data.users.map(u => `
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--line)">
<div style="width:28px;height:28px;border-radius:50%;background:var(--brand);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700">${u.username.charAt(0).toUpperCase()}</div>
<div style="flex:1"><strong style="font-size:13px">${u.username}</strong> <span style="color:var(--muted);font-size:11px">(${u.role})</span></div>
<span style="color:var(--muted);font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}</span>
${u.username !== data.current_user ? `<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:8px;cursor:pointer" onclick="removeUser('${u.username}')">삭제</button>` : '<span style="font-size:11px;color:var(--ok);font-weight:600">현재</span>'}
<div style="border-bottom:1px solid var(--line);padding:8px 0">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:28px;height:28px;border-radius:50%;background:var(--brand);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700">${u.username.charAt(0).toUpperCase()}</div>
<div style="flex:1"><strong style="font-size:13px">${u.username}</strong> <span style="color:var(--muted);font-size:11px">(${u.role})</span></div>
<span style="color:var(--muted);font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : ''}</span>
<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:8px;cursor:pointer" onclick="toggleUserEmail('${u.username}')">✉ 이메일</button>
${u.username !== data.current_user ? `<button class="btn" style="padding:4px 10px;font-size:11px;background:var(--err);color:#fff;border:none;border-radius:8px;cursor:pointer" onclick="removeUser('${u.username}')">삭제</button>` : '<span style="font-size:11px;color:var(--ok);font-weight:600">현재</span>'}
</div>
<div id="user-email-panel-${u.username}" style="display:none;margin-top:8px;padding:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px"></div>
</div>
`).join('');
} catch (e) {
@@ -6608,6 +6651,134 @@ async function removeUser(username) {
} catch (e) { alert(e.message || '삭제 실패'); }
}
// --- Per-User Email Accounts -------------------------------------------------
function toggleUserEmail(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
if (panel.style.display === 'none') {
panel.style.display = 'block';
loadUserEmailAccounts(username);
} else {
panel.style.display = 'none';
}
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
async function loadUserEmailAccounts(username) {
const panel = document.getElementById(`user-email-panel-${username}`);
if (!panel) return;
panel.innerHTML = '<div style="font-size:12px;color:var(--muted)">불러오는 중…</div>';
try {
const data = await api(`/api/users/${encodeURIComponent(username)}/email/config`);
const accounts = data.accounts || [];
const def = data.default || '';
const formId = `email-form-${username}`;
const list = accounts.length === 0
? '<div style="font-size:12px;color:var(--muted);margin-bottom:8px">등록된 이메일 계정이 없습니다.</div>'
: accounts.map(a => `
<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:#fff;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;font-size:12px">
<strong style="min-width:90px">${escapeHtml(a.id)}</strong>
${def === a.id ? '<span style="font-size:10px;background:var(--brand);color:#fff;padding:1px 6px;border-radius:6px">기본</span>' : ''}
<span style="color:var(--muted);flex:1;font-family:'IBM Plex Mono',monospace;font-size:11px">${escapeHtml(a.username)} @ ${escapeHtml(a.imap_host)}:${a.imap_port}</span>
${a.password_set ? '' : '<span style="font-size:10px;color:var(--err)">비번 없음</span>'}
${def !== a.id ? `<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="setDefaultUserEmailAccount('${username}','${escapeHtml(a.id)}')">기본 설정</button>` : ''}
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;cursor:pointer" onclick="testUserEmailAccount('${username}','${escapeHtml(a.id)}',this)" ${a.password_set ? '' : 'disabled'}>테스트</button>
<button class="btn" style="padding:2px 8px;font-size:10px;background:var(--err);color:#fff;border:none;border-radius:6px;cursor:pointer" onclick="deleteUserEmailAccount('${username}','${escapeHtml(a.id)}')">삭제</button>
</div>`).join('');
panel.innerHTML = `
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">이메일 계정 (${accounts.length})</div>
${list}
<details style="margin-top:8px"><summary style="font-size:12px;cursor:pointer;color:var(--muted)">+ 계정 추가/수정</summary>
<div id="${formId}" style="margin-top:8px;padding:10px;background:#fff;border:1px solid var(--line);border-radius:8px;display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:12px">
<label style="grid-column:1/3"><span style="color:var(--muted);font-size:11px">계정 ID (예: applecherry)</span>
<input data-f="id" type="text" placeholder="applecherry" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;font-family:'IBM Plex Mono',monospace" /></label>
<label><span style="color:var(--muted);font-size:11px">IMAP 호스트</span>
<input data-f="imap_host" type="text" placeholder="imap.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">IMAP 포트</span>
<input data-f="imap_port" type="number" value="993" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">SMTP 호스트</span>
<input data-f="smtp_host" type="text" placeholder="smtp.example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">SMTP 포트</span>
<input data-f="smtp_port" type="number" value="587" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">사용자명 (이메일)</span>
<input data-f="username" type="text" placeholder="user@example.com" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label><span style="color:var(--muted);font-size:11px">비밀번호 (vault에 암호화 저장)</span>
<input data-f="password" type="password" autocomplete="new-password" style="width:100%;border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px" /></label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<input data-f="imap_tls" type="checkbox" checked /> IMAP TLS</label>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)">
<input data-f="smtp_secure" type="checkbox" /> SMTP secure (465)</label>
<button class="btn btn-primary" style="grid-column:1/3;padding:7px;margin-top:6px;font-size:12px" onclick="submitUserEmailAccount('${username}')">저장</button>
<div id="${formId}-msg" style="grid-column:1/3;font-size:11px;color:var(--err)"></div>
</div>
</details>`;
} catch (e) {
panel.innerHTML = `<div style="font-size:12px;color:var(--err)">불러오기 실패: ${escapeHtml(e.message || e)}</div>`;
}
}
async function submitUserEmailAccount(username) {
const form = document.getElementById(`email-form-${username}`);
const msg = document.getElementById(`email-form-${username}-msg`);
if (!form) return;
const get = (k) => form.querySelector(`[data-f="${k}"]`);
const id = (get('id').value || '').trim();
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(id)) { msg.textContent = '계정 ID는 1-32자 영숫자/대시/언더스코어'; return; }
const body = {
imap_host: get('imap_host').value.trim(),
imap_port: Number(get('imap_port').value) || 993,
imap_tls: get('imap_tls').checked,
smtp_host: get('smtp_host').value.trim(),
smtp_port: Number(get('smtp_port').value) || 587,
smtp_secure: get('smtp_secure').checked,
username: get('username').value.trim(),
password: get('password').value,
};
if (!body.imap_host || !body.smtp_host || !body.username) { msg.textContent = 'IMAP/SMTP 호스트와 사용자명은 필수'; return; }
msg.textContent = '저장 중…'; msg.style.color = 'var(--muted)';
try {
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(id)}`, {
method: 'PUT', body: JSON.stringify(body),
});
loadUserEmailAccounts(username);
} catch (e) {
msg.textContent = e.message || '저장 실패'; msg.style.color = 'var(--err)';
}
}
async function deleteUserEmailAccount(username, accountId) {
if (!confirm(`계정 "${accountId}" 삭제하시겠습니까? (vault의 비밀번호도 함께 삭제됩니다)`)) return;
try {
await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' });
loadUserEmailAccounts(username);
} catch (e) { alert(e.message || '삭제 실패'); }
}
async function setDefaultUserEmailAccount(username, accountId) {
try {
await api(`/api/users/${encodeURIComponent(username)}/email/default`, {
method: 'PUT', body: JSON.stringify({ accountId }),
});
loadUserEmailAccounts(username);
} catch (e) { alert(e.message || '기본 설정 실패'); }
}
async function testUserEmailAccount(username, accountId, btn) {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = '연결 중…';
try {
const r = await api(`/api/users/${encodeURIComponent(username)}/email/accounts/${encodeURIComponent(accountId)}/test`, { method: 'POST' });
if (r.success) { btn.textContent = '✓ 성공'; setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 1500); }
else { alert(`테스트 실패: ${r.error || 'unknown'}`); btn.textContent = orig; btn.disabled = false; }
} catch (e) {
alert(e.message || '테스트 실패'); btn.textContent = orig; btn.disabled = false;
}
}
// --- Context Viewer Tab -------------------------------------------------------
let cvAutoRefreshTimer = null;
@@ -6759,10 +6930,7 @@ async function savePptPrefs() {
try {
await fetch('/api/settings/ppt', {
method: 'PUT',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ template: selectedPptTemplate, skin: selectedPptSkin }),
});
} catch (e) { console.error('savePptPrefs', e); }
@@ -8139,7 +8307,7 @@ function toggleXlsxEmbedBtn(btn) {
btn.innerHTML = '&#9650; 접기';
if (!container.dataset.loaded) {
container.innerHTML = '불러오는 중…';
fetch(url, { credentials: 'same-origin' })
fetch(url, { headers: authHeaders() })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
@@ -8194,7 +8362,7 @@ function toggleTxtEmbedBtn(btn) {
btn.innerHTML = '&#9650; 접기';
if (!container.dataset.loaded) {
container.textContent = '불러오는 중…';
fetch(url, { credentials: 'same-origin' })
fetch(url, { headers: authHeaders() })
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
@@ -8303,7 +8471,7 @@ function validateDownloadLinks(container) {
if (_dlLinkCache[url] === false) { markDownloadLinkError(btn); return; }
if (_dlLinkCache[url] === true) return;
_dlLinkCache[url] = 'pending';
fetch(url, { method: 'HEAD', credentials: 'same-origin' })
fetch(url, { method: 'HEAD', headers: authHeaders() })
.then(function(r) {
_dlLinkCache[url] = r.ok;
if (!r.ok) {
@@ -8335,7 +8503,7 @@ function downloadFile(e, el) {
var filename = decodeURIComponent(url.split('/').pop() || 'download');
fetch(url, {
credentials: 'same-origin'
headers: authHeaders()
})
.then(function(r) {
if (!r.ok) throw new Error(r.status);
@@ -8391,7 +8559,7 @@ function togglePptxPreview(fileUrl, containerId, btn) {
// Safely decode - handle already-decoded or invalid encodings
try { filePath = decodeURIComponent(filePath); } catch(e) {}
fetch('/api/pptx/preview?path=' + encodeURIComponent(filePath), {
credentials: 'same-origin'
headers: authHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
@@ -8418,7 +8586,7 @@ function handleImageUpload(input) {
formData.append('image', file);
fetch('/api/upload/image', {
method: 'POST',
credentials: 'same-origin',
headers: authHeaders(),
body: formData
})
.then(function(r) { return r.json(); })
@@ -8446,7 +8614,7 @@ function handleFileUpload(input) {
if (file.type.startsWith('image/')) {
var imgForm = new FormData();
imgForm.append('image', file);
fetch('/api/upload/image', { method: 'POST', credentials: 'same-origin', body: imgForm })
fetch('/api/upload/image', { method: 'POST', headers: authHeaders(), body: imgForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
@@ -8477,7 +8645,7 @@ function handleFileUpload(input) {
var xlsxFile = file;
var xlsxForm = new FormData();
xlsxForm.append('file', xlsxFile);
var xlsxUploadPromise = fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: xlsxForm })
var xlsxUploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: xlsxForm })
.then(function(r) { return r.json(); }).catch(function() { return {}; });
loadSheetJS(function() {
var r3 = new FileReader();
@@ -8518,7 +8686,7 @@ function handleFileUpload(input) {
var pdfFile = file;
var pdfForm = new FormData();
pdfForm.append('file', pdfFile);
var uploadPromise = fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: pdfForm })
var uploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: pdfForm })
.then(function(r) { return r.json(); });
var extractPromise = new Promise(function(resolve) {
@@ -8554,7 +8722,7 @@ function handleFileUpload(input) {
var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' });
var txtForm = new FormData();
txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' }));
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: txtForm })
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: txtForm })
.then(function(r) { return r.json(); })
.then(function(d) {
var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null;
@@ -8585,7 +8753,7 @@ function handleFileUpload(input) {
} else {
var otherForm = new FormData();
otherForm.append('file', file);
fetch('/api/upload/file', { method: 'POST', credentials: 'same-origin', body: otherForm })
fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: otherForm })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success && data.path) {
@@ -10618,8 +10786,7 @@ async function submitErrorResponse() {
try {
const resp = await fetch(`/api/bg-tasks/${_errTaskId}/error-response`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ action, category, inputs }),
});
const data = await resp.json();
@@ -10748,6 +10915,34 @@ function mobCloseMenu() {
document.getElementById('mob-tab-menu')?.classList.remove('active');
}
// ── Right-panel collapse ────────────────────────────────────────────────────
const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed';
function applyRightPanelCollapsed(collapsed) {
document.body.classList.toggle('right-collapsed', !!collapsed);
// Only override on desktop — narrow viewports use the mobile single-column layout.
const isDesktop = window.matchMedia('(min-width: 980px)').matches;
if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; }
if (collapsed) {
document.body.style.gridTemplateColumns = '280px 1fr 36px';
} else {
const saved = parseInt(localStorage.getItem('rightPanelWidth'), 10);
const max = Math.floor(window.innerWidth * 0.65);
const w = saved && saved >= 280 ? Math.min(saved, max) : '';
document.body.style.gridTemplateColumns = w ? `280px 1fr ${w}px` : '';
}
}
function toggleRightPanel() {
const next = !document.body.classList.contains('right-collapsed');
localStorage.setItem(RIGHT_COLLAPSE_KEY, next ? '1' : '0');
applyRightPanelCollapsed(next);
}
// Default to collapsed when no preference saved.
(function initRightPanelCollapse() {
const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY);
const collapsed = stored === null ? true : stored === '1';
applyRightPanelCollapsed(collapsed);
})();
// ── Right-panel resize ──────────────────────────────────────────────────────
(function initRightPanelResize() {
const MIN_W = 280;
@@ -10762,9 +10957,11 @@ function mobCloseMenu() {
return Math.max(MIN_W, Math.min(Math.floor(window.innerWidth * MAX_W_RATIO), px));
}
// Restore saved width
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
// Restore saved width (only if not collapsed)
if (!document.body.classList.contains('right-collapsed')) {
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
}
function setup() {
const handle = document.getElementById('right-panel-resize-handle');
+1
View File
@@ -419,6 +419,7 @@
const data = await res.json();
if (res.ok && data.success) {
if (data.token) { try { sessionStorage.setItem('smallclaw_token', data.token); } catch {} }
window.location.href = '/';
} else {
showError(data.error || '인증 실패');