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:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "astra",
|
||||
"version": "2.2.219",
|
||||
"version": "2.2.220",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astra",
|
||||
"version": "2.2.219",
|
||||
"version": "2.2.220",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
|
||||
+27
-1
@@ -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.219",
|
||||
"version": "2.2.220",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -66,6 +66,10 @@
|
||||
"command": "g1nation.research.runQueue",
|
||||
"title": "Astra: 학습 실행 (Research Agent — 승인된 큐 항목)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.growthCycle.runNow",
|
||||
"title": "Astra: 주간 성장 사이클 지금 실행 (평가→학습큐→노후점검→승인분 실행)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.embeddings.backfill",
|
||||
"title": "Astra: 두뇌 임베딩 전체 색인"
|
||||
@@ -314,6 +318,28 @@
|
||||
"default": "09:30",
|
||||
"markdownDescription": "데일리 브리핑 발송 시각 (KST, `HH:MM`). 기본 `09:30`."
|
||||
},
|
||||
"g1nation.growthCycle.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "**주간 성장 사이클** — 매주 지정 요일·시각에 검색 평가(골든셋)→학습 큐 갱신(Need Engine)→지식 노후 점검→성장 리포트→승인된 학습 자동 실행(Research Agent, 사이클당 최대 3건)을 자동 수행하고 요약을 알림(+텔레그램). 승인(approved) 자체는 여전히 사람이 — 자동화되는 것은 '승인된 항목의 실행'뿐."
|
||||
},
|
||||
"g1nation.growthCycle.day": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"maximum": 6,
|
||||
"markdownDescription": "주간 성장 사이클 실행 요일 (0=일 … 6=토). 기본 0(일요일)."
|
||||
},
|
||||
"g1nation.growthCycle.time": {
|
||||
"type": "string",
|
||||
"default": "20:00",
|
||||
"markdownDescription": "주간 성장 사이클 실행 시각 (KST, `HH:MM`). 기본 `20:00`."
|
||||
},
|
||||
"g1nation.growthCycle.autoRunApproved": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "사이클에서 **approved 상태의 학습 큐 항목**을 Research Agent 로 자동 실행할지 (사이클당 최대 3건). 끄면 사이클은 측정·식별만 하고 실행은 수동 명령으로."
|
||||
},
|
||||
"g1nation.teamVoiceGuide": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
|
||||
@@ -41,6 +41,7 @@ import { runInitialSetup } from './extension/initialSetup';
|
||||
import { startStocksWatcher } from './features/stocks';
|
||||
import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing';
|
||||
import { ensureDefaultBrainConfigured } from './extension/brainBootstrap';
|
||||
import { startGrowthCycleWatcher, runGrowthCycleOnce } from './features/growth/growthCycleWatcher';
|
||||
import { registerProviderCommands } from './extension/providerCommands';
|
||||
import { registerScaffoldCommand } from './extension/scaffoldCommand';
|
||||
import { registerLessonCommands } from './extension/lessonCommands';
|
||||
@@ -318,6 +319,14 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만).
|
||||
context.subscriptions.push(startDailyBriefingWatcher(context));
|
||||
|
||||
// 주간 성장 사이클 — Self-Evolving OS 폐루프 (평가→학습큐→노후점검→승인분 실행→통지).
|
||||
context.subscriptions.push(startGrowthCycleWatcher(context));
|
||||
context.subscriptions.push(vscode.commands.registerCommand('g1nation.growthCycle.runNow', async () => {
|
||||
vscode.window.showInformationMessage('성장 사이클 수동 실행 중… (완료 시 알림)');
|
||||
const summary = await runGrowthCycleOnce(context);
|
||||
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.
|
||||
|
||||
@@ -105,7 +105,22 @@ export async function buildBriefingText(context: vscode.ExtensionContext, ymd: s
|
||||
lines.push('', `🔗 *조건부 대기* (${conditional.length})`);
|
||||
for (const t of conditional.slice(0, 10)) lines.push(`· ${t.title}`);
|
||||
}
|
||||
if (!dueToday.length && !overdue.length && !events.length && !conditional.length) {
|
||||
// (3) /meet 등록 보류 — 텔레그램에서 바로 회신해 등록 완결 가능 (P5 HITL).
|
||||
let pendingCount = 0;
|
||||
try {
|
||||
const { loadPending } = await import('../datacollect/scheduling/meetRegistration');
|
||||
const pend = loadPending();
|
||||
if (pend && pend.items.length) {
|
||||
pendingCount = pend.items.length;
|
||||
lines.push('', `⏸ *회의 액션 보류* (${pend.items.length}) — 이 채팅에 \`confirm 1=ok 2=6/20 3=skip\` 으로 회신하면 등록됩니다 (목록: \`pending\`)`);
|
||||
for (const it of pend.items.slice(0, 8)) {
|
||||
const why = it.kind === 'undecided' ? '진행미정' : it.kind === 'nodate' ? '기한미정' : `조건부:${it.condition}`;
|
||||
lines.push(`· ${it.idx}. ${it.work} (${why})`);
|
||||
}
|
||||
}
|
||||
} catch { /* 보류 조회 실패는 브리핑 본 흐름에 영향 없음 */ }
|
||||
|
||||
if (!dueToday.length && !overdue.length && !events.length && !conditional.length && !pendingCount) {
|
||||
return null; // 알릴 것이 전혀 없음 — 발송 skip
|
||||
}
|
||||
} else if (!events.length) {
|
||||
|
||||
@@ -28,8 +28,8 @@ import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMe
|
||||
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
|
||||
import {
|
||||
transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
|
||||
savePending, loadPending, clearPending, classifyAction,
|
||||
registerAction, buildNotes, parseConfirmArgs, renderPendingQuestion,
|
||||
savePending, loadPending, classifyAction,
|
||||
registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions,
|
||||
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates,
|
||||
type PendingItem, type PendingFile,
|
||||
} from './scheduling/meetRegistration';
|
||||
@@ -780,109 +780,13 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
* 같은 녹취 재실행 시 중복 등록되지 않는다.
|
||||
*/
|
||||
async function runMeetConfirm(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<void> {
|
||||
const pend = loadPending();
|
||||
if (!pend || !pend.items.length) {
|
||||
chunk(view, '\nℹ️ 등록 보류 중인 액션 아이템이 없습니다. (`/meet <녹취파일>` 실행 후 보류가 생기면 사용)\n');
|
||||
return;
|
||||
}
|
||||
if (!context) {
|
||||
chunk(view, '\n⚠️ 확장 컨텍스트를 사용할 수 없어 등록을 진행할 수 없습니다.\n');
|
||||
return;
|
||||
}
|
||||
if (!arg.trim()) {
|
||||
chunk(view, renderPendingQuestion(pend) + '\n');
|
||||
return;
|
||||
}
|
||||
const calCfg = readCalendarConfig(context);
|
||||
if (!calCfg.refreshToken) {
|
||||
chunk(view, '\n⚠️ Google OAuth(쓰기)가 연결되지 않아 등록할 수 없습니다. (Astra Settings → Google 섹션)\n');
|
||||
return;
|
||||
}
|
||||
const gCfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const useTasks = gCfg.get<boolean>('meetUsesTasks', true);
|
||||
const useCalendar = gCfg.get<boolean>('meetUsesCalendar', true);
|
||||
|
||||
const { decisions, errors } = parseConfirmArgs(arg, new Date().getFullYear());
|
||||
for (const e of errors) chunk(view, ` ⚠️ ${e}\n`);
|
||||
if (!decisions.length) {
|
||||
chunk(view, '\n해석 가능한 답변이 없습니다. 예: `/meet confirm 1=6/20 2=ok 3=skip`\n');
|
||||
return;
|
||||
}
|
||||
|
||||
let registered = 0, skipped = 0, failed = 0;
|
||||
const doneIdx = new Set<number>();
|
||||
const newKeys: string[] = [];
|
||||
|
||||
for (const d of decisions) {
|
||||
const item = pend.items.find(i => i.idx === d.idx);
|
||||
if (!item) { chunk(view, ` ⚠️ ${d.idx}번 항목이 보류 목록에 없습니다.\n`); continue; }
|
||||
|
||||
if (d.action === 'skip') {
|
||||
skipped++;
|
||||
doneIdx.add(item.idx);
|
||||
chunk(view, ` · ⏭️ ${item.idx}. ${item.work} — 등록 안 함\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isConditional = item.kind === 'conditional';
|
||||
let title: string;
|
||||
let date: string | undefined;
|
||||
const extra: string[] = [];
|
||||
|
||||
if (isConditional) {
|
||||
extra.push(`■ 선행 조건: ${item.condition}`);
|
||||
if (d.action === 'ok') {
|
||||
// 날짜 없는 Tasks 등록 — 조건 충족 시 사용자가 날짜를 부여.
|
||||
title = `[조건부] ${item.work}`;
|
||||
date = undefined;
|
||||
extra.push('· 선행 조건 충족 후 진행 — 날짜 없는 task 로 등록됨 (충족 시 날짜 부여)');
|
||||
if (!useTasks) {
|
||||
failed++;
|
||||
chunk(view, ` · ⚠️ ${item.idx}. ${item.work} — 날짜 없는 등록은 Tasks 가 필요합니다 (meetUsesTasks 꺼짐). 확인일 날짜를 지정해주세요: \`${item.idx}=날짜\`\n`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
title = `[조건부 확인] ${item.work}`;
|
||||
date = d.date!;
|
||||
extra.push(`· ${date} 는 선행 조건 충족 여부를 점검하는 확인일입니다.`);
|
||||
}
|
||||
} else {
|
||||
title = item.work;
|
||||
date = d.action === 'ok' ? item.suggestedDate : d.date!;
|
||||
}
|
||||
|
||||
const notes = buildNotes({
|
||||
detail: item.detail, meetTitle: pend.meetTitle, owner: item.owner,
|
||||
dueRaw: item.due, dateLabel: date || '(날짜 없음 — 조건부)', extra,
|
||||
});
|
||||
const r = await registerAction(context, {
|
||||
title, date, notes,
|
||||
useTasks, useCalendar: isConditional && !date ? false : useCalendar,
|
||||
});
|
||||
if (r.failures.length === 0) {
|
||||
registered++;
|
||||
doneIdx.add(item.idx);
|
||||
newKeys.push(taskKey(item.work));
|
||||
chunk(view, ` · ✅ ${date || '(날짜 없음)'} — ${title} (${r.successes.join(' + ')})\n`);
|
||||
} else {
|
||||
failed++;
|
||||
if (r.successes.length) { doneIdx.add(item.idx); newKeys.push(taskKey(item.work)); }
|
||||
chunk(view, ` · ${date || '(날짜 없음)'} — ${title}${r.successes.length ? ` (✅ ${r.successes.join(' + ')})` : ''}\n`);
|
||||
for (const f of r.failures) chunk(view, ` ⚠️ ${f}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (newKeys.length) markRegistered(pend.transcriptHash, pend.meetTitle, newKeys);
|
||||
|
||||
// 처리된 항목 제거 — 남은 보류는 유지 후 안내.
|
||||
const remaining = pend.items.filter(i => !doneIdx.has(i.idx));
|
||||
if (remaining.length === 0) {
|
||||
clearPending();
|
||||
chunk(view, `\n✅ 보류 항목 처리 완료 — 등록 ${registered}건 · 건너뜀 ${skipped}건${failed ? ` · 실패 ${failed}건` : ''}\n`);
|
||||
} else {
|
||||
savePending({ ...pend, items: remaining });
|
||||
chunk(view, `\n등록 ${registered}건 · 건너뜀 ${skipped}건${failed ? ` · 실패 ${failed}건` : ''} — 아직 ${remaining.length}건이 보류 중입니다 (\`/meet pending\` 으로 확인)\n`);
|
||||
}
|
||||
// 코어는 출력 중립(processConfirmDecisions) — 텔레그램 인바운드와 공용 (P5 HITL).
|
||||
const { lines } = await processConfirmDecisions(context, arg);
|
||||
chunk(view, '\n' + lines.map(l => ` ${l}`).join('\n') + '\n');
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -252,6 +252,110 @@ export function logMeetRegistration(event: string, data: Record<string, unknown>
|
||||
logInfo(`/meet 등록 게이트: ${event}`, data);
|
||||
}
|
||||
|
||||
// ── confirm 처리 코어 (출력 중립 — 웹뷰·텔레그램 공용) ──────────────────────
|
||||
// runMeetConfirm(웹뷰)과 텔레그램 인바운드가 같은 로직을 쓰도록 문자열 라인을
|
||||
// 반환한다. 원격(텔레그램)에서도 보류 답변→등록 완결이 가능해진다 (P5 HITL).
|
||||
import { readCalendarConfig } from '../../calendar/calendarCache';
|
||||
|
||||
export async function processConfirmDecisions(
|
||||
context: vscode.ExtensionContext,
|
||||
arg: string,
|
||||
): Promise<{ lines: string[]; remaining: number }> {
|
||||
const lines: string[] = [];
|
||||
const pend = loadPending();
|
||||
if (!pend || !pend.items.length) {
|
||||
return { lines: ['ℹ️ 등록 보류 중인 액션 아이템이 없습니다.'], remaining: 0 };
|
||||
}
|
||||
if (!arg.trim()) {
|
||||
return { lines: [renderPendingQuestion(pend)], remaining: pend.items.length };
|
||||
}
|
||||
const calCfg = readCalendarConfig(context);
|
||||
if (!calCfg.refreshToken) {
|
||||
return { lines: ['⚠️ Google OAuth(쓰기)가 연결되지 않아 등록할 수 없습니다. (Astra Settings → Google 섹션)'], remaining: pend.items.length };
|
||||
}
|
||||
const gCfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const useTasks = gCfg.get<boolean>('meetUsesTasks', true);
|
||||
const useCalendar = gCfg.get<boolean>('meetUsesCalendar', true);
|
||||
|
||||
const { decisions, errors } = parseConfirmArgs(arg, new Date().getFullYear());
|
||||
for (const e of errors) lines.push(`⚠️ ${e}`);
|
||||
if (!decisions.length) {
|
||||
lines.push('해석 가능한 답변이 없습니다. 예: confirm 1=6/20 2=ok 3=skip');
|
||||
return { lines, remaining: pend.items.length };
|
||||
}
|
||||
|
||||
let registered = 0, skipped = 0, failed = 0;
|
||||
const doneIdx = new Set<number>();
|
||||
const newKeys: string[] = [];
|
||||
|
||||
for (const d of decisions) {
|
||||
const item = pend.items.find(i => i.idx === d.idx);
|
||||
if (!item) { lines.push(`⚠️ ${d.idx}번 항목이 보류 목록에 없습니다.`); continue; }
|
||||
|
||||
if (d.action === 'skip') {
|
||||
skipped++; doneIdx.add(item.idx);
|
||||
lines.push(`⏭️ ${item.idx}. ${item.work} — 등록 안 함`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isConditional = item.kind === 'conditional';
|
||||
let title: string;
|
||||
let date: string | undefined;
|
||||
const extra: string[] = [];
|
||||
|
||||
if (isConditional) {
|
||||
extra.push(`■ 선행 조건: ${item.condition}`);
|
||||
if (d.action === 'ok') {
|
||||
title = `[조건부] ${item.work}`;
|
||||
date = undefined;
|
||||
extra.push('· 선행 조건 충족 후 진행 — 날짜 없는 task 로 등록됨 (충족 시 날짜 부여)');
|
||||
if (!useTasks) {
|
||||
failed++;
|
||||
lines.push(`⚠️ ${item.idx}. ${item.work} — 날짜 없는 등록은 Tasks 가 필요합니다. 확인일을 지정하세요: ${item.idx}=날짜`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
title = `[조건부 확인] ${item.work}`;
|
||||
date = d.date!;
|
||||
extra.push(`· ${date} 는 선행 조건 충족 여부를 점검하는 확인일입니다.`);
|
||||
}
|
||||
} else {
|
||||
title = item.work;
|
||||
date = d.action === 'ok' ? item.suggestedDate : d.date!;
|
||||
}
|
||||
|
||||
const notes = buildNotes({
|
||||
detail: item.detail, meetTitle: pend.meetTitle, owner: item.owner,
|
||||
dueRaw: item.due, dateLabel: date || '(날짜 없음 — 조건부)', extra,
|
||||
});
|
||||
const r = await registerAction(context, {
|
||||
title, date, notes,
|
||||
useTasks, useCalendar: isConditional && !date ? false : useCalendar,
|
||||
});
|
||||
if (r.failures.length === 0) {
|
||||
registered++; doneIdx.add(item.idx); newKeys.push(taskKey(item.work));
|
||||
lines.push(`✅ ${date || '(날짜 없음)'} — ${title} (${r.successes.join(' + ')})`);
|
||||
} else {
|
||||
failed++;
|
||||
if (r.successes.length) { doneIdx.add(item.idx); newKeys.push(taskKey(item.work)); }
|
||||
lines.push(`${date || '(날짜 없음)'} — ${title}${r.successes.length ? ` (✅ ${r.successes.join(' + ')})` : ''}`);
|
||||
for (const f of r.failures) lines.push(` ⚠️ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (newKeys.length) markRegistered(pend.transcriptHash, pend.meetTitle, newKeys);
|
||||
|
||||
const remaining = pend.items.filter(i => !doneIdx.has(i.idx));
|
||||
if (remaining.length === 0) {
|
||||
clearPending();
|
||||
lines.push(`✅ 보류 항목 처리 완료 — 등록 ${registered}건 · 건너뜀 ${skipped}건${failed ? ` · 실패 ${failed}건` : ''}`);
|
||||
} else {
|
||||
savePending({ ...pend, items: remaining });
|
||||
lines.push(`등록 ${registered}건 · 건너뜀 ${skipped}건${failed ? ` · 실패 ${failed}건` : ''} — 아직 ${remaining.length}건 보류 중 (pending 으로 확인)`);
|
||||
}
|
||||
return { lines, remaining: remaining.length };
|
||||
}
|
||||
|
||||
// ── 회의 용어집 (반복 회의의 STT 보정 정확도용) ─────────────────────────────
|
||||
// meetPrompt 는 메타데이터를 "용어집 역할"로 쓴다 — 매번 수동 입력하는 대신,
|
||||
// 이전 /meet 실행에서 나온 인명(담당)·사용자 입력 메타데이터 용어를 워크스페이스에
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
}
|
||||
@@ -59,6 +59,27 @@ export function createTelegramBot(
|
||||
preview: text.length > 80 ? text.slice(0, 80) + '…' : text,
|
||||
});
|
||||
|
||||
// ── /meet 보류 항목 원격 처리 (P5 HITL) ────────────────────────
|
||||
// "confirm 1=ok 2=6/20" / "pending" 으로 회의 액션 보류를 텔레그램에서
|
||||
// 직접 확정 — 데일리 브리핑이 보류를 알려주면 그 자리에서 회신해 등록 완결.
|
||||
const meetCmd = text.match(/^\/?(?:meet\s+)?(confirm|pending|보류)\b\s*(.*)$/i);
|
||||
if (meetCmd) {
|
||||
try {
|
||||
const { loadPending, renderPendingQuestion, processConfirmDecisions } =
|
||||
await import('../../features/datacollect/scheduling/meetRegistration');
|
||||
const sub = meetCmd[1].toLowerCase();
|
||||
if (sub === 'pending' || sub === '보류') {
|
||||
const p = loadPending();
|
||||
return p && p.items.length ? renderPendingQuestion(p) : 'ℹ️ 등록 보류 중인 액션 아이템이 없습니다.';
|
||||
}
|
||||
const { lines } = await processConfirmDecisions(context, meetCmd[2] || '');
|
||||
return lines.join('\n').slice(0, 4000);
|
||||
} catch (e: any) {
|
||||
logError('Telegram meet-confirm failed.', { chatId, error: e?.message ?? String(e) });
|
||||
return `⚠️ 보류 처리 중 오류: ${e?.message ?? e}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1인 기업 모드 라우팅 ────────────────────────────────────────
|
||||
// 회사 모드 ON + 메시지가 work *order* 처럼 보이면 (만들어줘/해줘 또는
|
||||
// "CEO한테 …" 접두) RAG-chat 대신 dispatcher 로. dispatcher 가 끝에
|
||||
|
||||
Reference in New Issue
Block a user