revert: ASTRA 이메일 기능 제거 — Datacollect wiki화로 피벗

Revert "feat(astra): 이메일 Settings 패널 섹션" (eb4bef0)
Revert "feat(astra): Project Astra 이메일 자산화 Phase 1+2" (7e96e56)

방향 전환: 이메일은 ASTRA에 전용 소스로 넣는 대신 Datacollect가 수집·wiki화해
brain(제2뇌)에 저장하고, ASTRA는 기존 brain 검색으로 그대로 활용한다.
Gmail 인증은 Datacollect 소유. /email-status(라이브 현황)는 폐기.
gmailApi 파싱 로직은 Datacollect 이전 시 재사용 예정.

타입체크·빌드 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 19:15:19 +09:00
parent eb4bef0744
commit 681cfd2393
15 changed files with 6 additions and 892 deletions
-9
View File
@@ -1,14 +1,5 @@
# Astra Patch Notes
## v2.2.206 (2026-06-05)
### 📧 Project Astra — 이메일 자산화 (Phase 1+2)
- **Gmail 읽기전용 수집** (`/email-sync [일수]`) — OAuth 에 `gmail.readonly` 스코프 추가(공유 토큰), 본문/메타/스레드를 로컬 인덱스(`{brainPath}/memory/email_index.json`)에 저장. 본문은 로컬을 벗어나지 않으며 합성은 로컬 LLM only(프라이버시).
- **RAG 'email' 소스** — 수집된 메일이 기존 검색 파이프라인에 자동 합류, 답변에 **원문 메일 링크** 출처 제공. 기존 grounding(확인불가/citation) 그대로 적용.
- **하이브리드 검색** — TF-IDF + 임베딩(설정 시) 블렌드, brain 과 동일 공식.
- **미회신 추적** (`/email-status [일수]`) — 스레드의 마지막이 내 답장이 아닌 건을 추출(노이즈 카테고리 제외, 🔔 요청 추정).
- **백그라운드 자동 동기화** — `g1nation.email.autoSync` 켜면 주기 수집(설정 변경 시 재시작, unref). 슬래시 명령과 동일 코어(`emailSync.ts`) 공유.
- 신규: [features/email/](src/features/email/) (gmailApi·emailStore·emailSync·autoSync·handlers) + retrieval 'email' 소스 통합.
## v2.2.205 (2026-06-05)
### 🧹 백엔드 분리 준비 — Bridge 타깃 토글(로컬/NAS) + /research 제거
- **Datacollect Bridge 타깃 설정** 추가 — Astra Settings 패널에서 `로컬/NAS` 전환 + NAS URL/토큰(`x-bridge-token`). 기본 `로컬` = 현행 동작 그대로. ([bridgeClient.ts](src/features/datacollect/bridgeClient.ts) · [settings-panel](media/settings-panel.html) · [settingsPanelProvider.ts](src/features/settings/settingsPanelProvider.ts))
-40
View File
@@ -117,46 +117,6 @@
</div>
</section>
<!-- Email (Project Astra) -->
<section class="section" data-section="email">
<h2>이메일 (Project Astra)</h2>
<p class="hint">Gmail 을 <strong>읽기 전용</strong>으로 수집해 로컬 인덱스에 저장하고, 채팅이 메일 근거(원문 링크 포함)로 답하게 합니다. 본문은 로컬을 벗어나지 않으며 합성은 로컬 LLM 만 사용합니다. (최초 1회 <code>gmail.readonly</code> 재인증 필요)</p>
<div class="row">
<label>인덱스 상태</label>
<small id="emailStatus" class="hint"></small>
</div>
<div class="row">
<label for="emailAutoSync">자동 동기화</label>
<input id="emailAutoSync" type="checkbox" />
<small class="hint">켜면 백그라운드에서 주기적으로 자동 수집합니다.</small>
</div>
<div class="row">
<label for="emailInterval">동기화 간격(분)</label>
<div class="input-group narrow">
<input id="emailInterval" type="number" min="5" max="1440" step="5" />
<button data-save="email.interval">저장</button>
</div>
</div>
<div class="row">
<label for="emailDays">수집 범위(일)</label>
<div class="input-group narrow">
<input id="emailDays" type="number" min="1" max="365" step="1" />
<button data-save="email.days">저장</button>
</div>
</div>
<div class="row">
<label for="emailMax">최대 메일 수</label>
<div class="input-group narrow">
<input id="emailMax" type="number" min="1" max="2000" step="50" />
<button data-save="email.max">저장</button>
</div>
</div>
<div class="row">
<button id="emailSyncNow">지금 동기화</button>
<small id="emailSyncMsg" class="hint"></small>
</div>
</section>
<!-- Memory -->
<section class="section" data-section="memory">
<h2>메모리</h2>
-41
View File
@@ -34,15 +34,6 @@
const dcMaxPages = $('dcMaxPages');
const dcSynthTemp = $('dcSynthTemp');
// ---- Email (Project Astra) ----
const emailStatus = $('emailStatus');
const emailAutoSync = $('emailAutoSync');
const emailInterval = $('emailInterval');
const emailDays = $('emailDays');
const emailMax = $('emailMax');
const emailSyncNow = $('emailSyncNow');
const emailSyncMsg = $('emailSyncMsg');
// ---- Memory ----
const memEnabled = $('memEnabled');
const memShort = $('memShort');
@@ -162,23 +153,6 @@
vscode.postMessage({ type: 'datacollect.update', synthesisTemperature: Number(dcSynthTemp.value) })
);
// ---- Email listeners ----
emailAutoSync.addEventListener('change', () =>
vscode.postMessage({ type: 'email.update', autoSync: emailAutoSync.checked })
);
document.querySelector('[data-save="email.interval"]').addEventListener('click', () =>
vscode.postMessage({ type: 'email.update', autoSyncIntervalMinutes: Number(emailInterval.value) })
);
document.querySelector('[data-save="email.days"]').addEventListener('click', () =>
vscode.postMessage({ type: 'email.update', syncDays: Number(emailDays.value) })
);
document.querySelector('[data-save="email.max"]').addEventListener('click', () =>
vscode.postMessage({ type: 'email.update', syncMaxMessages: Number(emailMax.value) })
);
emailSyncNow.addEventListener('click', () =>
vscode.postMessage({ type: 'email.syncNow' })
);
// ---- Memory listeners ----
memEnabled.addEventListener('change', (e) =>
vscode.postMessage({ type: 'memory.update', memoryEnabled: e.target.checked })
@@ -435,21 +409,6 @@
setIfNotFocused(dcSynthTemp, dc.synthesisTemperature);
}
// ---- Email (Project Astra) ----
const em = state.email;
if (em) {
if (document.activeElement !== emailAutoSync) emailAutoSync.checked = !!em.autoSync;
setIfNotFocused(emailInterval, em.autoSyncIntervalMinutes);
setIfNotFocused(emailDays, em.syncDays);
setIfNotFocused(emailMax, em.syncMaxMessages);
emailStatus.textContent = em.indexedCount > 0
? `${em.indexedCount}건 저장됨${em.newestDate ? ` · 최신 ${em.newestDate}` : ''}`
: '수집된 메일 없음 — "지금 동기화" 또는 /email-sync 실행';
emailSyncMsg.textContent = em.lastSyncMessage || '';
emailSyncNow.disabled = !!em.syncing;
emailSyncNow.textContent = em.syncing ? '동기화 중…' : '지금 동기화';
}
// ---- Memory ----
const mem = state.memory;
memEnabled.checked = !!mem.memoryEnabled;
+1 -27
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.206",
"version": "2.2.205",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -230,32 +230,6 @@
"default": "",
"markdownDescription": "`/benchmark` 등 Datacollect slash 명령 결과물(markdown)을 저장할 폴더. **비워두면** Bridge 기본 위치(Bridge의 `WIKI_RAW_PATH` 환경변수)에 저장됩니다 — 코드/설정 어디에도 절대경로가 박히지 않습니다. 특정 폴더로 저장하려면 절대경로를 입력하세요. Astra Settings 패널의 'Datacollect' 섹션에서도 편집 가능."
},
"g1nation.email.syncDays": {
"type": "number",
"default": 7,
"minimum": 1,
"maximum": 365,
"markdownDescription": "[Project Astra] `/email-sync` 가 기본으로 수집할 최근 일수. 명령에서 `/email-sync 30` 처럼 그때그때 덮어쓸 수 있습니다. 수집된 메일은 로컬 인덱스(`{brainPath}/memory/email_index.json`)에 저장되어 채팅 답변의 근거로 쓰입니다(읽기 전용)."
},
"g1nation.email.syncMaxMessages": {
"type": "number",
"default": 200,
"minimum": 1,
"maximum": 2000,
"markdownDescription": "[Project Astra] `/email-sync` 1회 실행 시 가져올 최대 메일 수. 메일 본문은 로컬을 벗어나지 않으며, 합성은 로컬 LLM 만 사용합니다."
},
"g1nation.email.autoSync": {
"type": "boolean",
"default": false,
"markdownDescription": "[Project Astra] 켜면 백그라운드에서 주기적으로 Gmail 을 자동 수집합니다(`/email-sync` 와 동일 동작). 끄면 수동 `/email-sync` 만. 기본 off."
},
"g1nation.email.autoSyncIntervalMinutes": {
"type": "number",
"default": 30,
"minimum": 5,
"maximum": 1440,
"markdownDescription": "[Project Astra] 자동 동기화 간격(분). `g1nation.email.autoSync` 가 켜져 있을 때만 적용. 최소 5분."
},
"g1nation.datacollectCrawlDepth": {
"type": "number",
"default": 1,
-5
View File
@@ -6,8 +6,6 @@ import * as path from 'path';
import './features/teamops/handlers';
import './features/system/handlers';
import './features/datacollect/handlers';
import './features/email/handlers'; // Project Astra — /email-sync
import { startEmailAutoSync } from './features/email/autoSync';
// axios removed in favor of native fetch
import {
_getBrainDir,
@@ -121,9 +119,6 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push({ dispose: () => activityTracker.dispose() });
context.subscriptions.push({ dispose: () => lifecycle.dispose() });
// Project Astra — 이메일 백그라운드 자동 동기화 (g1nation.email.autoSync 켜져 있을 때만).
startEmailAutoSync(context);
// React to engine URL changes — re-target the SDK and reset state.
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => {
+2 -4
View File
@@ -19,14 +19,12 @@ import * as http from 'http';
import * as crypto from 'crypto';
import * as vscode from 'vscode';
// Calendar/Sheets/Tasks/Gmail 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 모두 동작.
// 옛 사용자(이전 스코프로 연결)는 새 기능(Gmail 등) 사용 시 권한 부족 에러 → 재연결 필요.
// gmail.readonly 는 읽기 전용 — 삭제/답장/전달 불가(데이터 무결성·보안).
// CalendarSheets 양쪽 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 둘 다 동작.
// 옛 사용자(Calendar 만 연결)는 Sheets 사용 시 권한 부족 에러 → 재연결 필요.
const SCOPE = [
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/tasks',
'https://www.googleapis.com/auth/gmail.readonly',
'openid',
'email',
].join(' ');
-76
View File
@@ -1,76 +0,0 @@
/**
* ============================================================
* Email Auto-Sync Scheduler (Project Astra, Phase 2)
*
* g1nation.email.autoSync 가 켜져 있으면 주기적으로 syncEmails 를 호출한다.
* - 기동 직후 곧장 돌리지 않고 60s 뒤 첫 실행(활성화 블로킹 방지) → 이후 간격 반복.
* - 매 tick 마다 enabled 를 재확인 → 설정에서 끄면 다음 tick 부터 멈춤.
* - 설정(autoSync / 간격) 변경 시 타이머 재시작(onDidChangeConfiguration).
* - 동시 실행 가드(running) + timer.unref() 로 프로세스 종료를 막지 않음.
*
* 슬래시 명령과 *같은* syncEmails 코어를 쓴다 — 수집 동작 단일 출처.
* ============================================================
*/
import * as vscode from 'vscode';
import { logInfo, logError } from '../../utils';
import { syncEmails } from './emailSync';
let timer: ReturnType<typeof setInterval> | null = null;
let kickoff: ReturnType<typeof setTimeout> | null = null;
let configListener: vscode.Disposable | null = null;
let running = false;
function readSettings() {
const c = vscode.workspace.getConfiguration('g1nation');
return {
enabled: c.get<boolean>('email.autoSync', false),
intervalMin: Math.max(5, c.get<number>('email.autoSyncIntervalMinutes', 30) ?? 30),
days: c.get<number>('email.syncDays', 7) ?? 7,
maxResults: Math.max(1, Math.min(2000, c.get<number>('email.syncMaxMessages', 200) ?? 200)),
};
}
async function tick(context: vscode.ExtensionContext): Promise<void> {
const s = readSettings();
if (!s.enabled || running) return; // 끄면 즉시 중단 / 겹치면 스킵
running = true;
try {
const r = await syncEmails(context, { days: s.days, maxResults: s.maxResults });
if (r.ok) logInfo('[email] auto-sync 완료', { added: r.added, total: r.total, embedded: r.embedded, failed: r.failed });
else logError('[email] auto-sync 실패', { error: r.error });
} catch (e: any) {
logError('[email] auto-sync 예외', { error: e?.message || String(e) });
} finally {
running = false;
}
}
function stopTimers(): void {
if (timer) { clearInterval(timer); timer = null; }
if (kickoff) { clearTimeout(kickoff); kickoff = null; }
}
function restartTimers(context: vscode.ExtensionContext): void {
stopTimers();
const s = readSettings();
if (!s.enabled) { logInfo('[email] auto-sync 비활성(설정 off)'); return; }
kickoff = setTimeout(() => { void tick(context); }, 60_000);
timer = setInterval(() => { void tick(context); }, s.intervalMin * 60_000);
if (typeof (kickoff as any).unref === 'function') (kickoff as any).unref();
if (typeof (timer as any).unref === 'function') (timer as any).unref();
logInfo(`[email] auto-sync 활성 — ${s.intervalMin}분 간격`);
}
/** extension activate 에서 1회 호출. 설정 변경 감시 + 타이머 시작. */
export function startEmailAutoSync(context: vscode.ExtensionContext): void {
if (!configListener) {
configListener = vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration('g1nation.email.autoSync') || e.affectsConfiguration('g1nation.email.autoSyncIntervalMinutes')) {
restartTimers(context);
}
});
context.subscriptions.push(configListener, { dispose: stopTimers });
}
restartTimers(context);
}
-111
View File
@@ -1,111 +0,0 @@
/**
* ============================================================
* Email Store — 수집된 이메일의 로컬 영속 저장소 (Project Astra, Phase 1)
*
* 저장 위치: {brainPath}/memory/email_index.json (LongTermMemory 와 동일 규약)
* - 이메일 본문은 로컬을 절대 벗어나지 않는다(프라이버시 불변식). 합성은 로컬 LLM only.
* - 각 레코드는 *수집 시점에* 토큰화돼 들어오므로(handlers.ts), 검색 때 재토큰화하지
* 않는다 — brainIndex 의 mtime 캐시와 같은 정신.
* - 모듈 레벨 캐시(path+mtime 키)로 매 질의마다 전체 파일을 다시 읽지 않는다.
* (Phase 2 에서 brainIndex 수준의 증분 인덱스 + 임베딩으로 확장.)
* ============================================================
*/
import * as fs from 'fs';
import * as path from 'path';
export interface EmailRecord {
/** Gmail message id (불변 — 중복 제거 키). */
messageId: string;
threadId: string;
from: string;
to: string;
subject: string;
/** 수신/발신 시각 epoch ms. */
date: number;
snippet: string;
/** 플레인 텍스트 본문(로컬 저장). */
bodyText: string;
/** 원문 메일로 점프하는 딥링크 (출처 추적용). */
permalink: string;
labels: string[];
/** 검색용 토큰(본문+제목+발신자) — 수집 시 1회 계산. */
tokens: string[];
/** 제목 토큰(타이틀 가중치). */
subjectTokens: string[];
/** (Phase 2) 임베딩 벡터 — 수집 시 1회 계산. 모델 불일치 시 검색에서 무시. */
embedding?: number[];
/** 임베딩을 만든 모델명(모델 교체 시 무효화 판단). */
embeddingModel?: string;
}
interface EmailStore {
version: number;
updatedAt: number;
records: EmailRecord[];
}
const STORE_VERSION = 1;
export function getEmailStorePath(brainPath: string): string {
return path.join(brainPath, 'memory', 'email_index.json');
}
// path -> { mtimeMs, records } : 같은 파일이 안 바뀌었으면 디스크 재독 생략.
const _cache = new Map<string, { mtimeMs: number; records: EmailRecord[] }>();
/** 저장된 이메일 레코드 로드. 파일이 없거나 깨졌으면 빈 배열. mtime 캐시 적용. */
export function loadEmailRecords(brainPath: string): EmailRecord[] {
const filePath = getEmailStorePath(brainPath);
let mtimeMs = 0;
try { mtimeMs = fs.statSync(filePath).mtimeMs; } catch { return []; }
const cached = _cache.get(filePath);
if (cached && cached.mtimeMs === mtimeMs) return cached.records;
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const store = JSON.parse(raw) as EmailStore;
const records = Array.isArray(store?.records) ? store.records : [];
_cache.set(filePath, { mtimeMs, records });
return records;
} catch {
return [];
}
}
/** 레코드 저장(원자적 쓰기). 디렉터리 자동 생성, 캐시 갱신. */
export function saveEmailRecords(brainPath: string, records: EmailRecord[]): void {
const filePath = getEmailStorePath(brainPath);
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const store: EmailStore = { version: STORE_VERSION, updatedAt: Date.now(), records };
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(store), 'utf-8');
fs.renameSync(tmp, filePath);
try {
_cache.set(filePath, { mtimeMs: fs.statSync(filePath).mtimeMs, records });
} catch { /* 캐시 갱신 실패는 무해 — 다음 로드가 디스크에서 다시 읽음 */ }
}
/**
* 신규 레코드를 messageId 기준으로 머지(중복은 새 값으로 덮어씀) 후 저장.
* 최신순 정렬, 상한(maxRetained) 초과 시 오래된 것부터 제거.
* 반환: { total, added }.
*/
export function upsertEmailRecords(
brainPath: string,
incoming: EmailRecord[],
maxRetained = 50000,
): { total: number; added: number } {
const existing = loadEmailRecords(brainPath);
const byId = new Map<string, EmailRecord>();
for (const r of existing) byId.set(r.messageId, r);
let added = 0;
for (const r of incoming) {
if (!byId.has(r.messageId)) added++;
byId.set(r.messageId, r);
}
let merged = Array.from(byId.values()).sort((a, b) => b.date - a.date);
if (merged.length > maxRetained) merged = merged.slice(0, maxRetained);
saveEmailRecords(brainPath, merged);
return { total: merged.length, added };
}
-106
View File
@@ -1,106 +0,0 @@
/**
* ============================================================
* Email Sync Core — 수집 로직 단일 출처 (Project Astra, Phase 2)
*
* 슬래시 명령(/email-sync)과 백그라운드 자동 동기화(autoSync.ts)가 *같은* 이 함수를
* 호출한다 — 수집 동작이 한 곳에만 있어 유지보수가 쉽다.
* onProgress 콜백으로 UI 스트리밍(수동) / 무음(백그라운드)을 모두 지원.
*
* 프라이버시 불변식: 메일 본문은 로컬 인덱스에만 저장. 합성은 로컬 LLM only.
* ============================================================
*/
import * as vscode from 'vscode';
import { getConfig } from '../../config';
import { tokenize } from '../../retrieval/scoring';
import { embedTexts } from '../../retrieval/embeddings';
import { listMessageIds, getMessage } from './gmailApi';
import { upsertEmailRecords, loadEmailRecords, type EmailRecord } from './emailStore';
/** getMessage 동시 호출 수 — Gmail rate limit 과 속도의 균형. */
const FETCH_CONCURRENCY = 6;
export interface SyncResult {
ok: boolean;
/** 인덱스 총 메일 수(머지 후). */
total: number;
/** 신규 추가 수. */
added: number;
/** 본문 fetch 실패 수. */
failed: number;
/** 임베딩된 수(0 = 임베딩 비활성/실패). */
embedded: number;
error?: string;
}
export async function syncEmails(
context: vscode.ExtensionContext,
opts: { days: number; maxResults: number; onProgress?: (msg: string) => void },
): Promise<SyncResult> {
const log = opts.onProgress || (() => { /* silent */ });
const base: SyncResult = { ok: false, total: 0, added: 0, failed: 0, embedded: 0 };
const brainPath = (getConfig().localBrainPath || '').trim();
if (!brainPath) return { ...base, error: '활성 브레인 경로가 설정돼 있지 않습니다.' };
const list = await listMessageIds(context, { query: `newer_than:${opts.days}d`, maxResults: opts.maxResults });
if (!list.ok) return { ...base, error: list.error };
if (list.data.length === 0) {
return { ok: true, total: loadEmailRecords(brainPath).length, added: 0, failed: 0, embedded: 0 };
}
log(`본문 가져오는 중… (${list.data.length}건)`);
const records: EmailRecord[] = [];
let fetched = 0;
let failed = 0;
for (let i = 0; i < list.data.length; i += FETCH_CONCURRENCY) {
const batch = list.data.slice(i, i + FETCH_CONCURRENCY);
const results = await Promise.all(batch.map((m) => getMessage(context, m.id)));
for (const r of results) {
if (!r.ok) { failed++; continue; }
const m = r.data;
const searchable = `${m.subject}\n${m.from}\n${m.bodyText || m.snippet}`;
records.push({
messageId: m.messageId,
threadId: m.threadId,
from: m.from,
to: m.to,
subject: m.subject,
date: m.date,
snippet: m.snippet,
bodyText: m.bodyText,
permalink: m.permalink,
labels: m.labels,
tokens: tokenize(searchable),
subjectTokens: tokenize(m.subject),
});
}
fetched += batch.length;
if (fetched % 30 === 0 || fetched >= list.data.length) {
log(` · ${Math.min(fetched, list.data.length)}/${list.data.length}`);
}
}
if (records.length === 0) {
return { ...base, failed, error: `본문을 하나도 가져오지 못했습니다 (실패 ${failed}건).` };
}
// 임베딩(best-effort) — 모델 설정 시 수집과 동시에 벡터화.
let embedded = 0;
const acfg = getConfig();
if (acfg.embeddingModel) {
log(`임베딩 생성 중… (모델: ${acfg.embeddingModel})`);
try {
const texts = records.map((r) => `${r.subject}\n${r.bodyText || r.snippet}`);
const vectors = await embedTexts(texts, { baseUrl: acfg.ollamaUrl, model: acfg.embeddingModel });
if (vectors.length === records.length) {
records.forEach((r, idx) => { r.embedding = vectors[idx]; r.embeddingModel = acfg.embeddingModel; });
embedded = vectors.length;
}
} catch { /* 키워드 검색으로 graceful fallback */ }
}
const { total, added } = upsertEmailRecords(brainPath, records);
return { ok: true, total, added, failed, embedded };
}
-162
View File
@@ -1,162 +0,0 @@
/**
* ============================================================
* 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) };
}
}
// ─── 파싱 헬퍼 ───────────────────────────────────────────────────────────────
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)) : [],
};
}
-110
View File
@@ -1,110 +0,0 @@
/**
* ============================================================
* Email handlers — /email-sync (Project Astra, Phase 1)
*
* Gmail 을 읽기 전용으로 수집해 {brainPath}/memory/email_index.json 에 저장한다.
* 저장된 메일은 RetrievalOrchestrator 의 'email' 소스로 자동 검색되므로(별도 QA
* 명령 불필요), 이후 일반 채팅 질문이 메일 근거+원문 링크로 답하게 된다.
*
* import 만으로 등록되도록 module scope 에서 registerSlashCommand 호출 — 배럴
* (extension.ts)에서 `import './features/email/handlers'` 한 줄 추가.
* ============================================================
*/
import * as vscode from 'vscode';
import { registerSlashCommand, chunk } from '../datacollect/slashRouter';
import { getConfig } from '../../config';
import { syncEmails } from './emailSync';
import { loadEmailRecords, getEmailStorePath, type EmailRecord } from './emailStore';
async function runEmailSync(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /email-sync 실행 불가.\n'); return true; }
const cfg = vscode.workspace.getConfiguration('g1nation');
const argDays = parseInt(arg.trim().split(/\s+/)[0] || '', 10);
const days = Number.isFinite(argDays) && argDays > 0 ? Math.min(365, argDays) : (cfg.get<number>('email.syncDays', 7) ?? 7);
const maxResults = Math.max(1, Math.min(2000, cfg.get<number>('email.syncMaxMessages', 200) ?? 200));
const brainPath = (getConfig().localBrainPath || '').trim();
if (!brainPath) {
chunk(view, '\n❌ 활성 브레인 경로가 설정돼 있지 않습니다. Astra Settings 에서 Brain 을 먼저 지정하세요.\n');
return true;
}
chunk(view, `\n📧 **이메일 수집 (읽기 전용)** — 최근 ${days}일, 최대 ${maxResults}\n · 저장 위치: \`${getEmailStorePath(brainPath)}\`\n`);
const r = await syncEmails(context, { days, maxResults, onProgress: (m) => chunk(view, ` ${m}\n`) });
if (!r.ok) { chunk(view, `\n❌ 수집 실패: ${r.error}\n`); return true; }
chunk(view, `\n✅ 수집 완료 — 신규 ${r.added}건 / 인덱스 총 ${r.total}${r.embedded ? ` · 임베딩 ${r.embedded}` : ''}${r.failed ? ` · 실패 ${r.failed}` : ''}\n`);
chunk(view, '이제 일반 채팅으로 메일 내용을 물어보면 근거 메일과 원문 링크로 답합니다. (예: "A 프로젝트 계약 조건 어떻게 바뀌었지?")\n');
return true;
}
// ─── /email-status — 미회신/놓친 요청 추적 (Scenario 2) ──────────────────────
// 수집된 메일을 스레드로 묶어, 마지막 메시지가 '내가 보낸 것(SENT)'이 아닌 스레드를
// '미회신'으로 추출한다. 프로모션/소셜/업데이트 카테고리는 노이즈로 제외.
const NOISE_LABELS = ['CATEGORY_PROMOTIONS', 'CATEGORY_SOCIAL', 'CATEGORY_UPDATES', 'CATEGORY_FORUMS', 'SPAM', 'TRASH'];
const REQUEST_HINT = /요청|문의|부탁|검토|회신|답장|확인\s*(?:부탁|요망)|please|could you|kindly|반려|승인|\?\s*$/i;
async function runEmailStatus(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /email-status 실행 불가.\n'); return true; }
const brainPath = (getConfig().localBrainPath || '').trim();
if (!brainPath) { chunk(view, '\n❌ 활성 브레인 경로 미설정.\n'); return true; }
const argDays = parseInt(arg.trim().split(/\s+/)[0] || '', 10);
const days = Number.isFinite(argDays) && argDays > 0 ? Math.min(90, argDays) : 7;
const all = loadEmailRecords(brainPath);
if (all.length === 0) {
chunk(view, '\nℹ️ 수집된 메일이 없습니다. 먼저 `/email-sync` 를 실행하세요.\n');
return true;
}
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const recent = all.filter((r) => r.date >= since);
// 스레드별 최신 메시지
const latestByThread = new Map<string, EmailRecord>();
for (const r of recent) {
const key = r.threadId || r.messageId;
const cur = latestByThread.get(key);
if (!cur || r.date > cur.date) latestByThread.set(key, r);
}
const unanswered = Array.from(latestByThread.values())
.filter((r) => !r.labels.includes('SENT')) // 내가 마지막에 답하지 않음
.filter((r) => !r.labels.some((l) => NOISE_LABELS.includes(l))) // 프로모션/소셜 등 제외
.sort((a, b) => a.date - b.date); // 오래된 미회신 먼저
chunk(view, `\n📭 **미회신 / 놓친 요청 — 최근 ${days}일** (${unanswered.length}건)\n`);
if (unanswered.length === 0) {
chunk(view, '\n✅ 미회신 스레드가 없습니다. 깔끔하네요.\n');
return true;
}
const MAX = 20;
const today = Date.now();
for (const r of unanswered.slice(0, MAX)) {
const ageDays = Math.floor((today - r.date) / (24 * 60 * 60 * 1000));
const dateStr = new Date(r.date).toISOString().slice(0, 10);
const isRequest = REQUEST_HINT.test(r.subject) || REQUEST_HINT.test(r.snippet);
const flag = isRequest ? '🔔 ' : '';
const fromShort = r.from.replace(/\s*<[^>]+>/, '').trim() || r.from;
chunk(view, `- ${flag}**${r.subject || '(제목 없음)'}** — ${fromShort} · ${dateStr} (${ageDays}일 경과)\n ↳ ${r.permalink}\n`);
}
if (unanswered.length > MAX) chunk(view, `\n_…+${unanswered.length - MAX}건 더 (\`/email-status ${days}\` 범위를 좁혀 보세요)_\n`);
chunk(view, '\n🔔 = 회신·검토 요청으로 보이는 메일. 자세한 내용은 채팅으로 물어보세요.\n');
return true;
}
registerSlashCommand({
name: '/email-sync',
description: 'Gmail 읽기전용 수집 → 로컬 인덱스 (이후 채팅이 메일 근거로 답변). 사용법: /email-sync [일수]',
handler: runEmailSync,
});
registerSlashCommand({
name: '/email-status',
description: '미회신/놓친 요청 추적 — 마지막이 내 답장이 아닌 스레드 추출. 사용법: /email-status [일수]',
handler: runEmailStatus,
});
@@ -6,9 +6,6 @@ import type { TelegramBot } from '../../integrations/telegram/telegramBot';
import { logError, logInfo } from '../../utils';
import { discoverModels } from '../../lib/discoverModels';
import { pickConfigTarget } from '../../lib/paths';
import { getConfig } from '../../config';
import { loadEmailRecords } from '../email/emailStore';
import { syncEmails } from '../email/emailSync';
/**
* Astra Settings webview.
@@ -104,20 +101,6 @@ interface SettingsState {
maxPages: number;
synthesisTemperature: number;
};
email: {
autoSync: boolean;
autoSyncIntervalMinutes: number;
syncDays: number;
syncMaxMessages: number;
/** 로컬 인덱스에 저장된 메일 수. */
indexedCount: number;
/** 가장 최근 메일 날짜(YYYY-MM-DD) — 없으면 ''. */
newestDate: string;
/** 마지막 '지금 동기화' 결과 메시지. */
lastSyncMessage: string;
/** 동기화 진행 중. */
syncing: boolean;
};
google: {
clientId: string;
/** secret 자체는 client 에 echo 안 함 — *설정 여부* 만. true 면 input placeholder 가 "저장됨" 으로 바뀜. */
@@ -165,10 +148,6 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
constructor(private readonly _deps: SettingsPanelDeps) {}
// Project Astra — 이메일 '지금 동기화' 상태(패널 표시용).
private _emailSyncing = false;
private _emailLastSyncMsg = '';
public resolveWebviewView(view: vscode.WebviewView): void {
this._view = view;
this._setupWebview(view.webview);
@@ -279,12 +258,6 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
case 'datacollect.update':
await this._handleDatacollectUpdate(msg);
return;
case 'email.update':
await this._handleEmailUpdate(msg);
return;
case 'email.syncNow':
await this._handleEmailSyncNow();
return;
case 'google.update':
await this._handleGoogleUpdate(msg);
return;
@@ -665,70 +638,6 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
}
}
// ────────────── Email (Project Astra) ──────────────
private async _handleEmailUpdate(msg: any): Promise<void> {
if (typeof msg.autoSync === 'boolean') {
await this._safeConfigUpdate('email.autoSync', msg.autoSync);
}
if (typeof msg.autoSyncIntervalMinutes === 'number' && Number.isFinite(msg.autoSyncIntervalMinutes)) {
await this._safeConfigUpdate('email.autoSyncIntervalMinutes', Math.max(5, Math.min(1440, Math.floor(msg.autoSyncIntervalMinutes))));
}
if (typeof msg.syncDays === 'number' && Number.isFinite(msg.syncDays)) {
await this._safeConfigUpdate('email.syncDays', Math.max(1, Math.min(365, Math.floor(msg.syncDays))));
}
if (typeof msg.syncMaxMessages === 'number' && Number.isFinite(msg.syncMaxMessages)) {
await this._safeConfigUpdate('email.syncMaxMessages', Math.max(1, Math.min(2000, Math.floor(msg.syncMaxMessages))));
}
}
/** 패널의 '지금 동기화' 버튼 — 슬래시 명령과 동일한 syncEmails 코어 호출. */
private async _handleEmailSyncNow(): Promise<void> {
if (this._emailSyncing) return;
const c = vscode.workspace.getConfiguration('g1nation');
const days = c.get<number>('email.syncDays', 7) ?? 7;
const maxResults = Math.max(1, Math.min(2000, c.get<number>('email.syncMaxMessages', 200) ?? 200));
this._emailSyncing = true;
this._emailLastSyncMsg = '동기화 중…';
await this._refreshState();
try {
const r = await syncEmails(this._deps.context, { days, maxResults });
this._emailLastSyncMsg = r.ok
? `완료 — 신규 ${r.added} / 총 ${r.total}${r.embedded ? ` · 임베딩 ${r.embedded}` : ''}${r.failed ? ` · 실패 ${r.failed}` : ''}`
: `실패 — ${r.error}`;
} catch (e: any) {
this._emailLastSyncMsg = `오류 — ${e?.message || String(e)}`;
} finally {
this._emailSyncing = false;
await this._refreshState();
}
}
private _buildEmailState(): SettingsState['email'] {
const c = vscode.workspace.getConfiguration('g1nation');
let indexedCount = 0;
let newestDate = '';
try {
const brainPath = (getConfig().localBrainPath || '').trim();
if (brainPath) {
const records = loadEmailRecords(brainPath);
indexedCount = records.length;
const newest = records.reduce((m, r) => (r.date > m ? r.date : m), 0);
if (newest > 0) newestDate = new Date(newest).toISOString().slice(0, 10);
}
} catch { /* status 표시 실패는 무해 */ }
return {
autoSync: c.get<boolean>('email.autoSync', false),
autoSyncIntervalMinutes: c.get<number>('email.autoSyncIntervalMinutes', 30) ?? 30,
syncDays: c.get<number>('email.syncDays', 7) ?? 7,
syncMaxMessages: c.get<number>('email.syncMaxMessages', 200) ?? 200,
indexedCount,
newestDate,
lastSyncMessage: this._emailLastSyncMsg,
syncing: this._emailSyncing,
};
}
private async _refreshState(): Promise<void> {
if (!this._view && !this._panel) return;
const cfg = vscode.workspace.getConfiguration('g1nation');
@@ -791,7 +700,6 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
maxPages: cfg.get<number>('datacollectMaxPages', 8) ?? 8,
synthesisTemperature: cfg.get<number>('datacollectSynthesisTemperature', 0.1) ?? 0.1,
},
email: this._buildEmailState(),
google: this._buildGoogleState(),
providers: await this._buildProvidersState(),
devilAgent: { enabled: cfg.get<boolean>('devilAgent.enabled', false) },
+1 -2
View File
@@ -119,8 +119,7 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
'procedural-memory': '📋 Procedural Memory (반복 절차)',
'episodic-memory': '📖 Episodic Memory (과거 대화 흐름)',
'project-scan': '🔍 Project Scan',
'recent-knowledge': '📄 Recent Project Knowledge',
'email': '📧 이메일 (수집된 메일 근거 — 원문 링크 포함)'
'recent-knowledge': '📄 Recent Project Knowledge'
};
// Group by source
+1 -96
View File
@@ -26,7 +26,6 @@ import { extractLessonEssence } from './lessonHelpers';
import { cosineSimilarity } from './embeddings';
import { applyActionabilityBoost, WorkStateSignals, ActionabilityWeights } from './actionabilityScoring';
import { applyHierarchicalReweight, classifyQueryLevel, AbstractionLevel, HierarchicalWeights } from './hierarchicalLevel';
import { loadEmailRecords } from '../features/email/emailStore';
export { tokenize, expandQuery, scoreTfIdf, scoreTfIdfPreTokenized, extractBestExcerpt } from './scoring';
export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
@@ -84,8 +83,6 @@ interface RetrievalOptions {
embeddingModel?: string;
/** Blend weight: 0 = TF-IDF only, 1 = cosine only. Default 0.5. */
embeddingBlendAlpha?: number;
/** Project Astra — email 소스에서 가져올 최대 메일 수. 기본 6. 0 이면 스킵. */
emailLimit?: number;
/**
* Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과 재가중.
* undefined 면 actionability re-rank 안 함 (legacy 동작).
@@ -154,19 +151,6 @@ export class RetrievalOrchestrator {
allChunks.push(...memoryChunks);
fusionLog.push(`Memory search: ${memoryChunks.length} chunks found`);
// ── ①-b Email Search (Project Astra) — 수집된 메일에서 근거 검색 ──
// 인덱스가 비어 있으면(미수집) 즉시 [] 반환하므로 비용 0.
const emailChunks = this.searchEmailIndex(
expandedTokens,
options.brain,
options.emailLimit ?? 6,
options.queryEmbedding,
options.embeddingModel,
options.embeddingBlendAlpha,
);
allChunks.push(...emailChunks);
if (emailChunks.length > 0) fusionLog.push(`Email search: ${emailChunks.length} chunks found`);
// ── ②-b Medium-Term Memory (recent sessions) ──
const mediumChunks = this.scoreRecentSessions(
expandedTokens,
@@ -359,84 +343,6 @@ export class RetrievalOrchestrator {
}
}
// ─── Email Search (Project Astra) ───
/**
* 수집된 이메일 인덱스({brainPath}/memory/email_index.json)에서 TF-IDF 로 근거 메일을
* 찾는다. 브레인 파일 검색과 *동일한* scoreTfIdfPreTokenized 를 써서 일관성 유지.
* 각 청크는 messageId/permalink 메타를 실어 답변이 원문 메일로 점프할 수 있게 한다.
* Phase 1 은 키워드(TF-IDF)만 — 임베딩 블렌드는 Phase 2.
*/
private searchEmailIndex(
expandedTokens: string[],
brain: BrainProfile,
limit: number,
queryEmbedding?: number[],
embeddingModel?: string,
embeddingBlendAlpha?: number,
): RetrievalChunk[] {
if (limit <= 0) return [];
try {
const records = loadEmailRecords(brain.localBrainPath);
if (records.length === 0) return [];
const scored = scoreTfIdfPreTokenized(
expandedTokens,
records.map((r) => ({
tokens: r.tokens,
titleTokens: r.subjectTokens,
lastModified: r.date,
conflictCount: 0,
})),
);
// (Phase 2) 하이브리드 블렌드 — 브레인 검색과 동일 공식. 같은 모델로 임베딩된
// 레코드만 코사인 가산, 없으면 순수 TF-IDF 유지(graceful fallback).
if (queryEmbedding && embeddingModel && (embeddingBlendAlpha ?? 0) > 0) {
const alpha = Math.max(0, Math.min(1, embeddingBlendAlpha!));
const maxTfidf = scored.reduce((m, s) => s.score > m ? s.score : m, 0) || 1;
for (const s of scored) {
const rec = records[s.index];
if (!rec.embedding || rec.embeddingModel !== embeddingModel) continue;
const cos = cosineSimilarity(queryEmbedding, rec.embedding);
const tfidfNorm = s.score / maxTfidf;
s.score = (1 - alpha) * tfidfNorm + alpha * Math.max(0, cos);
}
}
const ranked = scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
const out: RetrievalChunk[] = [];
for (const s of ranked) {
const r = records[s.index];
const dateStr = new Date(r.date).toISOString().slice(0, 10);
const title = `메일: "${r.subject || '(제목 없음)'}" (${dateStr}, from ${r.from})`;
const body = summarizeText(r.bodyText || r.snippet || '', 700);
const content = `${body}${r.permalink ? `\n[원문 링크] ${r.permalink}` : ''}`;
out.push({
id: `email-${r.messageId}`,
source: 'email' as const,
title,
content,
score: s.score,
tokenEstimate: estimateTokens(content),
metadata: {
category: 'email',
lastUpdated: r.date,
queryCoverage: s.queryCoverage,
emailMessageId: r.messageId,
emailThreadId: r.threadId,
emailFrom: r.from,
emailSubject: r.subject,
emailDate: r.date,
emailPermalink: r.permalink,
},
});
}
return out;
} catch {
return [];
}
}
// ─── Memory Layer Search ───
private searchMemoryLayers(
@@ -607,8 +513,7 @@ export class RetrievalOrchestrator {
'medium-term-memory': 0.78, // recent sessions: useful when the user references "last time / yesterday"
'episodic-memory': 0.7,
'project-scan': 0.6,
'recent-knowledge': 0.75,
'email': 0.9 // 메일은 직접 근거 — 브레인 노트와 동급으로 취급
'recent-knowledge': 0.75
};
for (const chunk of chunks) {
+1 -11
View File
@@ -16,8 +16,7 @@ export type RetrievalSource =
| 'procedural-memory' // Procedural Memory
| 'episodic-memory' // Episodic Memory
| 'project-scan' // Local Project Path scan
| 'recent-knowledge' // Recent Project Knowledge record
| 'email'; // Project Astra — 수집된 Gmail/이메일
| 'recent-knowledge'; // Recent Project Knowledge record
export type ConflictSeverity = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
@@ -44,15 +43,6 @@ export interface RetrievalChunk {
isLesson?: boolean;
/** 'lesson' | 'playbook' | 'qa-finding' when isLesson is true. */
lessonKind?: string;
// --- Email (Project Astra) — 출처 추적: 원문 메일로 점프 ---
emailMessageId?: string;
emailThreadId?: string;
emailFrom?: string;
emailSubject?: string;
emailDate?: number;
/** 원문 메일 딥링크. */
emailPermalink?: string;
};
}