From 7e96e56381d4e281d84aa30a5073beb4bdeeb186 Mon Sep 17 00:00:00 2001 From: g1nation Date: Fri, 5 Jun 2026 18:34:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(astra):=20Project=20Astra=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9E=90=EC=82=B0=ED=99=94=20Phase=201+2?= =?UTF-8?q?=20(v2.2.206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gmail 읽기전용 수집(/email-sync) — gmail.readonly 스코프(공유 토큰), 본문/메타/스레드를 로컬 인덱스에 저장. 본문 로컬 only(프라이버시). - RAG 'email' 소스 — 검색 파이프라인 자동 합류 + 원문 메일 링크 출처. - 하이브리드(TF-IDF+임베딩) 검색, brain 과 동일 공식. - /email-status — 미회신/놓친 요청 추적(스레드 SENT 라벨 휴리스틱). - 백그라운드 자동 동기화(g1nation.email.autoSync) — 슬래시와 동일 코어 공유. 신규 features/email/{gmailApi,emailStore,emailSync,autoSync,handlers}.ts + retrieval 'email' 소스 통합. 타입체크·407 테스트 통과. Co-Authored-By: Claude Opus 4.8 --- PATCHNOTES.md | 9 ++ package.json | 28 +++++- src/extension.ts | 5 + src/features/calendar/oauth.ts | 6 +- src/features/email/autoSync.ts | 76 +++++++++++++++ src/features/email/emailStore.ts | 111 +++++++++++++++++++++ src/features/email/emailSync.ts | 106 ++++++++++++++++++++ src/features/email/gmailApi.ts | 162 +++++++++++++++++++++++++++++++ src/features/email/handlers.ts | 110 +++++++++++++++++++++ src/retrieval/contextBudget.ts | 3 +- src/retrieval/index.ts | 97 +++++++++++++++++- src/retrieval/types.ts | 12 ++- 12 files changed, 719 insertions(+), 6 deletions(-) create mode 100644 src/features/email/autoSync.ts create mode 100644 src/features/email/emailStore.ts create mode 100644 src/features/email/emailSync.ts create mode 100644 src/features/email/gmailApi.ts create mode 100644 src/features/email/handlers.ts diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 4d4e804..28c996b 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,14 @@ # 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)) diff --git a/package.json b/package.json index c07c028..a59e1fc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.205", + "version": "2.2.206", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -230,6 +230,32 @@ "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, diff --git a/src/extension.ts b/src/extension.ts index 8c01430..907a74e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,8 @@ 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, @@ -119,6 +121,9 @@ 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) => { diff --git a/src/features/calendar/oauth.ts b/src/features/calendar/oauth.ts index 38c16f9..29baa1d 100644 --- a/src/features/calendar/oauth.ts +++ b/src/features/calendar/oauth.ts @@ -19,12 +19,14 @@ import * as http from 'http'; import * as crypto from 'crypto'; import * as vscode from 'vscode'; -// Calendar 와 Sheets 양쪽 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 둘 다 동작. -// 옛 사용자(Calendar 만 연결)는 Sheets 사용 시 권한 부족 에러 → 재연결 필요. +// Calendar/Sheets/Tasks/Gmail 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 모두 동작. +// 옛 사용자(이전 스코프로 연결)는 새 기능(Gmail 등) 사용 시 권한 부족 에러 → 재연결 필요. +// gmail.readonly 는 읽기 전용 — 삭제/답장/전달 불가(데이터 무결성·보안). 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(' '); diff --git a/src/features/email/autoSync.ts b/src/features/email/autoSync.ts new file mode 100644 index 0000000..8fdaa4c --- /dev/null +++ b/src/features/email/autoSync.ts @@ -0,0 +1,76 @@ +/** + * ============================================================ + * 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 | null = null; +let kickoff: ReturnType | null = null; +let configListener: vscode.Disposable | null = null; +let running = false; + +function readSettings() { + const c = vscode.workspace.getConfiguration('g1nation'); + return { + enabled: c.get('email.autoSync', false), + intervalMin: Math.max(5, c.get('email.autoSyncIntervalMinutes', 30) ?? 30), + days: c.get('email.syncDays', 7) ?? 7, + maxResults: Math.max(1, Math.min(2000, c.get('email.syncMaxMessages', 200) ?? 200)), + }; +} + +async function tick(context: vscode.ExtensionContext): Promise { + 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); +} diff --git a/src/features/email/emailStore.ts b/src/features/email/emailStore.ts new file mode 100644 index 0000000..067612b --- /dev/null +++ b/src/features/email/emailStore.ts @@ -0,0 +1,111 @@ +/** + * ============================================================ + * 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(); + +/** 저장된 이메일 레코드 로드. 파일이 없거나 깨졌으면 빈 배열. 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(); + 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 }; +} diff --git a/src/features/email/emailSync.ts b/src/features/email/emailSync.ts new file mode 100644 index 0000000..eaa8290 --- /dev/null +++ b/src/features/email/emailSync.ts @@ -0,0 +1,106 @@ +/** + * ============================================================ + * 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 { + 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 }; +} diff --git a/src/features/email/gmailApi.ts b/src/features/email/gmailApi.ts new file mode 100644 index 0000000..79e6981 --- /dev/null +++ b/src/features/email/gmailApi.ts @@ -0,0 +1,162 @@ +/** + * ============================================================ + * Gmail API v1 — 읽기 전용(read-only) 메시지 수집 (Project Astra, Phase 1) + * + * Calendar/Tasks/Sheets 와 *같은 Google OAuth 토큰*을 공유한다 + * (`getFreshAccessToken`). scope 에 `gmail.readonly` 가 포함돼야 함(oauth.ts). + * 삭제/답장/전달 등 쓰기 기능은 일절 없다(읽기 전용 — 데이터 무결성·보안). + * + * 외부 라이브러리 없음 — REST + native fetch. tasksApi.ts 와 동일한 셰이프: + * (context, ...) -> getFreshAccessToken -> fetch -> { ok, data } | { ok:false, error } + * ============================================================ + */ + +import * as vscode from 'vscode'; +import { getFreshAccessToken } from '../calendar/calendarApi'; + +const API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; + +export interface ParsedMessage { + messageId: string; + threadId: string; + from: string; + to: string; + subject: string; + /** epoch ms */ + date: number; + snippet: string; + bodyText: string; + permalink: string; + labels: string[]; +} + +type ApiResult = { ok: true; data: T } | { ok: false; error: string }; + +function authError(status: number, msg: string): boolean { + return status === 401 || status === 403 || /insufficient|scope|disabled|enable/i.test(msg); +} + +const REAUTH_HINT = + 'Gmail API 권한 부족 — "Astra: Google Calendar OAuth 연결" 명령을 다시 실행해 gmail.readonly 스코프 동의가 필요합니다. (Google Cloud Console 에서 Gmail API 활성화도 확인)'; + +/** + * 최근 메시지 id 목록을 조회. q 는 Gmail 검색 문법(예: `newer_than:7d`). + * maxResults 까지 pageToken 으로 페이지네이션한다. + */ +export async function listMessageIds( + context: vscode.ExtensionContext, + opts: { query?: string; maxResults?: number } = {}, +): Promise>> { + const tok = await getFreshAccessToken(context); + if (!tok.ok) return { ok: false, error: tok.error }; + + const target = Math.max(1, Math.min(2000, opts.maxResults ?? 200)); + const out: Array<{ id: string; threadId: string }> = []; + let pageToken: string | undefined; + + try { + while (out.length < target) { + const params = new URLSearchParams({ maxResults: String(Math.min(100, target - out.length)) }); + if (opts.query) params.set('q', opts.query); + if (pageToken) params.set('pageToken', pageToken); + const res = await fetch(`${API_BASE}/messages?${params.toString()}`, { + headers: { Authorization: `Bearer ${tok.accessToken}` }, + signal: AbortSignal.timeout(20000), + }); + const json: any = await res.json().catch(() => ({})); + if (!res.ok) { + const msg: string = json?.error?.message || `HTTP ${res.status}`; + return { ok: false, error: authError(res.status, msg) ? REAUTH_HINT : msg }; + } + const items: any[] = Array.isArray(json.messages) ? json.messages : []; + for (const m of items) { + if (m?.id) out.push({ id: String(m.id), threadId: String(m.threadId || '') }); + } + pageToken = json.nextPageToken; + if (!pageToken || items.length === 0) break; + } + return { ok: true, data: out.slice(0, target) }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +/** 단일 메시지 전체(format=full)를 조회해 파싱. */ +export async function getMessage( + context: vscode.ExtensionContext, + id: string, +): Promise> { + const tok = await getFreshAccessToken(context); + if (!tok.ok) return { ok: false, error: tok.error }; + try { + const res = await fetch(`${API_BASE}/messages/${encodeURIComponent(id)}?format=full`, { + headers: { Authorization: `Bearer ${tok.accessToken}` }, + signal: AbortSignal.timeout(20000), + }); + const json: any = await res.json().catch(() => ({})); + if (!res.ok) { + const msg: string = json?.error?.message || `HTTP ${res.status}`; + return { ok: false, error: authError(res.status, msg) ? REAUTH_HINT : msg }; + } + return { ok: true, data: parseMessage(json) }; + } catch (e: any) { + return { ok: false, error: e?.message || String(e) }; + } +} + +// ─── 파싱 헬퍼 ─────────────────────────────────────────────────────────────── + +function header(headers: any[], name: string): string { + const h = (headers || []).find((x) => String(x?.name || '').toLowerCase() === name.toLowerCase()); + return h ? String(h.value || '') : ''; +} + +function decodeBase64Url(data: string): string { + try { + return Buffer.from(String(data).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8'); + } catch { + return ''; + } +} + +/** payload 트리를 순회하며 text/plain 본문을 추출. 없으면 text/html 을 태그 제거해 사용. */ +function extractBody(payload: any): string { + if (!payload) return ''; + const plain = findPart(payload, 'text/plain'); + if (plain) return plain; + const html = findPart(payload, 'text/html'); + if (html) return html.replace(//gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+\n/g, '\n'); + return ''; +} + +function findPart(node: any, mime: string): string { + if (!node) return ''; + if (node.mimeType === mime && node.body?.data) return decodeBase64Url(node.body.data); + const parts: any[] = Array.isArray(node.parts) ? node.parts : []; + for (const p of parts) { + const found = findPart(p, mime); + if (found) return found; + } + return ''; +} + +function parseMessage(msg: any): ParsedMessage { + const headers: any[] = msg?.payload?.headers || []; + const internal = Number(msg?.internalDate); + const dateHeader = Date.parse(header(headers, 'Date')); + const date = Number.isFinite(internal) && internal > 0 + ? internal + : (Number.isFinite(dateHeader) ? dateHeader : Date.now()); + return { + messageId: String(msg?.id || ''), + threadId: String(msg?.threadId || ''), + from: header(headers, 'From'), + to: header(headers, 'To'), + subject: header(headers, 'Subject'), + date, + snippet: String(msg?.snippet || ''), + bodyText: extractBody(msg?.payload).trim(), + permalink: `https://mail.google.com/mail/u/0/#all/${String(msg?.id || '')}`, + labels: Array.isArray(msg?.labelIds) ? msg.labelIds.map((x: any) => String(x)) : [], + }; +} diff --git a/src/features/email/handlers.ts b/src/features/email/handlers.ts new file mode 100644 index 0000000..8599a78 --- /dev/null +++ b/src/features/email/handlers.ts @@ -0,0 +1,110 @@ +/** + * ============================================================ + * 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 { + 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('email.syncDays', 7) ?? 7); + const maxResults = Math.max(1, Math.min(2000, cfg.get('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 { + 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(); + 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, +}); diff --git a/src/retrieval/contextBudget.ts b/src/retrieval/contextBudget.ts index bf14bd9..109d9d4 100644 --- a/src/retrieval/contextBudget.ts +++ b/src/retrieval/contextBudget.ts @@ -119,7 +119,8 @@ export function assembleContext(chunks: RetrievalChunk[]): string { 'procedural-memory': '📋 Procedural Memory (반복 절차)', 'episodic-memory': '📖 Episodic Memory (과거 대화 흐름)', 'project-scan': '🔍 Project Scan', - 'recent-knowledge': '📄 Recent Project Knowledge' + 'recent-knowledge': '📄 Recent Project Knowledge', + 'email': '📧 이메일 (수집된 메일 근거 — 원문 링크 포함)' }; // Group by source diff --git a/src/retrieval/index.ts b/src/retrieval/index.ts index 6f4c2ac..75f7276 100644 --- a/src/retrieval/index.ts +++ b/src/retrieval/index.ts @@ -26,6 +26,7 @@ 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'; @@ -83,6 +84,8 @@ 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 동작). @@ -151,6 +154,19 @@ 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, @@ -343,6 +359,84 @@ 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( @@ -513,7 +607,8 @@ 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 + 'recent-knowledge': 0.75, + 'email': 0.9 // 메일은 직접 근거 — 브레인 노트와 동급으로 취급 }; for (const chunk of chunks) { diff --git a/src/retrieval/types.ts b/src/retrieval/types.ts index e6c6864..186cb2a 100644 --- a/src/retrieval/types.ts +++ b/src/retrieval/types.ts @@ -16,7 +16,8 @@ export type RetrievalSource = | 'procedural-memory' // Procedural Memory | 'episodic-memory' // Episodic Memory | 'project-scan' // Local Project Path scan - | 'recent-knowledge'; // Recent Project Knowledge record + | 'recent-knowledge' // Recent Project Knowledge record + | 'email'; // Project Astra — 수집된 Gmail/이메일 export type ConflictSeverity = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH'; @@ -43,6 +44,15 @@ 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; }; }