From 681cfd23938a686fbea55afdf289923c1d0992b3 Mon Sep 17 00:00:00 2001 From: g1nation Date: Fri, 5 Jun 2026 19:15:19 +0900 Subject: [PATCH] =?UTF-8?q?revert:=20ASTRA=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20Dataco?= =?UTF-8?q?llect=20wiki=ED=99=94=EB=A1=9C=20=ED=94=BC=EB=B2=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PATCHNOTES.md | 9 - media/settings-panel.html | 40 ----- media/settings-panel.js | 41 ----- 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 ------------ .../settings/settingsPanelProvider.ts | 92 ---------- src/retrieval/contextBudget.ts | 3 +- src/retrieval/index.ts | 97 +---------- src/retrieval/types.ts | 12 +- 15 files changed, 6 insertions(+), 892 deletions(-) delete mode 100644 src/features/email/autoSync.ts delete mode 100644 src/features/email/emailStore.ts delete mode 100644 src/features/email/emailSync.ts delete mode 100644 src/features/email/gmailApi.ts delete mode 100644 src/features/email/handlers.ts diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 28c996b..4d4e804 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -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)) diff --git a/media/settings-panel.html b/media/settings-panel.html index 3eb17c6..0bf76e0 100644 --- a/media/settings-panel.html +++ b/media/settings-panel.html @@ -117,46 +117,6 @@ - -
-

이메일 (Project Astra)

-

Gmail 을 읽기 전용으로 수집해 로컬 인덱스에 저장하고, 채팅이 메일 근거(원문 링크 포함)로 답하게 합니다. 본문은 로컬을 벗어나지 않으며 합성은 로컬 LLM 만 사용합니다. (최초 1회 gmail.readonly 재인증 필요)

-
- - -
-
- - - 켜면 백그라운드에서 주기적으로 자동 수집합니다. -
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- - -
-
-

메모리

diff --git a/media/settings-panel.js b/media/settings-panel.js index 5b7f8ac..5196c03 100644 --- a/media/settings-panel.js +++ b/media/settings-panel.js @@ -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; diff --git a/package.json b/package.json index a59e1fc..c07c028 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.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, diff --git a/src/extension.ts b/src/extension.ts index 907a74e..8c01430 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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) => { diff --git a/src/features/calendar/oauth.ts b/src/features/calendar/oauth.ts index 29baa1d..38c16f9 100644 --- a/src/features/calendar/oauth.ts +++ b/src/features/calendar/oauth.ts @@ -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 는 읽기 전용 — 삭제/답장/전달 불가(데이터 무결성·보안). +// Calendar 와 Sheets 양쪽 권한을 한 번에 요청 — 사용자가 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(' '); diff --git a/src/features/email/autoSync.ts b/src/features/email/autoSync.ts deleted file mode 100644 index 8fdaa4c..0000000 --- a/src/features/email/autoSync.ts +++ /dev/null @@ -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 | 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 deleted file mode 100644 index 067612b..0000000 --- a/src/features/email/emailStore.ts +++ /dev/null @@ -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(); - -/** 저장된 이메일 레코드 로드. 파일이 없거나 깨졌으면 빈 배열. 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 deleted file mode 100644 index eaa8290..0000000 --- a/src/features/email/emailSync.ts +++ /dev/null @@ -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 { - 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 deleted file mode 100644 index 79e6981..0000000 --- a/src/features/email/gmailApi.ts +++ /dev/null @@ -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 = { 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 deleted file mode 100644 index 8599a78..0000000 --- a/src/features/email/handlers.ts +++ /dev/null @@ -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 { - 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/features/settings/settingsPanelProvider.ts b/src/features/settings/settingsPanelProvider.ts index 5ceaee5..8d402d4 100644 --- a/src/features/settings/settingsPanelProvider.ts +++ b/src/features/settings/settingsPanelProvider.ts @@ -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 { - 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 { - if (this._emailSyncing) return; - const c = vscode.workspace.getConfiguration('g1nation'); - const days = c.get('email.syncDays', 7) ?? 7; - const maxResults = Math.max(1, Math.min(2000, c.get('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('email.autoSync', false), - autoSyncIntervalMinutes: c.get('email.autoSyncIntervalMinutes', 30) ?? 30, - syncDays: c.get('email.syncDays', 7) ?? 7, - syncMaxMessages: c.get('email.syncMaxMessages', 200) ?? 200, - indexedCount, - newestDate, - lastSyncMessage: this._emailLastSyncMsg, - syncing: this._emailSyncing, - }; - } - private async _refreshState(): Promise { 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('datacollectMaxPages', 8) ?? 8, synthesisTemperature: cfg.get('datacollectSynthesisTemperature', 0.1) ?? 0.1, }, - email: this._buildEmailState(), google: this._buildGoogleState(), providers: await this._buildProvidersState(), devilAgent: { enabled: cfg.get('devilAgent.enabled', false) }, diff --git a/src/retrieval/contextBudget.ts b/src/retrieval/contextBudget.ts index 109d9d4..bf14bd9 100644 --- a/src/retrieval/contextBudget.ts +++ b/src/retrieval/contextBudget.ts @@ -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 diff --git a/src/retrieval/index.ts b/src/retrieval/index.ts index 75f7276..6f4c2ac 100644 --- a/src/retrieval/index.ts +++ b/src/retrieval/index.ts @@ -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) { diff --git a/src/retrieval/types.ts b/src/retrieval/types.ts index 186cb2a..e6c6864 100644 --- a/src/retrieval/types.ts +++ b/src/retrieval/types.ts @@ -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; }; }