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:
co-authored by
Claude Fable 5
parent
553aa0b134
commit
a114d968b0
+252
-8
@@ -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을
|
||||
|
||||
Reference in New Issue
Block a user