diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index 47342c6..7ca348d 100644 --- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json +++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json @@ -1,5 +1,5 @@ { "result": "직답 결과 — single-pass mock 응답입니다.", - "createdAt": 1782814226952, + "createdAt": 1783071368763, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json index eebb3af..55eb95e 100644 --- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json +++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json @@ -1,5 +1,5 @@ { - "result": "---\nid: wiki_on\ndate: 2026-06-30T10:10:26.959Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (24ms)\n", - "createdAt": 1782814226961, + "result": "---\nid: wiki_on\ndate: 2026-07-03T09:36:08.766Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (13ms)\n", + "createdAt": 1783071368766, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json index 24e5ad0..bdd7c44 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -1,8 +1,8 @@ { "missionId": "wiki_on", "status": "completed", - "startTime": "2026-06-30T10:10:26.919Z", - "totalElapsedMs": 48, + "startTime": "2026-07-03T09:36:08.748Z", + "totalElapsedMs": 20, "results": { "direct": "직답 결과 — single-pass mock 응답입니다." }, @@ -12,16 +12,16 @@ { "from": "idle", "to": "direct", - "durationMs": 24, + "durationMs": 13, "message": "답변 작성 중... (단일 호출 fast-path)", - "ts": "2026-06-30T10:10:26.943Z" + "ts": "2026-07-03T09:36:08.761Z" }, { "from": "direct", "to": "completed", - "durationMs": 22, + "durationMs": 6, "message": "미션 완료", - "ts": "2026-06-30T10:10:26.965Z" + "ts": "2026-07-03T09:36:08.767Z" } ], "resilienceMetrics": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index 5f23787..9c85dd6 100644 --- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json +++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1782814234193, + "createdAt": 1783071374928, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json index dfd158b..672d892 100644 --- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json +++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1782814234192, + "createdAt": 1783071374927, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json index f4cbd58..ce207b0 100644 --- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json +++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json @@ -1,5 +1,5 @@ { "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", - "createdAt": 1782814234187, + "createdAt": 1783071374923, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json index 68f68d0..b172610 100644 --- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json +++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1782814234189, + "createdAt": 1783071374925, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1782814234171.json b/.astra/tests/stress/.astra/missions/stress_conflict_1783071374907.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1782814234171.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1783071374907.json index ddf892a..6a956bc 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1782814234171.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1783071374907.json @@ -1,7 +1,7 @@ { - "missionId": "stress_conflict_1782814234171", + "missionId": "stress_conflict_1783071374907", "status": "completed", - "startTime": "2026-06-30T10:10:34.172Z", + "startTime": "2026-07-03T09:36:14.907Z", "totalElapsedMs": 22, "results": { "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", @@ -14,30 +14,30 @@ { "from": "idle", "to": "outline", - "durationMs": 13, + "durationMs": 15, "message": "답변 구조 잡는 중...", - "ts": "2026-06-30T10:10:34.185Z" + "ts": "2026-07-03T09:36:14.922Z" }, { "from": "outline", "to": "section", - "durationMs": 3, + "durationMs": 2, "message": "본문 작성 중...", - "ts": "2026-06-30T10:10:34.188Z" + "ts": "2026-07-03T09:36:14.924Z" }, { "from": "section", "to": "polish", "durationMs": 2, "message": "최종 다듬기 중...", - "ts": "2026-06-30T10:10:34.190Z" + "ts": "2026-07-03T09:36:14.926Z" }, { "from": "polish", "to": "completed", - "durationMs": 4, + "durationMs": 3, "message": "미션 완료", - "ts": "2026-06-30T10:10:34.194Z" + "ts": "2026-07-03T09:36:14.929Z" } ], "resilienceMetrics": { diff --git a/docs/WIKI_DOC_PIPELINE.md b/docs/WIKI_DOC_PIPELINE.md new file mode 100644 index 0000000..c8e7365 --- /dev/null +++ b/docs/WIKI_DOC_PIPELINE.md @@ -0,0 +1,60 @@ +# 위키 문서 파이프라인 — 포맷 통일 규약 (v2.2.278) + +> **새 기능이 위키/지식 문서를 만들 때 반드시 이 문서를 따른다.** +> 기능마다 포맷이 하나씩 새로 생기던 파편화(wikify ≠ meet ≠ email …)를 끊기 위한 규약. + +## 구조 (2층) + +``` +[생성] buildKnowledgeWikiPrompt(src) ← 지식형 문서의 단일 프롬프트 빌더 + │ (P-Reinforce 정본: frontmatter + 🎯/🧠/🧩/📖/🔗/📚 섹션) + ▼ +[저장] persistWikiDoc(title, content, { store, docType, sources }) + │ ① 봉투: frontmatter 없으면 정본 봉투 자동 부착 (ensureFrontmatter) + │ ② 위치: 설정 폴더 → 두뇌/<종류별 폴더> → 워크스페이스 (로컬 직접 저장) + ▼ + brain 검색 · 지식 그래프에 자동 편입 +``` + +핵심 모듈: `src/features/datacollect/wikiFormat.ts` (정본 fetch·봉투·공용 빌더) · `wikiSave.ts` (저장). + +## 새 기능 추가 시 딱 2가지 + +### 1) 지식형 문서 (소스를 지식으로 합성 — wikify류) +`KnowledgeSource` 하나 정의해서 공용 빌더 호출. **새 프롬프트 골격을 만들지 말 것.** + +```ts +import { buildKnowledgeWikiPrompt, getCanonicalFormat } from '../datacollect/wikiFormat'; + +const canonical = await getCanonicalFormat(); // 브리지 정본 (10분 캐시, 실패시 내장 사본) +const prompt = buildKnowledgeWikiPrompt({ + kind: 'slack', // 소스 종류 + topic: '채널 논의 제목', + body: transcript, // 근거 원문 (30,000자 컷은 빌더가) + meta: { 채널: '#dev', 기간: '…' }, // [소스 메타] 표시 + sourceRef: 'slack:#dev/2026-07-03', // [S1] 출처 + raw_sources + extraRules: ['이 소스 종류만의 특수 규칙…'], // 번호는 빌더가 이어붙임 + trustHint: '내부 교신이므로 A~B 로', // source_trust_level 평가 힌트 +}, canonical); +// → callLmSynthesis(prompt, WIKIFY_SYSTEM류) → persistWikiDoc(..., { docType: 'slack' }) +``` + +기존 래퍼 예시: `wikifyPrompt.buildWikifyPrompt`(web) · `buildEmailCanonicalWikifyPrompt`(email) — 이 패턴을 복사할 것. + +### 2) 보고서형 문서 (자기 본문 구조가 있는 산출물 — meet/benchmark/review류) +본문 구조는 자유. **저장만 반드시 `persistWikiDoc` + `docType`** — 봉투(frontmatter)가 자동 통일된다. + +```ts +import { persistWikiDoc, describeSave } from '../datacollect/wikiSave'; +const saved = await persistWikiDoc(title, report, { docType: 'meeting', sources: [원본파일] }); +chunk(view, describeSave(saved, '회의록')); +``` + +## 금지 사항 +- ❌ 자체 frontmatter 골격을 손으로 작성 (봉투는 `ensureFrontmatter`가 담당) +- ❌ 브리지 `/api/wiki/template` 를 직접 fetch (→ `getCanonicalFormat()` 사용, 캐시 공유) +- ❌ `bridgeFetch(wiki.save)` 직접 호출 (→ `persistWikiDoc` — 로컬 저장·폴더 규칙·폴백 포함) +- ❌ 프롬프트에 섹션 구조 하드코딩 (정본 템플릿은 브리지 wiki_format.mjs 가 단일 소유) + +## 현재 등록된 docType +`wikify`(web) · `email` · `meeting` · `benchmark` · `youtube` · `review` diff --git a/media/settings-panel.html b/media/settings-panel.html index 5e67961..ef18110 100644 --- a/media/settings-panel.html +++ b/media/settings-panel.html @@ -18,6 +18,7 @@ + @@ -90,14 +91,6 @@ NAS Bridge의 x-bridge-token. nas 타깃일 때만 요청 헤더에 실립니다. -
- -
- - -
- /benchmark 등의 결과 markdown 저장 위치. 비워두면 Bridge의 WIKI_RAW_PATH 환경변수가 결정합니다 (코드에 절대경로 하드코딩 없음). 특정 폴더로 저장하려면 절대경로를 입력하세요. -
@@ -169,6 +162,81 @@
+
+ +
+ + +
+ /wikify · /meet · /benchmark · /youtube · /review 가 생성하는 지식 문서(markdown)의 저장 위치. PC 로컬에 직접 저장되며, 비워두면 활성 두뇌 폴더의 Datacollect_Wiki/ 에 저장돼 brain 검색에 자동 포함됩니다. 다른 곳에 두려면 절대경로 입력 (예: E:\Wiki\2nd\10_Wiki). +
+
+ +
+ + +
+ /email wikify 로 이메일 스레드를 지식화한 문서는 다른 wikify 산출물과 별도 폴더에서 관리됩니다. 비워두면 활성 두뇌 폴더의 Email_Wiki/ 에 저장. 다른 곳에 두려면 절대경로 입력. +
+ + + +
+

발송 스케줄

+

텔레그램으로 나가는 정기 메시지의 시각을 여기서 관리합니다 (KST 기준).

+
+ +
+ + +
+ HH:MM 을 쉼표로 구분 (예: 09:00,12:30,15:00). 이 시각마다 가격 갱신·매수시그널 감지·보고서 발송. +
+
+ +
+
+ +
+ + +
+ 평일(월~금) 이 시각에 1회 발송. 미회신 이메일 정리는 이 메시지에 포함됩니다. +
+
+ +
+

이벤트 알림

+

상황이 발생했을 때 즉시 발송되는 알림들입니다.

+
+ + 관망 종목이 매수사정권에 진입하는 순간 1회. +
+
+ +
+
+ +
+ + +
+ 0 = 끔(기본) — 미회신 정리는 데일리 브리핑이 담당. 즉시 알림을 원하면 시간 입력 (예: 24). +
+
+ + +
+

이메일 (/email)

+

끈 프로바이더는 수집 시도 자체를 하지 않아 채팅에 "제외/실패" 문구가 뜨지 않습니다.

+
+ + 데스크톱 클래식 Outlook COM — 별도 인증 없음. "새 Outlook" 토글 ON 상태는 미지원. +
+
+ + Google OAuth 재연결 1회 필요 (gmail.readonly 동의). 연결 전이거나 안 쓰면 꺼두세요 — /email 출력이 깔끔해집니다. +
diff --git a/media/settings-panel.js b/media/settings-panel.js index f778c95..baf3126 100644 --- a/media/settings-panel.js +++ b/media/settings-panel.js @@ -30,6 +30,15 @@ const dcBridgeNasUrl = $('dcBridgeNasUrl'); const dcBridgeNasToken = $('dcBridgeNasToken'); const dcSavePath = $('dcSavePath'); + const emailWikiSavePath = $('emailWikiSavePath'); + const emailOutlookEnabled = $('emailOutlookEnabled'); + const emailGmailEnabled = $('emailGmailEnabled'); + const msgStocksTimes = $('msgStocksTimes'); + const msgBriefingEnabled = $('msgBriefingEnabled'); + const msgBriefingTime = $('msgBriefingTime'); + const msgBuySignal = $('msgBuySignal'); + const msgDiscoverTop5 = $('msgDiscoverTop5'); + const msgEmailOverdue = $('msgEmailOverdue'); const dcCrawlDepth = $('dcCrawlDepth'); const dcMaxPages = $('dcMaxPages'); const dcSynthTemp = $('dcSynthTemp'); @@ -143,6 +152,9 @@ document.querySelector('[data-save="datacollect.savePath"]').addEventListener('click', () => vscode.postMessage({ type: 'datacollect.update', savePath: dcSavePath.value }) ); + document.querySelector('[data-save="email.wikifySavePath"]').addEventListener('click', () => + vscode.postMessage({ type: 'datacollect.update', emailWikifySavePath: emailWikiSavePath.value }) + ); document.querySelector('[data-save="datacollect.crawlDepth"]').addEventListener('click', () => vscode.postMessage({ type: 'datacollect.update', crawlDepth: Number(dcCrawlDepth.value) }) ); @@ -171,6 +183,30 @@ brainAutoPush.addEventListener('change', (e) => vscode.postMessage({ type: 'brain.update', autoPushBrain: e.target.checked }) ); + emailOutlookEnabled.addEventListener('change', (e) => + vscode.postMessage({ type: 'datacollect.update', emailOutlookEnabled: e.target.checked }) + ); + emailGmailEnabled.addEventListener('change', (e) => + vscode.postMessage({ type: 'datacollect.update', emailGmailEnabled: e.target.checked }) + ); + document.querySelector('[data-save="messenger.stocksReportTimes"]').addEventListener('click', () => + vscode.postMessage({ type: 'messenger.update', stocksReportTimes: msgStocksTimes.value }) + ); + msgBriefingEnabled.addEventListener('change', (e) => + vscode.postMessage({ type: 'messenger.update', briefingEnabled: e.target.checked }) + ); + document.querySelector('[data-save="messenger.briefingTime"]').addEventListener('click', () => + vscode.postMessage({ type: 'messenger.update', briefingTime: msgBriefingTime.value }) + ); + msgBuySignal.addEventListener('change', (e) => + vscode.postMessage({ type: 'messenger.update', buySignalAlert: e.target.checked }) + ); + msgDiscoverTop5.addEventListener('change', (e) => + vscode.postMessage({ type: 'messenger.update', discoverTopFive: e.target.checked }) + ); + document.querySelector('[data-save="messenger.emailOverdueHours"]').addEventListener('click', () => + vscode.postMessage({ type: 'messenger.update', emailOverdueHours: Number(msgEmailOverdue.value) }) + ); $('brainOpenSettings').addEventListener('click', () => vscode.postMessage({ type: 'openVscodeSettings' }) ); @@ -404,6 +440,19 @@ setIfNotFocused(dcBridgeNasUrl, dc.bridgeNasUrl); setIfNotFocused(dcBridgeNasToken, dc.bridgeNasToken); setIfNotFocused(dcSavePath, dc.savePath); + setIfNotFocused(emailWikiSavePath, dc.emailWikifySavePath); + if (state.messenger) { + setIfNotFocused(msgStocksTimes, state.messenger.stocksReportTimes); + msgBriefingEnabled.checked = !!state.messenger.briefingEnabled; + setIfNotFocused(msgBriefingTime, state.messenger.briefingTime); + msgBuySignal.checked = !!state.messenger.buySignalAlert; + msgDiscoverTop5.checked = !!state.messenger.discoverTopFive; + setIfNotFocused(msgEmailOverdue, state.messenger.emailOverdueHours); + } + if (state.email) { + emailOutlookEnabled.checked = !!state.email.outlookEnabled; + emailGmailEnabled.checked = !!state.email.gmailEnabled; + } setIfNotFocused(dcCrawlDepth, dc.crawlDepth); setIfNotFocused(dcMaxPages, dc.maxPages); setIfNotFocused(dcSynthTemp, dc.synthesisTemperature); diff --git a/media/sidebar.js b/media/sidebar.js index 8c0d7b0..4a5ac00 100644 --- a/media/sidebar.js +++ b/media/sidebar.js @@ -838,11 +838,27 @@ break; case 'streamChunk': if (streamBody) { + // 진행 표시(streamProgress)가 떠 있으면 걷어내고 본문을 잇는다. + if (streamBody._parent._progressBase !== undefined) { + streamBody._parent._raw = streamBody._parent._progressBase; + streamBody._parent._progressBase = undefined; + } streamBody._parent._raw += msg.value; streamBody.innerHTML = fmt(streamBody._parent._raw); chat.scrollTop = chat.scrollHeight; } break; + case 'streamProgress': { + // 제자리 갱신 진행 표시 — 직전 progress 줄을 교체한다 (로그 누적 방지). + if (!streamBody) break; + if (streamBody._parent._progressBase === undefined) { + streamBody._parent._progressBase = streamBody._parent._raw; + } + streamBody._parent._raw = streamBody._parent._progressBase + String(msg.value ?? ''); + streamBody.innerHTML = fmt(streamBody._parent._raw); + chat.scrollTop = chat.scrollHeight; + break; + } case 'streamReplace': // Progressive answering: the backend streamed raw tokens // live (including hidden reasoning, pre-sanitize text); @@ -850,6 +866,7 @@ // text via streamReplace so the bubble ends up correct // regardless of what slipped through during streaming. if (streamBody) { + streamBody._parent._progressBase = undefined; streamBody._parent._raw = String(msg.value ?? ''); streamBody.innerHTML = fmt(streamBody._parent._raw); chat.scrollTop = chat.scrollHeight; diff --git a/package.json b/package.json index 8b01efe..8ada0c8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.267", + "version": "2.2.291", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -271,7 +271,7 @@ "g1nation.datacollectSavePath": { "type": "string", "default": "", - "markdownDescription": "`/benchmark` 등 Datacollect slash 명령 결과물(markdown)을 저장할 폴더. **비워두면** Bridge 기본 위치(Bridge의 `WIKI_RAW_PATH` 환경변수)에 저장됩니다 — 코드/설정 어디에도 절대경로가 박히지 않습니다. 특정 폴더로 저장하려면 절대경로를 입력하세요. Astra Settings 패널의 'Datacollect' 섹션에서도 편집 가능." + "markdownDescription": "`/wikify` · `/meet` · `/benchmark` · `/youtube` · `/review` · `/email wikify` 가 생성하는 **지식 문서(markdown)의 저장 폴더** (v2.2.273부터 **PC 로컬에 직접 저장** — 이전에는 Bridge 서버에 저장돼 NAS 이전 후 파일이 안 보이던 문제 해소). **비워두면** 활성 두뇌 폴더의 `Datacollect_Wiki/` 에 저장돼 brain 검색에 자동 포함됩니다. 다른 위치를 쓰려면 절대경로 입력. Astra Settings 패널의 **지식·기억 탭**에서도 편집 가능." }, "g1nation.datacollectCrawlDepth": { "type": "number", @@ -350,6 +350,65 @@ "minimum": 2048, "markdownDescription": "`/meet` 최종 회의록(단일샷·병합) 생성 시 **출력 토큰 상한**(max_tokens). 기본 `8000`. 이 값을 명시하지 않으면 LM 서버 기본 상한에 잘려 **회의록 후반부·긴 액션표가 통째로 누락**되던 문제를 막는다. 잘림이 감지되면(finish=length) 끊긴 지점부터 자동 이어쓰기까지 수행하므로 이 값은 '한 번에 뽑는 양'이고 최종 길이는 이보다 길어질 수 있다. 아주 긴 회의(2시간+)에서 여전히 뒷부분이 짧다면 12000~16000 으로 올려보세요. (모델 컨텍스트 창을 넘지 않는 선에서)" }, + "g1nation.email.watcherEnabled": { + "type": "boolean", + "default": true, + "markdownDescription": "**이메일 워처** (v2.2.271) — 주기적으로 Outlook(+Gmail 연결 시)을 자동 수집해 스레드 상태를 `.astra/email_state.json` 에 누적 추적. `/email` 을 안 쳐도 미회신이 계속 추적되고, 임계 초과 시 텔레그램으로 알린다. false 면 `/email` 실행 시에만 갱신." + }, + "g1nation.email.watchIntervalMinutes": { + "type": "number", + "default": 60, + "minimum": 10, + "markdownDescription": "이메일 워처 수집 주기(분). 기본 `60`. Outlook COM + (연결 시) Gmail API 를 이 주기로 호출한다 — 너무 짧으면 PowerShell 스폰이 잦아지므로 30 이상 권장." + }, + "g1nation.email.overdueAlertHours": { + "type": "number", + "default": 0, + "minimum": 0, + "markdownDescription": "**미회신 즉시 알림 임계(시간)** — 기본 `0`(끔, v2.2.283): 미회신 정리는 **09:30 KST 데일리 브리핑의 '답할 이메일' 섹션**이 하루 1회 정돈된 형식으로 담당합니다. 즉시 알림을 추가로 원하면 시간(예: `24`)을 설정 — 사람 발신 메일이 그 시간 넘게 회신 대기면 텔레그램 1회 알림 (같은 대기 기간 중복 없음)." + }, + "g1nation.email.outlookEnabled": { + "type": "boolean", + "default": true, + "markdownDescription": "`/email` 의 **Outlook(클래식) 프로바이더** 사용 여부 (v2.2.276). 끄면 수집 시도 자체를 하지 않아 채팅에 Outlook 관련 실패 문구가 뜨지 않습니다. Settings 패널 → 연동 → 이메일에서도 토글 가능." + }, + "g1nation.email.gmailEnabled": { + "type": "boolean", + "default": true, + "markdownDescription": "`/email` 의 **Gmail 프로바이더** 사용 여부 (v2.2.276). Gmail 은 Google OAuth 재연결(gmail.readonly 동의)이 필요합니다 — 연결 전이거나 안 쓰면 꺼두세요. 끄면 수집 시도 자체를 하지 않아 채팅에 \"Gmail 제외\" 문구가 뜨지 않습니다. Settings 패널 → 연동 → 이메일에서도 토글 가능." + }, + "g1nation.email.includeSubfolders": { + "type": "boolean", + "default": true, + "markdownDescription": "Outlook 수집 시 **받은편지함의 하위 폴더(중첩 포함)도 순회** (v2.2.281, 기본 ON). 규칙(필터)으로 폴더에 자동 분류된 메일도 `/email` 브리핑·`scan`·워처의 대상이 됩니다. 폴더 수는 120개까지. 끄면 받은편지함 루트만." + }, + "g1nation.email.wikifySavePath": { + "type": "string", + "default": "", + "markdownDescription": "`/email wikify` 로 이메일 스레드를 지식화한 문서의 **전용 저장 폴더** (v2.2.274) — 다른 wikify 산출물(`datacollectSavePath`)과 **별도로 관리**됩니다. **비워두면** 활성 두뇌 폴더의 `Email_Wiki/` 에 저장돼 brain 검색에 자동 포함. 다른 위치를 쓰려면 절대경로 입력. Astra Settings 패널 **지식·기억 탭**에서도 편집 가능." + }, + "g1nation.email.syncDays": { + "type": "number", + "default": 7, + "minimum": 1, + "maximum": 30, + "markdownDescription": "워처·`/email` 이 한 번에 수집하는 최근 일수. 기본 `7`. 수집 창 밖으로 벗어난 미회신 스레드도 상태 저장소에 이미 있으면 계속 추적된다 (무활동 60일 후 자동 정리)." + }, + "g1nation.messenger.stocksReportTimes": { + "type": "string", + "default": "09:00,15:00", + "markdownDescription": "**주식 정기 보고서 발송 시각** (KST, `HH:MM` CSV — v2.2.284, 기존 하드코딩 09:00/15:00 대체). 이 시각마다 가격 갱신 → 매수시그널 감지 → Sheets 동기화 → 텔레그램 보고서. **비우면 정기 실행 없음** (수동 `/stocks run` 만). Settings 패널 → 메신저 탭에서도 편집 가능." + }, + "g1nation.messenger.buySignalAlert": { + "type": "boolean", + "default": true, + "markdownDescription": "**매수 시그널 진입 알림** — 관망 종목이 매수사정권에 진입하면 텔레그램 1회 알림 (주식 워처 실행 시각에 감지). 끄면 감지는 계속하되 발송만 생략." + }, + "g1nation.messenger.discoverTopFive": { + "type": "boolean", + "default": true, + "markdownDescription": "`/stocks discover` 완료 시 **발굴 Top 5 텔레그램 발송** 여부. 끄면 채팅에만 표시." + }, "g1nation.dailyBriefing.enabled": { "type": "boolean", "default": true, @@ -1156,7 +1215,12 @@ "g1nation.stocks.watcherEnabled": { "type": "boolean", "default": true, - "markdownDescription": "**주식 자동 모니터링 활성화** — VS Code 시작 시 watcher 가동. KST 09:00 / 15:00 자동 트리거: Yahoo 현재가 갱신 → Google Sheets 동기화 → 텔레그램 보고서. false 면 슬래시 명령으로만 수동 실행." + "markdownDescription": "**주식 자동 모니터링 활성화** — VS Code 시작 시 watcher 가동. KST 09:00 / 15:00 자동 트리거: Yahoo 현재가 갱신 → **매수사정권 진입 전이 감지 시 텔레그램 즉시 알림**(v2.2.268) → Google Sheets 동기화 → 텔레그램 정기 보고서. false 면 슬래시 명령으로만 수동 실행." + }, + "g1nation.stocks.discoverAutoAdd": { + "type": "boolean", + "default": true, + "markdownDescription": "`/stocks discover` 발굴 후보를 **워치리스트(stocks.json)에 자동 편입**(기본 ON, v2.2.268). 이미 있는 심볼은 건너뛰어 수동으로 다듬은 값(매수권장가·특이사항)을 보존한다. 편입 시 **매수권장가를 224일선 기반으로 자동 산출**(현재가>MA224 → MA224 / 그 아래면 현재가×0.97 / 시세 부족 시 현재가×0.90)해 워처가 매수사정권 진입을 감시·알림할 수 있는 상태로 넣는다. 끄면 이전처럼 `/stocks add` 수동 추가." }, "g1nation.stocks.spreadsheetId": { "type": "string", diff --git a/src/extension.ts b/src/extension.ts index 53471c7..ccf368d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import './features/teamops/handlers'; import './features/system/handlers'; import './features/datacollect/handlers'; +import './features/email/handlers'; // /email — Gmail + Outlook (v2.2.269) // axios removed in favor of native fetch import { _getBrainDir, @@ -40,6 +41,7 @@ import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './e import { runInitialSetup } from './extension/initialSetup'; import { startStocksWatcher } from './features/stocks'; import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing'; +import { startEmailWatcher } from './features/email/emailWatcher'; import { ensureDefaultBrainConfigured } from './extension/brainBootstrap'; import { ensureEmbeddingConfigured } from './extension/embeddingBootstrap'; import { ensureFeatureInventory } from './extension/featureInventory'; @@ -329,6 +331,7 @@ export async function activate(context: vscode.ExtensionContext) { // Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송. // 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만). context.subscriptions.push(startDailyBriefingWatcher(context)); + context.subscriptions.push(startEmailWatcher(context)); // 이메일 자동 추적 (v2.2.271) // 주간 성장 사이클 — Self-Evolving OS 폐루프 (평가→학습큐→노후점검→승인분 실행→통지). context.subscriptions.push(startGrowthCycleWatcher(context)); diff --git a/src/features/briefing/dailyBriefing.ts b/src/features/briefing/dailyBriefing.ts index 5e29bef..0f7a307 100644 --- a/src/features/briefing/dailyBriefing.ts +++ b/src/features/briefing/dailyBriefing.ts @@ -120,7 +120,35 @@ export async function buildBriefingText(context: vscode.ExtensionContext, ymd: s } } catch { /* 보류 조회 실패는 브리핑 본 흐름에 영향 없음 */ } - if (!dueToday.length && !overdue.length && !events.length && !conditional.length && !pendingCount) { + // (4) 이메일 — 오늘 답할 것 (v2.2.283 모바일 최적화: 번호·짧은 제목·오래된 순). + // 자동발신·무시 처리는 제외 — "내가 대응해야 하는 것"만. + let emailWaiting = 0; + try { + const { readEmailState, waitingHours } = await import('../email/emailState'); + const { isAutomated } = await import('../email/emailBrief'); + const all = readEmailState().threads; + const waiting = all + .filter(t => t.state === 'waiting' && !t.automated && !t.ccOnly + && !isAutomated(t.participants[0] || '', t.subject)) // 필터 규칙 갱신 즉시 반영 · CC 수신 제외 + .sort((a, b) => (a.waitingSince || '').localeCompare(b.waitingSince || '')); + if (waiting.length) { + emailWaiting = waiting.length; + const cut = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s); + const now = new Date(); + lines.push('', `📧 *답할 이메일* (${waiting.length}) — 오래된 순`); + waiting.slice(0, 7).forEach((t, i) => { + const h = waitingHours(t, now); + const age = h >= 48 ? `${Math.round(h / 24)}일` : `${Math.round(h)}h`; + const who = cut((t.participants[0] || '-').replace(/\(.*?\)/g, '').trim(), 10); + lines.push(`${i + 1}. ${cut(t.subject.replace(/^(RE|FW|회신|전달)\s*[::]\s*/i, ''), 34)}`); + lines.push(` ${who} · *${age}째 대기*${t.hasUnread ? ' · 🔵' : ''}`); + }); + if (waiting.length > 7) lines.push(`… 외 ${waiting.length - 7}건`); + lines.push(`_처리했으면 Astra 채팅에서 \`/email done O번호\`_`); + } + } catch { /* 이메일 상태 조회 실패는 브리핑 본 흐름에 영향 없음 */ } + + if (!dueToday.length && !overdue.length && !events.length && !conditional.length && !pendingCount && !emailWaiting) { return null; // 알릴 것이 전혀 없음 — 발송 skip } } else if (!events.length) { diff --git a/src/features/calendar/oauth.ts b/src/features/calendar/oauth.ts index 38c16f9..9fa2c46 100644 --- a/src/features/calendar/oauth.ts +++ b/src/features/calendar/oauth.ts @@ -25,6 +25,9 @@ const SCOPE = [ 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/tasks', + // v2.2.269: /email 명령의 Gmail 읽기 (읽기 전용 — 발송·수정·삭제 스코프 없음). + // 기존 연결 사용자는 재연결 1회로 동의 추가 필요. + 'https://www.googleapis.com/auth/gmail.readonly', 'openid', 'email', ].join(' '); diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index 56f3b84..c6c46a9 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -28,6 +28,7 @@ import { } from './prompts/youtubePrompts'; import { buildWikifyPrompt } from './prompts/wikifyPrompt'; import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt, buildMeetRevisePrompt, buildMeetEditorPrompt, buildTaskDetailPrompt } from './prompts/meetPrompt'; +import { persistWikiDoc, describeSave } from './wikiSave'; import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar'; import { transcriptHash, taskKey, loadRegisteredKeys, markRegistered, @@ -168,15 +169,7 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: fileMarkdown }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); - chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`); + chunk(view, describeSave(await persistWikiDoc(title, fileMarkdown, { docType: 'benchmark', sources: [url] }), '결과물')); } catch (e: any) { chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n`); } @@ -372,16 +365,10 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise s.body).join(sectionDivider), ``, ].join('\n'); - const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: fileMarkdown }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); + const saved = await persistWikiDoc(title, fileMarkdown, { docType: 'youtube' }); + if (!saved.ok) throw new Error(saved.error || '저장 실패'); savedOk++; - chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`); + chunk(view, `💾 **결과물 저장 완료**${progressTag} (${saved.via === 'bridge' ? 'NAS Bridge' : saved.locLabel}): \`${saved.path || '(경로 미확인)'}\`\n\n`); } catch (e: any) { savedFail++; chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`); @@ -446,13 +433,13 @@ async function wikifyOne(url: string, userContent: string, view: Webview | undef const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.'; // 포맷 정본을 브리지에서 가져온다 (wiki_format.mjs — research 와 동일 포맷 보장). // 구버전 브리지(엔드포인트 없음)면 wikifyPrompt 의 내장 사본 fallback. - let canonicalFormat: import('./prompts/wikifyPrompt').CanonicalWikiFormat | null = null; - try { - canonicalFormat = await bridgeFetch(BRIDGE_API.wiki.template, { method: 'GET' }, { timeoutMs: 5000 }); - chunk(view, `📐 포맷 정본 v${canonicalFormat?.version ?? '?'} (브리지)\n`); - } catch { - chunk(view, `📐 포맷 내장 사본 사용 (브리지 템플릿 미제공 — 브리지 갱신 시 정본 적용)\n`); - } + const { getCanonicalFormat } = await import('./wikiFormat'); + const canonicalFormat = await getCanonicalFormat(); + chunk(view, canonicalFormat + ? `📐 포맷 정본 v${canonicalFormat.version} (브리지) +` + : `📐 포맷 내장 사본 사용 (브리지 템플릿 미제공 — 브리지 갱신 시 정본 적용) +`); chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`); let report: string; try { @@ -484,15 +471,7 @@ async function wikifyOne(url: string, userContent: string, view: Webview | undef let host = url; try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ } const title = `위키 ${(userContent.trim() || data.title || host)} ${today}`; - const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: report }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); - chunk(view, `💾 위키 문서 저장 완료: \`${saved?.path || '(경로 미확인)'}\`\n`); + chunk(view, describeSave(await persistWikiDoc(title, report, { docType: 'wikify', sources: [url] }), '위키 문서')); } catch (e: any) { chunk(view, `⚠️ 위키 문서 저장 실패: ${e?.message || String(e)}\n`); } @@ -805,15 +784,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. const today = new Date().toISOString().slice(0, 10); const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting'; const title = `회의록 ${baseName} ${today}`; - const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: report }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); - chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`); + chunk(view, describeSave(await persistWikiDoc(title, report, { docType: 'meeting', sources: [filePath] }), '회의록')); } catch (e: any) { chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`); } @@ -1099,15 +1070,7 @@ async function runReview(arg: string, view: Webview | undefined, _context?: vsco const cfg = vscode.workspace.getConfiguration('g1nation'); const today = new Date().toISOString().slice(0, 10); const title = `코드리뷰 ${projectLabel} ${today}`; - const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); - const body: Record = { title, content: report }; - if (savePath) body.saveDir = savePath; - const saved = await bridgeFetch<{ success: boolean; path?: string }>( - BRIDGE_API.wiki.save, - { method: 'POST', body: JSON.stringify(body) }, - { timeoutMs: 30_000 }, - ); - chunk(view, `💾 **리뷰 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`); + chunk(view, describeSave(await persistWikiDoc(title, report, { docType: 'review' }), '리뷰')); } catch (e: any) { chunk(view, `⚠️ 리뷰 저장 실패(보고서는 위에 출력됨): ${e?.message || String(e)}\n`); } diff --git a/src/features/datacollect/prompts/wikifyPrompt.ts b/src/features/datacollect/prompts/wikifyPrompt.ts index 5aa8075..d8cf323 100644 --- a/src/features/datacollect/prompts/wikifyPrompt.ts +++ b/src/features/datacollect/prompts/wikifyPrompt.ts @@ -107,52 +107,74 @@ const FALLBACK_SECTIONS = `# [[{{TOPIC}}]] ## 📝 변경 이력 (Change history) - {{TODAY}}: {{ORIGIN}}`; +/** 내장 정본 사본 — wikiFormat(공용 빌더)이 브리지 실패 시 사용. 이 파일이 사본의 단일 소유자. */ +export const EMBEDDED_FALLBACK: CanonicalWikiFormat = { + version: FALLBACK_VERSION, + commonRules: FALLBACK_COMMON_RULES, + frontmatter: FALLBACK_FRONTMATTER, + sections: FALLBACK_SECTIONS, +}; + // ── 프롬프트 빌드 ──────────────────────────────────────────────────────────── +// v2.2.278 구조 통일: 골격 조립은 wikiFormat.buildKnowledgeWikiPrompt (단일 정본 +// 빌더) 가 담당하고, 여기는 소스 종류별 기술자(KnowledgeSource)만 정의하는 얇은 +// 래퍼다. 새 소스 종류를 추가할 땐 이 패턴을 따를 것 — 포맷 파편화 금지. export function buildWikifyPrompt(extracted: any, userContent: string, canonical?: CanonicalWikiFormat | null): string { - const fmt: CanonicalWikiFormat = canonical && canonical.commonRules && canonical.frontmatter && canonical.sections - ? canonical - : { version: FALLBACK_VERSION, commonRules: FALLBACK_COMMON_RULES, frontmatter: FALLBACK_FRONTMATTER, sections: FALLBACK_SECTIONS }; - - const today = new Date().toISOString().slice(0, 10); + const { buildKnowledgeWikiPrompt } = require('../wikiFormat') as typeof import('../wikiFormat'); const topic = (userContent.trim() || extracted?.title || extracted?.url || '웹사이트 지식').trim(); const url = extracted?.url || ''; - const idSlug = (topic.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9가-힣-]/g, '').slice(0, 80)) || 'web-wiki'; const headings = Array.isArray(extracted?.headings) ? extracted.headings.slice(0, 40) : []; - const body = String(extracted?.text || '').slice(0, 30000); - - const vars = { - ID: idSlug, TITLE: topic, TOPIC: topic, ROOT: topic, TODAY: today, - TAGS: '"web", "wikify"', SOURCES: `["${url}"]`, - ORIGIN: `Astra /wikify 로 ${url} 본문에서 초안 생성.`, - }; - - return `임무: 아래 웹사이트에서 추출한 본문을 근거로 P-Reinforce v${fmt.version.replace('-embedded', '')} 규격에 맞춰 고밀도 지식 문서를 작성하시오. -주제: '${topic}' - -[필수 규칙] -1. 반드시 아래 [웹사이트 본문]의 내용만을 근거로 작성하시오. 외부 지식을 절대 섞지 마시오. -2. 반드시 아래 Markdown 템플릿과 Frontmatter 형식을 정확히 따르시오. 섹션 이름과 구조를 변경하지 마시오. -3. 본문에 없는 정보는 지어내지 말고, 해당 섹션에 "소스에서 확인되지 않음"이라고 명시하시오. -4. 한국어로 작성하시오. 관련 개념·고유명사는 [[대괄호 두 개]]로 감싸 위키 링크로 만드시오. 위키 링크는 반드시 \`[[\` 로 열고 \`]]\` 로 닫으시오. -5. 원문이 JSON Schema·API 명세·기술 스펙·설정 레퍼런스인 경우, '📖 세부 내용'에 등장하는 모든 필드·속성·파라미터를 **누락 없이** 마크다운 표로 정리하시오. 표 컬럼은 [필드 | 타입 | 필수/선택 | 제약·설명]. 원문의 \`required\` 배열에 있는 항목만 '필수'로 표기하고, 그 목록을 임의로 바꾸거나 추가하지 마시오. -6. 이 문서의 출처는 웹 본문 1건이므로 '## 📚 출처'의 [S1]은 위 URL을 가리키며, source_trust_level 은 사이트 성격(공식 문서/학술 S~A, 기술 블로그 B, 커뮤니티/익명 C~D)으로 평가하시오. -${fill(fmt.commonRules, vars)} - -[웹사이트 메타] -- URL: ${url} -- 제목: ${extracted?.title || '(없음)'} -- 설명: ${extracted?.description || '(없음)'} -- 주요 헤딩: ${headings.join(' / ') || '(없음)'} - -[웹사이트 본문] -\`\`\` -${body} -\`\`\` - -[출력 템플릿 - 이 형식을 정확히 따르시오] - -${fill(fmt.frontmatter, vars)} - -${fill(fmt.sections, vars)}`; + return buildKnowledgeWikiPrompt({ + kind: 'web', + topic, + body: String(extracted?.text || ''), + meta: { + URL: url, + 제목: extracted?.title || '(없음)', + 설명: extracted?.description || '(없음)', + '주요 헤딩': headings.join(' / ') || '(없음)', + }, + tags: ['wikify'], + sourceRef: url || topic, + extraRules: [ + `원문이 JSON Schema·API 명세·기술 스펙·설정 레퍼런스인 경우, '📖 세부 내용'에 등장하는 모든 필드·속성·파라미터를 **누락 없이** 마크다운 표로 정리하시오. 표 컬럼은 [필드 | 타입 | 필수/선택 | 제약·설명]. 원문의 \`required\` 배열에 있는 항목만 '필수'로 표기하고, 그 목록을 임의로 바꾸거나 추가하지 마시오.`, + ], + trustHint: '사이트 성격(공식 문서/학술 S~A, 기술 블로그 B, 커뮤니티/익명 C~D)으로', + origin: `Astra /wikify 로 ${url} 본문에서 초안 생성.`, + }, canonical); +} + +/** + * 이메일 스레드 → P-Reinforce 정본 위키 문서 프롬프트 (v2.2.277). + * /wikify 와 **동일한 정본 템플릿**(frontmatter·섹션·지식그래프·출처)을 사용해 + * 이메일 지식 문서도 위키 규격에 통일한다. 이메일 특수 규칙(발신자·시점·요청 + * 보존, 액션 표)만 추가. + */ +export function buildEmailCanonicalWikifyPrompt( + subject: string, + transcript: string, + meta: { participants?: string[]; dateRange?: string }, + canonical?: CanonicalWikiFormat | null, +): string { + const { buildKnowledgeWikiPrompt } = require('../wikiFormat') as typeof import('../wikiFormat'); + const topic = (subject || '이메일 지식').trim(); + return buildKnowledgeWikiPrompt({ + kind: 'email', + topic, + body: transcript, + meta: { + 제목: topic, + 참여자: (meta.participants || []).join(', ') || '(미상)', + 기간: meta.dateRange || '(미상)', + }, + tags: ['wikify'], + sourceRef: `email:${topic.replace(/"/g, "'")}`, + extraRules: [ + `이메일 특수 규칙: '📖 세부 내용'에 **누가·언제·무엇을 말/요청/결정했는지** 시간 흐름대로 보존하고, 섹션 끝에 액션 표(| 누가 | 무엇을 | 기한 | 근거(메시지 번호) |)를 넣으시오. 액션이 없으면 "액션 없음".`, + `시점 명시 (사고 방지): **모든 상태 서술에 날짜를 병기**하시오 — "진행 중/예정/완료/논의 중" 같은 현재형 서술은 반드시 근거 메일 날짜 기준으로 쓴다 (예: "2026-07-02 기준 진행 중", "6/30 메일에서 요청됨"). **날짜 없는 현재형 서술 금지** — 이 문서는 나중에 읽히므로 시점 없는 "현재"는 독자를 오도한다. '🎯 한 줄 통찰'에도 기준 날짜를 포함하시오.`, + ], + trustHint: '당사자 간 원문 교신이므로 A~B 로', + origin: 'Astra /email wikify 로 이메일 스레드에서 초안 생성.', + }, canonical); } diff --git a/src/features/datacollect/slashRouter.ts b/src/features/datacollect/slashRouter.ts index 7b34817..0ab9d90 100644 --- a/src/features/datacollect/slashRouter.ts +++ b/src/features/datacollect/slashRouter.ts @@ -102,6 +102,16 @@ export function chunk(view: Webview | undefined, value: string) { view?.postMessage({ type: 'streamChunk', value }); } +/** + * 제자리 갱신 진행 표시 (v2.2.279) — 같은 자리의 진행 줄을 교체한다. + * 긴 배치 작업(/email scan 등)이 항목마다 로그를 쌓지 않고 + * "⏳ 처리 중 (3/15)" 한 줄만 업데이트할 때 사용. 다음 chunk() 가 오면 + * 진행 줄은 자동으로 걷히고 본문이 이어진다 (webview streamProgress 계약). + */ +export function progress(view: Webview | undefined, value: string) { + view?.postMessage({ type: 'streamProgress', value }); +} + /** * 슬래시 명령을 라우팅. chatHandlers에서 `userPrompt.startsWith('/')` 체크 후 호출. * 처리 성공/실패 모두 true 반환 (사용자가 명령을 의도했음을 명확히 표현했으므로 diff --git a/src/features/datacollect/wikiFormat.ts b/src/features/datacollect/wikiFormat.ts new file mode 100644 index 0000000..de7dc90 --- /dev/null +++ b/src/features/datacollect/wikiFormat.ts @@ -0,0 +1,176 @@ +/** + * ============================================================ + * 위키 문서 포맷 — 단일 정본 모듈 (v2.2.278) + * + * 배경: 위키 문서를 만드는 기능이 늘 때마다(/wikify → /meet → /email → …) + * 포맷이 하나씩 새로 생기는 파편화가 진행됐다. 이 모듈이 그 흐름을 끊는다 — + * **앞으로 위키 문서를 만드는 모든 기능은 여기만 쓴다.** + * + * 2층 구조: + * ① 봉투(frontmatter) 통일 — `ensureFrontmatter()` + * 저장되는 모든 문서에 정본 frontmatter 를 보장한다. LLM 이 정본 규격으로 + * 이미 생성한 문서(--- 시작)는 건드리지 않고, 보고서형 문서(/meet 회의록, + * /benchmark, /review, /youtube)에는 결정론 봉투를 씌운다. + * → wikiSave.persistWikiDoc 이 저장 직전 자동 호출 — 호출자는 신경 쓸 것 없음. + * ② 지식 문서 프롬프트 공용 빌더 — `buildKnowledgeWikiPrompt()` + * P-Reinforce 정본 템플릿(frontmatter+섹션)에 소스 종류별 특수 규칙만 + * 데이터로 주입한다. 새 소스 추가 = KnowledgeSource 하나 정의가 전부. + * + * 정본 템플릿은 Datacollect 브리지 wiki_format.mjs (getCanonicalFormat 이 + * 10분 캐시로 fetch, 실패 시 wikifyPrompt 의 내장 사본 fallback). + * ============================================================ + */ + +import { bridgeFetch, BRIDGE_API } from './bridgeClient'; +import type { CanonicalWikiFormat } from './prompts/wikifyPrompt'; + +// ── 정본 템플릿 fetch (일원화 + 캐시) ──────────────────────────────────────── +let _canonicalCache: { fmt: CanonicalWikiFormat | null; at: number } | undefined; +const CANONICAL_TTL_MS = 10 * 60_000; + +/** 브리지의 포맷 정본. 실패 시 null (호출부 빌더가 내장 사본으로 fallback). */ +export async function getCanonicalFormat(): Promise { + if (_canonicalCache && Date.now() - _canonicalCache.at < CANONICAL_TTL_MS) return _canonicalCache.fmt; + let fmt: CanonicalWikiFormat | null = null; + try { + fmt = await bridgeFetch(BRIDGE_API.wiki.template, { method: 'GET' }, { timeoutMs: 5000 }); + if (!fmt || !fmt.commonRules || !fmt.frontmatter || !fmt.sections) fmt = null; + } catch { fmt = null; } + _canonicalCache = { fmt, at: Date.now() }; + return fmt; +} + +// ── ① 봉투(frontmatter) 통일 ───────────────────────────────────────────────── + +export interface EnvelopeOpts { + /** 문서 종류 — frontmatter category·tags 로 들어감 (예: 'meeting', 'benchmark', 'review', 'youtube'). */ + docType: string; + /** raw_sources 항목 (URL·파일명·"email:제목" 등). */ + sources?: string[]; + /** 변경 이력 첫 줄 문구. 생략 시 docType 기반 기본 문구. */ + origin?: string; +} + +/** title → frontmatter id 슬러그 (wikifyPrompt 의 규칙과 동일). */ +export function slugifyId(title: string, fallback: string): string { + return (title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9가-힣-]/g, '').slice(0, 80)) || fallback; +} + +/** 문서가 이미 frontmatter(---) 로 시작하는지 — 코드펜스·공백 관대 판정. */ +export function hasFrontmatter(content: string): boolean { + const t = (content || '').trimStart(); + return t.startsWith('---') || t.replace(/^```(?:markdown|md)?\s*/i, '').trimStart().startsWith('---'); +} + +/** + * 정본 frontmatter 봉투 보장 — 이미 있으면 원문 그대로, 없으면 결정론 봉투 prepend. + * 봉투 필드는 정본(wiki_format.mjs v3.1) frontmatter 와 같은 이름을 사용해 + * 다운스트림(brain 인덱스·그래프·신뢰도 소비자)이 문서 종류와 무관하게 동작한다. + */ +export function ensureFrontmatter(title: string, content: string, opts: EnvelopeOpts): string { + if (hasFrontmatter(content)) return content; + const today = new Date().toISOString().slice(0, 10); + const sources = (opts.sources || []).map(s => JSON.stringify(String(s))); + const fm = [ + '---', + `id: ${slugifyId(title, opts.docType + '-doc')}`, + `title: ${JSON.stringify(title)}`, + `category: ${JSON.stringify(opts.docType)}`, + 'status: "draft"', + 'verification_status: "conceptual"', + 'aliases: []', + 'source_trust_level: "B"', + 'confidence_score: 0.80', + `created_at: ${today}`, + `updated_at: ${today}`, + `tags: ["${opts.docType}", "astra"]`, + `raw_sources: [${sources.join(', ')}]`, + '---', + '', + ].join('\n'); + return fm + content + `\n\n## 📝 변경 이력 (Change history)\n- ${today}: ${opts.origin || `Astra ${opts.docType} 산출물로 생성.`}\n`; +} + +// ── ② 지식 문서 프롬프트 공용 빌더 ─────────────────────────────────────────── + +/** + * 지식화(위키화) 대상 소스 기술자 — 새 소스 종류는 이 객체 하나로 추가한다. + * (예: 슬랙 스레드 지식화를 만들면 kind:'slack' + 특수 규칙 몇 줄이 전부.) + */ +export interface KnowledgeSource { + /** 소스 종류 라벨 — tags·출처 평가 힌트에 사용. */ + kind: string; + /** 문서 주제(제목). */ + topic: string; + /** 근거 원문 (추출 본문·트랜스크립트 등). 빌더가 30,000자 컷. */ + body: string; + /** [소스 메타] 블록에 표시할 키-값 (URL·참여자·기간 등). */ + meta?: Record; + /** frontmatter tags (kind 는 자동 포함). */ + tags?: string[]; + /** raw_sources / [S1] 표기 값 (예: URL, "email:제목"). */ + sourceRef: string; + /** 소스 종류별 특수 규칙 (번호는 빌더가 5번부터 이어붙임). */ + extraRules?: string[]; + /** source_trust_level 평가 힌트 한 줄. */ + trustHint?: string; + /** 변경 이력 문구. */ + origin?: string; +} + +/** 정본 골격 placeholder 치환 (wikifyPrompt.fill 과 동일 규칙). */ +function fill(tpl: string, vars: Record): string { + return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? ''); +} + +/** + * P-Reinforce 정본 지식 문서 프롬프트 — 모든 지식화 기능의 단일 진입점. + * canonical 미제공(null) 시 wikifyPrompt 내장 사본 사용. + */ +export function buildKnowledgeWikiPrompt(src: KnowledgeSource, canonical?: CanonicalWikiFormat | null): string { + // 내장 사본은 wikifyPrompt 가 소유 — 순환 없이 lazy require. + const { EMBEDDED_FALLBACK } = require('./prompts/wikifyPrompt') as { EMBEDDED_FALLBACK: CanonicalWikiFormat }; + const fmt = canonical && canonical.commonRules && canonical.frontmatter && canonical.sections + ? canonical : EMBEDDED_FALLBACK; + + const today = new Date().toISOString().slice(0, 10); + const topic = (src.topic || `${src.kind} 지식`).trim(); + const tags = [...new Set([src.kind, ...(src.tags || [])])].map(t => `"${t}"`).join(', '); + const vars = { + ID: slugifyId(topic, `${src.kind}-wiki`), TITLE: topic, TOPIC: topic, ROOT: topic, TODAY: today, + TAGS: tags, SOURCES: `[${JSON.stringify(src.sourceRef)}]`, + ORIGIN: src.origin || `Astra ${src.kind} 지식화로 초안 생성.`, + }; + + const baseRules = [ + `1. 반드시 아래 [소스 본문]의 내용만을 근거로 작성하시오. 외부 지식을 절대 섞지 마시오.`, + `2. 반드시 아래 Markdown 템플릿과 Frontmatter 형식을 정확히 따르시오. 섹션 이름과 구조를 변경하지 마시오.`, + `3. 본문에 없는 정보는 지어내지 말고, 해당 섹션에 "소스에서 확인되지 않음"이라고 명시하시오. 수치·날짜·금액·고유명사는 원문 그대로 보존하시오.`, + `4. 한국어로 작성하시오. 관련 개념·고유명사는 [[대괄호 두 개]]로 감싸 위키 링크로 만드시오. 위키 링크는 반드시 \`[[\` 로 열고 \`]]\` 로 닫으시오.`, + ]; + const extra = (src.extraRules || []).map((r, i) => `${5 + i}. ${r}`); + const trustRule = `${5 + extra.length}. 이 문서의 출처는 ${src.kind} 소스 1건이므로 '## 📚 출처'의 [S1]은 ${src.sourceRef} 를 가리키며, source_trust_level 은 ${src.trustHint || '소스 성격에 따라 정직하게'} 평가하시오.`; + + const metaLines = Object.entries(src.meta || {}).map(([k, v]) => `- ${k}: ${v || '(없음)'}`); + + return `임무: 아래 ${src.kind} 소스를 근거로 P-Reinforce v${fmt.version.replace('-embedded', '')} 규격에 맞춰 고밀도 지식 문서를 작성하시오. +주제: '${topic}' + +[필수 규칙] +${[...baseRules, ...extra, trustRule].join('\n')} +${fill(fmt.commonRules, vars)} + +[소스 메타] +${metaLines.join('\n') || '- (메타 없음)'} + +[소스 본문] +\`\`\` +${String(src.body || '').slice(0, 30000)} +\`\`\` + +[출력 템플릿 - 이 형식을 정확히 따르시오] + +${fill(fmt.frontmatter, vars)} + +${fill(fmt.sections, vars)}`; +} diff --git a/src/features/datacollect/wikiSave.ts b/src/features/datacollect/wikiSave.ts new file mode 100644 index 0000000..94103e6 Binary files /dev/null and b/src/features/datacollect/wikiSave.ts differ diff --git a/src/features/email/emailBrief.ts b/src/features/email/emailBrief.ts new file mode 100644 index 0000000..df51050 --- /dev/null +++ b/src/features/email/emailBrief.ts @@ -0,0 +1,166 @@ +/** + * ============================================================ + * /email 브리핑 — 받은편지함을 '주제(스레드)' 단위로 정리 (v2.2.270) + * + * 원시 메일 나열 대신 사람이 상황을 파악할 수 있는 형태로: + * ① RE:/FW: 를 벗겨 같은 주제 메일을 하나의 스레드로 묶고 + * ② 보낸편지함과 대조해 "마지막 발화가 누구인가"로 답변 여부 판정 + * (마지막이 상대방 → 🔴 회신 대기 / 마지막이 나 → ✅ 공 넘김) + * ③ 뉴스레터·자동발신은 📢 로 분리 (회신 대상 아님) + * + * 이 파일은 순수 로직만 — COM/네트워크 없음 (테스트 가능). + * ============================================================ + */ + +export interface MailLite { + /** 참조 라벨 (예: 'O3', 'G1') — 브리핑에서 read/wikify 로 이어지는 연결고리. */ + ref?: string; + entryId?: string; + subject: string; + from: string; + to?: string; + /** 'YYYY-MM-DD HH:mm' — 문자열 비교로 정렬 가능한 형식. */ + received: string; + unread?: boolean; + snippet?: string; + /** true = 내가 보낸 메일 (Sent Items / Gmail SENT 라벨). */ + outgoing?: boolean; + /** 내가 To 가 아니라 CC 로만 수신 (회신 대상 아님). */ + ccOnly?: boolean; +} + +/** 스레드 내 메시지 흐름 한 칸 — 브리핑의 '경과(히스토리)' 재료. */ +export interface TimelineEntry { + date: string; + from: string; + outgoing: boolean; + snippet?: string; +} + +export interface ThreadGroup { + /** 정규화된 주제 (RE:/FW: 제거 후). */ + topic: string; + /** 원 제목 (가장 최근 메일 기준). */ + subject: string; + /** 상대방 참여자 (내 발신 제외, 중복 제거, 최대 4명). */ + participants: string[]; + inCount: number; + outCount: number; + /** 스레드에서 가장 최근 메시지의 방향. */ + lastDirection: 'in' | 'out'; + lastDate: string; + /** 가장 최근 수신 메일의 스니펫. */ + snippet: string; + /** 읽지 않은 수신 메일 존재 여부. */ + hasUnread: boolean; + /** 뉴스레터·자동발신 (회신 대상 아님). */ + automated: boolean; + /** 수신 메일들의 참조 라벨 (최신순, 예: ['O1','O2']). */ + refs: string[]; + /** 가장 최근 수신 메일 entryId (본문 심화 조회용). */ + latestEntryId?: string; + /** 메시지 흐름 (시간순, 최근 6개) — 브리핑의 '경과' 서술 재료. */ + timeline: TimelineEntry[]; + /** 수신 메일 전부가 CC 수신 — 회신 불요, 확인만 분류 (v2.2.290). */ + ccOnly: boolean; +} + +/** RE:/FW:/회신:/전달: 접두를 반복 제거하고 소문자·공백 정규화 → 스레드 키. */ +export function normalizeSubject(subject: string): string { + let s = (subject || '').trim(); + // 여러 겹 (예: "RE: FW: RE: 제목") 모두 벗긴다. + const prefix = /^\s*(re|fw|fwd|회신|전달|답장|답변)\s*[::]\s*/i; + while (prefix.test(s)) s = s.replace(prefix, ''); + return s.replace(/\s+/g, ' ').trim().toLowerCase() || '(제목 없음)'; +} + +/** 뉴스레터·시스템 자동발신 판정 — 회신 대상이 아닌 노이즈 분리. */ +export function isAutomated(from: string, subject: string): boolean { + const f = (from || '').toLowerCase().trim(); + const s = (subject || '').toLowerCase(); + if (/no-?reply|donotreply|noreply|notification|알림|newsletter|뉴스레터|marketing|digest|다이제스트/i.test(f)) return true; + // 서비스 발신자 (v2.2.283 확장: confluence·AWS·plain Apple 등 — 텔레그램 미회신 노이즈의 주범) + if (/app store connect|google payments|google cloud|aws marketplace|amazon web services|apple developer|atlassian|confluence|jira|github|slack|figma|notion|linkedin|dropbox|플래티어/i.test(f)) return true; + if (f === 'apple' || f === 'aws' || f === 'google') return true; + // 제목 신호 (광고 표기·시스템 태그·다이제스트류) + if (/뉴스레터|newsletter|digest|다이제스트|weekly update|product update|receipt|영수증|결제 완료|invoice|\(광고\)|\[notification\]|놓치지 마세요/i.test(s)) return true; + return false; +} + +/** + * 수신 + 발신 메일을 주제별 스레드로 묶는다. 반환 정렬: + * 회신 대기(사람) → 진행 중 → 자동발신, 각 그룹 내 최신순. + */ +export function groupThreads(inbox: MailLite[], sent: MailLite[]): ThreadGroup[] { + const map = new Map(); + const put = (m: MailLite, dir: 'in' | 'out') => { + const key = normalizeSubject(m.subject); + let g = map.get(key); + if (!g) { g = { in: [], out: [] }; map.set(key, g); } + g[dir].push(m); + }; + for (const m of inbox) put(m, 'in'); + for (const m of sent) put(m, 'out'); + + const threads: ThreadGroup[] = []; + for (const [topic, g] of map) { + if (g.in.length === 0) continue; // 발신만 있는 주제는 브리핑 대상 아님 (받은편지함 정리가 목적) + const byDateDesc = (a: MailLite, b: MailLite) => b.received.localeCompare(a.received); + g.in.sort(byDateDesc); + g.out.sort(byDateDesc); + const latestIn = g.in[0]; + const latestOut = g.out[0]; + const lastDirection: 'in' | 'out' = + latestOut && latestOut.received.localeCompare(latestIn.received) > 0 ? 'out' : 'in'; + const participants = [...new Set(g.in.map(m => (m.from || '').trim()).filter(Boolean))].slice(0, 4); + // 시간순 타임라인 (수신+발신 합쳐 최근 6개) — "누가 언제 무슨 말을 했나" 경과 재료. + const timeline: TimelineEntry[] = [...g.in, ...g.out] + .sort((a, b) => a.received.localeCompare(b.received)) + .slice(-6) + .map(m => ({ + date: m.received, + from: m.outgoing ? '나' : (m.from || '-'), + outgoing: !!m.outgoing, + snippet: (m.snippet || '').slice(0, 100) || undefined, + })); + threads.push({ + topic, + subject: latestIn.subject || '(제목 없음)', + participants, + inCount: g.in.length, + outCount: g.out.length, + lastDirection, + lastDate: lastDirection === 'out' ? latestOut.received : latestIn.received, + snippet: (latestIn.snippet || '').trim(), + hasUnread: g.in.some(m => !!m.unread), + automated: g.in.every(m => isAutomated(m.from, m.subject)), + refs: g.in.map(m => m.ref || '').filter(Boolean), + latestEntryId: latestIn.entryId, + timeline, + ccOnly: g.in.length > 0 && g.in.every(m => m.ccOnly === true), + }); + } + + const rank = (t: ThreadGroup) => (t.automated ? 2 : t.lastDirection === 'in' ? 0 : 1); + threads.sort((a, b) => rank(a) - rank(b) || b.lastDate.localeCompare(a.lastDate)); + return threads; +} + +/** LLM 실패 시에도 쓸 수 있는 결정론 브리핑 렌더 (마크다운 리스트). */ +export function renderBriefFallback(threads: ThreadGroup[]): string { + const lines: string[] = []; + const section = (title: string, items: ThreadGroup[], fmt: (t: ThreadGroup) => string) => { + if (items.length === 0) return; + lines.push(`\n**${title}** (${items.length})\n`); + for (const t of items) lines.push(fmt(t)); + }; + const label = (t: ThreadGroup) => + `- ${t.hasUnread ? '🔵 ' : ''}**${t.subject}** — ${t.participants.join(', ') || '-'} · 메시지 ${t.inCount + t.outCount}건 · ${t.lastDate}${t.refs.length ? ` · [${t.refs.slice(0, 3).join(' ')}]` : ''}`; + + const humans = threads.filter(t => !t.automated); + section('🔴 회신 대기 — 상대방이 마지막으로 보냄', humans.filter(t => t.lastDirection === 'in'), label); + section('✅ 공 넘김 — 내가 마지막으로 답함', humans.filter(t => t.lastDirection === 'out'), label); + section('📢 자동발신 · 공지 (회신 불필요)', threads.filter(t => t.automated), + t => `- ${t.subject} — ${t.participants[0] || '-'} · ${t.lastDate}`); + return lines.join('\n'); +} diff --git a/src/features/email/emailCollect.ts b/src/features/email/emailCollect.ts new file mode 100644 index 0000000..095398e --- /dev/null +++ b/src/features/email/emailCollect.ts @@ -0,0 +1,100 @@ +/** + * 최근 메일 수집 공통 모듈 (v2.2.271) — /email 브리핑과 이메일 워처가 공유. + * Outlook(받은+보낸) + Gmail(연결 시)을 MailLite 로 정규화한다. + * 참조번호(O1../G1..)는 여기서 부여 — 호출자(핸들러)가 목록 캐시에 그대로 사용. + */ + +import * as vscode from 'vscode'; +import { listMessageIds, getMessage } from './gmailApi'; +import { listInbox, listSent, type OutlookMail } from './outlookApi'; +import type { MailLite } from './emailBrief'; + +export interface GmailThreadItem { threadId: string; subject: string; from: string; date: number } + +export interface CollectResult { + inbox: MailLite[]; + sent: MailLite[]; + /** O 참조번호 순서 그대로의 Outlook 원본 (목록 캐시용). */ + outlookMails: OutlookMail[]; + /** G 참조번호 순서 그대로의 Gmail 스레드 (목록 캐시용). */ + gmailItems: GmailThreadItem[]; + outlookError?: string; + gmailError?: string; +} + +const fmtDate = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace('T', ' '); + +/** 프로바이더 on/off (v2.2.276) — 끄면 수집 시도 자체를 안 해 채팅에 오류성 문구가 없다. */ +export function emailProviders(): { outlook: boolean; gmail: boolean } { + const cfg = vscode.workspace.getConfiguration('g1nation'); + return { + outlook: cfg.get('email.outlookEnabled', true) !== false, + gmail: cfg.get('email.gmailEnabled', true) !== false, + }; +} + +export async function collectRecentMails( + days: number, + context?: vscode.ExtensionContext, + opts: { gmailThreads?: number; outlookMax?: number; sentMax?: number } = {}, +): Promise { + const out: CollectResult = { inbox: [], sent: [], outlookMails: [], gmailItems: [] }; + const providers = emailProviders(); + + // Outlook — 받은편지함(+하위 폴더) + 보낸편지함 (답변 여부 판정용). 비활성 시 조용히 스킵. + const includeSub = vscode.workspace.getConfiguration('g1nation').get('email.includeSubfolders', true) !== false; + const [inR, sentR] = providers.outlook + ? await Promise.all([ + listInbox(days, opts.outlookMax ?? 40, includeSub), + listSent(days, opts.sentMax ?? 100), + ]) + : [{ ok: true as const, data: [] }, { ok: true as const, data: [] }]; + if (inR.ok) { + out.outlookMails = inR.data; + inR.data.forEach((m, i) => out.inbox.push({ + ref: `O${i + 1}`, entryId: m.entryId, subject: m.subject, from: m.from, + received: m.received, unread: m.unread, snippet: m.snippet, ccOnly: m.ccOnly, + })); + if (sentR.ok) sentR.data.forEach(m => out.sent.push({ + subject: m.subject, from: '나', to: m.to, received: m.received, outgoing: true, + })); + } else { + out.outlookError = inR.error; + } + + // Gmail — 활성 + 연결돼 있으면 스레드 병합 (SENT 라벨 = 내 발신). 비활성 시 조용히 스킵. + if (context && providers.gmail) { + const maxThreads = Math.max(1, Math.min(20, opts.gmailThreads ?? 12)); + const ids = await listMessageIds(context, { + query: `newer_than:${days}d -in:spam -in:trash -category:promotions -category:social`, + maxResults: 40, + }); + if (ids.ok) { + const seen = new Set(); + const firstOfThread: string[] = []; + for (const m of ids.data) { + if (!m.threadId || seen.has(m.threadId)) continue; + seen.add(m.threadId); + firstOfThread.push(m.id); + if (firstOfThread.length >= maxThreads) break; + } + for (const id of firstOfThread) { + const r = await getMessage(context, id); + if (!r.ok) continue; + const isSent = r.data.labels.includes('SENT'); + const lite: MailLite = { + subject: r.data.subject || '(제목 없음)', from: isSent ? '나' : r.data.from, + received: fmtDate(r.data.date), snippet: r.data.snippet, outgoing: isSent, + }; + if (isSent) { out.sent.push(lite); continue; } + out.gmailItems.push({ threadId: r.data.threadId, subject: lite.subject, from: r.data.from, date: r.data.date }); + lite.ref = `G${out.gmailItems.length}`; + out.inbox.push(lite); + } + } else { + out.gmailError = ids.error; + } + } + + return out; +} diff --git a/src/features/email/emailPrompts.ts b/src/features/email/emailPrompts.ts new file mode 100644 index 0000000..2e59f8a --- /dev/null +++ b/src/features/email/emailPrompts.ts @@ -0,0 +1,195 @@ +/** + * /email 프롬프트 — 위키화(지식 문서 합성) + 회신 초안. (v2.2.269) + * Datacollect bridge 의 email wikify 프롬프트와 같은 P-Reinforce 섹션 구조를 따라 + * brain(제2뇌) 문서 포맷 일관성을 유지한다. 환각 방지 규칙 동일. + */ + +export interface EmailForPrompt { + subject: string; + from: string; + to?: string; + date: string; + body: string; +} + +/** 메일(들)을 프롬프트용 트랜스크립트로 직렬화 — 메시지당 4,000자, 총 16,000자 컷. */ +export function buildEmailTranscript(mails: EmailForPrompt[]): string { + return mails + .map((m, i) => { + const body = (m.body || '').slice(0, 4000); + return `--- 메시지 ${i + 1} | ${m.date} | from: ${m.from}${m.to ? ` | to: ${m.to}` : ''}\n${body}`; + }) + .join('\n\n') + .slice(0, 16000); +} + +export function buildEmailWikifyPrompt(subject: string, transcript: string): string { + return `다음 이메일 스레드를 사실 기반 지식 문서로 합성하라. +[규칙] 스레드에 명시된 사실만 쓴다. 수치·날짜·금액·고유명사는 원문 그대로 보존(요약으로 뭉개지 말 것). 외부 지식 추가 금지. 근거가 약하면 "확인 필요"로 표기. 아래 섹션 구조를 정확히 유지한다. + +# ${subject} + +## 🎯 한 줄 통찰 +(이 스레드의 핵심을 한 문장으로) + +## 🧠 핵심 개념 +(논의된 개념·용어·대상 — 개조식) + +## 🧩 추출된 패턴 +(반복되는 요구·의사결정 방식·관계 — 있으면. 없으면 "해당 없음") + +## 📖 세부 내용 +(시간 흐름·핵심 논의·결정·요청 정리. 누가 무엇을 언제 말/요청했는지 보존.) + +## ✅ 액션 / 요청 사항 +(| 누가 | 무엇을 | 기한 | 근거(메시지 번호) | 표. 없으면 "없음") + +[이메일 스레드] +\`\`\` +${transcript} +\`\`\``; +} + +/** + * 받은편지함 브리핑 프롬프트 — 스레드 그룹 데이터를 사람이 읽는 현황 보고로. + * 순서·분류·답변여부는 코드가 이미 판정 — LLM 은 각 스레드의 '무슨 이야기인지' + * 한 줄 요약과 우선순위 제안만 담당 (판정 뒤집기 금지). + */ +export function buildInboxBriefPrompt(threadsBlock: string): string { + return `아래는 받은편지함을 주제(스레드)별로 묶고 답변 여부·대기시간까지 판정한 데이터다. 당신은 **PM 비서**다 — 이 브리핑은 메일 목록이 아니라 **의사결정 중심의 업무 관리 보고서**다: "무엇을 결정해야 하고, 지연되면 어떤 영향이 있는지"가 먼저 보여야 한다. + +# 판정 규칙 (데이터 우선 — 재분류 금지) +- 데이터(타임라인·본문발췌·스니펫)에 있는 내용만 사용. 없는 결론·약속·수치를 지어내지 말 것. +- 상태 이모지는 데이터 판정 그대로: 회신대기 → 🔴 회신 필요 / 공넘김 → 🟡 상대 회신 대기. +- 참조번호([O1 O2] 등)는 반드시 보존 — 사용자가 \`/email read O2\` 로 이어간다. + +# 상태 3분류 (회신대기 항목을 성격으로 나눔 — "회신 필요"에 섞인 것들 분리) +- 🟣 **결정 필요**: 내 의사결정이 걸린 것 (승인·선택·판단). +- 🔴 **회신 필요**: 답장을 보내야 끝나는 것. +- 🟡 **확인만**: 읽고 확인하면 끝 (회신 불요·정보 공유성). **수신=CC(회신불요) 표시가 있는 스레드는 무조건 여기로** — 내가 참조 수신자라 회신 대상이 아님. +- ⏳ 상태가 '공넘김'인 것 = 상대 회신 대기 — **7일 이상 장기 건만 표시** (단기 대기는 건수만). + +# 우선순위 분류 (🟣/🔴 를 시급도로) +- 🔥 **오늘 처리**: 결정이 걸려 있거나, 기한 임박·2일+ 대기·읽지않음. +- ⚠ **이번주 처리**: 2~3일 내 처리하면 되는 것. +- 🔥 안에서는 **효과 대비 시간(ROI) 순** — 영향 크고 소요 짧은 것 먼저. + +# 항목 작성 규칙 +- **성격 태그** 하나: [계약] [QA] [런칭] [개발] [영상] [정산] [보안] [협업] [기타]. +- **프로젝트명** 추출: 제목·내용의 프로젝트/고객사 (예: 하이마트, 롯데온, 락인컴퍼니, 하이브, Castlabs) — 위험도+건수 병기: 그 프로젝트에 🔥오늘 건이 있으면 🔴(오늘·n), ⚠이번주면 🟠(이번주·n), 확인만이면 🟡(확인·n), 그 외 🟢. +- **짧은 제목**: RE/FW·회사명 괄호 떼고 5~14자. 원본은 Outlook에 있으니 줄여도 됨. +- **결정 ≠ 내 액션 (엄격 구분)**: '결정'은 **Yes/No 또는 방향을 정하는 판단만** (예: "파트3 수정 여부", "런칭 승인 여부"). 검토·회신·피드백 작성·확인은 결정이 아니라 **'내 액션'**. 결정거리가 없으면 결정 줄을 **생략**하고 상태는 🔴 회신 필요로. +- **필드 순서 = 읽는 순서**: 결정(있을 때만) → 영향 → 예상시간 → From/내 액션/권장기한 → 요약 → Timeline. +- **From**: 메일 보낸 사람. **내 액션**: 내가 할 일 구체 동사. **권장기한**: 메일 명시 기한은 그대로, 없으면 "권장: 오늘/이번주". +- **영향 = P등급 + 4종**: P1 🚨 고객 / P1 🚨 계약 / P2 🚨 일정 / P3 🚨 비용·내부. 해당 없으면 "—". 🔥 안의 정렬은 P1 → P2 → P3, 같은 등급이면 소요 짧은 것 먼저. +- **예상시간**: 확인만 5분 / 회신 15분 / 검토 30분 / 회의 1시간. +- **시간 표기**: 48시간 미만 "N시간", 그 이상 반드시 **"N일"** (737시간 같은 표기 금지). +- **Timeline**: 타임라인에 메시지가 **2건 이상**일 때만 세로 Timeline (마지막 줄 "**현재: 내 액션 필요 (<대기>)**" 통일). **1건뿐이면** "최근 업데이트: <발화자> <요지>" 한 줄로. +- **인명·고유명사는 데이터 표기를 글자 그대로 복사** — 철자 변형·오타 생성 금지 (김성환→김성성 같은 오류 금지). +- 데이터에 없는 결론·약속·수치 금지. 🔥/⚠ 는 상세 블록, 🟡/⏳ 는 한 줄씩만. +- **숫자 정합성 (최우선)**: 🎯 Today Focus 는 **🔥 오늘 처리 항목과 1:1 동일** (같은 건수·같은 항목 — 목록에 없는 작업 추가 금지). 총시간 = 오늘 처리 예상시간의 합. 📈 계산은 오늘 처리분만 차감 (전부 0 되는 계산 금지). + +# 출력 형식 (마크다운, 이 구조 정확히) +■ **메일 현황** +- 상태: 🟣 결정 필요 n · 🔴 회신 필요 n · 🟡 확인만 n +- 우선순위: 🔥 오늘 n · ⚠ 이번주 n +- 장기 미처리: ⏳ 상대 대기 n · 내 후속 지연 n +- 프로젝트: 🔴 하이마트(오늘·3) · 🟠 롯데온(이번주·2) · 🟡 칼리버스(확인·4) · 🟢 기타 n건 + +## 🔥 오늘 처리 (n건 · P등급순) + +**[태그] <짧은 제목>** — 🟣 결정 필요 · **15분** · [참조번호] +- 결정: +- 영향: P1 🚨 <고객|계약> (P2 일정 / P3 비용·내부) +- From: <보낸 사람> / 내 액션: <구체 동사> / 권장기한: <기한> +- 요약: <핵심 요지 1줄> +- 원본: "<데이터의 주제(원제목) 글자 그대로>" — Outlook에서 열기: \`/email open <참조번호>\` +- Timeline: (2건+일 때 — 세로, 마지막 "**현재: 내 액션 필요 (<대기>)**") / 1건이면 "최근 업데이트: <요지>" + +## ⚠ 이번주 처리 (n건) +(같은 블록 형식) + +## 🟡 확인만 (n건 · 상위 5건) +- [태그] <짧은 제목> — <요지 반 줄> ([참조번호] · 5분) +(+건 더) + +## ⏳ 장기 대기 — 7일 이상 +**상대 답변 대기 (m건)** +- [태그] <짧은 제목> — <누구> 대기 · **** ([참조번호]) +**내 후속 지연 (k건)** — 위 처리 목록에 포함됨, 여기선 상기용 +- <짧은 제목> — **** 방치 +(7일 미만 <나머지>건은 생략 — 정상 진행 중) + +## 🎯 Today Focus — 오늘 처리 건 · 총 <합계>분 +① <짧은 제목> — <내 액션> (<소요>분) +(🔥 오늘 처리와 1:1 — 임의 항목 추가 금지) + +## 📈 오늘 처리 완료 시 +- 회신·결정 필요 건 → 건 · 🟣 결정 건 → <남는>건 +- **오늘 완료 예상**: ✅ 회신 건 · ✅ 승인/결정 건 · ✅ 검토 건 — 총 <합계>분 +- **오늘 안 하면**: + +[스레드 데이터] +${threadsBlock}`; +} + +/** + * 시점 헤더 주입 (v2.2.282, 결정론) — 이메일 지식 문서 상단에 "이 지식이 어느 + * 기간의 메일에 근거하는지"를 박는다. RAG 답변이 "진행 중" 같은 서술을 인용할 때 + * 사용자가 시점을 오인하는 사고 방지. LLM 출력에 의존하지 않고 코드가 삽입. + */ +export function injectTimeHeader(doc: string, dateRange: string, generatedYmd: string): string { + if (!dateRange || !doc) return doc; + const header = `> 🕒 **기준 시점**: 이메일 ${dateRange} (문서 생성 ${generatedYmd}) — 아래 내용은 이 기간 메일에 근거하며, 이후 상황이 변했을 수 있음.`; + const lines = doc.split('\n'); + let start = 0; + // frontmatter(---) 블록이 있으면 그 뒤부터. + if (lines[0]?.trim().startsWith('---')) { + const end = lines.findIndex((l, idx) => idx > 0 && l.trim() === '---'); + if (end > 0) start = end + 1; + } + // 첫 H1 제목 바로 아래에 삽입 — 어떤 청크 분할에서도 문서 앞머리에 남게. + const h1 = lines.findIndex((l, idx) => idx >= start && /^#\s/.test(l.trim())); + const at = h1 >= 0 ? h1 + 1 : start; + lines.splice(at, 0, '', header); + return lines.join('\n'); +} + +/** + * 브리핑 라벨 오타 후처리 (v2.2.288, 결정론) — 소형 로컬 모델이 구조 라벨을 + * 흘리는 사례('내 액점', '권 량', '회신_필요', '⚠ 이번 처리')를 코드가 교정한다. + * 라벨(형식 토큰)만 손대고 본문 내용은 건드리지 않는다. + */ +export function polishBriefLabels(text: string): string { + if (!text) return text; + return text + .replace(/내\s*액[점젼셩숀션]\s*[::]/g, '내 액션:') + .replace(/권\s*장?\s*기[한핸]\s*[::]/g, '권장기한:') + .replace(/권\s*량\s*[::]/g, '권장기한:') + .replace(/회신[_·]필요/g, '회신 필요') + .replace(/결정[_·]필요/g, '결정 필요') + .replace(/확인[_·]만/g, '확인만') + .replace(/⚠\s*이번(?!\s*주)\s*처리/g, '⚠ 이번주 처리') + .replace(/🔥\s*오늘(?!\s*처리)\s*처리?/g, '🔥 오늘 처리'); +} + +export function buildReplyDraftPrompt(mail: EmailForPrompt, instruction: string): string { + const guide = instruction.trim() + ? `[회신 지시사항 — 반드시 반영]\n${instruction.trim()}` + : '[회신 지시사항 없음 — 원문에 답해야 할 질문/요청에 정중히 응답하는 기본 회신을 작성]'; + return `아래 수신 메일에 대한 **한국어 비즈니스 회신 초안**을 작성하라. + +# 규칙 +- 원문에 없는 사실·수치·약속을 만들지 말 것. 확답이 어려운 부분은 "확인 후 회신드리겠습니다" 형태로. +- 정중하되 간결한 비즈니스 문체. 인사 → 본문 → 맺음. +- 출력은 **회신 본문 텍스트만** — 제목·머리말·코드펜스·설명 금지. (서명/발송은 사용자가 Outlook 에서 직접) + +${guide} + +[수신 메일] +제목: ${mail.subject} +보낸사람: ${mail.from} +받은시각: ${mail.date} + +${(mail.body || '').slice(0, 8000)}`; +} diff --git a/src/features/email/emailState.ts b/src/features/email/emailState.ts new file mode 100644 index 0000000..0b41a1e --- /dev/null +++ b/src/features/email/emailState.ts @@ -0,0 +1,294 @@ +/** + * ============================================================ + * 이메일 스레드 영속 상태 — `.astra/email_state.json` (v2.2.271) + * + * /email 이 "조회 도구"에서 "관리 도구"가 되기 위한 핵심: 워처가 주기적으로 + * 관측한 스레드를 여기 누적하므로, 수집 창(최근 N일) 밖으로 벗어난 미회신 + * 건도 사라지지 않고 계속 추적된다 (GTD Waiting-For 리스트의 코드화). + * + * 상태 머신: + * waiting — 마지막 발화가 상대방 (회신 대기). waitingSince 부터 나이 계산. + * answered — 마지막 발화가 나 (공 넘김). + * done — 사용자가 완료 처리. **새 수신이 오면 waiting 으로 자동 재개**. + * muted — 사용자가 무시 처리. 새 수신이 와도 조용 (뉴스레터·잡음 주제). + * + * 알림은 매수시그널과 같은 전이 방식 — waitingSince 기준 1회만 (alertedFor 로 중복 차단). + * 순수 로직(sync/overdue)과 파일 IO 를 분리해 테스트 가능하게 유지. + * ============================================================ + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { logError, logInfo } from '../../utils'; +import type { ThreadGroup } from './emailBrief'; + +export type ThreadLifecycle = 'waiting' | 'answered' | 'done' | 'muted'; + +export interface ThreadState { + /** normalizeSubject 키. */ + topic: string; + subject: string; + participants: string[]; + /** 처음 관측한 시각 (ISO). */ + firstSeen: string; + /** 스레드의 마지막 메시지 시각 ('YYYY-MM-DD HH:mm'). */ + lastActivity: string; + state: ThreadLifecycle; + /** 현재 회신 대기가 시작된 시각 — waiting 일 때만. */ + waitingSince?: string; + /** 이 waitingSince 에 대해 알림을 이미 보냈는지 (중복 차단). */ + alertedFor?: string; + automated: boolean; + /** CC 로만 수신 — 회신 대상 아님 (알림·답할 목록에서 제외). */ + ccOnly?: boolean; + hasUnread: boolean; + snippet: string; + inCount: number; + outCount: number; + /** 최근 관측 시 참조번호 (오래된 스레드는 비어 있을 수 있음). */ + refs: string[]; + latestEntryId?: string; +} + +export interface EmailStateFile { + updatedAt: string; + threads: ThreadState[]; +} + +// ── 파일 IO (stocksStore 패턴 — atomic write) ──────────────────────────────── +const STATE_REL = '.astra/email_state.json'; + +export function getEmailStatePath(): string | null { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return null; + return path.join(folders[0].uri.fsPath, STATE_REL); +} + +export function readEmailState(): EmailStateFile { + const p = getEmailStatePath(); + if (!p || !fs.existsSync(p)) return { updatedAt: '', threads: [] }; + try { + const parsed = JSON.parse(fs.readFileSync(p, 'utf-8')); + if (parsed && Array.isArray(parsed.threads)) return parsed as EmailStateFile; + return { updatedAt: '', threads: [] }; + } catch (e: any) { + logError('email_state.json 읽기 실패 — 빈 상태로 시작.', { error: e?.message ?? String(e) }); + return { updatedAt: '', threads: [] }; + } +} + +export function writeEmailState(state: EmailStateFile): boolean { + const p = getEmailStatePath(); + if (!p) return false; + try { + const dir = path.dirname(p); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const tmp = `${p}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8'); + fs.renameSync(tmp, p); + return true; + } catch (e: any) { + logError('email_state.json 쓰기 실패.', { error: e?.message ?? String(e) }); + return false; + } +} + +// ── 순수 로직 ──────────────────────────────────────────────────────────────── + +/** 관측 창을 벗어난 오래된 항목 정리 — 60일 무활동, 또는 done/muted 30일. */ +const KEEP_ACTIVE_DAYS = 60; +const KEEP_CLOSED_DAYS = 30; + +function daysBetween(isoOrYmd: string, now: Date): number { + const t = new Date(isoOrYmd.replace(' ', 'T')).getTime(); + if (!Number.isFinite(t)) return 0; + return (now.getTime() - t) / 86_400_000; +} + +/** + * 이번 관측(groupThreads 결과)을 저장된 상태에 병합한다. + * - 새 주제 → 생성. 기존 주제 → 활동/방향 갱신. + * - done 이었는데 **새 수신**이 오면 waiting 재개 (놓침 방지). muted 는 계속 조용. + * - 방향이 in→out 으로 바뀌면 answered + waitingSince/alertedFor 해제. + * - 관측되지 않은 기존 스레드는 그대로 유지 (수집 창 밖 미회신 보존 — 이 함수의 존재 이유). + */ +export function syncThreads(store: EmailStateFile, observed: ThreadGroup[], now: Date = new Date()): EmailStateFile { + const byTopic = new Map(store.threads.map(t => [t.topic, t])); + + for (const g of observed) { + const prev = byTopic.get(g.topic); + if (!prev) { + byTopic.set(g.topic, { + topic: g.topic, + subject: g.subject, + participants: g.participants, + firstSeen: now.toISOString(), + lastActivity: g.lastDate, + state: g.automated ? 'answered' : g.lastDirection === 'in' ? 'waiting' : 'answered', + waitingSince: !g.automated && g.lastDirection === 'in' ? g.lastDate : undefined, + automated: g.automated, + ccOnly: g.ccOnly, + hasUnread: g.hasUnread, + snippet: g.snippet, + inCount: g.inCount, + outCount: g.outCount, + refs: g.refs, + latestEntryId: g.latestEntryId, + }); + continue; + } + + const hasNewActivity = g.lastDate.localeCompare(prev.lastActivity) > 0; + prev.subject = g.subject; + prev.participants = g.participants; + prev.hasUnread = g.hasUnread; + prev.snippet = g.snippet || prev.snippet; + prev.inCount = g.inCount; + prev.outCount = g.outCount; + prev.refs = g.refs; + prev.latestEntryId = g.latestEntryId || prev.latestEntryId; + prev.automated = g.automated; + prev.ccOnly = g.ccOnly; + + if (!hasNewActivity) continue; + prev.lastActivity = g.lastDate; + + if (prev.state === 'muted') continue; // 무시 주제는 영구 조용 + if (g.automated) { prev.state = 'answered'; prev.waitingSince = undefined; continue; } + + if (g.lastDirection === 'out') { + // 내가 답했음 — 공 넘김. 대기·알림 상태 리셋. + prev.state = 'answered'; + prev.waitingSince = undefined; + prev.alertedFor = undefined; + } else { + // 새 수신 — done 이었어도 다시 회신 대기로 재개. + prev.state = 'waiting'; + prev.waitingSince = g.lastDate; + prev.alertedFor = undefined; + } + } + + // 정리 — 오래 무활동/닫힌 항목 제거. + const threads = [...byTopic.values()].filter(t => { + const age = daysBetween(t.lastActivity, now); + if (t.state === 'done' || t.state === 'muted') return age <= KEEP_CLOSED_DAYS || t.state === 'muted'; + return age <= KEEP_ACTIVE_DAYS; + }); + + return { updatedAt: now.toISOString(), threads }; +} + +/** 회신 대기 나이(시간). waiting 이 아니면 0. */ +export function waitingHours(t: ThreadState, now: Date = new Date()): number { + if (t.state !== 'waiting' || !t.waitingSince) return 0; + const start = new Date(t.waitingSince.replace(' ', 'T')).getTime(); + if (!Number.isFinite(start)) return 0; + return Math.max(0, (now.getTime() - start) / 3_600_000); +} + +/** + * 임계 초과 미회신 감지 — 사람 발신 waiting 스레드 중 thresholdHours 를 넘겼고 + * 아직 이 대기 기간에 알림 안 보낸 것. 반환된 스레드의 alertedFor 를 제자리 + * 마킹하므로 호출자는 반환 후 writeEmailState 로 저장해야 중복 알림이 없다. + */ +export function detectOverdue(store: EmailStateFile, thresholdHours: number, now: Date = new Date()): ThreadState[] { + if (thresholdHours <= 0) return []; + const overdue: ThreadState[] = []; + for (const t of store.threads) { + if (t.state !== 'waiting' || t.automated || !t.waitingSince) continue; + if (t.alertedFor === t.waitingSince) continue; + if (waitingHours(t, now) >= thresholdHours) { + t.alertedFor = t.waitingSince; + overdue.push(t); + } + } + return overdue; +} + +// ── 위키화 완료 레지스트리 (v2.2.275) ───────────────────────────────────────── +// `/email scan` 의 자동 위키화가 같은 스레드를 두 번 위키화하지 않게 하는 영속 +// 기록. email_state 의 60일 정리와 무관하게 유지되어, 오래된 스레드를 재스캔해도 +// 중복 문서가 생기지 않는다. 회차 상한에 걸린 나머지는 다음 실행에서 이어서. +const WIKIFIED_REL = '.astra/email_wikified.json'; + +function wikifiedPath(): string | null { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return null; + return path.join(folders[0].uri.fsPath, WIKIFIED_REL); +} + +/** 레지스트리 값 — v2.2.280부터 저장 경로 포함 (파일 삭제 감지용). 구형은 날짜 문자열. */ +type WikifiedEntry = string | { at: string; path?: string }; + +/** + * 완료 topic 집합 (순수 로직 — 테스트 export). verify 모드에서는 경로가 기록된 + * 항목의 **파일이 실제로 존재할 때만** 완료로 인정 — 사용자가 위키 문서를 지우면 + * 다음 스캔이 자동으로 다시 위키화한다. 경로 없는 구형 항목은 검증 불가라 완료 유지 + * (`/email scan reset` 으로 초기화). + */ +export function wikifiedTopicSet( + topics: Record, + exists: (p: string) => boolean, + verify: boolean, +): Set { + const out = new Set(); + for (const [topic, entry] of Object.entries(topics || {})) { + if (verify && entry && typeof entry === 'object' && entry.path && !exists(entry.path)) continue; + out.add(topic); + } + return out; +} + +export function readWikifiedTopics(verifyFiles = false): Set { + const p = wikifiedPath(); + if (!p || !fs.existsSync(p)) return new Set(); + try { + const parsed = JSON.parse(fs.readFileSync(p, 'utf-8')); + return wikifiedTopicSet(parsed?.topics || {}, f => { try { return fs.existsSync(f); } catch { return false; } }, verifyFiles); + } catch { return new Set(); } +} + +export function markWikified(topic: string, filePath?: string): void { + const p = wikifiedPath(); + if (!p) return; + try { + let data: { topics: Record } = { topics: {} }; + if (fs.existsSync(p)) { + try { data = JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { /* 손상 시 새로 시작 */ } + if (!data || typeof data !== 'object' || !data.topics) data = { topics: {} }; + } + data.topics[topic] = { at: new Date().toISOString(), path: filePath }; + const tmp = `${p}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8'); + fs.renameSync(tmp, p); + } catch (e: any) { + logError('email_wikified.json 기록 실패.', { error: e?.message ?? String(e) }); + } +} + +/** 완료 기록 전체 초기화 — `/email scan reset`. 다음 스캔이 처음부터 다시 위키화. */ +export function clearWikified(): boolean { + const p = wikifiedPath(); + if (!p) return false; + try { + if (fs.existsSync(p)) fs.rmSync(p); + logInfo('email_wikified.json 초기화.'); + return true; + } catch (e: any) { + logError('email_wikified.json 초기화 실패.', { error: e?.message ?? String(e) }); + return false; + } +} + +/** 사용자 트리아지 — done/muted 처리. topic 은 normalizeSubject 키. */ +export function setThreadState(store: EmailStateFile, topic: string, state: 'done' | 'muted' | 'waiting'): ThreadState | null { + const t = store.threads.find(x => x.topic === topic); + if (!t) return null; + t.state = state; + if (state !== 'waiting') { t.waitingSince = undefined; t.alertedFor = undefined; } + else if (!t.waitingSince) t.waitingSince = t.lastActivity; + logInfo('Email thread 상태 변경.', { topic, state }); + return t; +} diff --git a/src/features/email/emailWatcher.ts b/src/features/email/emailWatcher.ts new file mode 100644 index 0000000..510748a --- /dev/null +++ b/src/features/email/emailWatcher.ts @@ -0,0 +1,135 @@ +/** + * ============================================================ + * 이메일 워처 (v2.2.271) — /email 을 안 쳐도 알아서 축적·추적·알림. + * + * 주기(기본 60분)마다: + * 1. Outlook(+Gmail 연결 시) 최근 메일 증분 수집 + * 2. 주제별 스레드 묶음 → `.astra/email_state.json` 에 병합 (수집 창 밖 + * 미회신도 계속 보존 — GTD Waiting-For) + * 3. 임계(기본 24h) 초과 미회신 전이 감지 → 텔레그램 1회 알림 + * (매수시그널과 같은 전이 방식 — waitingSince 당 1회, 중복 없음) + * + * stocksWatcher 패턴 준수: 단계별 독립 try/catch, dispose 로 정리, + * 설정 `g1nation.email.watcherEnabled` 로 on/off. + * ============================================================ + */ + +import * as vscode from 'vscode'; +import { logError, logInfo } from '../../utils'; +import { TelegramHttpClient } from '../../integrations/telegram/telegramClient'; +import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands'; +import { collectRecentMails } from './emailCollect'; +import { groupThreads, isAutomated } from './emailBrief'; +import { readEmailState, writeEmailState, syncThreads, detectOverdue, waitingHours, type ThreadState } from './emailState'; + +let _timer: NodeJS.Timeout | undefined; +let _disposed = false; +let _running = false; + +function cfgNum(key: string, def: number): number { + const v = vscode.workspace.getConfiguration('g1nation').get(key, def); + return Number.isFinite(v) ? (v as number) : def; +} + +function pickChatId(): number | null { + const cfg = vscode.workspace.getConfiguration('g1nation'); + const allowed = cfg.get('telegram.allowedChatIds', []) || []; + if (allowed.length > 0 && Number.isFinite(allowed[0]) && allowed[0] !== 0) return allowed[0]; + return null; +} + +function fmtWaiting(t: ThreadState, now: Date): string { + const h = waitingHours(t, now); + return h >= 48 ? `${(h / 24).toFixed(1)}일째` : `${Math.round(h)}시간째`; +} + +export function buildOverdueAlertText(overdue: ThreadState[], thresholdHours: number, now: Date = new Date()): string { + // v2.2.284: 중앙 포맷(messageFormat.renderTelegram) — 모든 텔레그램 메시지 포맷 통일. + const { renderTelegram, tgWho, tgSubject } = require('../../integrations/telegram/messageFormat') as typeof import('../../integrations/telegram/messageFormat'); + return renderTelegram({ + emoji: '📧', title: `미회신 ${thresholdHours}h 초과`, count: overdue.length, + sections: [{ + count: overdue.length, + maxItems: 5, + items: overdue.map(t => ({ + main: tgSubject(t.subject), + sub: `${tgWho(t.participants[0] || '-')} · ${fmtWaiting(t, now)}${t.hasUnread ? ' · 🔵' : ''}`, + })), + }], + footer: '처리했으면 `/email done O번호`', + }); +} + +async function sendOverdueAlert(context: vscode.ExtensionContext, overdue: ThreadState[], thresholdHours: number): Promise { + const chatId = pickChatId(); + if (chatId === null) { logInfo('Email watcher: 텔레그램 chatId 미설정 — 알림 skip.'); return; } + const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || ''; + if (!token.trim()) { logInfo('Email watcher: 텔레그램 토큰 없음 — 알림 skip.'); return; } + const client = new TelegramHttpClient({ getToken: () => token }); + try { + await client.sendMessage({ chatId, text: buildOverdueAlertText(overdue, thresholdHours), parseMode: 'Markdown' }); + logInfo('Email watcher: 미회신 알림 발송.', { count: overdue.length }); + } catch (e: any) { + logError('Email watcher: 알림 발송 실패.', { error: e?.message ?? String(e) }); + } +} + +/** 한 사이클 — 수집 → 상태 병합 → 임계 초과 알림. 수동 실행(/email 등)과 공유 가능. */ +export async function emailWatcherFireOnce(context: vscode.ExtensionContext): Promise { + if (_running) return; // 수집 겹침 방지 (COM/네트워크 느릴 때) + _running = true; + try { + const days = Math.max(1, Math.min(30, cfgNum('email.syncDays', 7))); + const collected = await collectRecentMails(days, context, { gmailThreads: 12 }); + if (collected.outlookError && collected.inbox.length === 0) { + logInfo('Email watcher: 수집 실패 — skip.', { outlook: collected.outlookError, gmail: collected.gmailError }); + return; + } + const threads = groupThreads(collected.inbox, collected.sent); + const store = syncThreads(readEmailState(), threads); + + // v2.2.283: 기본 0(끔) — 미회신 정리는 09:30 데일리 브리핑이 담당 (하루 1회, 정돈된 형식). + // 즉시 알림을 원할 때만 시간(예: 24) 설정. + const threshold = cfgNum('email.overdueAlertHours', 0); + const overdue = detectOverdue(store, threshold) + // 자동발신 재필터 — 필터 규칙이 갱신돼도 과거 상태의 낡은 automated 플래그에 안 속게. + .filter(t => !t.ccOnly && !isAutomated(t.participants[0] || '', t.subject)); + writeEmailState(store); + logInfo('Email watcher: 사이클 완료.', { + observed: threads.length, stored: store.threads.length, overdue: overdue.length, + }); + if (overdue.length > 0) await sendOverdueAlert(context, overdue, threshold); + } catch (e: any) { + logError('Email watcher: 사이클 예외.', { error: e?.message ?? String(e) }); + } finally { + _running = false; + } +} + +function scheduleNext(context: vscode.ExtensionContext, first: boolean): void { + if (_disposed) return; + const intervalMin = Math.max(10, Math.min(24 * 60, cfgNum('email.watchIntervalMinutes', 60))); + // 첫 실행은 기동 3분 뒤 (VS Code 시작 직후 부하 회피), 이후 설정 주기. + const ms = first ? 3 * 60_000 : intervalMin * 60_000; + _timer = setTimeout(async () => { + await emailWatcherFireOnce(context); + scheduleNext(context, false); + }, ms); +} + +export function startEmailWatcher(context: vscode.ExtensionContext): vscode.Disposable { + const enabled = vscode.workspace.getConfiguration('g1nation').get('email.watcherEnabled', true); + if (!enabled) { + logInfo('Email watcher: 비활성 (g1nation.email.watcherEnabled=false).'); + return { dispose: () => {} }; + } + _disposed = false; + scheduleNext(context, true); + logInfo('Email watcher: 시작됨.'); + return { + dispose: () => { + _disposed = true; + if (_timer) { clearTimeout(_timer); _timer = undefined; } + }, + }; +} diff --git a/src/features/email/gmailApi.ts b/src/features/email/gmailApi.ts new file mode 100644 index 0000000..d38cd07 --- /dev/null +++ b/src/features/email/gmailApi.ts @@ -0,0 +1,187 @@ +/** + * ============================================================ + * Gmail API v1 — 읽기 전용(read-only) 메시지 수집 (Project Astra, Phase 1) + * + * Calendar/Tasks/Sheets 와 *같은 Google OAuth 토큰*을 공유한다 + * (`getFreshAccessToken`). scope 에 `gmail.readonly` 가 포함돼야 함(oauth.ts). + * 삭제/답장/전달 등 쓰기 기능은 일절 없다(읽기 전용 — 데이터 무결성·보안). + * + * 외부 라이브러리 없음 — REST + native fetch. tasksApi.ts 와 동일한 셰이프: + * (context, ...) -> getFreshAccessToken -> fetch -> { ok, data } | { ok:false, error } + * ============================================================ + */ + +import * as vscode from 'vscode'; +import { getFreshAccessToken } from '../calendar/calendarApi'; + +const API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; + +export interface ParsedMessage { + messageId: string; + threadId: string; + from: string; + to: string; + subject: string; + /** epoch ms */ + date: number; + snippet: string; + bodyText: string; + permalink: string; + labels: string[]; +} + +type ApiResult = { ok: true; data: T } | { ok: false; error: string }; + +function authError(status: number, msg: string): boolean { + return status === 401 || status === 403 || /insufficient|scope|disabled|enable/i.test(msg); +} + +const REAUTH_HINT = + 'Gmail API 권한 부족 — "Astra: Google Calendar OAuth 연결" 명령을 다시 실행해 gmail.readonly 스코프 동의가 필요합니다. (Google Cloud Console 에서 Gmail API 활성화도 확인)'; + +/** + * 최근 메시지 id 목록을 조회. q 는 Gmail 검색 문법(예: `newer_than:7d`). + * maxResults 까지 pageToken 으로 페이지네이션한다. + */ +export async function listMessageIds( + context: vscode.ExtensionContext, + opts: { query?: string; maxResults?: number } = {}, +): Promise>> { + const tok = await getFreshAccessToken(context); + if (!tok.ok) return { ok: false, error: tok.error }; + + const target = Math.max(1, Math.min(2000, opts.maxResults ?? 200)); + const out: Array<{ id: string; threadId: string }> = []; + let pageToken: string | undefined; + + try { + while (out.length < target) { + const params = new URLSearchParams({ maxResults: String(Math.min(100, target - out.length)) }); + if (opts.query) params.set('q', opts.query); + if (pageToken) params.set('pageToken', pageToken); + const res = await fetch(`${API_BASE}/messages?${params.toString()}`, { + headers: { Authorization: `Bearer ${tok.accessToken}` }, + signal: AbortSignal.timeout(20000), + }); + const json: any = await res.json().catch(() => ({})); + if (!res.ok) { + const msg: string = json?.error?.message || `HTTP ${res.status}`; + return { ok: false, error: authError(res.status, msg) ? REAUTH_HINT : msg }; + } + const items: any[] = Array.isArray(json.messages) ? json.messages : []; + for (const m of items) { + if (m?.id) out.push({ id: String(m.id), threadId: String(m.threadId || '') }); + } + pageToken = json.nextPageToken; + if (!pageToken || items.length === 0) break; + } + return { ok: true, data: out.slice(0, target) }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +/** 단일 메시지 전체(format=full)를 조회해 파싱. */ +export async function getMessage( + context: vscode.ExtensionContext, + id: string, +): Promise> { + const tok = await getFreshAccessToken(context); + if (!tok.ok) return { ok: false, error: tok.error }; + try { + const res = await fetch(`${API_BASE}/messages/${encodeURIComponent(id)}?format=full`, { + headers: { Authorization: `Bearer ${tok.accessToken}` }, + signal: AbortSignal.timeout(20000), + }); + const json: any = await res.json().catch(() => ({})); + if (!res.ok) { + const msg: string = json?.error?.message || `HTTP ${res.status}`; + return { ok: false, error: authError(res.status, msg) ? REAUTH_HINT : msg }; + } + return { ok: true, data: parseMessage(json) }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +/** 스레드 전체 메시지 조회 + 파싱 (v2.2.269 — /email read/wikify 용). 시간순 정렬. */ +export async function getThreadMessages( + context: vscode.ExtensionContext, + threadId: string, +): Promise> { + const tok = await getFreshAccessToken(context); + if (!tok.ok) return { ok: false, error: tok.error }; + try { + const res = await fetch(`${API_BASE}/threads/${encodeURIComponent(threadId)}?format=full`, { + headers: { Authorization: `Bearer ${tok.accessToken}` }, + signal: AbortSignal.timeout(25000), + }); + const json: any = await res.json().catch(() => ({})); + if (!res.ok) { + const msg: string = json?.error?.message || `HTTP ${res.status}`; + return { ok: false, error: authError(res.status, msg) ? REAUTH_HINT : msg }; + } + const msgs: any[] = Array.isArray(json?.messages) ? json.messages : []; + const parsed = msgs.map(parseMessage).sort((a, b) => a.date - b.date); + return { ok: true, data: parsed }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +// ─── 파싱 헬퍼 ─────────────────────────────────────────────────────────────── + +function header(headers: any[], name: string): string { + const h = (headers || []).find((x) => String(x?.name || '').toLowerCase() === name.toLowerCase()); + return h ? String(h.value || '') : ''; +} + +function decodeBase64Url(data: string): string { + try { + return Buffer.from(String(data).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8'); + } catch { + return ''; + } +} + +/** payload 트리를 순회하며 text/plain 본문을 추출. 없으면 text/html 을 태그 제거해 사용. */ +function extractBody(payload: any): string { + if (!payload) return ''; + const plain = findPart(payload, 'text/plain'); + if (plain) return plain; + const html = findPart(payload, 'text/html'); + if (html) return html.replace(//gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+\n/g, '\n'); + return ''; +} + +function findPart(node: any, mime: string): string { + if (!node) return ''; + if (node.mimeType === mime && node.body?.data) return decodeBase64Url(node.body.data); + const parts: any[] = Array.isArray(node.parts) ? node.parts : []; + for (const p of parts) { + const found = findPart(p, mime); + if (found) return found; + } + return ''; +} + +function parseMessage(msg: any): ParsedMessage { + const headers: any[] = msg?.payload?.headers || []; + const internal = Number(msg?.internalDate); + const dateHeader = Date.parse(header(headers, 'Date')); + const date = Number.isFinite(internal) && internal > 0 + ? internal + : (Number.isFinite(dateHeader) ? dateHeader : Date.now()); + return { + messageId: String(msg?.id || ''), + threadId: String(msg?.threadId || ''), + from: header(headers, 'From'), + to: header(headers, 'To'), + subject: header(headers, 'Subject'), + date, + snippet: String(msg?.snippet || ''), + bodyText: extractBody(msg?.payload).trim(), + permalink: `https://mail.google.com/mail/u/0/#all/${String(msg?.id || '')}`, + labels: Array.isArray(msg?.labelIds) ? msg.labelIds.map((x: any) => String(x)) : [], + }; +} diff --git a/src/features/email/handlers.ts b/src/features/email/handlers.ts new file mode 100644 index 0000000..725ecc2 --- /dev/null +++ b/src/features/email/handlers.ts @@ -0,0 +1,630 @@ +/** + * ============================================================ + * /email — Gmail + 클래식 Outlook 통합 이메일 명령 (v2.2.269) + * + * /email — 도움말 + 연결 상태 + * /email list [gmail|outlook] [일수] — 최근 메일 목록 (기본 둘 다, 7일) + * /email read G3 | O2 — 목록 번호로 내용 읽기 + * /email wikify G3 | O2 — 지식 문서로 위키화 → brain 반영 (wiki.save) + * /email reply O2 [지시사항] — Outlook 회신 초안 → Drafts 저장 (발송 안 함) + * + * 아키텍처 결정 (2026-07-02): Gmail 은 ASTRA 자체 Google OAuth(gmail.readonly + * 스코프 추가)로 직접 읽는다 — NAS bridge 의 gmail 라우트는 headless OAuth 가 + * 비현실적이라 미사용. Outlook 은 로컬 COM(PowerShell). LLM 합성은 PC 로컬, + * 위키 저장만 bridge(wiki.save) — /meet 와 동일 경로로 brain 에 반영된다. + * + * import 만으로 등록 — extension.ts 에서 `import './features/email/handlers'`. + * ============================================================ + */ + +import * as vscode from 'vscode'; +import { registerSlashCommand, chunk, progress } from '../datacollect/slashRouter'; +import { persistWikiDoc, describeSave } from '../datacollect/wikiSave'; +import { callLmSynthesis } from '../datacollect/llm'; +import { listMessageIds, getMessage, getThreadMessages, type ParsedMessage } from './gmailApi'; +import { listInbox, readMail, saveReplyDraft, openMailInOutlook, type OutlookMail } from './outlookApi'; +import { buildEmailTranscript, buildReplyDraftPrompt, buildInboxBriefPrompt, injectTimeHeader, polishBriefLabels, type EmailForPrompt } from './emailPrompts'; +import { buildEmailCanonicalWikifyPrompt } from '../datacollect/prompts/wikifyPrompt'; +import { getCanonicalFormat } from '../datacollect/wikiFormat'; +import { groupThreads, normalizeSubject } from './emailBrief'; +import { collectRecentMails, emailProviders, type GmailThreadItem } from './emailCollect'; +import { readEmailState, writeEmailState, syncThreads, setThreadState, waitingHours, readWikifiedTopics, markWikified, clearWikified, type ThreadState } from './emailState'; +import type { ThreadGroup } from './emailBrief'; +import { emailWatcherFireOnce } from './emailWatcher'; + +type Webview = Parameters[0]; + +// ── 목록 캐시 — read/wikify/reply 가 번호로 참조 ───────────────────────────── +const lastList: { gmail: GmailThreadItem[]; outlook: OutlookMail[] } = { gmail: [], outlook: [] }; + +/** "G3" / "o2" → { provider, idx(0-based) }. 형식 오류면 null. (테스트 export) */ +export function parseRef(token: string): { provider: 'gmail' | 'outlook'; idx: number } | null { + const m = (token || '').trim().match(/^([GgOo])\s*(\d{1,2})$/); + if (!m) return null; + const idx = parseInt(m[2], 10) - 1; + if (idx < 0) return null; + return { provider: /^[Gg]$/.test(m[1]) ? 'gmail' : 'outlook', idx }; +} + +const fmtDate = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace('T', ' '); +const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s); + +const WIKIFY_SYSTEM = '당신은 지식 큐레이터입니다. 제공된 이메일 스레드를 P-Reinforce 규격의 고밀도 위키 문서로 정리합니다. 스레드에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.'; + +/** 메일 목록 → 프롬프트 메타 (참여자·기간·최신 메일 날짜). */ +function mailsMeta(mails: EmailForPrompt[]): { participants: string[]; dateRange: string; latestYmd: string } { + const participants = [...new Set(mails.map(m => (m.from || '').trim()).filter(f => f && f !== '나'))].slice(0, 6); + const dates = mails.map(m => m.date).sort(); + const latest = dates[dates.length - 1] || ''; + return { + participants, + dateRange: dates.length ? `${dates[0]} ~ ${latest}` : '', + // 문서 제목용 — 위키화한 날이 아니라 **이메일의 최신 날짜** (시점 오인 방지). + latestYmd: latest.slice(0, 10) || new Date().toISOString().slice(0, 10), + }; +} + +// ── list ───────────────────────────────────────────────────────────────────── +async function listGmail(view: Webview, context: vscode.ExtensionContext, days: number): Promise { + chunk(view, `\n📥 **Gmail** — 최근 ${days}일 스레드 조회 중…\n`); + const ids = await listMessageIds(context, { query: `newer_than:${days}d -in:spam -in:trash -category:promotions -category:social`, maxResults: 60 }); + if (!ids.ok) { + chunk(view, ` ⚠️ ${ids.error}\n`); + lastList.gmail = []; + return; + } + // 스레드 단위 dedupe (최신 메시지 우선 순서 유지), 상위 15개만 메타 조회. + const seen = new Set(); + const threadFirstMsg: { threadId: string; msgId: string }[] = []; + for (const m of ids.data) { + if (!m.threadId || seen.has(m.threadId)) continue; + seen.add(m.threadId); + threadFirstMsg.push({ threadId: m.threadId, msgId: m.id }); + if (threadFirstMsg.length >= 15) break; + } + const items: GmailThreadItem[] = []; + for (const t of threadFirstMsg) { + const r = await getMessage(context, t.msgId); + if (r.ok) items.push({ threadId: t.threadId, subject: r.data.subject || '(제목 없음)', from: r.data.from, date: r.data.date }); + } + lastList.gmail = items; + if (items.length === 0) { + chunk(view, ' (해당 기간 메일 없음)\n'); + return; + } + for (let i = 0; i < items.length; i++) { + const it = items[i]; + chunk(view, `- **G${i + 1}** ${fmtDate(it.date)} · ${trunc(it.from, 30)} — ${trunc(it.subject, 60)}\n`); + } + chunk(view, '\n'); +} + +async function listOutlook(view: Webview, days: number): Promise { + chunk(view, `\n📥 **Outlook (클래식)** — 최근 ${days}일 받은편지함 조회 중…\n`); + const r = await listInbox(days, 25); + if (!r.ok) { + chunk(view, ` ⚠️ ${r.error}\n`); + lastList.outlook = []; + return; + } + lastList.outlook = r.data; + if (r.data.length === 0) { + chunk(view, ' (해당 기간 메일 없음)\n'); + return; + } + for (let i = 0; i < r.data.length; i++) { + const m = r.data[i]; + chunk(view, `- **O${i + 1}** ${m.received} · ${trunc(m.from, 30)} — ${m.unread ? '🔵 ' : ''}${trunc(m.subject, 60)}${m.folder ? ` · 📁${trunc(m.folder, 16)}` : ''}\n`); + } + chunk(view, '\n'); +} + +// ── read 대상 로드 (provider 공통 셰이프로) ─────────────────────────────────── +async function loadMails( + ref: { provider: 'gmail' | 'outlook'; idx: number }, + context: vscode.ExtensionContext | undefined, +): Promise<{ ok: true; subject: string; mails: EmailForPrompt[]; entryId?: string } | { ok: false; error: string }> { + if (ref.provider === 'gmail') { + const it = lastList.gmail[ref.idx]; + if (!it) return { ok: false, error: `G${ref.idx + 1} 없음 — 먼저 \`/email list gmail\` 로 목록을 불러오세요.` }; + if (!context) return { ok: false, error: 'ExtensionContext 없음' }; + const r = await getThreadMessages(context, it.threadId); + if (!r.ok) return { ok: false, error: r.error }; + const mails: EmailForPrompt[] = r.data.map((m: ParsedMessage) => ({ + subject: m.subject, from: m.from, to: m.to, date: fmtDate(m.date), body: m.bodyText || m.snippet, + })); + return { ok: true, subject: it.subject, mails }; + } + const it = lastList.outlook[ref.idx]; + if (!it) return { ok: false, error: `O${ref.idx + 1} 없음 — 먼저 \`/email list outlook\` 으로 목록을 불러오세요.` }; + const r = await readMail(it.entryId); + if (!r.ok) return { ok: false, error: r.error }; + return { + ok: true, + subject: r.data.subject || '(제목 없음)', + entryId: it.entryId, + mails: [{ subject: r.data.subject, from: r.data.from, to: r.data.to, date: r.data.received, body: r.data.body || '' }], + }; +} + +// ── 브리핑 (기본 동작) — 영속 상태(email_state.json) 기반 현황 ───────────────── +// v2.2.271: 매번 새로 긁는 스냅샷이 아니라, 워처가 누적한 상태에 이번 수집을 +// 병합해 보여준다 — 수집 창(7일) 밖으로 벗어난 오래된 미회신도 계속 표시. + +const fmtWait = (t: ThreadState, now: Date) => { + const h = waitingHours(t, now); + if (h <= 0) return ''; + return h >= 48 ? ` · **${(h / 24).toFixed(1)}일째 대기**` : ` · ${Math.round(h)}시간째 대기`; +}; + +/** LLM 실패 시에도 항상 나오는 결정론 렌더 — 상태 저장소 기반 (대시보드 + 상태 이모지). */ +function renderStateBrief(threads: ThreadState[], now: Date): string { + const lines: string[] = []; + const section = (title: string, items: ThreadState[], fmt: (t: ThreadState) => string) => { + if (items.length === 0) return; + lines.push(`\n**${title}** (${items.length})\n`); + for (const t of items) lines.push(fmt(t)); + }; + const label = (t: ThreadState) => + `- ${t.hasUnread ? '🔵 ' : ''}**${trunc(t.subject, 60)}** — ${t.participants.slice(0, 3).join(', ') || '-'}${fmtWait(t, now)}${t.refs.length ? ` · [${t.refs.slice(0, 3).join(' ')}]` : ''}`; + + const waiting = threads.filter(t => t.state === 'waiting' && !t.automated) + .sort((a, b) => (a.waitingSince || '').localeCompare(b.waitingSince || '')); + const answeredAll = threads.filter(t => t.state === 'answered' && !t.automated); + // 상대 회신 대기는 7일+ 장기 건만 강조 — 단기 대기는 건수만 (놓친 업무 발굴 목적). + const daysSince = (t: ThreadState) => { + const ms = new Date(t.lastActivity.replace(' ', 'T')).getTime(); + return Number.isFinite(ms) ? Math.floor((now.getTime() - ms) / 86_400_000) : 0; + }; + const longWait = answeredAll.filter(t => daysSince(t) >= 7) + .sort((a, b) => daysSince(b) - daysSince(a)).slice(0, 10); + const auto = threads.filter(t => t.automated && t.state !== 'muted') + .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)).slice(0, 8); + + // 상단 대시보드 — 코드 판정 기반 (LLM 우선순위 분류는 폴백에선 생략). + const oldCount = waiting.filter(t => waitingHours(t, now) >= 48).length; + lines.push('■ **메일 현황**\n'); + lines.push(`- 🔴 회신 필요 ${waiting.length}건 (48h+ ${oldCount}건) · ⏳ 상대 대기 ${answeredAll.length}건 (7일+ ${longWait.length}건) · 📢 자동발신 ${auto.length}건\n`); + + section('🔴 회신 필요 — 오래된 순', waiting, label); + section(`⏳ 장기 대기 — 7일 이상 (전체 ${answeredAll.length}건 중)`, longWait, + t => `- **${trunc(t.subject, 50)}** — ${t.participants[0] || '-'} 대기 · **${daysSince(t)}일째**${t.refs.length ? ` · [${t.refs.slice(0, 2).join(' ')}]` : ''}`); + section('📢 자동발신 · 공지 (회신 불필요)', auto, t => `- ${trunc(t.subject, 60)} — ${t.participants[0] || '-'} · ${t.lastActivity}`); + return lines.join('\n'); +} + +async function cmdBrief(rest: string[], view: Webview, context?: vscode.ExtensionContext): Promise { + // 기간을 명시하면(`/email 1`) 수집뿐 아니라 **표시도 그 범위로 제한** — 기본(무인자)일 때만 + // 누적 상태 전체(오래된 미회신 보존)를 보여준다. + const explicitDays = /^\d+$/.test((rest[0] || '').trim()); + const days = Math.max(1, Math.min(90, parseInt(rest[0] || '7', 10) || 7)); + const now = new Date(); + chunk(view, `\n📬 **이메일 브리핑** — 최근 ${days}일 수집 + 누적 상태 병합 중…\n\n`); + + // (1) 수집 (워처와 동일 경로) + 참조번호 캐시 갱신 + const col = await collectRecentMails(days, context, { gmailThreads: 12 }); + if (col.outlookError) chunk(view, `- ⚠️ Outlook: ${col.outlookError}\n`); + if (col.gmailError) chunk(view, `- ℹ️ Gmail 제외: ${trunc(col.gmailError, 120)}\n`); + lastList.outlook = col.outlookMails; + lastList.gmail = col.gmailItems; + + // (2) 영속 상태에 병합 — 이번 관측 + 과거 누적 (오래된 미회신 보존) + const observed = groupThreads(col.inbox, col.sent); + const store = syncThreads(readEmailState(), observed, now); + writeEmailState(store); + let active = store.threads.filter(t => t.state !== 'muted' && t.state !== 'done'); + if (explicitDays) { + const cutoff = new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10); + const before = active.length; + active = active.filter(t => t.lastActivity.slice(0, 10) >= cutoff); + chunk(view, `📅 표시 범위: 최근 ${days}일 (그 외 추적 중 ${before - active.length}건은 숨김 — 전체는 \`/email\`)\n\n`); + } + if (active.length === 0) { + chunk(view, '- 추적 중인 메일이 없습니다.\n'); + return; + } + const waitingCount = active.filter(t => t.state === 'waiting' && !t.automated).length; + chunk(view, `수집 ${col.inbox.length}건 → 추적 주제 ${active.length}개 (회신 대기 ${waitingCount}건). 정리 중…\n\n`); + + // 오늘 일정 (v2.2.287 — 결정론, 캘린더 캐시) — 메일은 결국 일정으로 이어지므로 함께 표시. + if (context) { + try { + const { readCalendarEventsCache } = await import('../calendar/calendarCache'); + const ymd = now.toISOString().slice(0, 10); + const events = readCalendarEventsCache(context) + .filter(e => (e.startIso || '').slice(0, 10) === ymd) + .sort((a, b) => a.startIso.localeCompare(b.startIso)); + if (events.length) { + chunk(view, `🗓 **오늘 일정** (${events.length})\n`); + for (const e of events.slice(0, 8)) { + chunk(view, `- ${e.allDay ? '종일' : e.startIso.slice(11, 16)} — ${trunc(e.summary || '(제목 없음)', 40)}\n`); + } + chunk(view, '\n'); + } + } catch { /* 캘린더 미연결/캐시 없음 — 조용히 생략 */ } + } + + // (3) 회신 대기 상위 8개는 본문 심화 (Outlook 한정 — '내용/할 일' 품질의 핵심) + const deep = new Map(); + const needDeep = active.filter(t => t.state === 'waiting' && !t.automated && t.latestEntryId) + .sort((a, b) => (a.waitingSince || '').localeCompare(b.waitingSince || '')).slice(0, 8); + for (const t of needDeep) { + const r = await readMail(t.latestEntryId!); + if (r.ok && r.data.body) deep.set(t.topic, r.data.body.slice(0, 1500).replace(/\s+/g, ' ')); + } + + // (4) LLM 서술 (분류·대기시간 판정은 코드 — 프롬프트에 명시). + // 타임라인(이번 관측에서 확보한 메시지 흐름)을 '경과' 재료로 공급. + const timelineByTopic = new Map(observed.map(g => [g.topic, g.timeline])); + const block = active.map((t, i) => { + const status = t.automated ? '자동발신' : t.state === 'waiting' ? '회신대기' : '공넘김'; + const ccMark = t.ccOnly ? ' | 수신=CC(회신불요)' : ''; + // 회신대기 = 내 대기시간, 공넘김 = 상대가 답 안 준 경과일 (장기 대기 7일+ 판정 재료) + const sinceMs = new Date(t.lastActivity.replace(' ', 'T')).getTime(); + const elapsedDays = Number.isFinite(sinceMs) ? Math.floor((now.getTime() - sinceMs) / 86_400_000) : 0; + const wh = waitingHours(t, now); + const wait = t.state === 'waiting' + ? ` | 대기=${wh >= 48 ? `${Math.round(wh / 24)}일` : `${Math.round(wh)}시간`}` + : t.state === 'answered' && !t.automated ? ` | 상대대기경과=${elapsedDays}일` : ''; + const lines = [ + `[${i + 1}] 상태=${status}${wait} | 주제="${t.subject}" | 참여자=${t.participants.join(',') || '-'} | 수신${t.inCount}/발신${t.outCount} | 최근=${t.lastActivity} | 읽지않음=${t.hasUnread ? 'Y' : 'N'}${ccMark} | 참조=[${t.refs.slice(0, 3).join(' ')}]`, + ]; + const tl = timelineByTopic.get(t.topic); + if (tl && tl.length && !t.automated) { + lines.push(` 타임라인: ${tl.map(e => `${e.date.slice(5, 16)} ${e.from}${e.snippet ? `("${e.snippet.slice(0, 60)}")` : ''}`).join(' → ')}`); + } + const body = deep.get(t.topic); + if (body) lines.push(` 본문발췌="${body.slice(0, 800)}"`); + else if (t.snippet) lines.push(` 스니펫="${t.snippet.slice(0, 200)}"`); + return lines.join('\n'); + }).join('\n'); + + try { + const brief = await callLmSynthesis( + buildInboxBriefPrompt(block), + '당신은 받은편지함 현황을 정리하는 비서다. 제공된 데이터에 있는 내용만 사용하고, 분류·순서·대기시간 판정을 바꾸지 않는다. 한국어 마크다운으로 출력한다.', + { maxTokens: 4500 }, + ); + chunk(view, (brief && brief.trim() ? polishBriefLabels(brief.trim()) : renderStateBrief(active, now)) + '\n'); + } catch { + chunk(view, renderStateBrief(active, now) + '\n'); + } + chunk(view, `\n💡 \`/email open O216\` **Outlook에서 열기** · \`/email read O2\` 읽기 · \`/email done O2\` 완료 · \`/email mute O3\` 무시 · \`/email wikify O2\` 지식화 · \`/email reply O2\` 회신초안\n`); +} + +// ── 전체 스캔 + 자동 위키화 (v2.2.275) ──────────────────────────────────────── +// `/email scan [all|일수] [최대건수]` — 7일 창을 넘어 메일함 전체(기본)를 수집해 +// 상태에 병합하고, 사람 발신 스레드를 자동으로 지식 문서화(Email_Wiki)한다. +// LLM 위키화는 건당 수십 초라 회차당 상한(기본 15건) — 이미 위키화된 스레드는 +// 레지스트리로 스킵되므로, 다시 실행하면 나머지를 이어서 처리한다. + +/** 스레드의 위키화 재료 로드 — Gmail 은 스레드 전체, Outlook 은 최신 메일 본문+타임라인. */ +async function loadThreadForWikify( + g: ThreadGroup, + context?: vscode.ExtensionContext, +): Promise { + const gmailRef = g.refs.find(r => r.startsWith('G')); + if (gmailRef && context) { + const parsed = parseRef(gmailRef); + const item = parsed ? lastList.gmail[parsed.idx] : undefined; + if (item) { + const r = await getThreadMessages(context, item.threadId); + if (r.ok && r.data.length) { + return r.data.map(m => ({ subject: m.subject, from: m.from, to: m.to, date: fmtDate(m.date), body: m.bodyText || m.snippet })); + } + } + } + if (g.latestEntryId) { + const r = await readMail(g.latestEntryId); + if (r.ok) { + // 타임라인 스니펫으로 과거 흐름 보강 (Outlook 은 스레드 일괄 조회가 없어 최신 본문 중심). + const history = g.timeline.slice(0, -1) + .map(e => `[${e.date}] ${e.from}: ${e.snippet || '(내용 요약 없음)'}`).join('\n'); + const body = (history ? `--- 이전 흐름 요약 ---\n${history}\n\n--- 최신 메일 ---\n` : '') + (r.data.body || ''); + return [{ subject: r.data.subject, from: r.data.from, to: r.data.to, date: r.data.received, body }]; + } + } + return null; +} + +async function cmdScan(rest: string[], view: Webview, context?: vscode.ExtensionContext): Promise { + // `/email scan reset` — 위키화 완료 기록 초기화 → 다음 스캔이 처음부터 다시. + if ((rest[0] || '').toLowerCase() === 'reset') { + const ok = clearWikified(); + chunk(view, ok + ? '\n🧹 **위키화 완료 기록 초기화** — 이제 `/email scan` 이 모든 스레드를 처음부터 다시 위키화합니다.\n' + : '\n⚠️ 초기화 실패 — 워크스페이스가 열려 있는지 확인하세요.\n'); + return; + } + const a0 = (rest[0] || 'all').toLowerCase(); + const days = a0 === 'all' ? 3650 : Math.max(1, Math.min(3650, parseInt(a0, 10) || 3650)); + const maxWikify = Math.max(1, Math.min(50, parseInt(rest[1] || '', 10) || 15)); + const rangeLabel = a0 === 'all' ? '전체(최대 10년·300건)' : `최근 ${days}일`; + + chunk(view, `\n🔎 **이메일 전체 스캔** — ${rangeLabel} 수집 중… (받은편지함 최대 300건 + 보낸편지함 200건)\n\n`); + const col = await collectRecentMails(days, context, { gmailThreads: 25, outlookMax: 300, sentMax: 200 }); + if (col.outlookError) chunk(view, `- ⚠️ Outlook: ${col.outlookError}\n`); + if (col.gmailError) chunk(view, `- ℹ️ Gmail 제외: ${trunc(col.gmailError, 120)}\n`); + lastList.outlook = col.outlookMails; + lastList.gmail = col.gmailItems; + if (col.inbox.length === 0) { chunk(view, '- 수집된 메일이 없습니다.\n'); return; } + + // 상태 병합 — 스캔 이후 /email 브리핑이 전체 이력을 반영. + const observed = groupThreads(col.inbox, col.sent); + writeEmailState(syncThreads(readEmailState(), observed)); + + // 자동 위키화 대상 선별 — 사람 발신 + 미위키화. + const wikified = readWikifiedTopics(true); // 파일 삭제 감지 — 지운 문서는 다시 위키화 + const humans = observed.filter(t => !t.automated); + const targets = humans.filter(t => !wikified.has(t.topic)); + const batch = targets.slice(0, maxWikify); + chunk(view, `수집 ${col.inbox.length}건 → 주제 ${observed.length}개 (사람 ${humans.length} · 자동발신 ${observed.length - humans.length})\n`); + chunk(view, `위키화 대상 ${targets.length}건 중 이번 회차 ${batch.length}건 처리 (이미 완료 ${humans.length - targets.length}건 스킵)\n\n`); + if (batch.length === 0) { + chunk(view, '✅ 모든 사람 발신 스레드가 이미 위키화되어 있습니다.\n'); + return; + } + + // 항목별 로그 대신 제자리 갱신 진행 표시 한 줄 (v2.2.279) — 완료 후 요약만 남는다. + let ok = 0; + const failures: string[] = []; + let savedDir = ''; + const today = new Date().toISOString().slice(0, 10); + const canonical = await getCanonicalFormat(); + for (let i = 0; i < batch.length; i++) { + const t = batch[i]; + progress(view, `\n⏳ **이메일 위키화 중 (${i + 1}/${batch.length})** — ${trunc(t.subject, 45)}\n`); + try { + const mails = await loadThreadForWikify(t, context); + if (!mails) throw new Error('본문 로드 실패'); + const meta = mailsMeta(mails); + const doc = await callLmSynthesis( + buildEmailCanonicalWikifyPrompt(t.subject, buildEmailTranscript(mails), meta, canonical), + WIKIFY_SYSTEM, + { maxTokens: 5000 }, + ); + if (!doc || !doc.trim()) throw new Error('LLM 응답 비어 있음'); + // 제목 날짜 = 이메일 최신 날짜 (위키화한 날 아님) + 기준 시점 헤더 주입 — 시점 오인 방지. + const stamped = injectTimeHeader(doc.trim(), meta.dateRange, today); + const saved = await persistWikiDoc(`이메일 ${trunc(t.subject, 60)} ${meta.latestYmd}`, stamped, { store: 'email', docType: 'email' }); + if (!saved.ok) throw new Error(saved.error || '저장 실패'); + if (saved.path) savedDir = saved.path.replace(/[\\/][^\\/]+$/, ''); + markWikified(t.topic, saved.path); + ok++; + } catch (e: any) { + failures.push(`${trunc(t.subject, 40)} — ${trunc(e?.message || String(e), 60)}`); + } + } + // 마지막 chunk 가 진행 줄을 걷어내고 요약으로 대체한다. + const remaining = targets.length - batch.length; + chunk(view, `\n📚 **스캔 완료** — 위키화 ${ok}건${failures.length ? ` · 실패 ${failures.length}건` : ''}${remaining > 0 ? ` · 남은 ${remaining}건 (\`/email scan\` 재실행으로 이어서)` : ' · 전부 완료'}\n`); + if (savedDir) chunk(view, `- 저장 폴더: \`${savedDir}\`\n`); + if (canonical) chunk(view, `- 포맷 정본 v${canonical.version} (브리지)\n`); + for (const f of failures.slice(0, 5)) chunk(view, `- ⚠️ ${f}\n`); + if (failures.length > 5) chunk(view, `- ⚠️ … 외 실패 ${failures.length - 5}건\n`); + chunk(view, `상태 저장소에도 병합됨 — \`/email\` 브리핑이 이제 이 범위를 반영합니다.\n`); +} + +// ── 원본 메일 열기 (v2.2.289) — 브리핑 [O번호] → 실제 메일로 점프 ─────────────── +async function cmdOpen(rest: string[], view: Webview): Promise { + const ref = parseRef(rest[0] || ''); + if (!ref) { chunk(view, '\n사용법: `/email open O216` — 해당 메일을 Outlook 창으로 엽니다 (Gmail 은 브라우저).\n'); return; } + if (ref.provider === 'gmail') { + const it = lastList.gmail[ref.idx]; + if (!it) { chunk(view, `\n⚠️ G${ref.idx + 1} 없음 — 먼저 \`/email\` 로 목록을 로드하세요.\n`); return; } + await vscode.env.openExternal(vscode.Uri.parse(`https://mail.google.com/mail/u/0/#all/${it.threadId}`)); + chunk(view, `\n🌐 브라우저에서 Gmail 스레드를 열었습니다: **${trunc(it.subject, 50)}**\n`); + return; + } + const it = lastList.outlook[ref.idx]; + if (!it) { chunk(view, `\n⚠️ O${ref.idx + 1} 없음 — 먼저 \`/email\` 로 목록을 로드하세요.\n`); return; } + const r = await openMailInOutlook(it.entryId); + chunk(view, r.ok + ? `\n📬 **Outlook 창으로 열었습니다**: ${trunc(it.subject, 50)} (${it.received} · ${trunc(it.from, 20)})\n` + : `\n⚠️ 열기 실패: ${r.error}\n`); +} + +// ── 트리아지 — done(완료) / mute(무시) / unmute ─────────────────────────────── +function refToTopic(ref: { provider: 'gmail' | 'outlook'; idx: number }): string | null { + const subject = ref.provider === 'gmail' + ? lastList.gmail[ref.idx]?.subject + : lastList.outlook[ref.idx]?.subject; + return subject ? normalizeSubject(subject) : null; +} + +async function cmdTriage(kind: 'done' | 'muted' | 'waiting', rest: string[], view: Webview): Promise { + // `/email done old 30` — 30일 이상 미회신을 일괄 완료 (스캔이 편입한 과거 백로그 정리용). + // done 은 새 수신이 오면 자동으로 waiting 재개되므로 안전하다. + if (kind === 'done' && (rest[0] || '').toLowerCase() === 'old') { + const days = Math.max(1, Math.min(365, parseInt(rest[1] || '30', 10) || 30)); + const store = readEmailState(); + const now = new Date(); + let n = 0; + for (const t of store.threads) { + if (t.state === 'waiting' && waitingHours(t, now) >= days * 24) { + t.state = 'done'; + t.waitingSince = undefined; + t.alertedFor = undefined; + n++; + } + } + writeEmailState(store); + chunk(view, `\n🧹 **${days}일 이상 미회신 ${n}건을 완료 처리**했습니다 — 해당 주제에 새 메일이 오면 자동으로 다시 추적됩니다.\n`); + return; + } + const ref = parseRef(rest[0] || ''); + if (!ref) { chunk(view, `\n사용법: \`/email ${kind === 'muted' ? 'mute' : kind === 'done' ? 'done' : 'unmute'} O2\` (먼저 \`/email\` 로 목록 로드)\n`); return; } + const topic = refToTopic(ref); + if (!topic) { chunk(view, `\n⚠️ ${rest[0]} 참조를 찾지 못했습니다 — 먼저 \`/email\` 을 실행하세요.\n`); return; } + const store = readEmailState(); + const t = setThreadState(store, topic, kind); + if (!t) { chunk(view, '\n⚠️ 추적 중인 주제가 아닙니다 — `/email` 로 상태를 먼저 갱신하세요.\n'); return; } + writeEmailState(store); + const label = kind === 'done' ? '✅ 완료 처리 (새 수신이 오면 자동 재개)' : kind === 'muted' ? '🔇 무시 처리 (이후 조용)' : '🔔 다시 추적'; + chunk(view, `\n${label}: **${t.subject}**\n`); +} + +// ── 서브커맨드 구현 ─────────────────────────────────────────────────────────── +async function cmdList(rest: string[], view: Webview, context?: vscode.ExtensionContext): Promise { + const provider = /^(gmail|outlook)$/i.test(rest[0] || '') ? rest[0].toLowerCase() : 'both'; + const daysTok = provider === 'both' ? rest[0] : rest[1]; + const days = Math.max(1, Math.min(90, parseInt(daysTok || '7', 10) || 7)); + const enabled = emailProviders(); + + if (provider !== 'outlook') { + if (!enabled.gmail) { + // 명시적으로 gmail 을 요청했을 때만 안내 — 기본(both)이면 조용히 스킵 (깔끔한 출력). + if (provider === 'gmail') chunk(view, '\nℹ️ Gmail 이 설정에서 비활성화돼 있습니다 (Settings → 연동 → 이메일).\n'); + } else if (context) await listGmail(view, context, days); + else chunk(view, '\n⚠️ Gmail: ExtensionContext 없음.\n'); + } + if (provider !== 'gmail') { + if (!enabled.outlook) { + if (provider === 'outlook') chunk(view, '\nℹ️ Outlook 이 설정에서 비활성화돼 있습니다 (Settings → 연동 → 이메일).\n'); + } else await listOutlook(view, days); + } + chunk(view, `\nℹ️ 이건 **원시 목록**입니다 — 내용·경과·할 일이 정리된 **비서 브리핑은 \`/email\`** 을 사용하세요.\n💡 읽기 \`/email read G1\` · 위키화 \`/email wikify O2\` · 회신초안 \`/email reply O2 [지시사항]\`\n`); +} + +async function cmdRead(rest: string[], view: Webview, context?: vscode.ExtensionContext): Promise { + const ref = parseRef(rest[0] || ''); + if (!ref) { chunk(view, '\n사용법: `/email read G3` 또는 `/email read O2`\n'); return; } + const loaded = await loadMails(ref, context); + if (!loaded.ok) { chunk(view, `\n⚠️ ${loaded.error}\n`); return; } + chunk(view, `\n📧 **${loaded.subject}** (메시지 ${loaded.mails.length}건)\n\n`); + for (const m of loaded.mails) { + chunk(view, `> **${m.date}** · ${m.from}${m.to ? ` → ${m.to}` : ''}\n\n${trunc(m.body, 3000)}\n\n`); + } +} + +async function cmdWikify(rest: string[], view: Webview, context?: vscode.ExtensionContext): Promise { + const ref = parseRef(rest[0] || ''); + if (!ref) { chunk(view, '\n사용법: `/email wikify G3` 또는 `/email wikify O2`\n'); return; } + const loaded = await loadMails(ref, context); + if (!loaded.ok) { chunk(view, `\n⚠️ ${loaded.error}\n`); return; } + + chunk(view, `\n🧪 **위키화** — "${trunc(loaded.subject, 50)}" P-Reinforce 정본 규격으로 합성 중…`); + const transcript = buildEmailTranscript(loaded.mails); + const meta = mailsMeta(loaded.mails); + const canonical = await getCanonicalFormat(); + let doc: string; + try { + doc = await callLmSynthesis( + buildEmailCanonicalWikifyPrompt(loaded.subject, transcript, meta, canonical), + WIKIFY_SYSTEM, + { maxTokens: 5000 }, + ); + } catch (e: any) { + chunk(view, ` ⚠️ LLM 합성 실패: ${e?.message || String(e)}\n`); + return; + } + if (!doc || !doc.trim()) { chunk(view, ' ⚠️ LLM 응답이 비어 있습니다.\n'); return; } + + try { + const today = new Date().toISOString().slice(0, 10); + // 제목 날짜 = 이메일 최신 날짜 + 기준 시점 헤더 (시점 오인 방지, v2.2.282). + const stamped = injectTimeHeader(doc.trim(), meta.dateRange, today); + chunk(view, ' ✓\n\n' + stamped + '\n\n'); + const saved = await persistWikiDoc(`이메일 ${trunc(loaded.subject, 60)} ${meta.latestYmd}`, stamped, { store: 'email', docType: 'email' }); + chunk(view, describeSave(saved, '지식 문서').replace('\n', ' — brain 검색에 반영됩니다.\n')); + } catch (e: any) { + chunk(view, `⚠️ 저장 실패(합성 결과는 위에 유지): ${e?.message || String(e)}\n`); + } +} + +async function cmdReply(rest: string[], view: Webview, context?: vscode.ExtensionContext): Promise { + const ref = parseRef(rest[0] || ''); + if (!ref) { chunk(view, '\n사용법: `/email reply O2 [지시사항]` (Outlook 전용)\n'); return; } + if (ref.provider === 'gmail') { + chunk(view, '\nℹ️ Gmail 은 **읽기 전용 스코프**라 초안 작성을 지원하지 않습니다 (Outlook 만 가능 — Drafts 저장, 발송 없음).\n'); + return; + } + const loaded = await loadMails(ref, context); + if (!loaded.ok || !loaded.entryId) { chunk(view, `\n⚠️ ${loaded.ok ? 'EntryID 없음' : loaded.error}\n`); return; } + const instruction = rest.slice(1).join(' '); + + chunk(view, `\n✍️ **회신 초안 작성** — "${trunc(loaded.subject, 50)}"…`); + let draft: string; + try { + draft = await callLmSynthesis( + buildReplyDraftPrompt(loaded.mails[0], instruction), + '당신은 한국어 비즈니스 이메일 초안 작성 보조 도구다. 원문에 없는 사실을 만들지 않는다. 회신 본문 텍스트만 출력한다.', + { maxTokens: 2048 }, + ); + } catch (e: any) { + chunk(view, ` ⚠️ LLM 실패: ${e?.message || String(e)}\n`); + return; + } + if (!draft || !draft.trim()) { chunk(view, ' ⚠️ LLM 응답이 비어 있습니다.\n'); return; } + chunk(view, ' ✓\n\n' + draft.trim() + '\n\n'); + + const r = await saveReplyDraft(loaded.entryId, draft.trim()); + chunk(view, r.ok + ? `📝 **Outlook ${r.data.savedTo}에 저장 완료** — 발송은 하지 않았습니다. Outlook 에서 검토·수정 후 직접 보내세요.\n` + : `⚠️ 초안 저장 실패: ${r.error}\n`); +} + +function cmdHelp(view: Webview): void { + chunk(view, [ + '', + '📧 **/email — Gmail + Outlook(클래식) 이메일**', + '', + '- `/email` — **받은편지함 현황** (누적 추적 — 오래된 미회신도 유지 · 대기시간 표시)', + '- `/email brief 14` — 14일 범위로 수집해 병합', + '- `/email scan [all|일수] [최대건수]` — **전체 스캔 + 자동 위키화** (사람 발신 스레드 → Email_Wiki, 회차당 기본 15건 · 재실행 시 이어서 · **문서를 지우면 자동 재위키화**)', + '- `/email scan reset` — 위키화 완료 기록 초기화 (처음부터 다시)', + '- `/email done O2` / `mute O3` / `unmute O3` — 완료·무시·재추적 (트리아지)', + '- `/email done old 30` — 30일 이상 미회신 **일괄 완료** (스캔이 쌓은 과거 백로그 정리)', + '- `/email 1` — 기간을 명시하면 **표시도 그 범위로 제한** (무인자 `/email` 은 누적 전체)', + '- `/email list [gmail|outlook] [일수]` — 원시 목록 (G1../O1.. 번호)', + '- `/email read O2` — 내용 읽기 (G=Gmail 스레드, O=Outlook)', + '- `/email open O216` — **원본 메일을 Outlook 창으로 열기** (Gmail 은 브라우저)', + '- `/email wikify O2` — 지식 문서로 위키화 → brain 반영', + '- `/email reply O2 [지시사항]` — Outlook 회신 초안 → **Drafts 저장만** (발송 안 함)', + '', + '- 🕐 백그라운드 워처가 주기(기본 60분)마다 자동 수집·추적하고, 미회신이 임계(기본 24h)를 넘으면 텔레그램으로 1회 알립니다. (`g1nation.email.*` 설정)', + '- Gmail: Google OAuth 재연결 1회 필요 (gmail.readonly 동의 — Astra Settings → Google 연결)', + '- Outlook: 클래식 데스크톱 Outlook 필요 ("새 Outlook" 토글 OFF) — 별도 인증 없음', + '', + ].join('\n')); +} + +async function runEmail(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise { + const parts = arg.trim().split(/\s+/).filter(Boolean); + const sub = (parts[0] || '').toLowerCase(); + const rest = parts.slice(1); + try { + switch (sub) { + case '': case 'brief': await cmdBrief(rest, view, context); break; + case 'help': cmdHelp(view); break; + case 'list': await cmdList(rest, view, context); break; + case 'read': await cmdRead(rest, view, context); break; + case 'open': await cmdOpen(rest, view); break; + case 'wikify': await cmdWikify(rest, view, context); break; + case 'reply': case 'draft': await cmdReply(rest, view, context); break; + case 'scan': await cmdScan(rest, view, context); break; + case 'done': await cmdTriage('done', rest, view); break; + case 'mute': await cmdTriage('muted', rest, view); break; + case 'unmute': await cmdTriage('waiting', rest, view); break; + case 'sync': + // 워처 사이클 즉시 1회 (수집→상태 병합→임계 알림) + if (context) { chunk(view, '\n🔄 이메일 워처 사이클 실행 중…\n'); await emailWatcherFireOnce(context); chunk(view, '완료 — `/email` 로 현황을 확인하세요.\n'); } + else chunk(view, '\n⚠️ ExtensionContext 없음.\n'); + break; + default: + // `/email 14` → 14일 브리핑, `/email gmail 7` → 목록. 그 외는 도움말. + if (/^\d+$/.test(sub)) await cmdBrief(parts, view, context); + else if (/^(gmail|outlook)$/.test(sub)) await cmdList(parts, view, context); + else cmdHelp(view); + } + } catch (e: any) { + chunk(view, `\n❌ /email 오류: ${e?.message || String(e)}\n`); + } + return true; +} + +registerSlashCommand({ + name: '/email', + description: 'Gmail + Outlook(클래식) — 받은편지함 브리핑(주제·답변여부 정리)·읽기·위키화·회신초안', + handler: runEmail, +}); diff --git a/src/features/email/outlookApi.ts b/src/features/email/outlookApi.ts new file mode 100644 index 0000000..b01caec --- /dev/null +++ b/src/features/email/outlookApi.ts @@ -0,0 +1,276 @@ +/** + * ============================================================ + * Outlook (클래식 데스크톱) — COM 자동화 via PowerShell (v2.2.269) + * + * 사용자 PC에 설치된 클래식 Outlook 을 COM 으로 직접 읽는다 — 별도 인증 없음 + * (Outlook 이 이미 로그인돼 있으면 그대로 동작). Graph API 미사용 (Azure 앱 + * 등록·관리자 동의가 필요해 개인/사내 환경에서 부담). + * + * 안전 원칙: + * - 읽기 + '초안 저장(Drafts)' 만. **발송(Send) 코드는 이 파일에 존재하지 않는다.** + * - EntryID 는 우리가 목록에서 받은 hex 문자열만 인라인 (임의 입력 스크립트 주입 차단). + * - 자유 텍스트(초안 본문)는 Base64 로 넘겨 PowerShell 인용 문제 원천 차단. + * + * 제약: "새 Outlook" (Monarch) 은 COM 미지원 — 클래식 Outlook 전용. 미설치/미실행 + * 환경이면 COM 생성 단계에서 에러 → 호출자가 안내 메시지로 변환. + * ============================================================ + */ + +import { execFile } from 'child_process'; + +export interface OutlookMail { + entryId: string; + subject: string; + from: string; + to?: string; + /** 'YYYY-MM-DD HH:mm' (로컬). */ + received: string; + unread: boolean; + snippet: string; + body?: string; + /** 받은편지함 하위 폴더에서 수집된 경우 폴더명 (루트는 빈 값). v2.2.281 */ + folder?: string; + /** 내가 To 가 아니라 CC/참조로만 수신 (v2.2.290) — 회신 대상 아님 → 확인만 분류. */ + ccOnly?: boolean; +} + +type OlResult = { ok: true; data: T } | { ok: false; error: string }; + +const OUTLOOK_HINT = + '클래식 Outlook COM 연결 실패 — 데스크톱 Outlook(클래식)이 설치·로그인돼 있어야 합니다. ' + + '"새 Outlook"(New Outlook 토글 ON)은 COM 을 지원하지 않으니 클래식 모드로 전환하세요.'; + +/** PowerShell 스크립트 실행 → stdout(UTF-8). COM 초기화 포함 공통 프리앰블. */ +function runPs(script: string, timeoutMs = 30_000): Promise> { + const full = [ + '$ErrorActionPreference = "Stop"', + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + 'try {', + ' $ol = New-Object -ComObject Outlook.Application', + ' $ns = $ol.GetNamespace("MAPI")', + script, + '} catch { Write-Error $_.Exception.Message; exit 1 }', + ].join('\n'); + return new Promise((resolve) => { + execFile( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', full], + { timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024, windowsHide: true }, + (err, stdout, stderr) => { + if (err) { + const msg = (stderr || err.message || '').trim(); + const comFail = /80080005|REGDB_E_CLASSNOTREG|Outlook\.Application|CO_E_SERVER_EXEC_FAILURE/i.test(msg); + resolve({ ok: false, error: comFail ? OUTLOOK_HINT : (msg || 'PowerShell 실행 실패') }); + return; + } + resolve({ ok: true, data: (stdout || '').trim() }); + }, + ); + }); +} + +/** ConvertTo-Json 이 단일 항목일 때 객체를 주므로 배열로 정규화. */ +function asArray(parsed: unknown): any[] { + if (Array.isArray(parsed)) return parsed; + if (parsed && typeof parsed === 'object') return [parsed]; + return []; +} + +/** EntryID 검증 — Outlook EntryID 는 hex 문자열. 스크립트 인라인 전 필수. */ +function safeEntryId(id: string): string | null { + return /^[0-9A-Fa-f]{8,}$/.test(id) ? id : null; +} + +/** + * 받은편지함 최근 목록 (기본 7일, 최대 max건). 전체 스캔은 days≤3650·max≤300. + * v2.2.281: includeSubfolders(기본 true)면 받은편지함의 **모든 하위 폴더(중첩 포함)**를 + * BFS 로 순회 — 규칙(필터)으로 분류된 메일도 수집된다. 폴더별로 최신순 정렬 후 + * 기간 컷, 전체 병합 → 최신순 → max 컷. 폴더 수는 120개 가드. + */ +export async function listInbox(days = 7, max = 25, includeSubfolders = true): Promise> { + const d = Math.max(1, Math.min(3650, Math.floor(days))); + const m = Math.max(1, Math.min(300, Math.floor(max))); + const script = ` + # v2.2.290: 모든 계정(Store)의 받은편지함 수집 — 다중 계정에서 메일 누락 방지. + $myName = "" + try { $myName = "" + $ns.CurrentUser.Name } catch {} + $inboxes = New-Object System.Collections.ArrayList + foreach ($store in $ns.Stores) { + try { [void]$inboxes.Add($store.GetDefaultFolder(6)) } catch {} + } + if ($inboxes.Count -eq 0) { [void]$inboxes.Add($ns.GetDefaultFolder(6)) } + $rootIds = New-Object System.Collections.ArrayList + $targets = New-Object System.Collections.ArrayList + foreach ($ib in $inboxes) { [void]$targets.Add($ib); [void]$rootIds.Add($ib.EntryID) } + ${includeSubfolders ? ` + $queue = New-Object System.Collections.Queue + foreach ($ib in $inboxes) { $queue.Enqueue($ib) } + while ($queue.Count -gt 0 -and $targets.Count -lt 120) { + $f = $queue.Dequeue() + foreach ($sub in $f.Folders) { + if ($targets.Count -ge 120) { break } + [void]$targets.Add($sub) + $queue.Enqueue($sub) + } + }` : ''} + $cut = (Get-Date).AddDays(-${d}) + $out = @() + foreach ($folder in $targets) { + $isRoot = $rootIds.Contains($folder.EntryID) + $items = $folder.Items + try { $items.Sort("[ReceivedTime]", $true) } catch { continue } + $count = 0 + foreach ($it in $items) { + if ($count -ge ${m}) { break } + if ($it.MessageClass -notlike "IPM.Note*") { continue } + $rt = $null + try { $rt = $it.ReceivedTime } catch {} + if (-not $rt) { continue } + if ($rt -lt $cut) { break } + $b = "" + try { $b = ($it.Body -replace "\\s+", " ").Trim() } catch {} + $sn = $b.Substring(0, [Math]::Min(120, $b.Length)) + # To/CC 판별 — To 문자열에 내 이름이 없으면 CC/참조 수신 (내 이름 미확인 시 To 로 간주) + $toStr = "" + try { $toStr = "" + $it.To } catch {} + $ccOnly = $false + if ($myName -ne "" -and $toStr -ne "") { $ccOnly = -not ($toStr -like ("*" + $myName + "*")) } + $out += [pscustomobject]@{ + entryId = $it.EntryID + subject = "" + $it.Subject + from = "" + $it.SenderName + received = $rt.ToString("yyyy-MM-dd HH:mm") + unread = [bool]$it.UnRead + snippet = $sn + folder = if ($isRoot) { "" } else { "" + $folder.Name } + ccOnly = $ccOnly + } + $count++ + } + } + $out | Sort-Object -Property received -Descending | Select-Object -First ${m} | ConvertTo-Json -Depth 3 -Compress`; + const r = await runPs(script, 90_000); + if (!r.ok) return r; + if (!r.data) return { ok: true, data: [] }; + try { + return { ok: true, data: asArray(JSON.parse(r.data)) as OutlookMail[] }; + } catch { + return { ok: false, error: 'Outlook 응답 JSON 파싱 실패' }; + } +} + +/** + * 보낸편지함(Sent Items, folder 5) 최근 목록 — 브리핑의 '답변 여부' 판정용. + * 같은 주제의 마지막 발신 시각을 알아야 "내가 회신했는지"를 판단할 수 있다. + */ +export async function listSent(days = 7, max = 40): Promise> { + const d = Math.max(1, Math.min(3650, Math.floor(days))); + const m = Math.max(1, Math.min(200, Math.floor(max))); + const script = ` + # v2.2.290: 모든 계정(Store)의 보낸편지함 — 다른 계정에서 보낸 회신도 답변으로 인식. + $sents = New-Object System.Collections.ArrayList + foreach ($store in $ns.Stores) { + try { [void]$sents.Add($store.GetDefaultFolder(5)) } catch {} + } + if ($sents.Count -eq 0) { [void]$sents.Add($ns.GetDefaultFolder(5)) } + $cut = (Get-Date).AddDays(-${d}) + $out = @() + foreach ($sent in $sents) { + $items = $sent.Items + try { $items.Sort("[SentOn]", $true) } catch { continue } + $count = 0 + foreach ($it in $items) { + if ($count -ge ${m}) { break } + if ($it.MessageClass -notlike "IPM.Note*") { continue } + $when = $null + try { $when = $it.SentOn } catch {} + if (-not $when) { continue } + if ($when -lt $cut) { break } + $out += [pscustomobject]@{ + entryId = $it.EntryID + subject = "" + $it.Subject + from = "나" + to = "" + $it.To + received = $when.ToString("yyyy-MM-dd HH:mm") + unread = $false + snippet = "" + } + $count++ + } + } + $out | Sort-Object -Property received -Descending | Select-Object -First ${m} | ConvertTo-Json -Depth 3 -Compress`; + const r = await runPs(script); + if (!r.ok) return r; + if (!r.data) return { ok: true, data: [] }; + try { + return { ok: true, data: asArray(JSON.parse(r.data)) as OutlookMail[] }; + } catch { + return { ok: false, error: 'Outlook 응답 JSON 파싱 실패' }; + } +} + +/** + * 메일을 Outlook 창으로 직접 연다 (v2.2.289) — 브리핑의 [O번호]에서 실제 메일로 + * 점프하는 다리. Display() 는 읽기용 인스펙터 창만 띄운다 (발송·수정 없음). + */ +export async function openMailInOutlook(entryId: string): Promise> { + const id = safeEntryId(entryId); + if (!id) return { ok: false, error: '잘못된 EntryID' }; + const script = ` + $it = $ns.GetItemFromID("${id}") + $it.Display() + "OK"`; + const r = await runPs(script); + if (!r.ok) return r; + return r.data.includes('OK') ? { ok: true, data: true } : { ok: false, error: '열기 확인 실패' }; +} + +/** 메일 1건 전체 읽기 (본문 12,000자 컷). */ +export async function readMail(entryId: string): Promise> { + const id = safeEntryId(entryId); + if (!id) return { ok: false, error: '잘못된 EntryID' }; + const script = ` + $it = $ns.GetItemFromID("${id}") + $b = "" + $it.Body + [pscustomobject]@{ + entryId = $it.EntryID + subject = "" + $it.Subject + from = "" + $it.SenderName + to = "" + $it.To + received = $it.ReceivedTime.ToString("yyyy-MM-dd HH:mm") + unread = [bool]$it.UnRead + snippet = "" + body = $b.Substring(0, [Math]::Min(12000, $b.Length)) + } | ConvertTo-Json -Depth 3 -Compress`; + const r = await runPs(script); + if (!r.ok) return r; + try { + const arr = asArray(JSON.parse(r.data)); + if (!arr.length) return { ok: false, error: '메일을 찾지 못했습니다' }; + return { ok: true, data: arr[0] as OutlookMail }; + } catch { + return { ok: false, error: 'Outlook 응답 JSON 파싱 실패' }; + } +} + +/** + * 회신 초안을 **Drafts(임시 보관함)에 저장만** 한다 — 발송하지 않음. + * 본문은 Base64 로 전달 (한글·따옴표·개행 안전). + */ +export async function saveReplyDraft(entryId: string, bodyText: string): Promise> { + const id = safeEntryId(entryId); + if (!id) return { ok: false, error: '잘못된 EntryID' }; + const b64 = Buffer.from(bodyText, 'utf-8').toString('base64'); + const script = ` + $it = $ns.GetItemFromID("${id}") + $reply = $it.Reply() + $draft = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("${b64}")) + $reply.Body = $draft + "\`r\`n\`r\`n" + $reply.Body + $reply.Save() + "OK"`; + const r = await runPs(script); + if (!r.ok) return r; + return r.data.includes('OK') + ? { ok: true, data: { savedTo: '임시 보관함(Drafts)' } } + : { ok: false, error: '초안 저장 확인 실패' }; +} diff --git a/src/features/settings/settingsPanelProvider.ts b/src/features/settings/settingsPanelProvider.ts index 744431d..41e1f45 100644 --- a/src/features/settings/settingsPanelProvider.ts +++ b/src/features/settings/settingsPanelProvider.ts @@ -103,6 +103,7 @@ interface SettingsState { bridgeNasToken: string; /** Empty → results saved to the Bridge's WIKI_RAW_PATH default. */ savePath: string; + emailWikifySavePath: string; crawlDepth: number; maxPages: number; synthesisTemperature: number; @@ -131,6 +132,18 @@ interface SettingsState { anthropic: { enabled: boolean; hasApiKey: boolean; defaultModel: string }; gemini: { enabled: boolean; hasApiKey: boolean; defaultModel: string }; }; + email: { + outlookEnabled: boolean; + gmailEnabled: boolean; + }; + messenger: { + stocksReportTimes: string; + briefingEnabled: boolean; + briefingTime: string; + buySignalAlert: boolean; + discoverTopFive: boolean; + emailOverdueHours: number; + }; devilAgent: { enabled: boolean; }; @@ -261,6 +274,10 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider { case 'advanced.update': await this._handleAdvancedUpdate(msg); return; + case 'messenger.update': + await this._handleMessengerUpdate(msg); + await this._refreshState(); + break; case 'datacollect.update': await this._handleDatacollectUpdate(msg); return; @@ -616,6 +633,29 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider { // /research·/benchmark·/youtube 가 호출하는 Bridge URL 과, 결과물 저장 위치. // savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다. + /** 메신저 탭 (v2.2.284) — 발송 스케줄·알림 토글. */ + private async _handleMessengerUpdate(msg: any): Promise { + if (typeof msg.stocksReportTimes === 'string') { + await this._safeConfigUpdate('messenger.stocksReportTimes', msg.stocksReportTimes.trim()); + } + if (typeof msg.briefingEnabled === 'boolean') { + await this._safeConfigUpdate('dailyBriefing.enabled', msg.briefingEnabled); + } + if (typeof msg.briefingTime === 'string' && /^\d{1,2}:\d{2}$/.test(msg.briefingTime.trim())) { + await this._safeConfigUpdate('dailyBriefing.time', msg.briefingTime.trim()); + } + if (typeof msg.buySignalAlert === 'boolean') { + await this._safeConfigUpdate('messenger.buySignalAlert', msg.buySignalAlert); + } + if (typeof msg.discoverTopFive === 'boolean') { + await this._safeConfigUpdate('messenger.discoverTopFive', msg.discoverTopFive); + } + if (typeof msg.emailOverdueHours === 'number' && Number.isFinite(msg.emailOverdueHours)) { + await this._safeConfigUpdate('email.overdueAlertHours', Math.max(0, Math.floor(msg.emailOverdueHours))); + } + } + + private async _handleDatacollectUpdate(msg: any): Promise { if (typeof msg.bridgeTarget === 'string') { const t = msg.bridgeTarget.trim() === 'nas' ? 'nas' : 'local'; @@ -633,6 +673,15 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider { if (typeof msg.savePath === 'string') { await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim()); } + if (typeof msg.emailWikifySavePath === 'string') { + await this._safeConfigUpdate('email.wikifySavePath', msg.emailWikifySavePath.trim()); + } + if (typeof msg.emailOutlookEnabled === 'boolean') { + await this._safeConfigUpdate('email.outlookEnabled', msg.emailOutlookEnabled); + } + if (typeof msg.emailGmailEnabled === 'boolean') { + await this._safeConfigUpdate('email.gmailEnabled', msg.emailGmailEnabled); + } if (typeof msg.crawlDepth === 'number' && Number.isFinite(msg.crawlDepth)) { await this._safeConfigUpdate('datacollectCrawlDepth', Math.max(0, Math.min(3, Math.floor(msg.crawlDepth)))); } @@ -702,12 +751,25 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider { bridgeNasUrl: cfg.get('datacollectBridgeNasUrl', '') || '', bridgeNasToken: cfg.get('datacollectBridgeNasToken', '') || '', savePath: cfg.get('datacollectSavePath', '') || '', + emailWikifySavePath: cfg.get('email.wikifySavePath', '') || '', crawlDepth: cfg.get('datacollectCrawlDepth', 1) ?? 1, maxPages: cfg.get('datacollectMaxPages', 8) ?? 8, synthesisTemperature: cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1, }, google: this._buildGoogleState(), providers: await this._buildProvidersState(), + email: { + outlookEnabled: cfg.get('email.outlookEnabled', true) !== false, + gmailEnabled: cfg.get('email.gmailEnabled', true) !== false, + }, + messenger: { + stocksReportTimes: cfg.get('messenger.stocksReportTimes', '09:00,15:00') || '', + briefingEnabled: cfg.get('dailyBriefing.enabled', true) !== false, + briefingTime: cfg.get('dailyBriefing.time', '09:30') || '09:30', + buySignalAlert: cfg.get('messenger.buySignalAlert', true) !== false, + discoverTopFive: cfg.get('messenger.discoverTopFive', true) !== false, + emailOverdueHours: cfg.get('email.overdueAlertHours', 0) ?? 0, + }, devilAgent: { enabled: cfg.get('devilAgent.enabled', false) }, bannerError: this._bannerError, }; diff --git a/src/features/stocks/discoveryAnalyzer.ts b/src/features/stocks/discoveryAnalyzer.ts index fe4fa53..f4dc2c1 100644 --- a/src/features/stocks/discoveryAnalyzer.ts +++ b/src/features/stocks/discoveryAnalyzer.ts @@ -209,30 +209,27 @@ export function renderTopFiveForTelegram( result: TopFiveResult, rangeLabel: string, ): string { + // v2.2.284: 중앙 포맷(messageFormat.renderTelegram) 사용 — 텔레그램 메시지 포맷 통일. + const { renderTelegram } = require('../../integrations/telegram/messageFormat') as typeof import('../../integrations/telegram/messageFormat'); if (result.items.length === 0) { - return `🎯 *Astra 발굴 Top 5* (${rangeLabel})\n${formatKstNow()}\n\n⚠️ LLM 분석 실패 — 채팅 창에서 raw 결과 확인.`; + return renderTelegram({ + emoji: '🎯', title: `Astra 발굴 Top 5 (${rangeLabel})`, timestamp: true, + sections: [{ lines: ['⚠️ LLM 분석 실패 — 채팅 창에서 raw 결과 확인.'] }], + }); } - const lines: string[] = [ - `🎯 *Astra 발굴 Top 5* (${rangeLabel})`, - formatKstNow(), - '', - ]; - for (const it of result.items) { - lines.push(`${it.rank}\\. *${it.name}* (\`${it.symbol}\`)`); - lines.push(` ${it.pitch}`); - if (it.candidate) { - const f = it.candidate.fundamentals; - const sc = it.candidate.score; - lines.push(` 종합 ${sc.total} (${sc.breakdown})${sc.valueTrapGuarded ? ' ⚠️VT가드' : ''}`); - lines.push(` ROE ${f.roe?.toFixed(1) ?? '-'}% · OM ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보 ${f.retentionRatio?.toLocaleString() ?? '-'}%`); - } - lines.push(''); - } - if (result.summary) { - lines.push(`💬 ${result.summary}`); - } - const joined = lines.join('\n'); - return joined.length > 3800 ? joined.slice(0, 3800) + '\n…(잘림)' : joined; + return renderTelegram({ + emoji: '🎯', title: `Astra 발굴 Top 5 (${rangeLabel})`, timestamp: true, + sections: [{ + items: result.items.map(it => { + const sc = it.candidate?.score; + return { + main: `*${it.name}* (\`${it.symbol}\`)${sc ? ` — 종합 ${sc.total}` : ''}`, + sub: `${it.pitch}${sc?.valueTrapGuarded ? ' ⚠️VT가드' : ''}`, + }; + }), + }], + footer: result.summary ? `💬 ${result.summary}` : undefined, + }); } export async function sendTopFiveToTelegram( diff --git a/src/features/stocks/signalClassifier.ts b/src/features/stocks/signalClassifier.ts index 8b1de4c..539582d 100644 --- a/src/features/stocks/signalClassifier.ts +++ b/src/features/stocks/signalClassifier.ts @@ -32,7 +32,10 @@ export function classifyStock(s: Stock): ClassifiedStock { const curPrice = parsePrice(s.현재가); const recPrice = parsePrice(s.매수권장가); const isPriceInZone = curPrice > 0 && recPrice > 0 && curPrice <= recPrice; - const filterPass = (s['3/4 필터'] || '').toString().includes('충족'); + // v2.2.268 버그 수정: '미충족' 이 '충족' 을 부분문자열로 포함해 미충족 종목이 + // 필터 통과로 오분류되던 문제 — 미충족 명시를 우선 배제. + const filterRaw = (s['3/4 필터'] || '').toString(); + const filterPass = filterRaw.includes('충족') && !filterRaw.includes('미충족'); let signal: Signal = 'HOLD'; let sortScore: 0 | 1 | 2 = 0; @@ -50,6 +53,25 @@ export function classifyStock(s: Stock): ClassifiedStock { }; } +/** + * BUY_ZONE **진입 전이** 감지 (v2.2.268) — 관망/고평가였다가 이번 사이클에 + * 매수사정권으로 바뀐 종목만 반환한다. store 의 각 종목 `직전신호`를 이번 신호로 + * **제자리 갱신**하므로, 호출자는 반환 후 writeStocksStore 로 저장해야 다음 + * 사이클에서 같은 종목이 중복 알림되지 않는다. + * + * 첫 실행(직전신호 미기록)에 이미 매수사정권인 종목도 '진입'으로 1회 알림 — + * 업그레이드 직후 현재 기회를 놓치지 않게 하는 의도된 동작. + */ +export function detectBuyZoneEntries(store: Stock[]): ClassifiedStock[] { + const entered: ClassifiedStock[] = []; + for (const s of store) { + const c = classifyStock(s); + if (c.signal === 'BUY_ZONE' && s.직전신호 !== 'BUY_ZONE') entered.push(c); + s.직전신호 = c.signal; + } + return entered; +} + /** 전체 분류 + 투자성향별 정렬. 텔레그램 / 시트 동기화 둘 다 이걸 호출. */ export function classifyAll(stocks: Stock[]): { swing: ClassifiedStock[]; diff --git a/src/features/stocks/slashStocks.ts b/src/features/stocks/slashStocks.ts index ecc9d18..7d0cd44 100644 --- a/src/features/stocks/slashStocks.ts +++ b/src/features/stocks/slashStocks.ts @@ -585,6 +585,29 @@ async function cmdDiscover(rest: string, view: Webview | undefined, context?: vs chunk(view, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}\n\n`); } + // ── 워치리스트 자동 편입 (v2.2.268, g1nation.stocks.discoverAutoAdd 기본 ON) ── + // 발굴 후보를 stocks.json 에 자동 추가 — 매수권장가(224일선 기반)까지 자동 설정돼 + // 워처가 매수사정권 진입을 감시할 수 있는 상태로 편입. 기존 심볼은 건드리지 않음 + // (사용자가 수동으로 다듬은 매수권장가·특이사항 보존). + const autoAdd = vscode.workspace.getConfiguration('g1nation').get('stocks.discoverAutoAdd', true); + if (autoAdd) { + const store = readStocksStore(); + const existing = new Set(store.map(s => s.심볼)); + const fresh = candidates.filter(c => !existing.has(c.symbol)); + if (fresh.length > 0) { + store.push(...fresh.map(c => c.asStock)); + if (writeStocksStore(store)) { + const withRec = fresh.filter(c => c.asStock.매수권장가).length; + chunk(view, `\n📥 **워치리스트 자동 편입**: 신규 ${fresh.length}개 추가 (기존 ${candidates.length - fresh.length}개 스킵) · 매수권장가 자동 설정 ${withRec}개 (224일선 기반)\n`); + chunk(view, ` → 워처(09:00/15:00 KST)가 매수사정권 진입 시 텔레그램으로 알립니다.\n`); + } else { + chunk(view, `\n⚠️ 자동 편입 실패 — stocks.json 쓰기 불가 (워크스페이스 확인).\n`); + } + } else { + chunk(view, `\n📥 자동 편입: 후보 전원이 이미 워치리스트에 있어 추가 없음.\n`); + } + } + // ── LLM 매력도 분석 + 텔레그램 전송 (자동 chain) ── // 사용자 의도: 발굴 목록이 나오면 *항상* 분석 + 텔레그램. 별도 명령 trigger 불필요. // 실패해도 (LLM timeout / 텔레그램 미설정) 위 발굴 목록은 화면에 그대로 남아 있음. @@ -592,7 +615,10 @@ async function cmdDiscover(rest: string, view: Webview | undefined, context?: vs const topFive = await analyzeTopCandidates(candidates, (msg) => chunk(view, msg + '\n')); chunk(view, renderTopFiveForChat(topFive)); - if (context) { + const tgTopFiveOn = vscode.workspace.getConfiguration('g1nation').get('messenger.discoverTopFive', true) !== false; + if (!tgTopFiveOn) { + // 텔레그램 발송 꺼짐 (Settings → 메신저) — 채팅 표시는 위에서 이미 완료. + } else if (context) { const rangeLabel = `시총 ${minCap.toLocaleString()}-${maxCap.toLocaleString()}억${sectorGroup ? ` · ${sectorGroup.key}` : ''}`; const tgText = renderTopFiveForTelegram(topFive, rangeLabel); const tgResult = await sendTopFiveToTelegram(context, tgText); @@ -603,7 +629,7 @@ async function cmdDiscover(rest: string, view: Webview | undefined, context?: vs chunk(view, '\n⚠️ 텔레그램 발송 skip: ExtensionContext 없음.\n'); } - chunk(view, '\n💡 종목을 stocks.json 에 추가하려면 `/stocks add <심볼> <이름>` 사용.\n'); + chunk(view, '\n💡 후보는 워치리스트에 자동 편입됨 (`g1nation.stocks.discoverAutoAdd` 로 끄기 가능). 제거는 `/stocks remove <심볼>`.\n'); } function cmdPath(view: Webview | undefined): void { diff --git a/src/features/stocks/stockDiscovery.ts b/src/features/stocks/stockDiscovery.ts index f764039..7f7c7e8 100644 --- a/src/features/stocks/stockDiscovery.ts +++ b/src/features/stocks/stockDiscovery.ts @@ -83,6 +83,22 @@ function evaluateKeywords(f: Fundamentals): string[] { return passed; } +/** + * 발굴 종목의 매수권장가 자동 산출 (v2.2.268) — 자동 편입된 종목이 매수 시그널을 + * 받을 수 있게 하는 핵심 (매수권장가 없으면 signalClassifier 가 영원히 BUY_ZONE 판정 불가). + * 규칙(224일선 기반 — "224선 바닥 안착 시 매수" 철학과 일관): + * - MA224 있고 현재가 > MA224 → MA224 (지지선 회귀 시 매수) + * - MA224 있고 현재가 ≤ MA224 → 현재가 × 0.97 (이미 지지선 아래 — 소폭 조정 시) + * - 시세 부족(MA224 없음) → 현재가 × 0.90 (10% 조정선 폴백) + */ +export function computeRecommendedBuy(currentPrice?: number, ma224?: number): number | undefined { + if (currentPrice === undefined || !Number.isFinite(currentPrice) || currentPrice <= 0) return undefined; + if (ma224 !== undefined && Number.isFinite(ma224) && ma224 > 0) { + return currentPrice > ma224 ? Math.round(ma224) : Math.round(currentPrice * 0.97); + } + return Math.round(currentPrice * 0.90); +} + /** Fundamentals → Stock (stocks.json 호환 형식) 변환. */ function fundamentalsToStock(entry: ScreenerEntry, f: Fundamentals, filterText: string): Stock { return { @@ -154,7 +170,7 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise { + // (7) DiscoveredCandidate 변환 + 점수 부착 + 매수권장가 자동 산출(224일선 기반). + const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed, ma224 }, i) => { const top3 = passed.slice(0, 3); const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`; + const asStock = fundamentalsToStock(entry, f, filterText); + const recBuy = computeRecommendedBuy(f.currentPrice, ma224); + if (recBuy !== undefined) asStock.매수권장가 = recBuy.toLocaleString(); return { symbol: entry.symbol, name: entry.name, market: entry.market, marketCapEok: entry.marketCapEok, passedKeywords: passed, - asStock: fundamentalsToStock(entry, f, filterText), + asStock, fundamentals: f, score: scores[i], }; diff --git a/src/features/stocks/stocksWatcher.ts b/src/features/stocks/stocksWatcher.ts index 82438f1..d99155b 100644 --- a/src/features/stocks/stocksWatcher.ts +++ b/src/features/stocks/stocksWatcher.ts @@ -2,7 +2,8 @@ import * as vscode from 'vscode'; import { logError, logInfo } from '../../utils'; import { readStocksStore, writeStocksStore } from './stocksStore'; import { fetchAllPrices } from './yahooClient'; -import { sendStocksReport } from './telegramReport'; +import { sendStocksReport, sendBuySignalAlert } from './telegramReport'; +import { detectBuyZoneEntries } from './signalClassifier'; import { syncToSheets } from './sheetsSync'; /** @@ -20,7 +21,13 @@ import { syncToSheets } from './sheetsSync'; * 트리거 시각 변경 시 SCHEDULE 만 수정 — 분 단위. */ -const SCHEDULE_HOURS_KST = [9, 15]; // 09:00, 15:00 KST +// v2.2.284: 하드코딩 [9, 15] → 설정 `g1nation.messenger.stocksReportTimes` +// ("HH:MM,HH:MM" CSV, KST). 빈 값이면 정기 실행 없음 (수동 /stocks run 만). +import { parseTimesCsv } from '../../integrations/telegram/messageFormat'; +function scheduleTimesKst(): { hour: number; minute: number }[] { + const cfg = vscode.workspace.getConfiguration('g1nation'); + return parseTimesCsv(cfg.get('messenger.stocksReportTimes', '09:00,15:00') || ''); +} let _timer: NodeJS.Timeout | undefined; let _disposed = false; @@ -49,13 +56,15 @@ function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } { * 시간대 변환: 사용자 OS 가 KST 가 아닐 수 있으므로 KST 기준 hour/minute 으로 비교 후 * 그 차이를 ms 로 변환 (변환은 단순 산수 — 분 차이 × 60000). */ -function msUntilNextRun(): number { +function msUntilNextRun(): number | null { + const times = scheduleTimesKst(); + if (times.length === 0) return null; // 스케줄 비움 = 정기 실행 없음 const kst = nowInKst(); const todayMinutes = kst.hour * 60 + kst.minute; let bestTodayMinutes: number | null = null; - for (const h of SCHEDULE_HOURS_KST) { - const targetMinutes = h * 60; + for (const t of times) { + const targetMinutes = t.hour * 60 + t.minute; if (targetMinutes > todayMinutes) { bestTodayMinutes = targetMinutes; break; @@ -66,7 +75,7 @@ function msUntilNextRun(): number { return (bestTodayMinutes - todayMinutes) * 60_000; } // 오늘 더 없음 → 내일 첫 시각. - const tomorrowFirstMinutes = SCHEDULE_HOURS_KST[0] * 60; + const tomorrowFirstMinutes = times[0].hour * 60 + times[0].minute; const remainingTodayMinutes = 24 * 60 - todayMinutes; return (remainingTodayMinutes + tomorrowFirstMinutes) * 60_000; } @@ -79,7 +88,7 @@ async function fireOnce(context: vscode.ExtensionContext): Promise { const kst = nowInKst(); logInfo('Stocks watcher fire 시작.', { kst: `${kst.hour}:${kst.minute}` }); - // (1) Yahoo 가격 갱신 + // (1) Yahoo 가격 갱신 + BUY_ZONE 진입 전이 감지 (v2.2.268) try { const store = readStocksStore(); const symbols = store.map(s => s.심볼).filter(Boolean); @@ -91,10 +100,22 @@ async function fireOnce(context: vscode.ExtensionContext): Promise { const p = prices.get(s.심볼); if (typeof p === 'number') s.현재가 = p; } + // 관망/고평가 → 매수사정권 전이 감지 (직전신호를 제자리 갱신 → 아래 write 로 저장) + const entered = detectBuyZoneEntries(store); writeStocksStore(store); + if (entered.length > 0) { + logInfo('Stocks watcher: 매수 시그널 진입 감지.', { symbols: entered.map(s => s.심볼).join(',') }); + const alertOn = vscode.workspace.getConfiguration('g1nation').get('messenger.buySignalAlert', true) !== false; + if (alertOn) { + const r = await sendBuySignalAlert(context, entered); + if (!r.ok) logInfo(`Stocks watcher: 진입 알림 skip — ${r.reason}`); + } else { + logInfo('Stocks watcher: 진입 알림 꺼짐 (messenger.buySignalAlert=false).'); + } + } } } catch (e: any) { - logError('Stocks watcher: 가격 갱신 실패.', { error: e?.message ?? String(e) }); + logError('Stocks watcher: 가격 갱신/전이 감지 실패.', { error: e?.message ?? String(e) }); } // (2) Sheets sync — 선택 (spreadsheetId 설정 시에만) @@ -120,6 +141,12 @@ async function fireOnce(context: vscode.ExtensionContext): Promise { function scheduleNext(context: vscode.ExtensionContext): void { if (_disposed) return; const ms = msUntilNextRun(); + if (ms === null) { + // 스케줄 비움 — 1시간 뒤 재확인 (사용자가 설정을 다시 채우면 자동 복귀). + logInfo('Stocks watcher: 스케줄 비어 있음 (messenger.stocksReportTimes) — 1시간 뒤 재확인.'); + _timer = setTimeout(() => scheduleNext(context), 3600_000); + return; + } const hours = Math.floor(ms / 3600_000); const minutes = Math.floor((ms % 3600_000) / 60_000); logInfo(`Stocks watcher: 다음 firing 까지 ${hours}h ${minutes}m.`); @@ -147,7 +174,7 @@ export function startStocksWatcher(context: vscode.ExtensionContext): vscode.Dis } _disposed = false; scheduleNext(context); - logInfo('Stocks watcher: 시작됨.', { schedule: SCHEDULE_HOURS_KST }); + logInfo('Stocks watcher: 시작됨.', { schedule: scheduleTimesKst().map(t => `${t.hour}:${String(t.minute).padStart(2, '0')}`).join(',') || '(없음)' }); return { dispose: () => { _disposed = true; diff --git a/src/features/stocks/telegramReport.ts b/src/features/stocks/telegramReport.ts index 6bcafbc..5fcb3e4 100644 --- a/src/features/stocks/telegramReport.ts +++ b/src/features/stocks/telegramReport.ts @@ -53,21 +53,35 @@ function renderGroup(label: string, stocks: ClassifiedStock[]): string[] { return lines; } -/** 보고서 텍스트 생성 (전송과 분리 — 테스트·로그용). */ -export function buildReportText(now: Date = new Date()): string { +/** 보고서 텍스트 생성 (전송과 분리 — 테스트·로그용). v2.2.284: 중앙 포맷 사용. */ +export function buildReportText(_now: Date = new Date()): string { const store = readStocksStore(); if (store.length === 0) { return '⚠️ stocks.json 에 종목 없음 — `/stocks add <심볼> <이름>` 으로 추가하세요.'; } const g = classifyAll(store); - const kstStr = formatKstTimestamp(now); + const { renderTelegram } = require('../../integrations/telegram/messageFormat') as typeof import('../../integrations/telegram/messageFormat'); - const out: string[] = [`🦅 *Kodari 정기 보고서* _${kstStr}_`, '']; - out.push(...renderGroup('스윙/중기', g.swing), ''); - out.push(...renderGroup('장기투자', g.long), ''); - out.push(...renderGroup('저평가우량주', g.ultraLow)); + const groupSection = (label: string, stocks: ClassifiedStock[]) => { + const buyZone = stocks.filter(s => s.signal === 'BUY_ZONE'); + const overvalued = stocks.filter(s => s.signal === 'OVERVALUED'); + const hold = stocks.filter(s => s.signal === 'HOLD'); + const lines: string[] = []; + for (const s of buyZone) lines.push(`🚨 ${s.이름}: ${s.현재가 ? s.현재가.toLocaleString() : '?'} / 권장 ${s.매수권장가 ?? '-'}`); + for (const s of overvalued.slice(0, 6)) lines.push(`⚠️ ${s.이름}: ${s.현재가 ? s.현재가.toLocaleString() : '?'} / 권장 ${s.매수권장가 ?? '-'}`); + if (overvalued.length > 6) lines.push(`… 감시 외 ${overvalued.length - 6}건`); + if (hold.length) lines.push(`📊 관망 ${hold.length}건`); + return { heading: label, count: stocks.length, lines }; + }; - return out.join('\n'); + return renderTelegram({ + emoji: '🦅', title: 'Kodari 정기 보고서', timestamp: true, + sections: [ + groupSection('스윙/중기', g.swing), + groupSection('장기투자', g.long), + groupSection('저평가우량주', g.ultraLow), + ], + }); } /** Date → "2026-05-25 09:00 KST" 형식. */ @@ -82,6 +96,49 @@ function formatKstTimestamp(d: Date): string { return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`; } +/** + * BUY_ZONE 진입 알림 텍스트 (v2.2.268) — 정기 보고서(스냅샷)와 달리, 이번 사이클에 + * *새로 매수사정권에 진입한* 종목만 담는 이벤트 알림. 놓치면 안 되는 신호라 별도 발송. + */ +export function buildBuySignalAlertText(entered: ClassifiedStock[], _now: Date = new Date()): string { + // v2.2.284: 중앙 포맷(messageFormat.renderTelegram) 사용 — 텔레그램 메시지 포맷 통일. + const { renderTelegram } = require('../../integrations/telegram/messageFormat') as typeof import('../../integrations/telegram/messageFormat'); + return renderTelegram({ + emoji: '🚨', title: '매수 시그널 진입', count: entered.length, timestamp: true, + sections: [{ + maxItems: 8, + count: entered.length, + items: entered.map(s => ({ + main: `*${s.이름}* (\`${s.심볼}\`) ${s.투자성향 ?? ''}`, + sub: `현재 ${s.현재가 ? s.현재가.toLocaleString() : '?'} ≤ 권장 ${s.매수권장가 ?? '-'}`, + })), + }], + footer: '자동 판정 참고용 — 매수 전 차트·수급 직접 확인 권장', + }); +} + +/** BUY_ZONE 진입 알림 전송 — 정기 보고서와 동일한 chatId/토큰 경로. */ +export async function sendBuySignalAlert( + context: vscode.ExtensionContext, + entered: ClassifiedStock[], +): Promise<{ ok: boolean; reason?: string }> { + if (entered.length === 0) return { ok: true }; + const chatId = pickChatId(); + if (chatId === null) return { ok: false, reason: '텔레그램 chatId 미설정' }; + const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || ''; + if (!token.trim()) return { ok: false, reason: '텔레그램 봇 토큰 없음' }; + const text = buildBuySignalAlertText(entered); + const client = new TelegramHttpClient({ getToken: () => token }); + try { + await client.sendMessage({ chatId, text, parseMode: 'Markdown' }); + logInfo('매수 시그널 진입 알림 발송 완료.', { chatId, count: entered.length }); + return { ok: true }; + } catch (e: any) { + logError('매수 시그널 진입 알림 발송 실패.', { chatId, error: e?.message ?? String(e) }); + return { ok: false, reason: e?.message ?? String(e) }; + } +} + /** * 보고서를 텔레그램으로 전송. 토큰 없거나 chatId 없으면 silent skip (warn 로그). * diff --git a/src/features/stocks/types.ts b/src/features/stocks/types.ts index d1236ee..b5fdaa8 100644 --- a/src/features/stocks/types.ts +++ b/src/features/stocks/types.ts @@ -36,6 +36,12 @@ export interface Stock { 현재가?: number; /** 분류 시트 — '스윙/중기' / '장기투자' / '저평가우량주' 중 하나. */ 투자성향?: '스윙/중기' | '장기투자' | '저평가우량주'; + /** + * 직전 워처 사이클의 신호 (v2.2.268) — BUY_ZONE **진입 전이** 감지용. + * 워처가 매 fire 마다 갱신. 관망/고평가 → 매수사정권으로 바뀌는 순간에만 + * 텔레그램 진입 알림을 1회 발송 (정기 보고서의 스냅샷과 별개). + */ + 직전신호?: string; } export type StocksStore = Stock[]; diff --git a/src/integrations/telegram/messageFormat.ts b/src/integrations/telegram/messageFormat.ts new file mode 100644 index 0000000..92fd197 --- /dev/null +++ b/src/integrations/telegram/messageFormat.ts @@ -0,0 +1,121 @@ +/** + * ============================================================ + * 텔레그램 메시지 — 단일 포맷 모듈 (v2.2.284) + * + * 배경: 기능마다 텔레그램 메시지 포맷이 제각각(주식 보고서·매수시그널·발굴 Top5· + * 데일리 브리핑·이메일 알림)이라 모바일 시인성이 낮고 유지보수가 파편화됐다. + * wikiFormat 이 위키 문서 포맷을 통일한 것처럼, **앞으로 텔레그램으로 나가는 + * 모든 정기/알림 메시지는 이 모듈의 renderTelegram() 으로 조립한다.** + * + * 모바일 규약: + * <이모지> *<제목>* (건수) ← 헤더 한 줄 + * _YYYY-MM-DD HH:MM KST_ ← 타임스탬프 (선택) + * *<섹션 제목>* (N) ← 섹션 + * 1. <본문 34자 컷> ← 번호 항목 + * <메타 한 줄> ← 보조 정보 (들여쓰기) + * … 외 N건 ← 잘림 표기 (묵살 금지) + * _<푸터 힌트>_ ← 다음 행동 안내 + * 전체 3,800자 캡 (텔레그램 4,096 한도 여유분). + * ============================================================ + */ + +export interface TgItem { + /** 본문 한 줄 — 렌더러가 34자로 컷하고 번호를 붙인다. */ + main: string; + /** 보조 메타 (발신자·대기시간 등) — 들여쓰기 줄. */ + sub?: string; +} + +export interface TgSection { + /** 섹션 제목 (없으면 항목만). */ + heading?: string; + /** 섹션 건수 표기 — 항목이 잘려도 전체 규모를 알린다. */ + count?: number; + /** 번호 매김 항목 (최대 maxItems, 기본 7). */ + items?: TgItem[]; + /** 자유 라인 (이미 포맷된 짧은 줄들 — 번호 없이). */ + lines?: string[]; + maxItems?: number; +} + +export interface TgMessage { + emoji: string; + title: string; + count?: number; + /** true 면 헤더 아래 KST 타임스탬프. */ + timestamp?: boolean; + sections: TgSection[]; + /** 다음 행동 힌트 — italic 푸터. */ + footer?: string; +} + +const MAX_LEN = 3800; + +export const tgCut = (s: string, n: number): string => + ((s || '').length > n ? (s || '').slice(0, n - 1) + '…' : (s || '')); + +/** 발신자 표기 정리 — "김성환(칼리버스-칼리버스)" → "김성환", 10자 컷. */ +export function tgWho(name: string, max = 10): string { + return tgCut((name || '-').replace(/\(.*?\)/g, '').trim() || '-', max); +} + +/** 제목 정리 — RE:/FW: 접두 제거 + 컷. */ +export function tgSubject(subject: string, max = 34): string { + let s = (subject || '').trim(); + const prefix = /^\s*(re|fw|fwd|회신|전달|답장)\s*[::]\s*/i; + while (prefix.test(s)) s = s.replace(prefix, ''); + return tgCut(s, max); +} + +export function kstNowStamp(d: Date = new Date()): string { + const fmt = new Intl.DateTimeFormat('ko-KR', { + timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }); + const parts = fmt.formatToParts(d); + const get = (t: string) => parts.find(p => p.type === t)?.value || ''; + return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`; +} + +/** 단일 렌더러 — 모든 텔레그램 정기/알림 메시지의 조립 지점. */ +export function renderTelegram(msg: TgMessage): string { + const lines: string[] = []; + lines.push(`${msg.emoji} *${msg.title}*${typeof msg.count === 'number' ? ` (${msg.count})` : ''}`); + if (msg.timestamp) lines.push(`_${kstNowStamp()}_`); + + for (const sec of msg.sections) { + const items = sec.items || []; + const shown = items.slice(0, Math.max(1, sec.maxItems ?? 7)); + if (!sec.heading && shown.length === 0 && !(sec.lines || []).length) continue; + lines.push(''); + if (sec.heading) lines.push(`*${sec.heading}*${typeof sec.count === 'number' ? ` (${sec.count})` : ''}`); + for (const l of sec.lines || []) lines.push(l); + shown.forEach((it, i) => { + lines.push(`${i + 1}. ${tgCut(it.main, 40)}`); + if (it.sub) lines.push(` ${tgCut(it.sub, 44)}`); + }); + const hidden = (typeof sec.count === 'number' ? sec.count : items.length) - shown.length; + if (hidden > 0) lines.push(`… 외 ${hidden}건`); + } + + if (msg.footer) lines.push('', `_${msg.footer}_`); + const out = lines.join('\n'); + return out.length > MAX_LEN ? out.slice(0, MAX_LEN) + '\n…(잘림)' : out; +} + +/** "09:00,15:00" 형식 스케줄 파서 — 메신저 설정 공용. 잘못된 토큰은 무시. */ +export function parseTimesCsv(csv: string): { hour: number; minute: number }[] { + return (csv || '') + .split(',') + .map(t => t.trim()) + .filter(Boolean) + .map(t => { + const m = t.match(/^(\d{1,2})\s*:\s*(\d{2})$/); + if (!m) return null; + const hour = parseInt(m[1], 10), minute = parseInt(m[2], 10); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null; + return { hour, minute }; + }) + .filter((x): x is { hour: number; minute: number } => x !== null) + .sort((a, b) => a.hour * 60 + a.minute - (b.hour * 60 + b.minute)); +} diff --git a/tests/buySignalWatch.test.ts b/tests/buySignalWatch.test.ts new file mode 100644 index 0000000..3994b5b --- /dev/null +++ b/tests/buySignalWatch.test.ts @@ -0,0 +1,80 @@ +/** + * v2.2.268 — 발굴 자동 편입 + 매수 시그널 진입 알림. + * - computeRecommendedBuy: 224일선 기반 매수권장가 자동 산출 규칙 + * - detectBuyZoneEntries: 관망/고평가 → 매수사정권 전이 감지 + 중복 알림 방지 + */ +import { computeRecommendedBuy } from '../src/features/stocks/stockDiscovery'; +import { detectBuyZoneEntries } from '../src/features/stocks/signalClassifier'; +import type { Stock } from '../src/features/stocks/types'; + +describe('computeRecommendedBuy — 224일선 기반 매수권장가', () => { + test('현재가 > MA224 → MA224 (지지선 회귀 시 매수)', () => { + expect(computeRecommendedBuy(12000, 10000)).toBe(10000); + }); + + test('현재가 ≤ MA224 → 현재가 × 0.97', () => { + expect(computeRecommendedBuy(9000, 10000)).toBe(Math.round(9000 * 0.97)); + }); + + test('MA224 없음(시세 부족) → 현재가 × 0.90 폴백', () => { + expect(computeRecommendedBuy(10000, undefined)).toBe(9000); + }); + + test('현재가 없음/비정상 → undefined (권장가 미설정)', () => { + expect(computeRecommendedBuy(undefined, 10000)).toBeUndefined(); + expect(computeRecommendedBuy(0, 10000)).toBeUndefined(); + expect(computeRecommendedBuy(-100)).toBeUndefined(); + }); +}); + +describe('detectBuyZoneEntries — 매수사정권 진입 전이 감지', () => { + const stock = (over: Partial): Stock => ({ + 이름: '테스트', 심볼: '000001', + '3/4 필터': '[자동 평가] 충족 (ROE, 성장성, 유동성)', + 현재가: 9000, 매수권장가: '10,000', + ...over, + }); + + test('신규 진입 (직전신호 없음 + 현재가 ≤ 권장가) → 알림 대상', () => { + const store = [stock({})]; + const entered = detectBuyZoneEntries(store); + expect(entered).toHaveLength(1); + expect(entered[0].signal).toBe('BUY_ZONE'); + expect(store[0].직전신호).toBe('BUY_ZONE'); + }); + + test('직전에도 매수사정권이었으면 중복 알림 안 함', () => { + const store = [stock({ 직전신호: 'BUY_ZONE' })]; + expect(detectBuyZoneEntries(store)).toHaveLength(0); + expect(store[0].직전신호).toBe('BUY_ZONE'); + }); + + test('고평가 → 매수사정권 전이 → 알림 + 직전신호 갱신', () => { + const store = [stock({ 직전신호: 'OVERVALUED' })]; + const entered = detectBuyZoneEntries(store); + expect(entered).toHaveLength(1); + expect(store[0].직전신호).toBe('BUY_ZONE'); + }); + + test('필터 미충족(관망)은 가격이 권장가 아래여도 알림 없음', () => { + const store = [stock({ '3/4 필터': '[자동 평가] 미충족 (사유: …)', 직전신호: 'HOLD' })]; + expect(detectBuyZoneEntries(store)).toHaveLength(0); + expect(store[0].직전신호).toBe('HOLD'); + }); + + test('매수권장가 없으면 BUY_ZONE 불가 → 알림 없음 (자동 산출의 존재 이유)', () => { + const store = [stock({ 매수권장가: undefined })]; + expect(detectBuyZoneEntries(store)).toHaveLength(0); + expect(store[0].직전신호).toBe('OVERVALUED'); + }); + + test('매수사정권 이탈(가격 상승) → 직전신호가 OVERVALUED 로 → 재진입 시 다시 알림', () => { + const store = [stock({})]; + detectBuyZoneEntries(store); // 1차 진입 + store[0].현재가 = 12000; // 권장가 위로 이탈 + expect(detectBuyZoneEntries(store)).toHaveLength(0); + expect(store[0].직전신호).toBe('OVERVALUED'); + store[0].현재가 = 9500; // 재진입 + expect(detectBuyZoneEntries(store)).toHaveLength(1); + }); +}); diff --git a/tests/emailCommand.test.ts b/tests/emailCommand.test.ts new file mode 100644 index 0000000..35d61dc --- /dev/null +++ b/tests/emailCommand.test.ts @@ -0,0 +1,282 @@ +/** + * v2.2.269 — /email (Gmail + Outlook) 순수 로직 테스트. + * COM/네트워크는 통합 영역이라 제외 — 참조 파싱·프롬프트·트랜스크립트 컷만 고정. + */ +import { parseRef } from '../src/features/email/handlers'; +import { buildEmailTranscript, buildEmailWikifyPrompt, buildReplyDraftPrompt } from '../src/features/email/emailPrompts'; +import { normalizeSubject, isAutomated, groupThreads, renderBriefFallback, type MailLite } from '../src/features/email/emailBrief'; + +describe('parseRef — G3/O2 참조 파싱', () => { + test('G/O 대소문자 + 공백 허용', () => { + expect(parseRef('G3')).toEqual({ provider: 'gmail', idx: 2 }); + expect(parseRef('o2')).toEqual({ provider: 'outlook', idx: 1 }); + expect(parseRef('G 12')).toEqual({ provider: 'gmail', idx: 11 }); + }); + + test('형식 오류 → null', () => { + expect(parseRef('')).toBeNull(); + expect(parseRef('3')).toBeNull(); + expect(parseRef('X2')).toBeNull(); + expect(parseRef('G0')).toBeNull(); + expect(parseRef('gmail')).toBeNull(); + }); +}); + +describe('buildEmailTranscript — 직렬화·컷', () => { + const mail = (body: string, i = 1) => ({ + subject: '테스트', from: `sender${i}@x.com`, date: '2026-07-02 10:00', body, + }); + + test('메시지 구분자 + 발신자 포함', () => { + const t = buildEmailTranscript([mail('안녕하세요', 1), mail('회신입니다', 2)]); + expect(t).toContain('--- 메시지 1'); + expect(t).toContain('--- 메시지 2'); + expect(t).toContain('sender2@x.com'); + }); + + test('메시지당 4,000자·전체 16,000자 컷', () => { + const long = 'a'.repeat(10000); + const t1 = buildEmailTranscript([mail(long)]); + expect(t1.length).toBeLessThan(4200); + const t5 = buildEmailTranscript([1, 2, 3, 4, 5].map(i => mail(long, i))); + expect(t5.length).toBeLessThanOrEqual(16000); + }); +}); + +describe('프롬프트 — 환각 방지 규칙 포함', () => { + test('wikify — 섹션 구조 + 원문 보존 규칙', () => { + const p = buildEmailWikifyPrompt('계약 조건 변경', 'transcript'); + expect(p).toContain('# 계약 조건 변경'); + expect(p).toContain('한 줄 통찰'); + expect(p).toContain('외부 지식 추가 금지'); + expect(p).toContain('원문 그대로 보존'); + }); + + test('reply — 지시사항 반영 + 사실 창작 금지', () => { + const mail = { subject: 'S', from: 'a@b.c', date: '2026-07-02', body: '본문' }; + const withInst = buildReplyDraftPrompt(mail, '다음 주 미팅 제안으로 회신'); + expect(withInst).toContain('다음 주 미팅 제안으로 회신'); + expect(withInst).toContain('만들지 말 것'); + const noInst = buildReplyDraftPrompt(mail, ''); + expect(noInst).toContain('지시사항 없음'); + }); +}); + +describe('normalizeSubject — RE:/FW: 겹겹이 제거', () => { + test('여러 겹 접두 + 한글 접두', () => { + expect(normalizeSubject('RE: FW: RE: 계약 검토')).toBe('계약 검토'); + expect(normalizeSubject('회신: 전달: 견적서')).toBe('견적서'); + expect(normalizeSubject('[QA] 결과 보고')).toBe('[qa] 결과 보고'); // 태그는 보존 + }); + test('빈 제목 → (제목 없음)', () => { + expect(normalizeSubject(' ')).toBe('(제목 없음)'); + }); +}); + +describe('isAutomated — 자동발신 분리', () => { + test('노리플라이·서비스 발신 감지', () => { + expect(isAutomated('no-reply@apple.com', 'WWDC 안내')).toBe(true); + expect(isAutomated('App Store Connect', 'Version approved')).toBe(true); + expect(isAutomated('AWS Marketplace', 'Product Update')).toBe(true); + }); + test('사람 발신은 통과', () => { + expect(isAutomated('김원일(칼리버스)', 'FW: 문의사항입니다')).toBe(false); + }); +}); + +describe('groupThreads — 주제 묶기 + 답변 여부', () => { + const mail = (o: Partial): MailLite => ({ + subject: '계약 검토', from: '김철수', received: '2026-07-01 10:00', ...o, + }); + + test('RE:/FW: 가 같은 스레드로 묶이고 참조번호 보존', () => { + const t = groupThreads([ + mail({ ref: 'O1', subject: 'RE: 계약 검토', received: '2026-07-02 09:00' }), + mail({ ref: 'O2', subject: 'FW: 계약 검토', received: '2026-07-01 08:00' }), + ], []); + expect(t).toHaveLength(1); + expect(t[0].inCount).toBe(2); + expect(t[0].refs).toEqual(['O1', 'O2']); + }); + + test('마지막이 상대방 → 회신대기(in), 내 발신이 더 최근 → 공넘김(out)', () => { + const inbox = [mail({ ref: 'O1', received: '2026-07-01 10:00' })]; + const waiting = groupThreads(inbox, [])[0]; + expect(waiting.lastDirection).toBe('in'); + const answered = groupThreads(inbox, [ + mail({ subject: 'RE: 계약 검토', from: '나', received: '2026-07-01 11:00', outgoing: true }), + ])[0]; + expect(answered.lastDirection).toBe('out'); + }); + + test('발신만 있는 주제는 제외, 정렬 = 회신대기 → 공넘김 → 자동발신', () => { + const t = groupThreads([ + mail({ ref: 'O1', subject: '뉴스레터 7월호', from: 'no-reply@x.com', received: '2026-07-02 12:00' }), + mail({ ref: 'O2', subject: '견적 문의', received: '2026-07-01 09:00' }), + mail({ ref: 'O3', subject: '일정 확인', received: '2026-07-02 08:00' }), + ], [ + mail({ subject: 'RE: 일정 확인', from: '나', received: '2026-07-02 09:00', outgoing: true }), + mail({ subject: '단독 발신 건', from: '나', received: '2026-07-02 10:00', outgoing: true }), + ]); + expect(t).toHaveLength(3); // '단독 발신 건' 제외 + expect(t[0].subject).toBe('견적 문의'); // 회신대기 + expect(t[1].subject).toContain('일정 확인'); // 공넘김 + expect(t[2].automated).toBe(true); // 자동발신 맨 뒤 + }); + + test('renderBriefFallback — 섹션·마크다운 리스트 생성', () => { + const t = groupThreads([mail({ ref: 'O1' })], []); + const out = renderBriefFallback(t); + expect(out).toContain('🔴 회신 대기'); + expect(out).toContain('- '); + expect(out).toContain('[O1]'); + }); +}); + +describe('groupThreads — 타임라인 (경과 재료)', () => { + const m = (o: Partial): MailLite => ({ + subject: '계약 검토', from: '김철수', received: '2026-07-01 10:00', ...o, + }); + + test('수신+발신 시간순 흐름, 발신자는 "나"로', () => { + const t = groupThreads([ + m({ ref: 'O1', snippet: '검토 부탁드립니다', received: '2026-06-30 09:00' }), + m({ ref: 'O2', snippet: '재질문 드립니다', received: '2026-07-02 10:00' }), + ], [ + m({ subject: 'RE: 계약 검토', from: '나', received: '2026-07-01 11:00', outgoing: true }), + ])[0]; + expect(t.timeline.map(e => e.from)).toEqual(['김철수', '나', '김철수']); + expect(t.timeline[0].snippet).toContain('검토 부탁'); + expect(t.timeline[1].outgoing).toBe(true); + }); + + test('타임라인은 최근 6개로 캡', () => { + const inbox = Array.from({ length: 9 }, (_, i) => + m({ ref: `O${i + 1}`, received: `2026-07-0${(i % 3) + 1} 0${i}:00` })); + const t = groupThreads(inbox, [])[0]; + expect(t.timeline.length).toBe(6); + }); +}); + +describe('buildInboxBriefPrompt — PM 업무 허브 형식 (v2.2.288)', () => { + test('정합성·결정/액션 분리·P등급·장기 구분·완료 예상 포함', () => { + const { buildInboxBriefPrompt } = require('../src/features/email/emailPrompts'); + const p = buildInboxBriefPrompt('[1] 상태=회신대기 | 주제="테스트"'); + expect(p).toContain('상태: 🟣 결정 필요'); + expect(p).toContain('우선순위: 🔥 오늘'); + expect(p).toContain('1:1 동일'); // Today Focus 숫자 정합성 + expect(p).toContain('Yes/No 또는 방향'); // 결정 ≠ 액션 + expect(p).toContain('P1 🚨 고객'); // P등급 영향 + expect(p).toContain('상위 5건'); // 확인만 컷 + expect(p).toContain('상대 답변 대기'); // 장기 대기 성격 구분 + expect(p).toContain('내 후속 지연'); + expect(p).toContain('오늘·3'); // 프로젝트 건수 병기 + expect(p).toContain('최근 업데이트'); // Timeline 1건 대체 + expect(p).toContain('글자 그대로 복사'); // 인명 오타 방지 + expect(p).toContain('오늘 완료 예상'); // 결과물 중심 요약 + expect(p).toContain('메일 명시 기한은 그대로'); // 기한 환각 방지 유지 + expect(p).toContain('737시간 같은 표기 금지'); + expect(p).toContain('오늘 안 하면'); + }); +}); + +describe('polishBriefLabels — 라벨 오타 후처리 (v2.2.288)', () => { + const { polishBriefLabels } = require('../src/features/email/emailPrompts'); + + test('알려진 오타 패턴 교정', () => { + const out = polishBriefLabels('- 내 액점: 회신\n- 권 량: 오늘\n🔴 회신_필요\n## ⚠ 이번 처리 (3건)'); + expect(out).toContain('내 액션:'); + expect(out).toContain('권장기한:'); + expect(out).toContain('회신 필요'); + expect(out).toContain('⚠ 이번주 처리'); + expect(out).not.toContain('액점'); + }); + + test('정상 텍스트는 무변경', () => { + const ok = '- 내 액션: 회신\n- 권장기한: 오늘\n🔴 회신 필요\n## ⚠ 이번주 처리 (3건)\n본문 내용은 그대로'; + expect(polishBriefLabels(ok)).toBe(ok); + }); +}); + +describe('buildEmailCanonicalWikifyPrompt — /wikify 정본 포맷 사용 (v2.2.277)', () => { + test('frontmatter + 정본 섹션 + 이메일 특수 규칙 포함', () => { + const { buildEmailCanonicalWikifyPrompt } = require('../src/features/datacollect/prompts/wikifyPrompt'); + const p = buildEmailCanonicalWikifyPrompt('QA 결과 보고', 'transcript-body', { participants: ['최성연'], dateRange: '2026-07-01 ~ 2026-07-02' }, null); + // 정본 골격 + expect(p).toContain('id: '); + expect(p).toContain('raw_sources'); + expect(p).toContain('## 🎯 한 줄 통찰'); + expect(p).toContain('## 🔗 지식 그래프'); + expect(p).toContain('## 📚 출처'); + // 이메일 특수 규칙 + expect(p).toContain('누가·언제·무엇을'); + expect(p).toContain('| 누가 | 무엇을 | 기한 |'); + expect(p).toContain('최성연'); + expect(p).toContain('transcript-body'); + // 환각 방지 + expect(p).toContain('지어내지'); + }); +}); + +describe('injectTimeHeader — 기준 시점 헤더 (v2.2.282)', () => { + const { injectTimeHeader } = require('../src/features/email/emailPrompts'); + + test('frontmatter + H1 아래에 시점 헤더 삽입', () => { + const doc = '---\nid: x\n---\n# [[제목]]\n\n## 🎯 한 줄 통찰\n내용'; + const out = injectTimeHeader(doc, '2026-06-28 10:00 ~ 2026-07-02 20:02', '2026-07-03'); + const lines = out.split('\n'); + const h1 = lines.findIndex((l: string) => l.startsWith('# ')); + expect(lines[h1 + 2]).toContain('🕒 **기준 시점**'); + expect(out).toContain('2026-07-02 20:02'); + expect(out).toContain('문서 생성 2026-07-03'); + expect(out).toContain('변했을 수 있음'); + }); + + test('H1 없으면 frontmatter 뒤에, 기간 없으면 원문 그대로', () => { + const noH1 = injectTimeHeader('본문뿐', '2026-07-01 ~ 2026-07-02', '2026-07-03'); + expect(noH1.split('\n')[1]).toContain('기준 시점'); + expect(injectTimeHeader('doc', '', '2026-07-03')).toBe('doc'); + }); + + test('프롬프트에 날짜 병기 규칙 포함 (시점 없는 현재형 금지)', () => { + const { buildEmailCanonicalWikifyPrompt } = require('../src/features/datacollect/prompts/wikifyPrompt'); + const p = buildEmailCanonicalWikifyPrompt('T', 'B', {}, null); + expect(p).toContain('날짜 없는 현재형 서술 금지'); + expect(p).toContain('기준 진행 중'); + }); +}); + +describe('isAutomated — v2.2.283 노이즈 필터 확장 (텔레그램 미회신 오탐)', () => { + test('Confluence 다이제스트·AWS·광고·[Notification] 잡힘', () => { + expect(isAutomated('Confluence', '한예성 님, 일일 다이제스트를 놓치지 마세요')).toBe(true); + expect(isAutomated('Amazon Web Services', 'Upcoming routine retirement')).toBe(true); + expect(isAutomated('Apple', '(광고) WWDC26에서 발표된 최신 소식')).toBe(true); + expect(isAutomated('김성환(칼리버스)', 'FW: [Notification] Upcoming routine retirement')).toBe(true); + expect(isAutomated('플래티어 IDT', '[플래티어IDT 뉴스레터] 소식')).toBe(true); + }); + test('사람 발신 업무 메일은 여전히 통과', () => { + expect(isAutomated('김성환(칼리버스-칼리버스)', 'RE: 메타버스 기술 취약점 진단 견적서 및 계약서 송부의 건')).toBe(false); + expect(isAutomated('오실묵(롯데이노베이트)', 'AWS 미사용 서비스 종료 검토의 건')).toBe(false); + }); +}); + +describe('groupThreads — CC 수신 판별 (v2.2.290)', () => { + const m = (o: Partial): MailLite => ({ + subject: '참조 공유', from: '김철수', received: '2026-07-03 10:00', ...o, + }); + + test('수신 전부 CC → ccOnly=true (확인만 분류 대상)', () => { + const t = groupThreads([m({ ref: 'O1', ccOnly: true }), m({ ref: 'O2', ccOnly: true, received: '2026-07-02 09:00' })], []); + expect(t[0].ccOnly).toBe(true); + }); + + test('하나라도 To 수신이면 ccOnly=false (회신 대상 유지)', () => { + const t = groupThreads([m({ ref: 'O1', ccOnly: true }), m({ ref: 'O2', ccOnly: false, received: '2026-07-02 09:00' })], []); + expect(t[0].ccOnly).toBe(false); + }); + + test('ccOnly 미수집(undefined)이면 false — 오탐 방지', () => { + const t = groupThreads([m({ ref: 'O1' })], []); + expect(t[0].ccOnly).toBe(false); + }); +}); diff --git a/tests/emailState.test.ts b/tests/emailState.test.ts new file mode 100644 index 0000000..e6dae74 --- /dev/null +++ b/tests/emailState.test.ts @@ -0,0 +1,121 @@ +/** + * v2.2.271 — 이메일 영속 상태 머신 (syncThreads / detectOverdue / setThreadState). + * "명령 안 쳐도 f/u 되는" 핵심 로직: 창 밖 미회신 보존, done 자동 재개, 알림 중복 차단. + */ +import { syncThreads, detectOverdue, setThreadState, waitingHours, type EmailStateFile } from '../src/features/email/emailState'; +import type { ThreadGroup } from '../src/features/email/emailBrief'; + +const NOW = new Date('2026-07-03T12:00:00'); + +const group = (o: Partial): ThreadGroup => ({ + topic: '계약 검토', subject: '계약 검토', participants: ['김철수'], + inCount: 1, outCount: 0, lastDirection: 'in', lastDate: '2026-07-03 10:00', + snippet: '검토 부탁드립니다', hasUnread: true, automated: false, refs: ['O1'], + timeline: [], + ccOnly: false, + ...o, +}); +const empty = (): EmailStateFile => ({ updatedAt: '', threads: [] }); + +describe('syncThreads — 관측 병합', () => { + test('새 수신 스레드 → waiting + waitingSince 기록', () => { + const s = syncThreads(empty(), [group({})], NOW); + expect(s.threads[0].state).toBe('waiting'); + expect(s.threads[0].waitingSince).toBe('2026-07-03 10:00'); + }); + + test('관측 안 된 기존 스레드는 보존 — 수집 창 밖 미회신이 사라지지 않음', () => { + let s = syncThreads(empty(), [group({ topic: '옛건', subject: '옛건', lastDate: '2026-06-20 09:00' })], NOW); + s = syncThreads(s, [group({})], NOW); // 다음 사이클엔 '옛건' 미관측 + expect(s.threads.map(t => t.topic).sort()).toEqual(['계약 검토', '옛건']); + expect(s.threads.find(t => t.topic === '옛건')!.state).toBe('waiting'); + }); + + test('내가 답하면 answered + 대기·알림 리셋', () => { + let s = syncThreads(empty(), [group({})], NOW); + s.threads[0].alertedFor = s.threads[0].waitingSince; + s = syncThreads(s, [group({ lastDirection: 'out', outCount: 1, lastDate: '2026-07-03 11:00' })], NOW); + expect(s.threads[0].state).toBe('answered'); + expect(s.threads[0].waitingSince).toBeUndefined(); + expect(s.threads[0].alertedFor).toBeUndefined(); + }); + + test('done 스레드에 새 수신 → waiting 자동 재개 (놓침 방지)', () => { + let s = syncThreads(empty(), [group({})], NOW); + setThreadState(s, '계약 검토', 'done'); + s = syncThreads(s, [group({ lastDate: '2026-07-03 11:30', inCount: 2 })], NOW); + expect(s.threads[0].state).toBe('waiting'); + expect(s.threads[0].waitingSince).toBe('2026-07-03 11:30'); + }); + + test('muted 스레드는 새 수신에도 조용', () => { + let s = syncThreads(empty(), [group({})], NOW); + setThreadState(s, '계약 검토', 'muted'); + s = syncThreads(s, [group({ lastDate: '2026-07-03 11:30' })], NOW); + expect(s.threads[0].state).toBe('muted'); + }); + + test('60일 무활동 스레드는 정리', () => { + let s = syncThreads(empty(), [group({ topic: '고대', subject: '고대', lastDate: '2026-04-01 09:00' })], NOW); + s = syncThreads(s, [], NOW); + expect(s.threads).toHaveLength(0); + }); +}); + +describe('detectOverdue — 임계 초과 1회 알림', () => { + test('24시간 초과 → 감지 + alertedFor 마킹, 재호출 시 중복 없음', () => { + const s = syncThreads(empty(), [group({ lastDate: '2026-07-02 10:00' })], NOW); // 26시간 전 + const first = detectOverdue(s, 24, NOW); + expect(first).toHaveLength(1); + expect(detectOverdue(s, 24, NOW)).toHaveLength(0); // 같은 대기 기간엔 1회만 + }); + + test('임계 미만·자동발신·answered 는 제외', () => { + const s = syncThreads(empty(), [ + group({ topic: '방금', subject: '방금', lastDate: '2026-07-03 11:00' }), + group({ topic: '뉴스', subject: '뉴스', automated: true, lastDate: '2026-07-01 10:00' }), + group({ topic: '답함', subject: '답함', lastDirection: 'out', lastDate: '2026-07-01 10:00' }), + ], NOW); + expect(detectOverdue(s, 24, NOW)).toHaveLength(0); + }); + + test('새 수신으로 대기 리셋되면 다시 알림 가능', () => { + let s = syncThreads(empty(), [group({ lastDate: '2026-07-01 10:00' })], NOW); + detectOverdue(s, 24, NOW); // 1차 알림 + s = syncThreads(s, [group({ lastDate: '2026-07-02 09:00', inCount: 2 })], NOW); // 새 수신 (그래도 27시간 전) + expect(detectOverdue(s, 24, NOW)).toHaveLength(1); // 새 대기 기간 → 재알림 + }); + + test('임계 0 = 끔', () => { + const s = syncThreads(empty(), [group({ lastDate: '2026-07-01 10:00' })], NOW); + expect(detectOverdue(s, 0, NOW)).toHaveLength(0); + }); +}); + +describe('waitingHours', () => { + test('26시간 전 수신 → ~26', () => { + const s = syncThreads(empty(), [group({ lastDate: '2026-07-02 10:00' })], NOW); + expect(Math.round(waitingHours(s.threads[0], NOW))).toBe(26); + }); +}); + +describe('wikifiedTopicSet — 파일 삭제 감지 (v2.2.280)', () => { + const { wikifiedTopicSet } = require('../src/features/email/emailState'); + const topics = { + '구형항목': '2026-07-01T00:00:00Z', // 경로 없는 legacy + '살아있음': { at: '2026-07-03', path: 'E:/wiki/a.md' }, + '삭제됨': { at: '2026-07-03', path: 'E:/wiki/deleted.md' }, + }; + const exists = (p: string) => p === 'E:/wiki/a.md'; + + test('verify=true → 파일 지워진 항목은 완료에서 제외 (재위키화 대상)', () => { + const s = wikifiedTopicSet(topics, exists, true); + expect(s.has('살아있음')).toBe(true); + expect(s.has('삭제됨')).toBe(false); + expect(s.has('구형항목')).toBe(true); // 경로 없어 검증 불가 — 완료 유지 (reset 으로 해소) + }); + + test('verify=false → 기록 그대로', () => { + expect(wikifiedTopicSet(topics, exists, false).size).toBe(3); + }); +}); diff --git a/tests/messageFormat.test.ts b/tests/messageFormat.test.ts new file mode 100644 index 0000000..79ebaa0 --- /dev/null +++ b/tests/messageFormat.test.ts @@ -0,0 +1,56 @@ +/** + * v2.2.284 — 텔레그램 중앙 포맷 (renderTelegram / parseTimesCsv). + * 위키 포맷처럼 메시지 포맷도 단일 모듈 — 모든 발신 빌더가 이 규약을 공유한다. + */ +import { renderTelegram, parseTimesCsv, tgWho, tgSubject, tgCut } from '../src/integrations/telegram/messageFormat'; + +describe('renderTelegram — 모바일 규약', () => { + test('헤더·번호 항목·보조줄·잘림 표기·푸터', () => { + const out = renderTelegram({ + emoji: '📧', title: '답할 이메일', count: 10, + sections: [{ + count: 10, maxItems: 2, + items: [ + { main: '계약 검토', sub: '김성환 · 2일째' }, + { main: '견적 문의', sub: '오실묵 · 1일째' }, + { main: '세번째', sub: 'x' }, + ], + }], + footer: '처리했으면 `/email done O번호`', + }); + expect(out).toContain('📧 *답할 이메일* (10)'); + expect(out).toContain('1. 계약 검토'); + expect(out).toContain(' 김성환 · 2일째'); + expect(out).toContain('… 외 8건'); // count 기준 잘림 표기 + expect(out).toContain('_처리했으면'); + expect(out).not.toContain('세번째'); // maxItems 컷 + }); + + test('3,800자 캡', () => { + const out = renderTelegram({ + emoji: 'x', title: 't', + sections: [{ lines: Array.from({ length: 300 }, () => 'a'.repeat(40)) }], + }); + expect(out.length).toBeLessThanOrEqual(3810); + expect(out).toContain('…(잘림)'); + }); + + test('보조 유틸 — 발신자 괄호 제거·RE/FW 제거', () => { + expect(tgWho('김성환(칼리버스-칼리버스)')).toBe('김성환'); + expect(tgSubject('RE: FW: 계약 검토의 건')).toBe('계약 검토의 건'); + expect(tgCut('12345', 3)).toBe('12…'); + }); +}); + +describe('parseTimesCsv — 발송 스케줄 파서', () => { + test('정상 파싱 + 정렬', () => { + expect(parseTimesCsv('15:00, 09:00')).toEqual([ + { hour: 9, minute: 0 }, { hour: 15, minute: 0 }, + ]); + expect(parseTimesCsv('9:30')).toEqual([{ hour: 9, minute: 30 }]); + }); + test('잘못된 토큰 무시·빈 값 → []', () => { + expect(parseTimesCsv('25:00, abc, 12:60, 12:30')).toEqual([{ hour: 12, minute: 30 }]); + expect(parseTimesCsv('')).toEqual([]); + }); +}); diff --git a/tests/wikiFormat.test.ts b/tests/wikiFormat.test.ts new file mode 100644 index 0000000..b02804d --- /dev/null +++ b/tests/wikiFormat.test.ts @@ -0,0 +1,64 @@ +/** + * v2.2.278 — 위키 문서 포맷 통일 (봉투 + 공용 빌더). + */ +import { ensureFrontmatter, hasFrontmatter, slugifyId, buildKnowledgeWikiPrompt } from '../src/features/datacollect/wikiFormat'; +import { buildWikifyPrompt, buildEmailCanonicalWikifyPrompt } from '../src/features/datacollect/prompts/wikifyPrompt'; + +describe('ensureFrontmatter — 봉투 통일', () => { + test('frontmatter 없는 보고서형 문서에 정본 봉투 부착', () => { + const out = ensureFrontmatter('회의록 주간회의 2026-07-03', '# 회의록\n내용…', { + docType: 'meeting', sources: ['transcript.txt'], + }); + expect(out.startsWith('---')).toBe(true); + expect(out).toContain('title: "회의록 주간회의 2026-07-03"'); + expect(out).toContain('category: "meeting"'); + expect(out).toContain('tags: ["meeting", "astra"]'); + expect(out).toContain('raw_sources: ["transcript.txt"]'); + expect(out).toContain('# 회의록'); // 본문 보존 + expect(out).toContain('## 📝 변경 이력'); + }); + + test('이미 frontmatter 있는 문서(정본 프롬프트 산출물)는 그대로', () => { + const doc = '---\nid: x\n---\n본문'; + expect(ensureFrontmatter('t', doc, { docType: 'wikify' })).toBe(doc); + }); + + test('코드펜스로 감싼 frontmatter 도 인식 (LLM 출력 변형 대응)', () => { + expect(hasFrontmatter('```markdown\n---\nid: x\n---\n```')).toBe(true); + expect(hasFrontmatter('# 그냥 제목')).toBe(false); + }); + + test('slugifyId — 한글 유지·특수문자 제거·80자 캡', () => { + expect(slugifyId('회의록 A/B 테스트!', 'x')).toBe('회의록-ab-테스트'); + expect(slugifyId('', 'fallback')).toBe('fallback'); + }); +}); + +describe('buildKnowledgeWikiPrompt — 단일 정본 빌더', () => { + test('kind별 규칙 주입 + 정본 골격', () => { + const p = buildKnowledgeWikiPrompt({ + kind: 'slack', topic: '배포 논의', body: 'BODY', + meta: { 채널: '#dev' }, sourceRef: 'slack:#dev', + extraRules: ['슬랙 특수 규칙 X'], trustHint: '내부 교신이므로 A~B 로', + }, null); + expect(p).toContain("주제: '배포 논의'"); + expect(p).toContain('5. 슬랙 특수 규칙 X'); // extraRules 번호 이어붙임 + expect(p).toContain('6. 이 문서의 출처는 slack'); // trust 규칙이 그 다음 번호 + expect(p).toContain('- 채널: #dev'); + expect(p).toContain('## 🎯 한 줄 통찰'); + expect(p).toContain('## 🔗 지식 그래프'); + expect(p).toContain('raw_sources: ["slack:#dev"]'); + }); + + test('web·email 래퍼가 같은 정본 골격을 공유 (파편화 종료)', () => { + const web = buildWikifyPrompt({ url: 'https://x.com', title: 'T', text: 'B' }, '', null); + const email = buildEmailCanonicalWikifyPrompt('T', 'B', {}, null); + for (const marker of ['[필수 규칙]', '[소스 메타]', '[소스 본문]', '## 🎯 한 줄 통찰', '## 📚 출처', 'P-Reinforce v3.1']) { + expect(web).toContain(marker); + expect(email).toContain(marker); + } + // 종류별 특수 규칙은 각자 유지 + expect(web).toContain('JSON Schema'); + expect(email).toContain('누가·언제·무엇을'); + }); +}); diff --git a/tests/wikiSave.test.ts b/tests/wikiSave.test.ts new file mode 100644 index 0000000..0d174d3 --- /dev/null +++ b/tests/wikiSave.test.ts @@ -0,0 +1,74 @@ +/** + * v2.2.273 — 지식 문서 로컬 저장 (순수 로직: 파일명 안전화 + 저장 폴더 우선순위). + */ +import * as path from 'path'; +import { sanitizeWikiFileName, pickWikiDir } from '../src/features/datacollect/wikiSave'; + +describe('sanitizeWikiFileName', () => { + test('금지 문자 치환 + .md 보장', () => { + const f = sanitizeWikiFileName('회의록 a/b:c*d?eg|h 2026-07-03'); + expect(f).not.toMatch(/[\\/:*?"<>|]/); + expect(f.endsWith('.md')).toBe(true); + expect(f).toContain('회의록'); + }); + + test('빈 제목 → untitled, 120자 캡', () => { + expect(sanitizeWikiFileName('')).toMatch(/^untitled_\d+\.md$/); + expect(sanitizeWikiFileName('가'.repeat(300)).length).toBeLessThanOrEqual(124); + }); + + test('.md 중복 방지', () => { + expect(sanitizeWikiFileName('문서.md')).toBe('문서.md'); + }); +}); + +describe('pickWikiDir — 저장 폴더 우선순위', () => { + const abs = path.resolve('E:/커스텀/위키'); + + test('① 설정 절대경로 최우선', () => { + const r = pickWikiDir(abs, 'E:/brain', 'E:/ws'); + expect(r).toEqual({ dir: abs, label: '설정 폴더' }); + }); + + test('② 설정 없으면 두뇌/Datacollect_Wiki', () => { + const r = pickWikiDir('', 'E:/brain', 'E:/ws'); + expect(r!.label).toBe('두뇌'); + expect(r!.dir).toBe(path.join('E:/brain', 'Datacollect_Wiki')); + }); + + test('③ 두뇌 없으면 워크스페이스/.astra/wiki', () => { + const r = pickWikiDir('', '', 'E:/ws'); + expect(r!.label).toBe('워크스페이스'); + expect(r!.dir).toBe(path.join('E:/ws', '.astra', 'wiki')); + }); + + test('상대경로 설정은 무시하고 다음 후보로 (경로 오염 방지)', () => { + const r = pickWikiDir('상대/경로', 'E:/brain', null); + expect(r!.label).toBe('두뇌'); + }); + + test('④ 전부 없으면 null', () => { + expect(pickWikiDir('', '', null)).toBeNull(); + }); +}); + +describe('pickWikiDir — 이메일 지식 문서 별도 관리 (kind=email)', () => { + test('기본 폴더가 Email_Wiki 로 분리 — 일반(Datacollect_Wiki)과 다름', () => { + const email = pickWikiDir('', 'E:/brain', 'E:/ws', 'email'); + const general = pickWikiDir('', 'E:/brain', 'E:/ws', 'general'); + expect(email!.dir).toBe(path.join('E:/brain', 'Email_Wiki')); + expect(general!.dir).toBe(path.join('E:/brain', 'Datacollect_Wiki')); + expect(email!.dir).not.toBe(general!.dir); + }); + + test('이메일 전용 설정 경로가 최우선', () => { + const abs = path.resolve('E:/이메일위키'); + const r = pickWikiDir(abs, 'E:/brain', 'E:/ws', 'email'); + expect(r).toEqual({ dir: abs, label: '설정 폴더' }); + }); + + test('두뇌 없으면 워크스페이스 .astra/wiki/email', () => { + const r = pickWikiDir('', '', 'E:/ws', 'email'); + expect(r!.dir).toBe(path.join('E:/ws', '.astra', 'wiki', 'email')); + }); +});