feat(growth): 주간 성장 사이클 자동화 + 텔레그램 양방향 HITL (v2.2.220)

P4 — Self-Evolving OS 폐루프 자동화:
- growthCycleWatcher: 매주(기본 일 20:00 KST, 설정 가능) 자동으로
  ① 골든셋 검색 평가(recall/MRR 주간 추이) ② 학습 큐 갱신(Need Engine)
  ③ 지식 노후 점검 ④ 성장 리포트 ⑤ 승인(approved)된 학습 큐 항목을
  Research Agent 로 자동 실행(사이클당 최대 3건) ⑥ 요약 알림+텔레그램.
  승인 자체는 여전히 사람 — Permission Based Learning 유지, 자동화되는
  것은 '승인된 것의 실행'뿐. 결과물은 기존 수동 명령과 동일 위치
  (.astra/eval/, .astra/growth/) — 완전 호환. 수동 트리거 명령
  (growthCycle.runNow) 제공. 단계별 독립 try/catch.

P5 — 텔레그램 양방향 HITL:
- /meet confirm 코어를 출력 중립 processConfirmDecisions 로 추출
  (웹뷰·텔레그램 공용) — 핸들러는 위임 호출로 슬림화.
- 텔레그램 인바운드에 confirm/pending(보류) 분기 — 회사 밖에서
  "confirm 1=ok 2=6/20 3=skip" 회신으로 보류 액션 등록 완결.
- 데일리 브리핑에 보류 목록 + 회신 안내 포함 — 아침 브리핑에서
  바로 확정하는 흐름 완성.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 18:29:58 +09:00
