feat(core): 자기지식 접지·웹 접근·환경 자가점검 — 할루시네이션 방어 3중화 (v2.2.247)

- Alignment Self-Learning: 자가 조사(질문 전 두뇌 검색)·사용자 답변 두뇌 저장·핵심메시지/프로젝트 컨텍스트 주입 (alignmentResearch.ts 신규)
- 웹 접근: Bridge 폴백 직접 fetch(webFetch.ts 신규)·<fetch_url> 액션 태그·기업 모드 URL/아키텍처 컨텍스트 주입·bare 도메인 인식
- 트리거 버그 수정: startsWith('/') 가 절대경로를 슬래시 명령으로 오인 — 분석 지시·URL 주입 전멸 원인 (회귀 테스트 고정)
- 자기지식 접지: 기능 인벤토리 lazy 재생성·학습 메커니즘 정본 섹션·[인벤토리 대조] 태그 의무화·결정론적 재구현 제안 정정 훅(featureConceptMap.ts 신규)
- 환경 자가점검: HealthCheckMonitor 에 Bridge/두뇌 볼륨/git 자격증명/확장 버전 검사 4종 + readyBar ⚠ 표시
- 두뇌 동기화: 원격 미설정 시 로컬 새로고침 모드·staged 기준 commit 판정·인증 부재 안내
- 기타: outputFormat 기본 markdown(제목 렌더 복구)·레슨/행동제약 truncation 보호 구역 이동·[CONTEXT] 절단 우선순위 재정렬

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
g1nation
2026-06-12 23:46:07 +09:00
co-authored by Claude Fable 5
parent 553aa0b134
commit a114d968b0
42 changed files with 4178 additions and 2088 deletions
+252 -8
View File
@@ -8,6 +8,7 @@ import {
buildApiUrl,
getActiveBrainProfile,
getBrainProfiles,
invalidateBrainFilesCache,
logError,
logInfo,
resolveEngine,
@@ -108,6 +109,8 @@ registerSidebarHandler(handleChronicleMessage);
registerSidebarHandler(handleAgentMessage);
import { resolveScopeForAgent } from './skills/agentKnowledgeMap';
import { clearBrainTokenIndex } from './retrieval/brainIndex';
import { extractUrls } from './features/web/webFetch';
import { buildUrlContext } from './lib/contextBuilders/urlContext';
import { estimateModelParamsB } from './lib/contextManager';
import {
buildOrRefreshArchitectureDoc,
@@ -123,8 +126,13 @@ import {
listResumableSessions,
analyzeIntent,
resolveActivePipeline,
gatherEvidenceForQuestions,
selfAnswerQuestions,
saveAlignmentKnowledge,
SELF_RESEARCH_PREFIX,
} from './features/company';
import { AIService } from './core/services';
import { HealthCheckMonitor } from './core/health';
import { presentOfficeSnapshot } from './features/astraOffice';
export interface SidebarLmStudioDeps {
@@ -1104,6 +1112,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
effectiveContextLength,
cappedForSmallModel,
lmStudioError: this._lmStudioLastError ?? null,
healthWarnings: HealthCheckMonitor.lastReports,
});
this._view.webview.postMessage(payload);
} catch (err: any) {
@@ -1432,6 +1441,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
this._view?.webview.postMessage({ type: 'clearChat' });
}
/**
* 두뇌 동기화 — 두 모드:
* 1. 원격 미설정 (profile.secondBrainRepo 비어 있음, 기본): 검색 인덱스/캐시
* 새로고침만. 사용자의 멘탈 모델("동기화 = 내 로컬 두뇌 폴더 인식")과 일치.
* git 을 건드리지 않으므로 인증도 불필요.
* 2. 원격 설정됨: pull(rebase) → commit/push → 인덱스 갱신 (백업/공유 모드).
*
* 배경: 옛 구현은 원격 설정 여부와 무관하게 git push 를 강행해서, 두뇌 폴더가
* 우연히 다른 git 레포 안에 있으면 그 레포의 원격 인증을 요구하는 혼란이 있었다.
*/
public async syncBrain() {
const activeBrain = getActiveBrainProfile();
const brainDir = activeBrain.localBrainPath;
@@ -1439,20 +1458,122 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
vscode.window.showErrorMessage("Second Brain directory not found.");
return;
}
// ── 모드 1: 원격 미설정 → 로컬 새로고침만 (git 없음, 인증 불필요) ──
if (!activeBrain.secondBrainRepo || !activeBrain.secondBrainRepo.trim()) {
try {
invalidateBrainFilesCache();
clearBrainTokenIndex(brainDir);
const count = findBrainFiles(brainDir).length;
vscode.window.showInformationMessage(
`두뇌 새로고침 완료 — 문서 ${count}개 인식됨. ` +
`(이 두뇌는 원격 저장소가 설정되어 있지 않아 git 동기화는 건너뜁니다. ` +
`새 문서는 평소에도 자동 인식되므로 이 버튼은 강제 새로고침 용도입니다.)`,
);
} catch (e: any) {
vscode.window.showErrorMessage(`두뇌 새로고침 실패: ${String(e?.message ?? e).slice(0, 150)}`);
}
return;
}
vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Astra: Syncing Second Brain...",
cancellable: false
}, async () => {
const { execSync } = require('child_process');
const run = (cmd: string): string =>
execSync(cmd, { cwd: brainDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) as string;
// ── ⓪ upstream 확인 — 없으면 pull/push 없이 로컬 커밋만 (정직 보고) ──
// 사례: 두뇌 폴더에 원격 없는 중첩 .git 이 생겨 있던 환경에서
// "There is no tracking information" 으로 동기화 전체가 죽었다.
// 원격 추적이 없는 두뇌는 로컬 버전 관리만 수행하고 그 사실을 알린다.
let hasUpstream = true;
try {
const { execSync } = require('child_process');
execSync(`git add .`, { cwd: brainDir });
execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir });
execSync(`git push`, { cwd: brainDir });
vscode.window.showInformationMessage("Second Brain synced successfully.");
} catch (err: any) {
vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push).");
run('git rev-parse --abbrev-ref --symbolic-full-name "@{u}"');
} catch {
hasUpstream = false;
}
// ── ① 원격 변경 수신 (pull --rebase --autostash) ──
let pulledFiles = 0;
if (hasUpstream) {
try {
run('git fetch');
// upstream 대비 새로 들어올 파일 수 — 사용자에게 "몇 개 받았는지" 보여주기 위해.
try {
const incoming = run('git diff --name-only HEAD "@{u}"').trim();
pulledFiles = incoming ? incoming.split('\n').filter(Boolean).length : 0;
} catch { /* 카운트만 생략 */ }
run('git pull --rebase --autostash');
} catch (err: any) {
const detail = String(err?.stderr || err?.message || err).slice(0, 200);
logError('Brain sync: pull failed.', { brainDir, detail });
vscode.window.showErrorMessage(`두뇌 동기화 실패 (원격 수신 중): ${detail}`);
return;
}
}
// ── ② 로컬 변경 송신 (add → commit → push) ──
// 변경 판정은 *staged* 기준 (`git diff --cached --quiet`) — 두뇌 폴더가
// 더 큰 레포의 하위 폴더일 때 `git status --porcelain` 은 폴더 *밖* 의
// 변경까지 보고해서, staged 가 비어 있는데 commit 을 시도하다
// "no changes added" 로 실패하는 버그가 있었다.
let pushed = false;
let committedLocally = false;
try {
run('git add .');
let hasStaged = false;
try {
run('git diff --cached --quiet'); // exit 0 = staged 없음
} catch {
hasStaged = true; // exit 1 = staged 변경 존재
}
if (hasStaged) {
run('git commit -m "[G1-Sync] Manual knowledge update"');
committedLocally = true;
if (hasUpstream) {
run('git push');
pushed = true;
}
} else if (hasUpstream) {
// 커밋할 변경 없음 — ahead 상태(이전 커밋 미push)면 push 만.
const ahead = run('git rev-list --count "@{u}"..HEAD').trim();
if (ahead && ahead !== '0') {
run('git push');
pushed = true;
}
}
} catch (err: any) {
const detail = String(err?.stderr || err?.message || err).slice(0, 200);
logError('Brain sync: push failed.', { brainDir, detail });
// 인증 부재는 사용자가 직접 해결해야 하는 환경 문제 — git 원문 대신
// 행동 가능한 안내. (확장은 비대화형이라 아이디/토큰을 물어볼 수 없다.)
if (/could not read Username|Authentication failed|terminal prompts disabled|403/i.test(detail)) {
vscode.window.showErrorMessage(
`두뇌 동기화 실패: git 인증 정보가 저장되어 있지 않습니다. ` +
`터미널에서 "cd ${brainDir} && git push" 를 1회 실행해 아이디/토큰을 입력하면 ` +
`키체인에 저장되어 이후 버튼이 정상 작동합니다. (로컬 커밋은 완료된 상태)`,
);
} else {
vscode.window.showErrorMessage(`두뇌 동기화 실패 (송신 중): ${detail}`);
}
return;
}
// ── ③ 검색 인덱스/캐시 무효화 — 새 문서가 즉시 검색에 잡히게 ──
try {
invalidateBrainFilesCache();
clearBrainTokenIndex(brainDir);
} catch { /* 캐시 무효화 실패는 치명적이지 않음 — TTL/mtime 이 결국 따라잡음 */ }
const parts: string[] = [];
if (pulledFiles > 0) parts.push(`원격 문서 ${pulledFiles}개 수신`);
if (pushed) parts.push('로컬 변경 push 완료');
if (committedLocally && !pushed) parts.push('로컬 커밋 완료');
if (!hasUpstream) parts.push('⚠️ 원격 추적 브랜치 없음 — 로컬 버전 관리만 수행됨');
if (parts.length === 0) parts.push('변경 없음 — 이미 최신 상태');
vscode.window.showInformationMessage(`두뇌 동기화 완료: ${parts.join(' · ')} (검색 인덱스 갱신됨)`);
});
}
@@ -1928,6 +2049,39 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
} catch { /* history 못 가져와도 alignment 자체는 동작 */ }
}
// 현재 워크스페이스 아키텍처 컨텍스트 — "이 프로젝트가 뭐냐"류의 재질문 차단.
// 첫 라운드만 (후속 라운드는 contract 에 이미 흡수). 분석기는 작은 모델이므로
// 3,000자로 재절단 (architecture 원본 cap 은 16,000자).
let projectContext: string | undefined;
if (!opts.previousContract) {
try {
const arch = this._buildProjectArchitectureContext();
if (arch) {
projectContext = arch.length > 3000
? arch.slice(0, 3000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
: arch;
}
} catch { /* architecture 없어도 alignment 는 계속 */ }
// 사용자 프롬프트에 URL 이 있으면 본문도 컨텍스트에 — "그 사이트가
// 뭐냐"는 재질문 차단. 결과는 5분 캐시되어 dispatch 와 중복 비용 없음.
try {
if (cfg.webAutoFetchEnabled !== false) {
const urls = extractUrls(opts.userPrompt, 2);
if (urls.length > 0) {
const webBlocks: string[] = [];
for (const url of urls) {
webBlocks.push(await buildUrlContext(url));
}
const web = webBlocks.join('\n\n');
const webCapped = web.length > 2000 ? web.slice(0, 2000) + '\n…(이하 생략)' : web;
projectContext = projectContext
? `${projectContext}\n\n${webCapped}`
: webCapped;
}
}
} catch { /* URL 수집 실패해도 alignment 는 계속 */ }
}
const analysis = await analyzeIntent(
new AIService(),
{
@@ -1937,19 +2091,55 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
activePipelineName: activePipeline?.name,
availableRoleCategories: extractActiveRoleCategories(state),
priorChatSummary,
projectContext,
},
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
);
const contract = analysis.contract;
let contract = analysis.contract;
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
// ── 자가 조사: 사용자에게 묻기 전에 두뇌에서 스스로 답 찾기 ──
// 답을 찾은 질문은 answeredQuestions 로 이동(자가 조사 marker), 못 찾은
// 것만 카드에 노출. 라운드 카운트는 소비하지 않는다 (사용자 응답이 아니므로).
if (cfg.companyAlignmentSelfResearch !== false
&& contract.openQuestions.length > 0 && !reachedLimit) {
try {
const brain = getActiveBrainProfile();
const evidence = gatherEvidenceForQuestions(brain.localBrainPath, contract.openQuestions);
if (evidence.some((e) => e.excerpts.length > 0)) {
const answers = await selfAnswerQuestions(new AIService(), {
userPrompt: opts.userPrompt,
evidence,
model: cfg.companyIntentClassifierModel || cfg.defaultModel,
});
const solved = answers.filter((a) => a.answered && a.answer.trim());
if (solved.length > 0) {
const solvedSet = new Set(solved.map((s) => s.question));
// 비파괴적 갱신 — analysis.contract 원본은 건드리지 않는다.
contract = {
...contract,
answeredQuestions: [
...contract.answeredQuestions,
...solved.map((s) => ({ q: s.question, a: SELF_RESEARCH_PREFIX + s.answer })),
],
openQuestions: contract.openQuestions.filter((q) => !solvedSet.has(q)),
};
this._pixelOfficeBroadcast({
recentLogs: this._pixelOffice.appendLog(`🔎 자가 조사로 질문 ${solved.length}건 해결`),
});
}
}
} catch { /* 자가 조사 실패 — 원래 질문 그대로 노출 */ }
}
if (shouldAutoProceedAlignment(opts.mode, contract, reachedLimit)) {
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
this._view?.webview.postMessage(buildAlignmentAutoProceedPayload(contract, reachedLimit));
try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
this._alignment.clear();
this._saveAlignmentKnowledgeIfAny(opts.userPrompt, contract.answeredQuestions);
await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract);
return;
}
@@ -2014,6 +2204,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
async _proceedWithCurrentAlignment(): Promise<void> {
const pending = this._alignment.consume();
if (!pending) return;
this._saveAlignmentKnowledgeIfAny(pending.userOriginalPrompt, pending.contract.answeredQuestions);
await this._runCompanyTurn(
pending.userOriginalPrompt,
undefined,
@@ -2022,6 +2213,26 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
);
}
/**
* Alignment 학습 루프 (Phase 3) — 사용자가 직접 답해준 Q/A 를 두뇌에 저장.
* 자가 조사 항목과 짧은 답의 필터링은 saveAlignmentKnowledge 내부에서 처리.
* fire-and-forget: 저장 실패가 dispatch 를 막으면 안 된다.
*/
private _saveAlignmentKnowledgeIfAny(userPrompt: string, qaList: Array<{ q: string; a: string }>): void {
try {
const cfg = getConfig();
if (cfg.companyAlignmentKnowledgeSave === false) return;
if (!qaList || qaList.length === 0) return;
const brain = getActiveBrainProfile();
const saved = saveAlignmentKnowledge(brain.localBrainPath, { userPrompt, qaList });
if (saved) {
this._pixelOfficeBroadcast({
recentLogs: this._pixelOffice.appendLog('💾 확인된 정보 두뇌 저장'),
});
}
} catch { /* non-fatal */ }
}
/**
* 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때.
* 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌.
@@ -2131,6 +2342,37 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
// `signal.aborted` between phases and short-circuits cleanly.
const abort = this._companyTurn.startTurn();
try {
// ── 자동 수집 컨텍스트 (architecture + URL pre-fetch) ──
// 일반 챗은 둘 다 자동 주입받지만 기업 모드 dispatcher 는 빠져 있던 공백.
// 실패해도 turn 은 진행 (try/catch).
let architectureContextBlock: string | undefined;
try {
const arch = this._buildProjectArchitectureContext();
if (arch) {
architectureContextBlock = arch.length > 6000
? arch.slice(0, 6000) + '\n…(이하 생략 — 전체는 architecture.md 참조)'
: arch;
}
} catch { /* architecture 없어도 진행 */ }
let webContextBlock: string | undefined;
try {
const cfgTurn = getConfig();
if (cfgTurn.webAutoFetchEnabled !== false && !resumeTimestamp) {
const urls = extractUrls(userPrompt, 2);
if (urls.length > 0) {
this._pixelOfficeBroadcast({
recentLogs: this._pixelOffice.appendLog(`🌐 URL ${urls.length}건 수집 중`),
});
const blocks: string[] = [];
for (const url of urls) {
blocks.push(await buildUrlContext(url)); // 5분 캐시 — alignment 와 중복 비용 없음
}
const joined = blocks.join('\n\n');
webContextBlock = joined.length > 8000 ? joined.slice(0, 8000) + '\n…(이하 생략)' : joined;
}
}
} catch { /* URL 수집 실패해도 진행 */ }
const deps = buildDispatcherDeps({
context: this._context,
ai,
@@ -2140,6 +2382,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
approvalGates: this._approvalGates,
pipelineIdOverride,
requirementContract,
architectureContextBlock,
webContextBlock,
});
// 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를
// 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을