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