parent b540923890
commit b72501fae5
8 changed files with 423 additions and 105 deletions
+239
View File
@@ -0,0 +1,239 @@
/**
* 주간 성장 사이클 워처 — Self-Evolving OS 의 폐루프 자동화.
*
* 기존엔 평가·학습큐·노후점검·학습실행이 전부 수동 명령이었다. 이 워처가 매주
* (기본 일요일 20:00 KST) 자동으로:
* 1. 검색 평가 — 골든셋 recall@k/MRR (주간 추이 데이터, TF-IDF 경로)
* 2. 학습 큐 갱신 — Reflection → Need Engine → proposed 항목 병합
* 3. 지식 노후 점검 — 분야별 반감기 감쇠 평가
* 4. 성장 리포트 — Reflection 추이 + 스킬 점수 + 성공 패턴
* 5. 승인된 학습 실행 — approved 큐 항목을 Research Agent 로 조사 패키지화
* (사이클당 최대 3건 — LLM 시간 상한. 승인 자체는 여전히 사람: Permission
* Based Learning 유지 — 자동화되는 건 '승인된 것의 실행'뿐)
* 6. 요약 통지 — VS Code 알림 + (연결 시) 텔레그램
*
* 측정 → 부족 식별 → (사람 승인) → 학습 실행 → 재측정이 사람 손 없이 돈다.
* 모든 단계는 독립 try/catch — 한 단계 실패가 사이클을 멈추지 않는다.
* 결과물은 기존 수동 명령과 동일 위치(.astra/eval/, .astra/growth/)에 저장 —
* 수동 명령과 완전 호환.
*/
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 { RetrievalOrchestrator } from '../../retrieval';
import { getBrainTokenIndex } from '../../retrieval/brainIndex';
import { loadGoldenSet, runRetrievalEval, formatReportMarkdown } from '../../retrieval/evalHarness';
import { loadReflections, formatGrowthReport } from '../../intelligence/reflectionStore';
import { computeNeeds, knowledgeInventory, computeKnowledgeDebt, formatNeedsMarkdown } from '../../intelligence/needEngine';
import { auditKnowledgeDecay, formatDecayReport } from '../../intelligence/knowledgeDecay';
import { computeSkillScores, formatSkillScoresMarkdown, loadSuccessPatterns, formatSuccessPatternsMarkdown } from '../../intelligence/skillScore';
import { runResearch, formatProposalMarkdown } from '../../intelligence/researchAgent';
import type { ExistingKnowledgeRef } from '../../intelligence/knowledgeValidation';
import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../../intelligence/learningQueue';
import { simpleChatCompletion } from '../../intelligence/llmCall';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
const EVAL_KS = [1, 3, 5];
const MAX_RESEARCH_PER_CYCLE = 3;
let _timer: NodeJS.Timeout | undefined;
let _disposed = false;
let _lastFiredYmd = '';
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
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';
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday: new Date(`${ymd}T00:00:00Z`).getUTCDay() };
}
function cycleSchedule(): { day: number; hour: number; minute: number } {
const cfg = vscode.workspace.getConfiguration('g1nation');
const day = Math.min(6, Math.max(0, cfg.get<number>('growthCycle.day', 0)));
const raw = (cfg.get<string>('growthCycle.time', '20:00') || '20:00').trim();
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
return { day, hour: m ? Math.min(23, Number(m[1])) : 20, minute: m ? Math.min(59, Number(m[2])) : 0 };
}
function msUntilNextFire(): number {
const { hour, minute, ymd, weekday } = nowInKst();
const sch = cycleSchedule();
const nowMin = hour * 60 + minute;
const targetMin = sch.hour * 60 + sch.minute;
let daysAhead = (sch.day - weekday + 7) % 7;
if (daysAhead === 0 && (targetMin <= nowMin || _lastFiredYmd === ymd)) daysAhead = 7;
return (daysAhead * 24 * 60 + targetMin - nowMin) * 60_000;
}
/** 사이클 1회 실행 — 단계별 독립 실패 허용, 마지막에 요약 통지. */
export async function runGrowthCycleOnce(context: vscode.ExtensionContext): Promise<string> {
const brain = getActiveBrainProfile();
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
return '두뇌 폴더 없음 — 사이클 건너뜀';
}
const config = getConfig();
const growthDir = path.join(brain.localBrainPath, '.astra', 'growth');
fs.mkdirSync(growthDir, { recursive: true });
const summary: string[] = [];
const now = new Date();
// (1) 검색 평가 — 골든셋이 있을 때만. TF-IDF 경로(임베딩 backfill 은 무겁고 선택적).
try {
const { entries } = loadGoldenSet(brain.localBrainPath);
if (entries.length > 0) {
const allFiles = findBrainFiles(brain.localBrainPath);
getBrainTokenIndex(brain.localBrainPath, allFiles);
const orchestrator = new RetrievalOrchestrator();
const report = await runRetrievalEval({
entries, ks: EVAL_KS,
ranker: async (q) => orchestrator
.rankBrainForEval(q, brain, {
limit: Math.max(...EVAL_KS) + 5,
chunkLevelRetrieval: config.chunkLevelRetrieval === true,
chunkTargetChars: config.chunkTargetChars,
})
.map(r => r.relativePath),
});
const md = formatReportMarkdown(report, {
brainName: brain.name, dateStr: now.toLocaleString(),
embeddingModel: '', alpha: 0, notes: '주간 자동 사이클 (TF-IDF 경로)',
});
const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
fs.writeFileSync(path.join(brain.localBrainPath, '.astra', 'eval', `report-${stamp}-auto.md`), md, 'utf8');
summary.push(`검색 recall@1 ${(report.recallAtK[1] * 100).toFixed(0)}% · MRR ${report.mrr.toFixed(2)}`);
}
} catch (e: any) { logError('성장 사이클: 검색 평가 실패.', { error: e?.message ?? String(e) }); }
// (2) 학습 큐 갱신 (Need Engine)
let proposedCount = 0;
try {
const records = loadReflections(brain.localBrainPath);
const needs = computeNeeds(records);
const queue = mergeNeedsIntoQueue(loadQueue(brain.localBrainPath), needs, now.toISOString());
saveQueue(brain.localBrainPath, queue);
const md = [formatNeedsMarkdown(needs, knowledgeInventory(records), computeKnowledgeDebt(records)), formatQueueMarkdown(queue)].join('\n---\n\n');
fs.writeFileSync(path.join(growthDir, 'learning-needs.md'), md, 'utf8');
proposedCount = queue.filter(q => q.status === 'proposed').length;
summary.push(`학습 제안 ${proposedCount}건 대기`);
} catch (e: any) { logError('성장 사이클: 학습 큐 갱신 실패.', { error: e?.message ?? String(e) }); }
// (3) 지식 노후 점검
try {
const allFiles = findBrainFiles(brain.localBrainPath);
const entries: Array<{ relPath: string; lastUpdated: number }> = [];
for (const f of allFiles) {
try {
const abs = path.isAbsolute(f) ? f : path.join(brain.localBrainPath, f);
entries.push({ relPath: path.relative(brain.localBrainPath, abs) || f, lastUpdated: fs.statSync(abs).mtimeMs });
} catch { /* skip */ }
}
const items = auditKnowledgeDecay(entries);
fs.writeFileSync(path.join(growthDir, 'decay-report.md'), formatDecayReport(items, { brainName: brain.name, dateStr: now.toLocaleString() }), 'utf8');
const stale = items.filter(i => i.status === 'stale').length;
if (stale > 0) summary.push(`노후 지식 ${stale}`);
} catch (e: any) { logError('성장 사이클: 노후 점검 실패.', { error: e?.message ?? String(e) }); }
// (4) 성장 리포트
try {
const records = loadReflections(brain.localBrainPath);
const md = [
formatGrowthReport(records),
formatSkillScoresMarkdown(computeSkillScores(records)),
formatSuccessPatternsMarkdown(loadSuccessPatterns(brain.localBrainPath)),
].join('\n\n');
fs.writeFileSync(path.join(growthDir, 'growth-report.md'), md, 'utf8');
} catch (e: any) { logError('성장 사이클: 성장 리포트 실패.', { error: e?.message ?? String(e) }); }
// (5) 승인된 학습 자동 실행 — 사람이 approved 로 바꾼 항목만 (Permission Based Learning 유지)
try {
const autoRun = vscode.workspace.getConfiguration('g1nation').get<boolean>('growthCycle.autoRunApproved', true);
if (autoRun && config.defaultModel && config.ollamaUrl) {
const queue = loadQueue(brain.localBrainPath);
const approved = queue.filter(q => q.status === 'approved').slice(0, MAX_RESEARCH_PER_CYCLE);
if (approved.length > 0) {
const orchestrator = new RetrievalOrchestrator();
const allFiles = findBrainFiles(brain.localBrainPath);
getBrainTokenIndex(brain.localBrainPath, allFiles);
const fetchInternalRefs = async (topic: string): Promise<ExistingKnowledgeRef[]> => {
const refs: ExistingKnowledgeRef[] = [];
for (const r of orchestrator.rankBrainForEval(topic, brain, { limit: 5 }).slice(0, 5)) {
try {
const abs = path.join(brain.localBrainPath, r.relativePath);
refs.push({ title: path.basename(r.relativePath), content: fs.readFileSync(abs, 'utf8').slice(0, 2000), lastUpdated: fs.statSync(abs).mtimeMs, filePath: r.relativePath });
} catch { /* skip */ }
}
return refs;
};
const proposalsDir = path.join(growthDir, 'proposals');
fs.mkdirSync(proposalsDir, { recursive: true });
let ran = 0;
for (const item of approved) {
const pkg = await runResearch({
item, fetchInternalRefs,
callLlm: (system, user, maxTokens) => simpleChatCompletion(system, user, {
baseUrl: config.ollamaUrl, model: config.defaultModel, temperature: 0.3, maxTokens, timeoutMs: 180000,
}),
nowIso: new Date().toISOString(),
});
fs.writeFileSync(path.join(proposalsDir, `${item.id}.md`), formatProposalMarkdown(pkg, { dateStr: new Date().toLocaleString(), modelName: config.defaultModel }), 'utf8');
item.status = 'in-progress';
item.updatedAt = new Date().toISOString();
ran++;
}
saveQueue(brain.localBrainPath, queue);
summary.push(`승인 학습 ${ran}건 실행 → proposals/`);
}
}
} catch (e: any) { logError('성장 사이클: 학습 실행 실패.', { error: e?.message ?? String(e) }); }
const text = summary.length ? summary.join(' · ') : '변화 없음 (Reflection/골든셋 데이터 대기)';
logInfo('주간 성장 사이클 완료.', { summary: text });
// (6) 통지 — VS Code + (연결 시) 텔레그램
const note = `🌱 Astra 주간 성장 사이클 — ${text}`
+ (proposedCount > 0 ? `\n승인하려면 ${LEARNING_QUEUE_REL_PATH} 에서 status 를 approved 로 바꾸세요.` : '');
void vscode.window.showInformationMessage(note.replace(/\n/g, ' '));
try {
const allowed = vscode.workspace.getConfiguration('g1nation').get<number[]>('telegram.allowedChatIds', []) || [];
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
if (allowed.length && token.trim()) {
const client = new TelegramHttpClient({ getToken: () => token });
await client.sendMessage({ chatId: allowed[0], text: note });
}
} catch (e: any) { logInfo('성장 사이클 텔레그램 통지 실패 (무시).', { error: e?.message ?? String(e) }); }
return text;
}
function scheduleNext(context: vscode.ExtensionContext): void {
if (_disposed) return;
const ms = msUntilNextFire();
logInfo('주간 성장 사이클 예약.', { inHours: (ms / 3_600_000).toFixed(1) });
_timer = setTimeout(async () => {
const enabled = vscode.workspace.getConfiguration('g1nation').get<boolean>('growthCycle.enabled', true);
if (enabled) {
_lastFiredYmd = nowInKst().ymd;
try { await runGrowthCycleOnce(context); } catch (e: any) {
logError('주간 성장 사이클 실패.', { error: e?.message ?? String(e) });
}
}
scheduleNext(context);
}, ms);
}
/** VS Code 시작 시 호출 — disposable 반환. */
export function startGrowthCycleWatcher(context: vscode.ExtensionContext): vscode.Disposable {
_disposed = false;
scheduleNext(context);
return new vscode.Disposable(() => {
_disposed = true;
if (_timer) { clearTimeout(_timer); _timer = undefined; }
});
}