feat(growth): Sleep-time 지식 사전 소화 — 응답 지연을 유휴 시간으로 이동 (v2.2.224)

deep research(3표 검증) 1순위 적용: sleep-time compute (arXiv 2504.13171).
유휴 시간에 로컬 LLM이 두뇌의 raw context를 learned context로 미리 소화해,
응답 시점 RAG가 고밀도 소화 노트를 검색하게 한다 — 로컬 LLM의 최대 약점
(느린 추론)의 비용을 사용자 응대 시점에서 유휴 시간으로 구조적으로 이동.

- sleepDigest.ts: 매일 03:00 KST(설정 가능) 최근 7일 변경 파일이 많은 폴더
  순으로 소화 노트 생성 (<두뇌>/Digests/<슬러그>.md, 런당 ≤5건).
  노트 = 예상 질의 Q&A + 핵심 사실 + 문서 간 연결 (출처 제목 인용 강제,
  "원문에 없는 내용 지어내지 마라" — 환각 방지 동일 원칙).
- 노후화 자동 감지: 소스 mtime > generated_at 이면 재생성, 아니면 skip
  (steady-state 비용 0). 노트는 삭제해도 안전 (자동 재생성).
- 승인 게이트 불요 근거: 외부 지식 유입이 아니라 기존 두뇌의 재구성.
  원문 우선 원칙을 노트 머리에 명기.
- 수동 명령 "Astra: 지식 사전 소화 지금 실행" + sleepDigest.enabled/time 설정.
- 실 LLM(gemma-4-26b)+실 위키 3문서로 프롬프트 품질 검증 완료 (출처 인용·
  무환각 확인). 테스트 8건 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 11:29:40 +09:00
parent 72faa07480
commit 7584c6bbc1
5 changed files with 392 additions and 3 deletions
+2 -2
View File
@@ -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",
+15 -1
View File
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.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,
+10
View File
@@ -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 소화 노트(<brain>/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.
+264
View File
@@ -0,0 +1,264 @@
/**
* Sleep-time 지식 사전 소화 (Sleep-time Compute, arXiv 2504.13171 의 ASTRA 이식).
*
* 아이디어: 로컬 LLM 의 최대 약점(느린 추론)을 응답 시점에서 *유휴 시간*으로 옮긴다.
* 유휴 시간에 두뇌의 "raw context"(위키 원문 더미)를 "learned context"(예상 질의별
* 압축 소화 노트)로 변환해 두면, 응답 시점의 RAG 가 이미 소화된 고밀도 노트를
* 검색해 적은 토큰으로 더 정확히 답한다.
*
* 설계 (ASTRA 제약 반영):
* - 저장: <brain>/Digests/<slug>.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<string, Array<{ file: string; mtimeMs: number }>>();
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<string> {
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<string>('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<boolean>('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; }
});
}
+101
View File
@@ -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> = {}): 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');
});
});