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(/