v2.2.268-291: /email 통합(브리핑·스캔·워처) + stocks 자동편입·매수알림 + 지식문서·메신저 중앙화

[/email — Gmail + 클래식 Outlook (신규 기능군)]
- 기본 명령: brief(PM 업무 허브 대시보드)·list·read·wikify·reply(Drafts만)·open(Outlook 창 열기)
- Gmail: 자체 OAuth gmail.readonly 스코프 추가 (재연결 1회 필요) / Outlook: COM(PowerShell), 전 계정(Store)·하위 폴더(120개 가드) 수집, To/CC 판별
- 브리핑: 상태 3분류(결정/회신/확인만)·P등급 영향·프로젝트 위험도·Today Focus·오늘 완료 예상·기간 명시 시 표시 범위 제한
- 영속 상태(email_state.json): 회신대기/공넘김 상태머신, 창 밖 미회신 보존, done old N 일괄정리
- 워처: 주기 자동 수집 + 미회신 임계 알림(기본 끔) + 데일리 브리핑 '답할 이메일' 섹션
- 전체 스캔(/email scan): 자동 위키화(회차 15건·레지스트리·파일삭제 감지 재생성)·진행 표시(streamProgress)
- 자동발신 필터 확장(Confluence·AWS·광고·[Notification]) + 라벨 오타 후처리(polishBriefLabels)

[/stocks]
- discover 자동 편입(stocks.json) + 매수권장가 224일선 자동 산출
- 매수사정권 진입 전이 감지 → 텔레그램 1회 알림 (직전신호 추적)
- 버그 수정: '미충족'이 .includes('충족')에 오탐되던 필터 판정

[지식 문서 파이프라인 통일 (wikiFormat/wikiSave)]
- PC 로컬 직접 저장 (NAS 볼륨 미아 문제 해소) — 설정 폴더 → 두뇌 → 워크스페이스
- 이메일 전용 Email_Wiki 분리(email.wikifySavePath) + 봉투(frontmatter) 자동 통일
- 정본 프롬프트 단일 빌더(buildKnowledgeWikiPrompt) — web/email 래퍼화, 템플릿 fetch 일원화
- 이메일 문서 시점 3중 방어: 기준시점 헤더·제목=메일 최신일·날짜 없는 현재형 금지
- docs/WIKI_DOC_PIPELINE.md 개발 규약

[텔레그램 메신저 중앙화 (messageFormat)]
- 단일 렌더러 renderTelegram — 5개 발신 빌더(주식보고·매수시그널·Top5·데일리·미회신) 통일
- 주식 보고 시각 하드코딩 제거(messenger.stocksReportTimes CSV) + 알림 토글 2종
- 설정 패널 '메신저' 탭 신설 · '이메일' 토글 섹션(Gmail/Outlook on/off)

