diff --git a/package-lock.json b/package-lock.json index 4d9b73a..c435aea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.223", + "version": "2.2.224", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.223", + "version": "2.2.224", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index e84ef6b..7293912 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.223", + "version": "2.2.224", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -74,6 +74,10 @@ "command": "g1nation.embeddings.backfill", "title": "Astra: 두뇌 임베딩 전체 색인" }, + { + "command": "g1nation.sleepDigest.runNow", + "title": "Astra: 지식 사전 소화 지금 실행 (Sleep-time Digest)" + }, { "command": "g1nation.exportChat", "title": "Astra: Export Chat as Markdown" @@ -318,6 +322,16 @@ "default": "09:30", "markdownDescription": "데일리 브리핑 발송 시각 (KST, `HH:MM`). 기본 `09:30`." }, + "g1nation.sleepDigest.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "**Sleep-time 지식 사전 소화** — 매일 지정 시각(유휴 시간)에 최근 7일 내 변경된 두뇌 지식을 폴더별 '소화 노트'(`<두뇌>/Digests/`)로 변환합니다 (런당 최대 5건, 소스 미변경 시 skip). 소화 노트는 예상 질의 Q&A·핵심 사실·문서 간 연결을 담아 RAG 검색 품질을 높입니다. 원문이 항상 우선 — 노트는 삭제해도 안전 (자동 재생성). 근거: sleep-time compute (arXiv 2504.13171)." + }, + "g1nation.sleepDigest.time": { + "type": "string", + "default": "03:00", + "markdownDescription": "사전 소화 실행 시각 (KST, `HH:MM`). 기본 `03:00` — VS Code 가 켜져 있는 유휴 시간 권장." + }, "g1nation.growthCycle.enabled": { "type": "boolean", "default": true, diff --git a/src/extension.ts b/src/extension.ts index 9c12607..d8d59b4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -43,6 +43,7 @@ import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing'; import { ensureDefaultBrainConfigured } from './extension/brainBootstrap'; import { ensureEmbeddingConfigured } from './extension/embeddingBootstrap'; import { startGrowthCycleWatcher, runGrowthCycleOnce } from './features/growth/growthCycleWatcher'; +import { startSleepDigestWatcher, runSleepDigestOnce } from './features/growth/sleepDigest'; import { registerProviderCommands } from './extension/providerCommands'; import { registerScaffoldCommand } from './extension/scaffoldCommand'; import { registerLessonCommands } from './extension/lessonCommands'; @@ -332,6 +333,15 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showInformationMessage(`성장 사이클 완료 — ${summary}`); })); + // Sleep-time 지식 사전 소화 — 유휴 시간(기본 03:00 KST)에 최근 변경 지식을 + // learned-context 소화 노트(/Digests/)로 변환. 응답 지연을 유휴 시간으로 이동. + context.subscriptions.push(startSleepDigestWatcher()); + context.subscriptions.push(vscode.commands.registerCommand('g1nation.sleepDigest.runNow', async () => { + vscode.window.showInformationMessage('지식 사전 소화 실행 중… (LLM 호출, 수 분 소요 가능)'); + const summary = await runSleepDigestOnce(); + vscode.window.showInformationMessage(`사전 소화 완료 — ${summary}`); + })); + // 7. Auto-open all three Astra webviews as tabs in editor column 3. // The sidebar/activity-bar entry point was removed in 2.81 — all three views // (Chat, Approvals, Settings) now stack as tabs in the third editor column. diff --git a/src/features/growth/sleepDigest.ts b/src/features/growth/sleepDigest.ts new file mode 100644 index 0000000..9b3d141 --- /dev/null +++ b/src/features/growth/sleepDigest.ts @@ -0,0 +1,264 @@ +/** + * Sleep-time 지식 사전 소화 (Sleep-time Compute, arXiv 2504.13171 의 ASTRA 이식). + * + * 아이디어: 로컬 LLM 의 최대 약점(느린 추론)을 응답 시점에서 *유휴 시간*으로 옮긴다. + * 유휴 시간에 두뇌의 "raw context"(위키 원문 더미)를 "learned context"(예상 질의별 + * 압축 소화 노트)로 변환해 두면, 응답 시점의 RAG 가 이미 소화된 고밀도 노트를 + * 검색해 적은 토큰으로 더 정확히 답한다. + * + * 설계 (ASTRA 제약 반영): + * - 저장: /Digests/.md — 검색 가능한 지식 폴더. type: digest frontmatter. + * 사람이 열람·수정·삭제 가능 (파일이 UI). 원문이 항상 우선 — 소화 노트는 요약 캐시. + * - 대상 선정: 최근 N일 내 수정 파일이 많은 폴더 순 (최근 추가된 지식이 가장 질의될 + * 확률이 높다는 가설 — sleep-time 효과는 질의 예측 가능성에 비례). + * - 노후화: 소스 파일 mtime > 소화 노트 generated_at 이면 재생성. 아니면 skip + * (steady-state 비용 0). + * - 승인 불요: 외부 지식 유입이 아니라 기존 두뇌의 재구성이므로 Permission Based + * Learning 의 승인 게이트 대상이 아니다. 단 전부 파일로 투명하게. + * - 스케줄: 매일 KST 03:00 (기본) — stocksWatcher 와 동일한 setTimeout 체인 패턴. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { getConfig } from '../../config'; +import { findBrainFiles, getActiveBrainProfile, logError, logInfo } from '../../utils'; +import { simpleChatCompletion } from '../../intelligence/llmCall'; + +export const DIGEST_DIR = 'Digests'; +const MAX_PER_RUN = 5; +const RECENT_WINDOW_DAYS = 7; +const MAX_FILES_PER_TARGET = 8; +const MAX_INPUT_CHARS = 9000; + +let _timer: NodeJS.Timeout | undefined; +let _disposed = false; +let _lastFiredYmd = ''; + +// ── 대상 선정 (순수 로직 — 테스트 대상) ───────────────────────────────────── + +export interface DigestTarget { + /** 폴더 키 (brain 상대 경로, '' = 루트 직속). */ + folder: string; + /** 파일명 슬러그. */ + slug: string; + /** 최근 수정 파일 절대 경로 (mtime 내림차순, 상한 적용). */ + files: string[]; + /** 가장 최신 소스 mtime — 노후화 판정용. */ + newestMtimeMs: number; +} + +export function folderSlug(folder: string): string { + const base = (folder || 'root').replace(/[\\/]+/g, '--').replace(/[^a-zA-Z0-9가-힣_.-]+/g, '-'); + return base.slice(0, 80) || 'root'; +} + +/** + * 최근 windowDays 내 수정된 파일을 폴더(두뇌 1-2단계 경로)별로 묶어, + * 수정 파일 수가 많은 순으로 소화 대상을 고른다. Digests/ 자신은 제외. + */ +export function selectDigestTargets( + brainPath: string, + allFiles: string[], + nowMs: number, + windowDays = RECENT_WINDOW_DAYS, +): DigestTarget[] { + const cutoff = nowMs - windowDays * 86_400_000; + const byFolder = new Map>(); + for (const f of allFiles) { + let mtimeMs = 0; + try { mtimeMs = fs.statSync(f).mtimeMs; } catch { continue; } + if (mtimeMs < cutoff) continue; + const rel = path.relative(brainPath, f); + const parts = rel.split(/[\\/]/); + if (parts[0] === DIGEST_DIR) continue; // 소화 노트 자신은 재소화하지 않음 + // 폴더 키: 최대 2단계 (예: "Topics_Rag", "Coding/Python"). 루트 직속 파일은 ''. + const folder = parts.length > 2 ? parts.slice(0, 2).join('/') : parts.length === 2 ? parts[0] : ''; + const arr = byFolder.get(folder) || []; + arr.push({ file: f, mtimeMs }); + byFolder.set(folder, arr); + } + return Array.from(byFolder.entries()) + .map(([folder, items]) => { + items.sort((a, b) => b.mtimeMs - a.mtimeMs); + return { + folder, + slug: folderSlug(folder), + files: items.slice(0, MAX_FILES_PER_TARGET).map(i => i.file), + newestMtimeMs: items[0].mtimeMs, + }; + }) + .sort((a, b) => b.files.length - a.files.length || b.newestMtimeMs - a.newestMtimeMs); +} + +/** 소화 노트가 최신인지 — generated_at ≥ 소스 최신 mtime 이면 skip. */ +export function isDigestFresh(digestFile: string, newestSourceMtimeMs: number): boolean { + try { + if (!fs.existsSync(digestFile)) return false; + const head = fs.readFileSync(digestFile, 'utf8').slice(0, 600); + const m = head.match(/^generated_at:\s*["']?([^"'\n]+)["']?\s*$/m); + if (!m) return false; + const t = Date.parse(m[1].trim()); + return Number.isFinite(t) && t >= newestSourceMtimeMs; + } catch { + return false; + } +} + +// ── 소화 노트 생성 ─────────────────────────────────────────────────────────── + +const DIGEST_SYSTEM = [ + '너는 지식 소화기다. 주어진 위키 문서들을 읽고, 나중에 사용자가 물어볼 법한 질문에 즉시 답할 수 있는 "소화 노트"를 만든다.', + '형식 (마크다운):', + '## 예상 질문과 답', + '- **Q: <예상 질문>** — A: <원문 근거 기반 압축 답변. 출처 문서 제목을 [제목] 으로 표기>', + ' (질문 4~7개 — 문서들이 실제로 답할 수 있는 것만)', + '## 핵심 사실', + '- <문서들에서 가장 중요한 사실·수치·결론. 출처 [제목] 표기>', + '## 문서 간 연결', + '- <문서들 사이의 관계·공통 주제·모순(있다면)>', + '규칙: 원문에 없는 내용을 지어내지 마라. 모순이 있으면 숨기지 말고 명시하라. 한국어로.', +].join('\n'); + +function buildDigestInput(brainPath: string, target: DigestTarget): { input: string; sourceTitles: string[] } { + const pieces: string[] = []; + const titles: string[] = []; + let used = 0; + for (const f of target.files) { + if (used >= MAX_INPUT_CHARS) break; + let content = ''; + try { content = fs.readFileSync(f, 'utf8'); } catch { continue; } + const title = path.basename(f, '.md'); + titles.push(title); + const budget = Math.min(MAX_INPUT_CHARS - used, Math.floor(MAX_INPUT_CHARS / Math.min(target.files.length, 4))); + const body = content.slice(0, budget); + pieces.push(`=== [${title}] ===\n${body}`); + used += body.length; + } + return { input: pieces.join('\n\n'), sourceTitles: titles }; +} + +export function digestMarkdown(target: DigestTarget, body: string, sourceTitles: string[], nowIso: string): string { + const label = target.folder || '(두뇌 루트)'; + return [ + '---', + 'type: digest', + `title: "소화 노트: ${label}"`, + `generated_at: ${nowIso}`, + `sources: [${sourceTitles.map(t => `"${t.replace(/"/g, "'")}"`).join(', ')}]`, + '---', + '', + `# 소화 노트: ${label}`, + '', + `> ⚙️ 자동 생성 (sleep-time 사전 소화) — **원문이 항상 우선**입니다. 소스가 바뀌면 자동 재생성되며, 이 파일은 삭제해도 안전합니다.`, + '', + body.trim(), + '', + ].join('\n'); +} + +// ── 실행 ───────────────────────────────────────────────────────────────────── + +/** 사전 소화 1회 실행. 반환: 요약 문자열 (생성/스킵 건수). */ +export async function runSleepDigestOnce(): Promise { + const brain = getActiveBrainProfile(); + if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) return '두뇌 폴더 없음 — skip'; + const config = getConfig(); + if (!config.defaultModel || !config.ollamaUrl) return 'LLM 미설정 — skip'; + + const brainPath = brain.localBrainPath; + const digestDir = path.join(brainPath, DIGEST_DIR); + const allFiles = findBrainFiles(brainPath); + const targets = selectDigestTargets(brainPath, allFiles, Date.now()); + if (targets.length === 0) return '최근 변경 지식 없음 — 소화할 것 없음'; + + fs.mkdirSync(digestDir, { recursive: true }); + const readmePath = path.join(digestDir, 'README.md'); + if (!fs.existsSync(readmePath)) { + fs.writeFileSync(readmePath, [ + '# Digests — sleep-time 사전 소화 노트', + '', + '유휴 시간에 ASTRA 가 최근 변경된 두뇌 지식을 미리 읽고 만든 "learned context" 입니다.', + '검색(RAG)이 원문과 함께 이 노트를 찾아 응답 품질·속도를 높입니다.', + '**원문이 항상 우선** — 소스 변경 시 자동 재생성되고, 삭제해도 다음 실행 때 다시 만들어집니다.', + '', + ].join('\n'), 'utf8'); + } + + let made = 0, skipped = 0, failed = 0; + for (const target of targets) { + if (made >= MAX_PER_RUN) break; + const digestFile = path.join(digestDir, `${target.slug}.md`); + if (isDigestFresh(digestFile, target.newestMtimeMs)) { skipped++; continue; } + try { + const { input, sourceTitles } = buildDigestInput(brainPath, target); + if (!input.trim()) { skipped++; continue; } + const body = await simpleChatCompletion( + DIGEST_SYSTEM, + `[대상 폴더] ${target.folder || '(루트)'}\n\n${input}`, + { baseUrl: config.ollamaUrl, model: config.defaultModel, temperature: 0.2, maxTokens: 1400, timeoutMs: 180000 }, + ); + if (!body.trim()) { failed++; continue; } + fs.writeFileSync(digestFile, digestMarkdown(target, body, sourceTitles, new Date().toISOString()), 'utf8'); + made++; + logInfo('Sleep digest 생성.', { folder: target.folder, files: target.files.length }); + } catch (e: any) { + failed++; + logError('Sleep digest 실패 (다음 대상 계속).', { folder: target.folder, error: e?.message ?? String(e) }); + } + } + const summary = `소화 ${made}건 생성 · ${skipped}건 최신(스킵)${failed ? ` · ${failed}건 실패` : ''}`; + logInfo('Sleep digest 사이클 완료.', { summary }); + return summary; +} + +// ── 일일 스케줄러 (KST, stocksWatcher 패턴) ────────────────────────────────── + +function nowInKst(): { hour: number; minute: number; ymd: string } { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(new Date()); + const get = (t: string) => parts.find(p => p.type === t)?.value || '00'; + return { hour: Number(get('hour')), minute: Number(get('minute')), ymd: `${get('year')}-${get('month')}-${get('day')}` }; +} + +function digestTime(): { hour: number; minute: number } { + const raw = (vscode.workspace.getConfiguration('g1nation').get('sleepDigest.time', '03:00') || '03:00').trim(); + const m = raw.match(/^(\d{1,2}):(\d{2})$/); + return m ? { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) } : { hour: 3, minute: 0 }; +} + +function msUntilNextFire(): number { + const { hour, minute, ymd } = nowInKst(); + const t = digestTime(); + const nowMin = hour * 60 + minute; + const targetMin = t.hour * 60 + t.minute; + if (targetMin > nowMin && _lastFiredYmd !== ymd) return (targetMin - nowMin) * 60_000; + return ((24 * 60 - nowMin) + targetMin) * 60_000; +} + +function scheduleNext(): void { + if (_disposed) return; + const ms = msUntilNextFire(); + logInfo('Sleep digest 다음 실행 예약.', { inHours: (ms / 3_600_000).toFixed(1) }); + _timer = setTimeout(async () => { + const enabled = vscode.workspace.getConfiguration('g1nation').get('sleepDigest.enabled', true); + if (enabled) { + _lastFiredYmd = nowInKst().ymd; + try { await runSleepDigestOnce(); } catch (e: any) { + logError('Sleep digest 사이클 실패.', { error: e?.message ?? String(e) }); + } + } + scheduleNext(); + }, ms); +} + +/** VS Code 시작 시 호출 — disposable 반환. */ +export function startSleepDigestWatcher(): vscode.Disposable { + _disposed = false; + scheduleNext(); + return new vscode.Disposable(() => { + _disposed = true; + if (_timer) { clearTimeout(_timer); _timer = undefined; } + }); +} diff --git a/tests/sleepDigest.test.ts b/tests/sleepDigest.test.ts new file mode 100644 index 0000000..4ba311e --- /dev/null +++ b/tests/sleepDigest.test.ts @@ -0,0 +1,101 @@ +/** + * Sleep-time 사전 소화 — 순수 로직 테스트 (대상 선정·노후화 판정·노트 형식). + * LLM 호출(runSleepDigestOnce)은 제외 — 통합 검증은 수동 명령으로. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + selectDigestTargets, folderSlug, isDigestFresh, digestMarkdown, DIGEST_DIR, + type DigestTarget, +} from '../src/features/growth/sleepDigest'; + +const tmpBrain = () => fs.mkdtempSync(path.join(os.tmpdir(), 'astra-digest-')); + +function mkFile(brain: string, rel: string, ageDays: number): string { + const abs = path.join(brain, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `# ${path.basename(rel)}\n내용`, 'utf8'); + const t = new Date(Date.now() - ageDays * 86_400_000); + fs.utimesSync(abs, t, t); + return abs; +} + +describe('selectDigestTargets', () => { + test('최근 변경 파일이 많은 폴더 우선, 오래된 파일 제외', () => { + const brain = tmpBrain(); + const files = [ + mkFile(brain, 'Topics_Rag/a.md', 1), + mkFile(brain, 'Topics_Rag/b.md', 2), + mkFile(brain, 'Topics_Rag/c.md', 3), + mkFile(brain, 'Coding/Python/x.md', 1), + mkFile(brain, 'Coding/Python/y.md', 2), + mkFile(brain, 'Old_Folder/z.md', 30), // 윈도우 밖 + ]; + const targets = selectDigestTargets(brain, files, Date.now(), 7); + expect(targets[0].folder).toBe('Topics_Rag'); + expect(targets[0].files).toHaveLength(3); + expect(targets[1].folder).toBe('Coding/Python'); + expect(targets.find(t => t.folder === 'Old_Folder')).toBeUndefined(); + }); + test('Digests 폴더 자신은 재소화하지 않음 + 루트 직속은 빈 키', () => { + const brain = tmpBrain(); + const files = [ + mkFile(brain, `${DIGEST_DIR}/old-digest.md`, 1), + mkFile(brain, 'root-note.md', 1), + ]; + const targets = selectDigestTargets(brain, files, Date.now(), 7); + expect(targets.find(t => t.folder.includes(DIGEST_DIR))).toBeUndefined(); + expect(targets.find(t => t.folder === '')).toBeDefined(); + }); + test('파일 상한 8개', () => { + const brain = tmpBrain(); + const files = Array.from({ length: 12 }, (_, i) => mkFile(brain, `Big/f${i}.md`, 1)); + const targets = selectDigestTargets(brain, files, Date.now(), 7); + expect(targets[0].files.length).toBeLessThanOrEqual(8); + }); +}); + +describe('folderSlug', () => { + test('경로 구분자·특수문자 정리', () => { + expect(folderSlug('Coding/Python')).toBe('Coding--Python'); + expect(folderSlug('')).toBe('root'); + expect(folderSlug('AI & ML!')).toBe('AI-ML-'); + }); +}); + +describe('isDigestFresh — 노후화 판정', () => { + const target = (over: Partial = {}): DigestTarget => ({ + folder: 'X', slug: 'X', files: [], newestMtimeMs: Date.now(), ...over, + }); + test('노트 없음 → 재생성 필요', () => { + expect(isDigestFresh(path.join(tmpBrain(), 'no.md'), Date.now())).toBe(false); + }); + test('generated_at ≥ 소스 mtime → fresh', () => { + const brain = tmpBrain(); + const f = path.join(brain, 'd.md'); + const t = target(); + fs.writeFileSync(f, digestMarkdown(t, '본문', ['s1'], new Date(t.newestMtimeMs + 1000).toISOString()), 'utf8'); + expect(isDigestFresh(f, t.newestMtimeMs)).toBe(true); + }); + test('소스가 노트보다 최신 → 재생성 필요', () => { + const brain = tmpBrain(); + const f = path.join(brain, 'd.md'); + fs.writeFileSync(f, digestMarkdown(target(), '본문', ['s1'], new Date(Date.now() - 86_400_000).toISOString()), 'utf8'); + expect(isDigestFresh(f, Date.now())).toBe(false); + }); +}); + +describe('digestMarkdown', () => { + test('frontmatter(type: digest, sources) + 원문 우선 고지', () => { + const md = digestMarkdown( + { folder: 'Topics_Rag', slug: 'Topics_Rag', files: [], newestMtimeMs: 0 }, + '## 예상 질문과 답\n- Q…', ['문서 "A"', 'B'], '2026-06-12T03:00:00Z', + ); + expect(md).toMatch(/^---\ntype: digest/); + expect(md).toContain('generated_at: 2026-06-12T03:00:00Z'); + expect(md).toContain("문서 'A'"); // 따옴표 이스케이프 + expect(md).toContain('원문이 항상 우선'); + expect(md).toContain('# 소화 노트: Topics_Rag'); + }); +});