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'));
});
});