테스트 803개 통과 (이메일 상태머신·CC판별·포맷·매수전이·봉투 등 60+ 신규)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 18:57:12 +09:00
parent 92d81e38ce
commit fa397d7fa1
44 changed files with 3635 additions and 174 deletions
@@ -1,5 +1,5 @@
{
"result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1782814226952,
"createdAt": 1783071368763,
"modelVersion": "unknown"
}
@@ -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"
}
@@ -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": {
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1782814234193,
"createdAt": 1783071374928,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1782814234192,
"createdAt": 1783071374927,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1782814234187,
"createdAt": 1783071374923,
"modelVersion": "unknown"
}
@@ -1,5 +1,5 @@
{
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1782814234189,
"createdAt": 1783071374925,
"modelVersion": "unknown"
}
@@ -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": {
+60
View File
@@ -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`
+76 -8
View File
@@ -18,6 +18,7 @@
<button type="button" class="tab" data-tab="model">모델·연결</button>
<button type="button" class="tab" data-tab="knowledge">지식·기억</button>
<button type="button" class="tab" data-tab="integrations">연동</button>
<button type="button" class="tab" data-tab="messenger">메신저</button>
<button type="button" class="tab" data-tab="advanced">동작·고급</button>
</nav>
@@ -90,14 +91,6 @@
</div>
<small class="hint">NAS Bridge의 <code>x-bridge-token</code>. <strong>nas</strong> 타깃일 때만 요청 헤더에 실립니다.</small>
</div>
<div class="row">
<label for="dcSavePath">결과물 저장 폴더</label>
<div class="input-group">
<input id="dcSavePath" type="text" placeholder="(비우면 Bridge 기본 위치)" spellcheck="false" />
<button data-save="datacollect.savePath">저장</button>
</div>
<small class="hint">/benchmark 등의 결과 markdown 저장 위치. 비워두면 Bridge의 <code>WIKI_RAW_PATH</code> 환경변수가 결정합니다 (코드에 절대경로 하드코딩 없음). 특정 폴더로 저장하려면 절대경로를 입력하세요.</small>
</div>
<div class="row">
<label for="dcCrawlDepth">크롤 깊이 기본값</label>
<div class="input-group narrow">
@@ -169,6 +162,81 @@
<div class="row">
<button id="brainOpenSettings" class="link">brainProfiles 편집 (VS Code Settings)</button>
</div>
<div class="row">
<label for="dcSavePath">지식 문서 저장 폴더</label>
<div class="input-group">
<input id="dcSavePath" type="text" placeholder="(비우면 활성 두뇌의 Datacollect_Wiki)" spellcheck="false" />
<button data-save="datacollect.savePath">저장</button>
</div>
<small class="hint"><code>/wikify</code> · <code>/meet</code> · <code>/benchmark</code> · <code>/youtube</code> · <code>/review</code> 가 생성하는 지식 문서(markdown)의 저장 위치. <strong>PC 로컬에 직접 저장</strong>되며, 비워두면 활성 두뇌 폴더의 <code>Datacollect_Wiki/</code> 에 저장돼 brain 검색에 자동 포함됩니다. 다른 곳에 두려면 절대경로 입력 (예: <code>E:\Wiki\2nd\10_Wiki</code>).</small>
</div>
<div class="row">
<label for="emailWikiSavePath">이메일 지식 문서 저장 폴더</label>
<div class="input-group">
<input id="emailWikiSavePath" type="text" placeholder="(비우면 활성 두뇌의 Email_Wiki)" spellcheck="false" />
<button data-save="email.wikifySavePath">저장</button>
</div>
<small class="hint"><code>/email wikify</code> 로 이메일 스레드를 지식화한 문서는 다른 wikify 산출물과 <strong>별도 폴더에서 관리</strong>됩니다. 비워두면 활성 두뇌 폴더의 <code>Email_Wiki/</code> 에 저장. 다른 곳에 두려면 절대경로 입력.</small>
</div>
</section>
<!-- Messenger — 텔레그램 발송 스케줄·알림 (v2.2.284 중앙화) -->
<section class="section" data-section="messenger" data-tab="messenger">
<h2>발송 스케줄</h2>
<p class="hint">텔레그램으로 나가는 정기 메시지의 시각을 여기서 관리합니다 (KST 기준).</p>
<div class="row">
<label for="msgStocksTimes">주식 정기 보고서 시각</label>
<div class="input-group">
<input id="msgStocksTimes" type="text" placeholder="09:00,15:00 (비우면 정기 실행 없음)" spellcheck="false" />
<button data-save="messenger.stocksReportTimes">저장</button>
</div>
<small class="hint">HH:MM 을 쉼표로 구분 (예: <code>09:00,12:30,15:00</code>). 이 시각마다 가격 갱신·매수시그널 감지·보고서 발송.</small>
</div>
<div class="row toggle">
<label><input id="msgBriefingEnabled" type="checkbox"> 데일리 브리핑 (일정·할 일·답할 이메일)</label>
</div>
<div class="row">
<label for="msgBriefingTime">데일리 브리핑 시각</label>
<div class="input-group narrow">
<input id="msgBriefingTime" type="text" placeholder="09:30" spellcheck="false" />
<button data-save="messenger.briefingTime">저장</button>
</div>
<small class="hint">평일(월~금) 이 시각에 1회 발송. 미회신 이메일 정리는 이 메시지에 포함됩니다.</small>
</div>
</section>
<section class="section" data-section="messengerAlerts" data-tab="messenger">
<h2>이벤트 알림</h2>
<p class="hint">상황이 발생했을 때 즉시 발송되는 알림들입니다.</p>
<div class="row toggle">
<label><input id="msgBuySignal" type="checkbox"> 🚨 매수 시그널 진입 알림</label>
<small class="hint">관망 종목이 매수사정권에 진입하는 순간 1회.</small>
</div>
<div class="row toggle">
<label><input id="msgDiscoverTop5" type="checkbox"> 🎯 발굴 Top 5 (/stocks discover 후)</label>
</div>
<div class="row">
<label for="msgEmailOverdue">📧 이메일 미회신 즉시 알림 임계(시간)</label>
<div class="input-group narrow">
<input id="msgEmailOverdue" type="number" min="0" step="1" />
<button data-save="messenger.emailOverdueHours">저장</button>
</div>
<small class="hint"><code>0</code> = 끔(기본) — 미회신 정리는 데일리 브리핑이 담당. 즉시 알림을 원하면 시간 입력 (예: 24).</small>
</div>
</section>
<!-- Email providers (/email) -->
<section class="section" data-section="email" data-tab="integrations">
<h2>이메일 (/email)</h2>
<p class="hint">끈 프로바이더는 수집 시도 자체를 하지 않아 채팅에 "제외/실패" 문구가 뜨지 않습니다.</p>
<div class="row toggle">
<label><input id="emailOutlookEnabled" type="checkbox"> Outlook(클래식) 사용</label>
<small class="hint">데스크톱 클래식 Outlook COM — 별도 인증 없음. "새 Outlook" 토글 ON 상태는 미지원.</small>
</div>
<div class="row toggle">
<label><input id="emailGmailEnabled" type="checkbox"> Gmail 사용</label>
<small class="hint">Google OAuth 재연결 1회 필요 (gmail.readonly 동의). 연결 전이거나 안 쓰면 꺼두세요 — /email 출력이 깔끔해집니다.</small>
</div>
</section>
<!-- Telegram -->
+49
View File
@@ -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);
+17
View File
@@ -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;
+67 -3
View File
@@ -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",
+3
View File
@@ -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));
+29 -1
View File
@@ -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) {
+3
View File
@@ -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(' ');
+15 -52
View File
@@ -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<boo
finalReport,
``,
].join('\n');
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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<boole
sections.map((s) => s.body).join(sectionDivider),
``,
].join('\n');
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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<any>(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<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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<string>('datacollectSavePath', '') || '').trim();
const body: Record<string, unknown> = { 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`);
}
@@ -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);
}
+10
View File
@@ -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 (
+176
View File
@@ -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<CanonicalWikiFormat | null> {
if (_canonicalCache && Date.now() - _canonicalCache.at < CANONICAL_TTL_MS) return _canonicalCache.fmt;
let fmt: CanonicalWikiFormat | null = null;
try {
fmt = await bridgeFetch<CanonicalWikiFormat>(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<string, string>;
/** 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, string>): 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)}`;
}
Binary file not shown.
+166
View File
@@ -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<string, { in: MailLite[]; out: MailLite[] }>();
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');
}
+100
View File
@@ -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<boolean>('email.outlookEnabled', true) !== false,
gmail: cfg.get<boolean>('email.gmailEnabled', true) !== false,
};
}
export async function collectRecentMails(
days: number,
context?: vscode.ExtensionContext,
opts: { gmailThreads?: number; outlookMax?: number; sentMax?: number } = {},
): Promise<CollectResult> {
const out: CollectResult = { inbox: [], sent: [], outlookMails: [], gmailItems: [] };
const providers = emailProviders();
// Outlook — 받은편지함(+하위 폴더) + 보낸편지함 (답변 여부 판정용). 비활성 시 조용히 스킵.
const includeSub = vscode.workspace.getConfiguration('g1nation').get<boolean>('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<string>();
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;
}
+195
View File
@@ -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** "최근 업데이트: <MM/DD> <발화자> <요지>" .
- **· ** · ( ).
- ·· . 🔥/ , 🟡/ .
- ** ()**: 🎯 Today Focus **🔥 1:1 ** ( · ). = . 📈 ( 0 ).
# (, )
** **
- : 🟣 n · 🔴 n · 🟡 n
- : 🔥 n · n
- : n · n
- : 🔴 (·3) · 🟠 (·2) · 🟡 (·4) · 🟢 n건
## 🔥 (n건 · P등급순)
**[] <짧은 제목>** 🟣 · **15** · []
- : <Yes/No·방향 판단 없으면 생략>
- 영향: P1 🚨 <고객|계약> (P2 / P3 ·)
- From: <보낸 사람> / : <구체 동사> / : <기한>
- : <핵심 요지 1줄>
- : "<데이터의 주제(원제목) 글자 그대로>" Outlook에서 : \`/email open <참조번호>\`
- Timeline: (2+ , "**현재: 내 액션 필요 (<대기>)**") / 1 "최근 업데이트: <MM/DD> <요지>"
## (n건)
( )
## 🟡 (n건 · 5)
- [] <짧은 제목> <요지 > ([] · 5)
(+<n-5> )
## 7
** (m건)**
- [] <짧은 제목> <누구> · **<N일째>** ([])
** (k건)** ,
- <짧은 제목> **<N일째>**
(7 <나머지> )
## 🎯 Today Focus <n> · <합계>
<짧은 제목> < 액션> (<소요>)
(🔥 1:1 )
## 📈
- · <N> <N-오늘n> · 🟣 <n> <남는>
- ** **: <n> · / <n> · <n> <합계>
- ** **: <P1·P2 영향 집계 : 고객 1건 · 계약 2건 지연 가능>
[ ]
${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)}`;
}
+294
View File
@@ -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 .
* - inout 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<string, WikifiedEntry>,
exists: (p: string) => boolean,
verify: boolean,
): Set<string> {
const out = new Set<string>();
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<string> {
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<string, WikifiedEntry> } = { 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;
}
+135
View File
@@ -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<number>(key, def);
return Number.isFinite(v) ? (v as number) : def;
}
function pickChatId(): number | null {
const cfg = vscode.workspace.getConfiguration('g1nation');
const allowed = cfg.get<number[]>('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<void> {
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<void> {
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<boolean>('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; }
},
};
}
+187
View File
@@ -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<T> = { 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<ApiResult<Array<{ id: string; threadId: string }>>> {
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<ApiResult<ParsedMessage>> {
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<ApiResult<ParsedMessage[]>> {
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(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/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)) : [],
};
}
+630
View File
@@ -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<typeof chunk>[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<void> {
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<string>();
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<void> {
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<void> {
// 기간을 명시하면(`/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<string, string>();
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<EmailForPrompt[] | null> {
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<void> {
// `/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<void> {
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<void> {
// `/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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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,
});
+276
View File
@@ -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<T> = { 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<OlResult<string>> {
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건). days3650·max300.
* v2.2.281: includeSubfolders( true) ** ( )**
* BFS () .
* , max . 120 .
*/
export async function listInbox(days = 7, max = 25, includeSubfolders = true): Promise<OlResult<OutlookMail[]>> {
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<OlResult<OutlookMail[]>> {
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<OlResult<true>> {
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<OlResult<OutlookMail>> {
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<OlResult<{ savedTo: string }>> {
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: '초안 저장 확인 실패' };
}
@@ -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<void> {
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<void> {
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<string>('datacollectBridgeNasUrl', '') || '',
bridgeNasToken: cfg.get<string>('datacollectBridgeNasToken', '') || '',
savePath: cfg.get<string>('datacollectSavePath', '') || '',
emailWikifySavePath: cfg.get<string>('email.wikifySavePath', '') || '',
crawlDepth: cfg.get<number>('datacollectCrawlDepth', 1) ?? 1,
maxPages: cfg.get<number>('datacollectMaxPages', 8) ?? 8,
synthesisTemperature: cfg.get<number>('datacollectSynthesisTemperature', 0.1) ?? 0.1,
},
google: this._buildGoogleState(),
providers: await this._buildProvidersState(),
email: {
outlookEnabled: cfg.get<boolean>('email.outlookEnabled', true) !== false,
gmailEnabled: cfg.get<boolean>('email.gmailEnabled', true) !== false,
},
messenger: {
stocksReportTimes: cfg.get<string>('messenger.stocksReportTimes', '09:00,15:00') || '',
briefingEnabled: cfg.get<boolean>('dailyBriefing.enabled', true) !== false,
briefingTime: cfg.get<string>('dailyBriefing.time', '09:30') || '09:30',
buySignalAlert: cfg.get<boolean>('messenger.buySignalAlert', true) !== false,
discoverTopFive: cfg.get<boolean>('messenger.discoverTopFive', true) !== false,
emailOverdueHours: cfg.get<number>('email.overdueAlertHours', 0) ?? 0,
},
devilAgent: { enabled: cfg.get<boolean>('devilAgent.enabled', false) },
bannerError: this._bannerError,
};
+19 -22
View File
@@ -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(
+23 -1
View File
@@ -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[];
+28 -2
View File
@@ -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<boolean>('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<boolean>('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 {
+24 -4
View File
@@ -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<Discov
// 3키워드 하드게이트를 완화한다(≥1 통과면 후보). 3키워드 미만이면 점수순 상위만
// 안내 문구와 함께. 일반 모드는 기존대로 ≥3 (정밀도 우선).
const minKeywords = opts.sector ? 1 : 3;
const prelim: { entry: ScreenerEntry; f: Fundamentals; passed: string[]; momentum?: MomentumInputs }[] = [];
const prelim: { entry: ScreenerEntry; f: Fundamentals; passed: string[]; momentum?: MomentumInputs; ma224?: number }[] = [];
for (const entry of allEntries) {
const f = fundsMap.get(entry.symbol);
if (!f) continue;
@@ -200,6 +216,7 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
alignment: align?.alignment,
rsi: rsi?.classification,
};
p.ma224 = r?.ma224Today; // 매수권장가 자동 산출용
}
}
@@ -218,17 +235,20 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
}));
const scores = scoreCandidates(scoreInputs);
// (7) DiscoveredCandidate 변환 + 점수 부착.
const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed }, i) => {
// (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],
};
+36 -9
View File
@@ -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<string>('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<void> {
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<void> {
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<boolean>('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<void> {
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;
+65 -8
View File
@@ -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 ).
*
+6
View File
@@ -36,6 +36,12 @@ export interface Stock {
현재가?: number;
/** 분류 시트 — '스윙/중기' / '장기투자' / '저평가우량주' 중 하나. */
?: '스윙/중기' | '장기투자' | '저평가우량주';
/**
* (v2.2.268) BUY_ZONE ** ** .
* fire . /
* 1 ( ).
*/
직전신호?: string;
}
export type StocksStore = Stock[];
+121
View File
@@ -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));
}
+80
View File
@@ -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>): 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);
});
});
+282
View File
@@ -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>): 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>): 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>): 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);
});
});
+121
View File
@@ -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>): 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);
});
});
+56
View File
@@ -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([]);
});
});
+64
View File
@@ -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('누가·언제·무엇을');
});
});
+74
View File
@@ -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?e<f>g|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'));
});
});