feat: v2.2.3 - Stability, Self-Reflector & Intent Alignment

- 버전 2.2.3 상향 및 PATCHNOTES.md 업데이트

- [신규] src/features/selfReflector/ - 성찰 실행/검증/프롬프트 모듈 추가

- [신규] intentAlignment.ts, intentClassifier.ts - 의도 정렬 시스템 추가

- [신규] pixelOfficeState.ts - 픽셀 오피스 상태 관리 추가

- sidebarProvider, dispatcher, chatHandlers 핵심 로직 최적화

- astra-2.2.3.vsix 패키지 생성 완료 (298 tests PASS)
This commit is contained in:
2026-05-15 14:16:14 +09:00
parent ed7e497194
commit 72412450c3
33 changed files with 4964 additions and 125 deletions
+180 -5
View File
@@ -19,12 +19,153 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
provider._lmStudio?.activity.bump();
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
// ── 1인 기업 모드 우선 분기 ──
// When company mode is active, route the prompt through the
// CEO planner / sequential dispatcher / synthesis pipeline
// instead of the normal single-agent path. The user-facing
// chat surface is the same — only the runtime differs.
// 회사 모드일 땐 들어온 메시지를 intent classifier에 한 번 통과시켜
// (a) 잡담/질문/짧은 응답 → 일반 채팅 경로, (b) 후속 대화 → 일반 채팅
// 경로 + followup 라벨, (c) 신규 업무 → 풀 파이프라인 dispatch.
// classifier가 비활성화돼 있거나 호출 실패 시엔 안전하게 new_task로
// 폴백 — 즉 사용자가 의도한 작업 요청을 놓치는 일은 절대 없다.
if (provider.isCompanyModeEnabled() && typeof data.value === 'string' && data.value.trim()) {
await provider._runCompanyTurn(data.value.trim());
let userPrompt = data.value.trim();
const { getConfig } = await import('../config');
const cfg = getConfig();
const { readCompanyState, resolveActivePipeline } = await import('../features/company');
const state = readCompanyState(provider._context);
// ── alignment 답변 라우팅 ──
// 사용자가 이전 메시지에서 alignment 카드를 받아 답변하는 중이면
// 이 메시지를 분류기/dispatch가 아니라 alignment 답변 핸들러로
// 보낸다. 답변이 처리되면서 자동으로 다음 라운드 또는 pipeline
// 으로 진행됨.
if (provider.isAlignmentPending()) {
await provider._handleAlignmentAnswer(userPrompt);
return true;
}
// ── 사용자 키워드 override ──
// 입력 맨 앞에 `[파이프라인:id]` 또는 `[pipeline:id]`가 있으면
// 분류기 무관하게 그 파이프라인 강제 + 그 키워드는 prompt에서
// 제거 후 dispatcher에 전달. id가 유효하지 않으면 무시(분류기 정상 경로).
let keywordOverrideId: string | undefined;
const keywordMatch = userPrompt.match(/^\s*\[(?:파이프라인|pipeline)\s*:\s*([a-z0-9_-]+)\s*\]\s*/i);
if (keywordMatch) {
const id = keywordMatch[1].toLowerCase();
if (state.pipelines?.[id]) {
keywordOverrideId = id;
userPrompt = userPrompt.slice(keywordMatch[0].length).trim() || userPrompt;
}
}
// ── alignment bypass 키워드 ──
// 입력 맨 앞 `[건너뛰기]` 또는 `[skip]` → alignment 단계 1회 우회.
// 사용자가 "지금은 빨리 가자"라고 명시한 경우에만 사용. prompt에서
// 키워드 제거.
let alignmentBypass = false;
const bypassMatch = userPrompt.match(/^\s*\[(?:건너뛰기|skip)\]\s*/i);
if (bypassMatch) {
alignmentBypass = true;
userPrompt = userPrompt.slice(bypassMatch[0].length).trim() || userPrompt;
}
if (cfg.companyDisableIntentClassifier) {
// 분류기 우회 모드 — 분류 단계는 건너뛰지만 alignment는 별도로
// 작동(사용자가 alignment off로 설정하지 않은 한). 분류기 끄는
// 이유는 보통 "잡담도 다 pipeline으로"인데 그럴수록 alignment
// 효과가 큼.
try { provider.pixelOfficeOnIntentClassified('new_task', userPrompt); } catch { /* noop */ }
if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) {
await provider._runCompanyTurn(userPrompt, undefined, keywordOverrideId);
} else {
await provider._runIntentAlignment({
userPrompt,
pipelineIdOverride: keywordOverrideId,
mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart',
roundsLimit: cfg.companyIntentAlignmentMaxRounds,
roundsAsked: 0,
});
}
return true;
}
const { classifyChatIntent } = await import('../features/company');
const { AIService } = await import('../core/services');
const last = provider.getLastCompanyTurnSummary();
const activePipeline = resolveActivePipeline(state);
// 사용 가능한 모든 파이프라인을 분류기 후보로 전달 — 단, 활성화돼
// 있어야 추천 의미가 있는 게 아니라 *정의돼 있기만 하면* 후보. 사용자가
// 평소엔 짧은 걸 활성화해 두고 가끔 풀 사이클 의도가 명확한 발화를
// 했을 때 분류기가 그쪽을 추천할 수 있게.
const allPipelines = Object.values(state.pipelines ?? {});
const verdict = await classifyChatIntent(
new AIService(),
userPrompt,
{
previousBrief: last?.brief,
previousReportTail: last?.reportTail,
previousTurnAt: last?.finishedAt,
activePipelineName: activePipeline?.name,
availablePipelines: allPipelines.length > 0
? allPipelines.map((p) => ({
id: p.id,
name: p.name,
stageCount: p.stages.length,
}))
: undefined,
},
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
);
// Pixel Office: 분류 결과를 UI layer로만 흘림. 아래 분기 자체엔 영향 없음.
try { provider.pixelOfficeOnIntentClassified(verdict.intent, userPrompt); } catch { /* noop */ }
if (verdict.intent === 'new_task') {
// 우선순위: (1) 사용자 키워드 (2) autoSelect가 켜져 있고 분류기 추천 있음 (3) 사용자 활성 파이프라인.
let effectiveOverride = keywordOverrideId;
if (!effectiveOverride && cfg.companyAutoSelectPipeline && verdict.suggestedPipelineId) {
effectiveOverride = verdict.suggestedPipelineId;
}
// 분류기가 추천을 냈지만 autoSelect가 꺼져 있을 땐 라벨로만 안내.
if (verdict.suggestedPipelineId && !effectiveOverride && !cfg.companyAutoSelectPipeline) {
const tip = state.pipelines?.[verdict.suggestedPipelineId];
if (tip) {
provider._view?.webview.postMessage({
type: 'companyIntentDecision',
value: {
intent: 'new_task',
reason: `🧭 추천 파이프라인: "${tip.name}" (자동 적용은 설정 토글)`,
label: '🛠️ 신규 업무',
},
});
}
} else if (effectiveOverride && effectiveOverride !== state.activePipelineId) {
const used = state.pipelines?.[effectiveOverride];
if (used) {
provider._view?.webview.postMessage({
type: 'companyIntentDecision',
value: {
intent: 'new_task',
reason: keywordOverrideId
? `🔧 키워드 override → "${used.name}"`
: `🧭 CEO 자동 선택 → "${used.name}"`,
label: '🛠️ 신규 업무',
},
});
}
}
// ── Intent Alignment 진입 ──
// off 모드이거나 bypass 키워드가 있으면 alignment 우회하고
// legacy 동작 (즉시 dispatch). 그 외엔 분석기 1라운드 돌려
// confidence에 따라 자동 진행 또는 카드 표시.
if (cfg.companyIntentAlignmentMode === 'off' || alignmentBypass) {
await provider._runCompanyTurn(userPrompt, undefined, effectiveOverride);
} else {
await provider._runIntentAlignment({
userPrompt,
pipelineIdOverride: effectiveOverride,
mode: cfg.companyIntentAlignmentMode === 'strict' ? 'strict' : 'smart',
roundsLimit: cfg.companyIntentAlignmentMaxRounds,
roundsAsked: 0,
});
}
} else {
await provider._handleCompanyCasual(userPrompt, verdict.intent, verdict.reason, data);
}
return true;
}
await provider._handlePrompt(data);
@@ -48,6 +189,9 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
// Restore the Company chip from globalState so the user sees the same
// mode they had on at last shutdown.
await provider._sendCompanyStatus();
// Pixel Office — 첫 로드 시 빈 idle 상태라도 한 번 push해서 webview가
// 영역 자체를 그릴 수 있게.
provider.pixelOfficeResend();
return true;
case 'getReadyStatus':
await provider._sendReadyStatus();
@@ -71,6 +215,10 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
await provider._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
await provider._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
await provider._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
// 직전 회사 turn 컨텍스트 캐시 비우기 — 새 세션은 followup 기준점이 없다.
provider.clearLastCompanyTurnSummary();
// 진행 중이던 alignment도 새 세션과 함께 폐기.
provider.cancelPendingAlignment();
provider.clearChat();
await provider._sendBrainStatus();
return true;
@@ -78,9 +226,17 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
// 1인 기업 모드는 AgentExecutor를 거치지 않으므로 별도 abort 경로.
// 두 경로 모두 신호를 보내 두면 중간에 모드 전환되어도 안전.
provider.abortCompanyTurn();
// 진행 중인 Intent Alignment도 같이 정리 — 사용자가 Stop 누르면
// 의도상 모든 대기 상태 해제.
provider.cancelPendingAlignment();
provider._agent.stop();
return true;
case 'loadSession':
// 세션 전환 시 직전 회사 turn 캐시 무효화 — 로드된 세션의 직전 회사
// 작업은 별도 파일에서 복원되어야 하지 메모리 캐시에 남은 다른
// 세션의 보고서가 새 세션 첫 메시지의 followup 판정을 오염시키면 안 됨.
provider.clearLastCompanyTurnSummary();
provider.cancelPendingAlignment();
await provider._loadSession(data.id);
return true;
case 'deleteSession':
@@ -401,6 +557,25 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
});
return true;
}
case 'getPixelOfficeState':
// webview가 처음 로드되었거나 사용자가 토글 ON 했을 때 캐시된
// 현재 상태를 다시 받기 위한 요청. read-only.
provider.pixelOfficeResend();
return true;
case 'openPixelOfficePanel':
// 사이드바 mini Pixel Office의 ⛶ 버튼 → editor area에 전체보기 panel 열기.
provider.openPixelOfficePanel();
return true;
case 'respondCompanyAlignment': {
// alignment 카드 버튼: 'proceed' = 현 contract로 dispatch, 'cancel' = 폐기.
const decision = typeof data.decision === 'string' ? data.decision : '';
if (decision === 'proceed') {
await provider._proceedWithCurrentAlignment();
} else if (decision === 'cancel') {
provider.cancelPendingAlignment();
}
return true;
}
case 'respondCompanyApproval': {
// Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제.
// payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? }