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
parent 553aa0b134
commit a114d968b0
42 changed files with 4178 additions and 2088 deletions
+34 -7
View File
@@ -20,8 +20,10 @@ import { SessionManager } from './core/session';
import { AgentWorkflowManager } from './agents/AgentWorkflowManager';
import { buildAstraModeArchitectureContext } from './lib/contextBuilders/astraModeArchitecture';
import { isScheduleRequest, buildScheduleContext } from './lib/contextBuilders/scheduleContext';
import { isSelfAssessRequest, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
import { extractUrlFromPrompt, buildUrlContext } from './lib/contextBuilders/urlContext';
import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
import { ensureFeatureInventory } from './extension/featureInventory';
import { buildUrlContext } from './lib/contextBuilders/urlContext';
import { extractUrls } from './features/web/webFetch';
import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop';
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
@@ -35,6 +37,7 @@ import {
isExplicitSecondBrainRequest,
isSecondBrainInventoryRequest,
isNoBrainDataRefusal,
isAnalysisRequest,
} from './lib/contextBuilders/promptDetection';
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
@@ -154,6 +157,7 @@ import { applyFileCreateEditActions } from './agent/actions/fileCreateEdit';
import { applyFileDeleteReadActions } from './agent/actions/fileDeleteRead';
import { applyRunCommandActions } from './agent/actions/runCommand';
import { applyListFilesActions } from './agent/actions/listFiles';
import { applyWebFetchActions } from './agent/actions/webFetch';
import { applyBrainOpsActions } from './agent/actions/brainOps';
import { applyCalendarActions } from './agent/actions/calendar';
import { applySheetsActions } from './agent/actions/sheets';
@@ -542,8 +546,14 @@ export class AgentExecutor {
// [자기 평가 정본 주입] 기능 개선/자기 평가 질의는 RAG 경쟁에 맡기지 않고
// 현행 기능 인벤토리를 결정론적으로 주입 — 모델이 검색 없이 기억으로 답해
// 이미 있는 기능을 신규 제안하던 구식화 버그(3회 재발)의 마지막 구멍 봉쇄.
if (prompt && loopDepth === 0 && !isCasualConversation && activeBrain?.localBrainPath && isSelfAssessRequest(prompt)) {
if (prompt && loopDepth === 0 && !isCasualConversation && activeBrain?.localBrainPath
&& (isSelfAssessRequest(prompt) || (isAnalysisRequest(prompt) && isAboutSelf(prompt)))) {
try {
// 인벤토리 lazy 재생성 — 활성화 시 1회 생성은 brain 볼륨이 늦게
// 마운트되면 조용히 건너뛰어 파일이 영영 없는 상태가 됐다 (그 결과
// "파일 없음" 안내만 주입돼 모델이 구현 여부를 알 수 없었음).
// 질의 시점에 한 번 더 보장. idempotent — 있으면 즉시 return.
await ensureFeatureInventory(this.context);
const selfAssessBlock = buildSelfAssessContext(activeBrain.localBrainPath);
contextBlock += `\n\n${selfAssessBlock}`;
// 성공 로그 필수 — "주입이 됐는데 모델이 무시" vs "주입 자체가 안 됨"을
@@ -554,11 +564,26 @@ export class AgentExecutor {
}
}
// [URL 실데이터] 채팅 프롬프트에 URL 이 있으면 브리지로 본문을 추출해 주입.
// [근거 기반 분석 강제] 분석/검토/의견형 요청인데 모델이 코드를 읽지 않고
// "~로 보입니다" 추측으로 답하는 실패 모드 차단. 워크스페이스가 열려 있으면
// "주장 전에 read_file 로 실제 확인하라"는 지시를 주입 — 강제 주입 패턴의
// 5번째 적용 (일정→캘린더, 자기평가→인벤토리, 정정→캡처, URL→실데이터와 동일).
if (prompt && loopDepth === 0 && !isCasualConversation && isAnalysisRequest(prompt)
&& vscode.workspace.workspaceFolders?.length) {
contextBlock += `\n\n[근거 기반 분석 규칙 — 이 요청은 분석/검토형]
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
- 일반론·추측으로 빈칸을 채우지 마라.`;
}
// [URL 실데이터] 채팅 프롬프트에 URL 이 있으면 본문을 추출해 주입.
// /wikify 만 URL 접근이 가능하고 일반 채팅은 "접근 불가"라고 답하던 공백 수정.
if (prompt && loopDepth === 0 && !isCasualConversation) {
const url = extractUrlFromPrompt(prompt);
if (url) {
// v2: Bridge 추출 → 직접 fetch 폴백 (urlContext 내부) + 최대 2개 URL + config 게이트.
if (prompt && loopDepth === 0 && !isCasualConversation && getConfig().webAutoFetchEnabled !== false) {
const urls = extractUrls(prompt, 2);
for (const url of urls) {
try {
contextBlock += `\n\n${await buildUrlContext(url)}`;
logInfo('URL 컨텍스트 주입 시도.', { url });
@@ -720,6 +745,7 @@ export class AgentExecutor {
negativeCtx,
actualModel,
contextLength: config.contextLength,
dynamicBlocks: this._turnCtx.dynamicBlocks,
})
: buildAstraModeSystemPrompt({
prompt,
@@ -1586,6 +1612,7 @@ export class AgentExecutor {
await applyFileDeleteReadActions(ctx);
await applyRunCommandActions(ctx);
await applyListFilesActions(ctx);
await applyWebFetchActions(ctx);
await applyBrainOpsActions(ctx);
await applyCalendarActions(ctx);
await applySheetsActions(ctx);
+43
View File
@@ -0,0 +1,43 @@
import { HandlerContext } from './types';
import { buildUrlContext } from '../../lib/contextBuilders/urlContext';
/**
* Action 15: Fetch URL (read-only — transaction record 불필요)
*
* LLM 이 작업 중 웹 페이지의 실제 내용이 필요할 때 emit 하는 태그.
* 결과 블록을 chatHistory 에 internal push — read_file 과 동일 패턴이라
* 일반 챗의 continuation loop(컨텍스트 주입 → 자동 재호출)가 그대로 작동해
* fetch 직후 모델이 내용을 분석한다.
*
* buildUrlContext 재사용: Bridge 추출 우선 → 직접 fetch 폴백 → 정직 실패
* 블록 + 5분 캐시. 실패 블록도 chatHistory 에 push 한다 — 모델이 "가져왔다"고
* 지어내지 않고 실패 사실을 사용자에게 전달해야 하므로.
*/
const FETCH_URL_MAX_PER_TURN = 2;
export async function applyWebFetchActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, report } = ctx;
const fetchRegex = /<fetch_url\s+url=['"]?(https?:\/\/[^'">\s]+)['"]?\s*\/?>(?:<\/fetch_url>)?/gi;
let match;
let handled = 0;
const seen = new Set<string>();
while ((match = fetchRegex.exec(aiMessage)) !== null) {
if (handled >= FETCH_URL_MAX_PER_TURN) {
report.push(`⚠️ fetch_url 한도 초과 — 회당 최대 ${FETCH_URL_MAX_PER_TURN}개만 처리합니다.`);
break;
}
const url = match[1].trim();
if (seen.has(url)) continue;
seen.add(url);
handled++;
try {
const block = await buildUrlContext(url);
const ok = block.includes('실데이터');
report.push(ok ? `🌐 Fetched: ${url}` : `⚠️ Fetch failed: ${url}`);
ctx.chatHistory.push({ role: 'system', content: block, internal: true });
} catch (err: any) {
// buildUrlContext 는 throw 하지 않지만 방어적으로.
report.push(`⚠️ Fetch error: ${url}${String(err?.message ?? err).slice(0, 100)}`);
}
}
}
@@ -22,6 +22,11 @@ export interface BuildAgentModeSystemPromptInput {
actualModel: string;
/** For token-cost logging — getConfig().contextLength. */
contextLength: number;
/**
* 행동 제약·동적 블록 (behavior-constraints / cove-checklist 등) — Astra 모드와
* 동일하게 [CONTEXT] *밖* 보호 구역에 join. 없으면 옛 동작 그대로.
*/
dynamicBlocks?: Map<string, string>;
}
export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInput): string {
@@ -38,6 +43,7 @@ export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInpu
negativeCtx,
actualModel,
contextLength,
dynamicBlocks,
} = input;
// The Agent's prompt IS the primary directive (role / persona / tone / output format),
@@ -69,10 +75,20 @@ export function buildAgentModeSystemPrompt(input: BuildAgentModeSystemPromptInpu
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — agentBlock(앞)·reminder(뒤)·negative 는 보호.
// memoryCtx(RAG/메모리/lessons)도 [CONTEXT] 안에 넣어 토큰이 빡빡할 때 대화 기록보다 먼저 잘리게 한다.
// 순서 = 잘림 우선순위 (truncation 은 body *뒤*부터 자름): contextBlock(사용자가
// 지금 가리킨 실데이터 — URL 본문/열린 파일/일정)을 맨 앞에 둬 최후까지 보존하고,
// 배경 지식(brain RAG)과 memory 는 그보다 먼저 잘리게 한다.
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
// [자기 지식 + 1인칭] Astra 모드와 공용 — Agent 모드에서도 자기 오보고/3인칭 화법 방지.
const selfIdentityBlock = '\n\n' + buildSelfIdentityBlock();
const fullSystemPrompt = `${agentBlock}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}\n\n${strippedSystemPrompt}${selfIdentityBlock}${designerCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
// 동적 블록 (행동 제약·CoVe 등) — [CONTEXT] 밖에 join 해 truncation 보호.
let dynamicBlocksJoined = '';
if (dynamicBlocks && dynamicBlocks.size > 0) {
for (const body of dynamicBlocks.values()) {
if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
}
}
const fullSystemPrompt = `${agentBlock}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}\n\n${strippedSystemPrompt}${selfIdentityBlock}${designerCtx}${secondBrainTraceCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${contextBlock}\n${knowledgeContextForPrompt}\n${memoryCtx}\n[/CONTEXT]\n${negativeCtx}${agentTailReminder}`;
return fullSystemPrompt;
}
@@ -101,5 +101,8 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
}
}
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${selfGrowthIdentityCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
// [CONTEXT] 내부 순서 = 잘림 우선순위 (truncation 은 body *뒤*부터 자름):
// contextBlock(사용자가 지금 가리킨 실데이터 — URL 본문/열린 파일/일정)을 맨 앞에
// 둬 최후까지 보존, 배경 지식(brain RAG)과 memory 는 그보다 먼저 잘리게 한다.
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${selfGrowthIdentityCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${contextBlock}\n${knowledgeContextForPrompt}\n${memoryCtx}\n[/CONTEXT]\n${negativeCtx}`;
}
+25
View File
@@ -24,6 +24,8 @@ import { appendReflection } from '../../intelligence/reflectionStore';
import { detectGaps } from '../../intelligence/gapDetector';
import { appendSuccessPattern } from '../../intelligence/skillScore';
import { getConfig } from '../../config';
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
const devilRebuttalHook: PostAnswerHook = {
id: 'devil-rebuttal',
@@ -226,6 +228,28 @@ const criticLoopHook: PostAnswerHook = {
},
};
/**
* 인벤토리 자동 대조 — 자기 평가/자기 분석 턴에서 답변이 *이미 구현된 기능*을
* 신규 도입하라고 제안하면 결정론적으로(LLM 콜 0) 정정 푸터를 붙인다.
*
* 배경: 인벤토리를 프롬프트에 주입해도 작은 로컬 모델이 mid-prompt 컨텍스트를
* 무시하고 "Reflection Layer 도입하라"(이미 Self-Reflector 3단계 구현) 같은
* 제안을 반복하는 실패가 재현됐다. 주입은 1차 방어선 — 이 훅이 최종 방어선.
*/
const inventoryCrossCheckHook: PostAnswerHook = {
id: 'inventory-cross-check',
runAsync: false,
run(ctx: PostAnswerHookContext): void {
if (!ctx.assistantAnswer || !ctx.assistantAnswer.trim()) return;
// 자기 대상 턴에만 — 일반 코딩/리서치 답변에 오탐 푸터를 붙이지 않게.
if (!(isSelfAssessRequest(ctx.userPrompt) || isAboutSelf(ctx.userPrompt))) return;
const hits = detectReimplementedProposals(ctx.assistantAnswer);
if (hits.length === 0) return;
const footer = formatReimplementationFooter(hits);
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
},
};
export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
devilRebuttalHook,
postHocSelfCheckHook,
@@ -233,6 +257,7 @@ export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
requirementCoverageHook,
confidenceEscalationHook,
criticLoopHook,
inventoryCrossCheckHook,
];
/** 모든 hook 을 안전하게 실행 — 한 hook 의 throw 가 다른 hook 막지 않음. */
+26 -2
View File
@@ -269,6 +269,23 @@ export interface IAgentConfig {
companyIntentAlignmentMode: 'off' | 'smart' | 'strict';
/** alignment 라운드 최대 횟수 (질문→답변 사이클). 1~5. */
companyIntentAlignmentMaxRounds: number;
/**
* URL 자동 수집 — 사용자 프롬프트에 http(s) URL 이 있으면 LLM 호출 전에
* 본문을 가져와 컨텍스트로 주입 (일반 챗 + 기업 모드 + alignment 공통 게이트).
* Bridge 추출 우선, 실패 시 직접 fetch 폴백.
*/
webAutoFetchEnabled: boolean;
/**
* Alignment 자가 조사 — openQuestions 를 사용자에게 보여주기 전에 두뇌를
* 검색해 스스로 답할 수 있는 질문을 걸러낸다. 끄면 기존 동작 (질문 즉시 노출).
*/
companyAlignmentSelfResearch: boolean;
/**
* Alignment 학습 루프 — 사용자가 alignment 라운드에서 직접 답한 Q/A 를
* 두뇌의 "Alignment Knowledge" 폴더에 노트로 저장. 다음 turn 의 자가 조사가
* 이 노트를 발견해 같은 질문을 두 번 묻지 않게 된다.
*/
companyAlignmentKnowledgeSave: boolean;
/**
* Pixel Office 시각화 패널을 사이드바에 표시할지 여부. UI layer 전용 —
* 끈다고 Agent 행동이 바뀌지 않는다. Off면 webview는 패널을 숨기고
@@ -538,6 +555,9 @@ export function getConfig(): IAgentConfig {
// 이유는 config 가 features/ 아래 모듈을 의존하면 의도치 않은 순환 import 가 생기기 때문.
// 둘이 어긋나면 안 되므로 변경 시 양쪽 같이 갱신.
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
webAutoFetchEnabled: cfg.get<boolean>('web.autoFetchUrls', true),
companyAlignmentSelfResearch: cfg.get<boolean>('company.alignmentSelfResearch', true),
companyAlignmentKnowledgeSave: cfg.get<boolean>('company.alignmentKnowledgeSave', true),
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', false),
hollowCheckEnabled: cfg.get<boolean>('hollowCheck.enabled', true),
hollowCheckAutoRetry: cfg.get<boolean>('hollowCheck.autoRetry', true),
@@ -557,8 +577,12 @@ export function getConfig(): IAgentConfig {
polishPersonaOverride: (cfg.get<string>('polishPersonaOverride', '') || '').trim(),
liveStreamTokens: cfg.get<boolean>('liveStreamTokens', true),
outputFormat: ((): 'plain' | 'markdown' => {
const v = (cfg.get<string>('outputFormat', 'plain') || 'plain').trim().toLowerCase();
return v === 'markdown' ? 'markdown' : 'plain';
// 기본 'markdown' — 채팅 webview 가 marked 로 렌더하므로 plain 이 기본이면
// 최종본(streamReplace)에서 ## 헤더가 제거되어 제목/본문이 같은 크기로
// 보이는 가독성 문제가 생긴다 (스트리밍 중에는 raw 가 그대로 렌더되어
// 멀쩡하다가 완료 순간 평문으로 변하는 증상).
const v = (cfg.get<string>('outputFormat', 'markdown') || 'markdown').trim().toLowerCase();
return v === 'plain' ? 'plain' : 'markdown';
})(),
chronicleAutoRecord: cfg.get<boolean>('chronicleAutoRecord', true),
lmStudioTopP: Math.max(0, Math.min(1, cfg.get<number>('lmStudio.sampling.topP', 0.9))),
+92 -4
View File
@@ -1,16 +1,33 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execSync } from 'child_process';
import { getConfig } from '../config';
import { logInfo, logWarn, logError } from '../utils';
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
/**
* HealthCheckMonitor: Periodically monitors the environment
* HealthCheckMonitor: Periodically monitors the environment
* (Ollama, Disk, API) to ensure the agent stays functional.
*
*
* v2.2.245: 머신 로컬 전제 조건 4종 추가 — Bridge 응답·두뇌 볼륨 마운트·
* git push 자격증명·Antigravity 확장 버전 정합. 한 세션에서 이 4가지가 전부
* 사고로 터진 적이 있는데 (URL 분석 실패 / 인벤토리 미생성 / 동기화 실패 /
* 구버전 실행), 전부 사후 디버깅으로만 발견됐다. 이제 readyBar ⚠️ 로 사전 가시화.
*
* Properly tracks the interval timer for cleanup on deactivation.
*/
export class HealthCheckMonitor {
private static intervalHandle: ReturnType<typeof setInterval> | null = null;
/** 마지막 검사 결과 — readyBar payload 가 읽어간다 (재검사 비용 없이). */
private static _lastReports: string[] = [];
/** 직전 토스트 내용 — 같은 경고를 10분마다 반복 토스트하지 않기 위한 dedupe. */
private static _lastToastKey = '';
public static get lastReports(): string[] {
return this._lastReports.slice();
}
public static async runAllChecks(): Promise<{ ok: boolean; reports: string[] }> {
const reports: string[] = [];
@@ -46,9 +63,80 @@ export class HealthCheckMonitor {
reports.push('Write permissions denied in the current workspace.');
}
// 4. Datacollect Bridge 응답 — 어떤 HTTP 응답이든(404 포함) 살아 있는 것.
// 네트워크 오류만 다운으로 판정. Bridge 다운이어도 URL 분석은 직접
// fetch 폴백으로 동작하므로 경고는 영향 범위를 정확히 알린다.
try {
const bridgeUrl = getBridgeBaseUrl();
if (bridgeUrl) {
try {
await fetch(bridgeUrl, { signal: AbortSignal.timeout(3000) });
} catch {
reports.push(`Datacollect Bridge(${bridgeUrl})가 응답하지 않습니다 — /wikify·/benchmark 와 고품질 URL 추출이 비활성 (URL 분석 자체는 직접 fetch 폴백으로 동작).`);
}
}
} catch { /* config 읽기 실패 등 — 검사 자체를 조용히 skip */ }
// 5. 두뇌 볼륨/경로 — 외장 볼륨이 늦게 마운트되면 검색·레슨·인벤토리가
// 조용히 비활성화되던 사고의 사전 감지.
let brain: ReturnType<typeof getActiveBrainProfile> | undefined;
try {
brain = getActiveBrainProfile();
if (brain?.localBrainPath && !fs.existsSync(brain.localBrainPath)) {
reports.push(`두뇌 폴더가 없습니다 (외장 볼륨 미마운트?): ${brain.localBrainPath} — 검색·레슨·인벤토리가 비활성화됩니다.`);
}
} catch { /* profile 읽기 실패 — skip */ }
// 6. git push 자격증명 — 원격 동기화를 *설정한* 두뇌만 검사 (secondBrainRepo
// 비어 있으면 두뇌 동기화가 로컬 새로고침 모드라 자격증명 불필요).
try {
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
try {
execSync('git push --dry-run', {
cwd: brain.localBrainPath,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
timeout: 5000,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (e: any) {
const msg = String(e?.stderr || e?.message || '');
if (/could not read Username|Authentication failed|terminal prompts disabled/i.test(msg)) {
reports.push(`두뇌 원격 push 자격증명이 없습니다 — 터미널에서 "cd ${brain.localBrainPath} && git push" 1회 실행으로 키체인에 저장하세요.`);
}
// 그 외(오프라인·upstream 없음 등)는 소음 방지 — 보고하지 않음.
}
}
} catch { /* skip */ }
// 7. Antigravity 확장 버전 정합 — VS Code 와 Antigravity 양쪽에 설치된
// 경우 버전이 갈리면 "고쳤는데 안 고쳐짐" 혼란의 단골 원인 (실사례:
// 2.2.19 vs 2.2.232 공존). Antigravity 미사용 머신에서는 자동 skip.
try {
const agExtRegistry = path.join(os.homedir(), '.antigravity', 'extensions', 'extensions.json');
if (fs.existsSync(agExtRegistry)) {
const arr = JSON.parse(fs.readFileSync(agExtRegistry, 'utf8'));
const mine = Array.isArray(arr)
? arr.find((e: any) => e?.identifier?.id === 'g1nation.astra')
: null;
const myVersion = vscode.extensions.getExtension('g1nation.astra')?.packageJSON?.version;
if (mine?.version && myVersion && mine.version !== myVersion) {
reports.push(`Antigravity 에 다른 버전의 Astra(${mine.version})가 설치되어 있습니다 — 현재 실행 중 ${myVersion}. Antigravity 쪽은 수동 업데이트가 필요합니다.`);
}
}
} catch { /* registry 파싱 실패 — skip */ }
this._lastReports = reports;
if (reports.length > 0) {
logWarn(`Health Check Warnings: ${reports.join(' | ')}`);
vscode.window.showWarningMessage(`Astra Health Warning: ${reports[0]}`);
// 동일 경고 반복 토스트 방지 — 내용이 바뀐 경우에만 1회 토스트.
const toastKey = reports.join('|');
if (toastKey !== this._lastToastKey) {
this._lastToastKey = toastKey;
vscode.window.showWarningMessage(`Astra Health Warning: ${reports[0]}${reports.length > 1 ? ` (외 ${reports.length - 1}건 — 준비 상태 바 참조)` : ''}`);
}
} else {
this._lastToastKey = '';
}
return {
+136
View File
@@ -0,0 +1,136 @@
/**
* 기능 개념 지도 — vscode 의존 없는 순수 모듈 (테스트 용이).
*
* 두 소비처:
* 1. featureInventory.ts — "ASTRA 기능 인벤토리" 마크다운 생성 (LLM 프롬프트 주입용)
* 2. postAnswerHooks inventory-cross-check — 답변이 *이미 구현된 기능*을 신규
* 제안하는지 결정론적으로 검사해 정정 푸터를 붙임
*
* 2번이 존재하는 이유: 인벤토리를 프롬프트에 주입해도 작은 로컬 모델은 mid-prompt
* 컨텍스트를 무시하고 일반론으로 "X를 도입하라"고 제안하는 실패가 반복 재현됐다.
* 프롬프트 주입은 1차 방어선일 뿐 — 모델이 무시해도 사용자에게 정정이 보이는
* 결정론적 안전망(LLM 콜 0)이 최종 방어선이다.
*/
export interface ConceptEntry {
/** 학술/일반 명칭 (마크다운 표기용). */
concept: string;
/** ASTRA 구현 내역 한 줄. */
impl: string;
/** 답변 본문에서 이 개념을 식별하는 키워드 (소문자 비교, 한·영). */
keywords: string[];
}
export const CONCEPT_ENTRIES: ConceptEntry[] = [
{
concept: 'CoVe / Chain-of-Verification / Self-Critique',
impl: '구현됨 — coveEnabled(답변 전 그라운딩 체크리스트) + critic-loop 훅(문제 신호 턴 LLM 검수) + citationTrace(출처 역추적)',
keywords: ['cove', 'chain-of-verification', 'self-critique', 'selfcritique', 'self-correction', '자기 검증', '자기검증', '자기 수정', '크리틱 에이전트', '비판적 사고 루프', '스스로 검토하는 단계'],
},
{
concept: '지식 노후 점검 자동화 / Automated Decay Audit',
impl: '구현됨 — 주간 성장 사이클이 매주 자동 실행 (decay-report.md) + "Astra: 지식 노후 점검" 수동 명령',
keywords: ['노후화', '노후 점검', 'decay', '지식 신선도'],
},
{
concept: '지식 충돌 감지/해결 / Conflict Resolver',
impl: '구현됨 — 검색 시점 [CONFLICT WARNING] + 일일 충돌 스캔 + 신뢰도(trust·confidence·최신성) 비교 우선 권고. 최종 결정만 사람',
keywords: ['충돌 감지', '충돌 해결', 'conflict resolver', '상충되는 정보 발견'],
},
{
concept: '피드백 태깅 / 오류 분류 / Feedback Tagging',
impl: '구현됨 — Correction Loop가 사용자 정정을 자동 분류(사실오류/근거누락/맥락누락/추론오류/지시불이행/형식오류)해 레슨+회귀 케이스로 저장',
keywords: ['피드백 태깅', '피드백 루프 자동화', '오류 분류', '교훈으로 자동', '자동으로 \'교훈\'', '교훈을 자동', 'feedback tagging'],
},
{
concept: '멀티스텝 플래닝 / Multi-Step Planning / CoT 강제',
impl: '구현됨 — multiAgentEnabled(Planner→Researcher→Writer, 기본 OFF) + 1인 기업 모드 디스패처',
keywords: ['멀티스텝 플래닝', 'multi-step planning', 'cot 강제', 'reasoning chain', '추론 체인', 'chain of thought', '생각 단계(thought)', '생각의 단계'],
},
{
concept: '골든셋 자동 평가 / Regression Test',
impl: '구현됨 — 주간 사이클 자동 평가 + 직전 대비 회귀 경보(regression-alert.md) + 정정 회귀 재검사',
keywords: ['골든셋', '회귀 테스트', 'regression test'],
},
{
concept: 'Sleep-time / 유휴 시간 학습',
impl: '구현됨 — 일일 지식 사전 소화 (Digests/)',
keywords: ['sleep-time', '유휴 시간 학습', '유휴시간 학습'],
},
{
concept: '확신도 게이팅 / 환각 방지 표명',
impl: '구현됨 — [GROUNDING] 강함/보통/약함 + 약함 시 표명 강제 + 학습큐 자동 등록',
keywords: ['확신도 게이팅', '환각 방지', '확신 편향'],
},
{
concept: 'Reflection Layer / 자기 성찰 / 메타 학습 루프 / Self-Reflection',
impl: '구현됨 — Self-Reflector Phase A(답변 자가 점검 블록, opt-in) + Phase B(외부 검증 LLM + 자동 재시도) + Phase C(생성 파일 syntax 검증) + Hollow Code Check(빈 깡통 감지 + 자동 재작업) + 약점 프로필→자기검토 블록(최근 정정 통계가 다음 턴 행동을 직접 변경)',
keywords: ['reflection layer', 'self-reflection', '자기 성찰', '성찰 계층', '성찰 단계', '성찰(reflection)', '메타 학습'],
},
{
concept: '질문 전 자가 조사 / Self-Research / 경험 기반 제약 주입',
impl: '구현됨 — Intent Alignment 자가 조사(질문을 사용자에게 노출하기 전 두뇌 검색으로 선해결) + 사용자 답변 두뇌 자동 저장(Alignment Knowledge 학습 루프) + 레슨 체크리스트 truncation 보호 구역 주입',
keywords: ['자가 조사', 'self-research', '경험 기반 제약'],
},
];
/**
* 신규 도입/추가/구축을 제안하는 문장인지 (또는 부재를 단정하는 문장).
* '합' 포함 — "추가합니다/구축합니다" 활용형 미매치 갭 (회귀 테스트로 발견).
* '강화' 동사 + "도입:" 명사형(소제목 스타일) 추가 — 12B 답변의
* "Reasoning Chain 도입: ..." 패턴 미매치 갭 (회귀 테스트로 발견).
*/
const PROPOSAL_RE = /(도입|추가|구축|신설|개발|보강|강화|만들)(하|해|할|합|을|를)|(도입|추가|구축|신설)\s*[::]|필요합니다|제안합니다|추천합니다|부족합니다|부재|없습니다|미흡/;
/** 같은 문장에서 이미 구현 사실을 인지하고 있으면 정정 불필요. */
const ACKNOWLEDGED_RE = /(이미|기)\s*(구현|존재|있)|구현돼|구현되어|작동\s*중/;
export interface ReimplementationHit {
concept: string;
impl: string;
/** 감지된 문장 (검증/디버그용, 120자 cap). */
sentence: string;
}
/**
* 답변이 이미 구현된 기능을 "도입하라/없다"고 제안·단정하는 문장을 결정론적으로
* 감지. LLM 콜 0 — 문장 단위 키워드 + 제안 동사 공기(co-occurrence) 검사.
* 보수적 설계: 같은 문장에 "이미 구현/존재" 인지가 있으면 제외 (정정 노이즈 방지).
*/
export function detectReimplementedProposals(answer: string): ReimplementationHit[] {
if (!answer || !answer.trim()) return [];
// 문장 분리 — 마침표/줄바꿈/콜론 기준의 느슨한 분할이면 충분.
const sentences = answer.split(/(?<=[.!?다요음됨])\s+|\n+/);
const hits: ReimplementationHit[] = [];
const seen = new Set<string>();
for (const sentence of sentences) {
const lower = sentence.toLowerCase();
if (!PROPOSAL_RE.test(sentence)) continue;
if (ACKNOWLEDGED_RE.test(sentence)) continue;
for (const entry of CONCEPT_ENTRIES) {
if (seen.has(entry.concept)) continue;
if (entry.keywords.some((k) => lower.includes(k.toLowerCase()))) {
seen.add(entry.concept);
hits.push({
concept: entry.concept,
impl: entry.impl,
sentence: sentence.trim().slice(0, 120),
});
}
}
}
return hits;
}
/** 정정 푸터 마크다운 — 빈 hits 면 ''. */
export function formatReimplementationFooter(hits: ReimplementationHit[]): string {
if (hits.length === 0) return '';
const lines: string[] = [];
lines.push('\n\n---');
lines.push('⚠️ **기능 인벤토리 자동 대조** (결정론적 검사 — LLM 아님): 위 답변이 신규 도입을 제안한 항목 중 다음은 **이미 구현되어 있습니다**.');
for (const h of hits) {
lines.push(`- **${h.concept}** → ${h.impl}`);
}
lines.push('');
lines.push('_위 제안을 채택하기 전에 기존 구현을 먼저 확인하세요. (두뇌의 "ASTRA 기능 인벤토리.md" 참조)_');
return lines.join('\n');
}
+25 -13
View File
@@ -32,20 +32,14 @@ const HOOK_DESCRIPTIONS: Record<string, string> = {
};
/**
* 학술/일반 개념 명칭 ↔ ASTRA 구현 매핑. 자기 개선 제안에서 모델이 학술 명칭
* ("CoVe 도입하라")으로 제안할 때 설정 키(coveEnabled)와 같은 것임을 모르는
* 이름 매핑 갭을 막는다. 코드와 함께 배포되므로 릴리스마다 자동 최신화.
* 학술/일반 개념 명칭 ↔ ASTRA 구현 매핑 — featureConceptMap.ts 로 이전 (순수
* 모듈). 인벤토리 마크다운과 inventory-cross-check 훅(결정론적 재구현 제안
* 감지)이 같은 정본을 공유한다.
*/
const CONCEPT_MAP: Array<[concept: string, impl: string]> = [
['CoVe / Chain-of-Verification / Self-Critique', '구현됨 — coveEnabled(답변 전 그라운딩 체크리스트) + critic-loop 훅(문제 신호 턴 LLM 검수) + citationTrace(출처 역추적)'],
['지식 노후 점검 자동화 / Automated Decay Audit', '구현됨 — 주간 성장 사이클이 매주 자동 실행 (decay-report.md)'],
['지식 충돌 감지/해결 / Conflict Resolver', '구현됨 — 검색 시점 [CONFLICT WARNING] + 일일 충돌 스캔 + 신뢰도(trust·confidence·최신성) 비교 우선 권고. 최종 결정만 사람'],
['피드백 태깅 / 오류 분류 / Feedback Tagging', '구현됨 — Correction Loop가 사용자 정정을 자동 분류(사실오류/근거누락/맥락누락/추론오류/지시불이행/형식오류)해 레슨+회귀 케이스로 저장'],
['멀티스텝 플래닝 / Multi-Step Planning / CoT 강제', '구현됨 — multiAgentEnabled(Planner→Researcher→Writer, 기본 OFF) + 1인 기업 모드 디스패처'],
['골든셋 자동 평가 / Regression Test', '구현됨 — 주간 사이클 자동 평가 + 직전 대비 회귀 경보(regression-alert.md) + 정정 회귀 재검사'],
['Sleep-time / 유휴 시간 학습', '구현됨 — 일일 지식 사전 소화 (Digests/)'],
['확신도 게이팅 / 환각 방지 표명', '구현됨 — [GROUNDING] 강함/보통/약함 + 약함 시 표명 강제 + 학습큐 자동 등록'],
];
import { CONCEPT_ENTRIES } from './featureConceptMap';
const CONCEPT_MAP: Array<[concept: string, impl: string]> =
CONCEPT_ENTRIES.map((e) => [e.concept, e.impl]);
function stripMd(s: string): string {
return (s || '').replace(/\*\*|`|\[|\]/g, '').replace(/\s+/g, ' ').trim();
@@ -88,6 +82,24 @@ export function buildInventoryMarkdown(pkg: any, nowIso: string): string {
'아래 개념들은 명칭이 달라도 **이미 구현되어 있다**. 이들을 "도입/추가하라"고 제안하면 오답이다:',
...CONCEPT_MAP.map(([concept, impl]) => `- **${concept}**: ${impl}`),
'',
'## 📖 학습 메커니즘 — 정본 (자기 학습 방식 질문에는 이 섹션만 근거로 답할 것)',
'',
'**대원칙: 모델 가중치는 절대 학습되지 않는다.** 모든 "학습"은 두뇌 폴더',
'(설정된 localBrainPath — ConnectAI 프로젝트 루트가 아님)에 *파일로* 쌓이고,',
'다음 턴의 검색·프롬프트 주입을 통해 행동이 바뀌는 **외부 기억 기반** 방식이다.',
'두뇌 폴더를 지우면 학습도 사라진다. "패턴을 학습해 선제적으로 제시한다" 류의',
'서술은 거짓이다 — 선제 제시 엔진은 존재하지 않는다.',
'',
'자동 학습 회로는 정확히 다음 5가지뿐이다:',
'1. **Correction Loop** — 사용자 정정 발화 감지 → 오류 유형 자동 분류 → 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl) 저장',
'2. **레슨(Experience Memory)** — 과거 실수가 "실패 방지 체크리스트"로 다음 턴 프롬프트에 주입 (truncation 보호 구역)',
'3. **약점 프로필** — 최근 정정 통계가 자기검토 블록을 통해 다음 턴 행동을 직접 변경',
'4. **Alignment Knowledge** — 1인 기업 모드 질문에 사용자가 답해준 내용을 두뇌 "Alignment Knowledge" 폴더에 저장 → 같은 질문 재발 방지',
'5. **주간 성장 사이클** — decay 리포트(지식 노후), 골든셋 회귀 평가, 학습 큐 갱신',
'',
'보조 기억(학습이 아닌 *기억*): 단기 대화 히스토리, [PRIOR TURN CONCLUSION] 앵커,',
'세션 요약(중기), 두뇌 RAG(장기), Project Chronicle(기록 생성 — 검색 두뇌와 별개).',
'',
];
return lines.join('\n');
}
+311
View File
@@ -0,0 +1,311 @@
/**
* Alignment Self-Research — 사용자에게 묻기 전에 두뇌를 먼저 검색.
*
* Intent Alignment 분석기가 만든 openQuestions 를 사용자에게 노출하기 전에,
* 활성 두뇌(지식 폴더)를 TF-IDF 로 검색해 *스스로 답할 수 있는 질문* 을 걸러낸다.
* 답을 찾은 질문은 answeredQuestions 로 옮겨지고(자가 조사 marker 부착),
* 정말 모르는 질문만 사용자에게 도달한다.
*
* Phase 3(학습 루프): 사용자가 직접 답해준 Q/A 는 두뇌의 전용 폴더에 일반
* 노트로 저장된다. 다음 turn 에서 같은 질문이 나오면 이 모듈의 자가 조사가
* 그 노트를 발견해 스스로 해결 — 같은 것을 두 번 묻지 않는 구조.
*
* 설계 원칙 (intentAlignment 와 동일):
* - 절대 throw 하지 않는다. 검색/LLM/IO 실패는 "자가 조사 안 한 것"과
* 동일하게 동작해야 한다 — alignment 흐름 차단 금지.
* - Lesson(Experience Memory) 시스템과 분리 — 그쪽은 "실수 회피" 카드라
* 주입 위치/의미가 다르다. 여기 저장물은 평범한 지식 노트.
*/
import * as fs from 'fs';
import * as path from 'path';
import { IAIService } from '../../core/services';
import { findBrainFiles, logError, logInfo } from '../../utils';
import { getBrainTokenIndex } from '../../retrieval/brainIndex';
import { tokenize, expandQuery, scoreTfIdfPreTokenized, extractBestExcerpt } from '../../retrieval/scoring';
/**
* 자가 조사로 채워진 답변의 식별 prefix. formatContractForPrompt 를 거쳐
* dispatcher 의 LLM 에게 그대로 전달되므로, "사용자가 직접 말한 사실"과
* 구분되도록 의미를 텍스트 자체에 내장한다. webview 카드 렌더에서도 이
* prefix 로 자가 조사 항목을 골라낸다.
*/
export const SELF_RESEARCH_PREFIX = '(자가 조사로 두뇌에서 확인) ';
/** 두뇌 저장 폴더 이름 — Phase 3 의 지식 노트가 쌓이는 곳. */
export const ALIGNMENT_KNOWLEDGE_DIR = 'Alignment Knowledge';
/** 질문 1개에 대해 두뇌에서 모은 근거 발췌. */
export interface QuestionEvidence {
question: string;
excerpts: Array<{ title: string; relativePath: string; excerpt: string }>;
}
/** 질문별 검색 상한 — 질문당 top 2 파일, 발췌 600자, 전체 합계 4,000자. */
const FILES_PER_QUESTION = 2;
const EXCERPT_MAX_CHARS = 600;
const TOTAL_EVIDENCE_CAP = 4000;
/**
* openQuestions 각각을 두뇌에 TF-IDF 검색해 근거 발췌를 모은다.
* 빈 두뇌 / 잘못된 경로 / IO 에러 → 해당 질문의 excerpts 가 빈 배열 (throw 금지).
* 인덱스는 brainIndex 의 mtime 캐시를 그대로 활용하므로 재호출 비용이 낮다.
*/
export function gatherEvidenceForQuestions(
brainPath: string,
questions: string[],
): QuestionEvidence[] {
const empty = questions.map((q) => ({ question: q, excerpts: [] as QuestionEvidence['excerpts'] }));
if (!brainPath || questions.length === 0) return empty;
try {
const files = findBrainFiles(brainPath);
if (files.length === 0) return empty;
const index = getBrainTokenIndex(brainPath, files);
if (index.length === 0) return empty;
let totalChars = 0;
return questions.map((question) => {
const queryTokens = expandQuery(tokenize(question));
if (queryTokens.length === 0) return { question, excerpts: [] };
const scored = scoreTfIdfPreTokenized(queryTokens, index)
.filter((s) => s.score > 0)
.slice(0, FILES_PER_QUESTION);
const excerpts: QuestionEvidence['excerpts'] = [];
for (const s of scored) {
if (totalChars >= TOTAL_EVIDENCE_CAP) break;
const doc = index[s.index];
let content = '';
try {
content = fs.readFileSync(doc.filePath, 'utf8');
} catch {
continue; // 인덱스에 있는데 디스크에서 사라진 파일 — skip
}
const excerpt = extractBestExcerpt(content, tokenize(question), EXCERPT_MAX_CHARS);
if (!excerpt.trim()) continue;
totalChars += excerpt.length;
excerpts.push({ title: doc.title, relativePath: doc.relativePath, excerpt });
}
return { question, excerpts };
});
} catch (e: any) {
logError('alignmentResearch: evidence gathering failed; skipping self-research.', {
error: e?.message ?? String(e),
});
return empty;
}
}
const SELF_ANSWER_SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *자가 조사 판정가*입니다. 사용자에게 질문을 던지기 전에, 두뇌(저장된 지식 노트)에서 검색된 근거만으로 각 질문에 답할 수 있는지 판정합니다.
규칙:
- 근거 발췌에 *명시적으로 적혀 있는* 내용으로만 답하세요. 일반 상식·추측으로 채우는 것은 금지입니다.
- 근거가 부분적이거나 모호하면 그 질문은 "unanswered" 입니다. 확신이 없으면 unanswered 가 정답입니다.
- answered 인 질문의 answer 는 근거에서 추출한 사실을 2~3문장으로 요약하고, 출처 노트 제목을 괄호로 덧붙이세요.
⚠️ 반드시 아래 JSON 한 번만 출력. 다른 텍스트(설명·코드펜스·머리말) 일체 금지.
{
"answers": [
{ "question": "<원문 그대로>", "status": "answered" | "unanswered", "answer": "<answered 일 때만, 아니면 빈 문자열>" }
]
}`;
/** 자가 조사 판정 결과 한 건. */
export interface SelfAnswer {
question: string;
answered: boolean;
answer: string;
}
/**
* LLM 1회 호출로 "근거만으로 답 가능한 질문"을 판정. 호출/파싱 실패 시
* 모든 질문을 unanswered 로 반환 — 원래의 질문 카드 흐름이 그대로 동작.
*/
export async function selfAnswerQuestions(
ai: IAIService,
input: {
userPrompt: string;
evidence: QuestionEvidence[];
model?: string;
timeoutMs?: number;
},
): Promise<SelfAnswer[]> {
const withEvidence = input.evidence.filter((e) => e.excerpts.length > 0);
const fallback = input.evidence.map((e) => ({ question: e.question, answered: false, answer: '' }));
if (withEvidence.length === 0) return fallback;
const lines: string[] = [];
lines.push('[사용자 원본 요청]');
lines.push(input.userPrompt);
lines.push('');
lines.push('[판정할 질문과 두뇌 검색 근거]');
for (const e of withEvidence) {
lines.push('');
lines.push(`질문: ${e.question}`);
for (const x of e.excerpts) {
lines.push(`- 근거 (노트: "${x.title}", 경로: ${x.relativePath}):`);
lines.push(` ${x.excerpt.replace(/\n/g, '\n ')}`);
}
}
lines.push('');
lines.push('판정 JSON만 출력:');
let raw = '';
try {
const result = await ai.chat({
system: SELF_ANSWER_SYSTEM_PROMPT,
user: lines.join('\n'),
model: input.model,
timeoutMs: input.timeoutMs,
});
raw = result.content || '';
} catch (e: any) {
logError('alignmentResearch: self-answer call failed; all questions pass through.', {
error: e?.message ?? String(e),
});
return fallback;
}
const parsed = _parseSelfAnswerJson(raw);
if (!parsed) {
logInfo('alignmentResearch: self-answer parse failed; all questions pass through.', {
rawHead: raw.slice(0, 100),
});
return fallback;
}
// LLM 출력을 원래 질문 목록에 매핑 — 누락된 질문은 unanswered 로 채움.
const byQuestion = new Map(parsed.map((a) => [a.question.trim(), a]));
return input.evidence.map((e) => {
const hit = byQuestion.get(e.question.trim());
if (hit && hit.status === 'answered' && hit.answer.trim()) {
return { question: e.question, answered: true, answer: hit.answer.trim() };
}
return { question: e.question, answered: false, answer: '' };
});
}
/** 4-stage 관용 파서 — intentAlignment 와 동일 패턴 (작은 모델의 펜스/머리말 대응). */
export function _parseSelfAnswerJson(raw: string): Array<{
question: string;
status: 'answered' | 'unanswered';
answer: string;
}> | null {
if (!raw || !raw.trim()) return null;
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
const stage1 = (fenced ? fenced[1] : raw).trim();
for (const candidate of [stage1, _extractFirstBalancedObject(stage1)]) {
if (!candidate) continue;
try {
const obj = JSON.parse(candidate);
const coerced = _coerceSelfAnswers(obj);
if (coerced) return coerced;
} catch { /* 다음 stage */ }
}
return null;
}
function _coerceSelfAnswers(obj: unknown): ReturnType<typeof _parseSelfAnswerJson> {
if (!obj || typeof obj !== 'object') return null;
const answers = (obj as Record<string, unknown>).answers;
if (!Array.isArray(answers)) return null;
const out: Array<{ question: string; status: 'answered' | 'unanswered'; answer: string }> = [];
for (const a of answers) {
if (!a || typeof a !== 'object') continue;
const r = a as Record<string, unknown>;
const question = typeof r.question === 'string' ? r.question.trim() : '';
if (!question) continue;
const status = r.status === 'answered' ? 'answered' : 'unanswered';
const answer = typeof r.answer === 'string' ? r.answer.trim() : '';
out.push({ question, status, answer });
}
return out.length > 0 ? out : null;
}
function _extractFirstBalancedObject(s: string): string | null {
const start = s.indexOf('{');
if (start === -1) return null;
let depth = 0;
let inString = false;
let escape = false;
for (let i = start; i < s.length; i++) {
const ch = s[i];
if (inString) {
if (escape) escape = false;
else if (ch === '\\') escape = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') { inString = true; continue; }
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return s.slice(start, i + 1);
}
}
return null;
}
// ─── Phase 3: 사용자 답변의 두뇌 저장 (학습 루프) ────────────────────────────
/** 저장 대상 필터 — 사용자가 *직접* 답한 실질적 정보만. */
const MIN_ANSWER_CHARS = 20;
/**
* 사용자가 alignment 라운드에서 직접 답해준 Q/A 를 두뇌의 전용 폴더에 일반
* 노트로 저장한다. 자가 조사 항목(SELF_RESEARCH_PREFIX)과 20자 미만의 짧은
* 답은 제외 — 두뇌 오염 방지. 같은 날 같은 요청의 노트가 이미 있으면 skip.
*
* @returns 저장된 파일 경로, 저장할 것이 없거나 실패하면 null (throw 금지).
*/
export function saveAlignmentKnowledge(
brainPath: string,
input: { userPrompt: string; qaList: Array<{ q: string; a: string }> },
): string | null {
try {
if (!brainPath || !fs.existsSync(brainPath)) return null;
const userAnswered = input.qaList.filter(
(qa) => !qa.a.startsWith(SELF_RESEARCH_PREFIX) && qa.a.trim().length >= MIN_ANSWER_CHARS,
);
if (userAnswered.length === 0) return null;
const date = new Date();
const ymd = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const slug = _slugify(input.userPrompt, 30);
const dir = path.join(brainPath, ALIGNMENT_KNOWLEDGE_DIR);
const filePath = path.join(dir, `${ymd} ${slug}.md`);
if (fs.existsSync(filePath)) return null; // 같은 turn 재진입/재실행 — 중복 저장 방지
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const body: string[] = [];
body.push(`# ${input.userPrompt.replace(/\s+/g, ' ').trim().slice(0, 50)}`);
body.push('');
body.push(`> 1인 기업 모드 Intent Alignment 에서 사용자가 직접 제공한 정보. (${ymd})`);
body.push('');
body.push('## 원본 요청');
body.push(input.userPrompt.trim());
body.push('');
body.push('## 확인된 정보');
for (const qa of userAnswered) {
body.push(`### Q. ${qa.q.replace(/\n/g, ' ').trim()}`);
body.push(qa.a.trim());
body.push('');
}
fs.writeFileSync(filePath, body.join('\n').trimEnd() + '\n', 'utf8');
logInfo('alignmentResearch: saved user-provided knowledge to brain.', { filePath });
return filePath;
} catch (e: any) {
logError('alignmentResearch: knowledge save failed (non-fatal).', {
error: e?.message ?? String(e),
});
return null;
}
}
/** 파일명 안전 slug — 한글 보존, 경로 위험 문자만 제거. */
export function _slugify(text: string, maxChars: number): string {
const cleaned = text
.replace(/\s+/g, ' ')
.trim()
.replace(/[\\/:*?"<>|#%{}$!'@`+=[\]]/g, '')
.slice(0, maxChars)
.trim();
return cleaned || 'alignment';
}
+47 -16
View File
@@ -215,6 +215,30 @@ export interface DispatcherDeps {
* 'off'였던 경우.
*/
requirementContract?: RequirementContract;
/**
* 현재 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약, 호출자가
* 절단해 전달). 일반 챗은 이 컨텍스트를 자동 주입받지만 기업 모드는 빠져
* 있던 공백 수정 — specialist 가 "이 프로젝트가 뭐냐"를 추측하지 않게.
* contract 와 같은 4개 지점(planner/specialist/verifier/inspector)에 prepend.
*/
architectureContextBlock?: string;
/**
* 사용자 프롬프트에 포함된 URL 의 pre-fetch 결과 ([URL CONTENT] 블록).
* 기업 모드에는 continuation loop 가 없어 LLM 주도 fetch 결과를 재분석할
* 기회가 없으므로, dispatch 전에 호출자가 가져와 모든 에이전트에게 배포.
*/
webContextBlock?: string;
}
/**
* deps 의 자동 수집 컨텍스트(architecture + web)를 한 prefix 문자열로 합성.
* 둘 다 없으면 빈 문자열 — 기존 동작과 100% 동일.
*/
function buildExtraContextPrefix(deps: DispatcherDeps): string {
const blocks = [deps.architectureContextBlock, deps.webContextBlock]
.map((b) => (b || '').trim())
.filter(Boolean);
return blocks.length > 0 ? blocks.join('\n\n') : '';
}
/**
@@ -353,11 +377,13 @@ export async function runCompanyTurn(
};
} else {
const ceoModel = modelForAgent(state, 'ceo', deps.defaultModel);
const plannerExtraPrefix = buildExtraContextPrefix(deps);
const plannerContract = deps.requirementContract
? formatContractForPrompt(deps.requirementContract)
: undefined;
const plannerResult = await runCeoPlanner(deps.ai, userPrompt, state, {
model: ceoModel,
contractBlock: deps.requirementContract
? formatContractForPrompt(deps.requirementContract)
: undefined,
contractBlock: [plannerExtraPrefix, plannerContract].filter(Boolean).join('\n\n') || undefined,
signal: deps.signal,
});
plan = plannerResult.plan;
@@ -666,11 +692,13 @@ async function _dispatchOne(
peerOutputs,
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
// alignment 단계에서 도출된 contract가 deps에 있으면 모든 specialist의
// system 프롬프트에 같은 ground truth로 prepend된다. 추측 방지.
contractBlock: deps.requirementContract
? formatContractForPrompt(deps.requirementContract)
: undefined,
// alignment 단계에서 도출된 contract + 자동 수집 컨텍스트(architecture/web)가
// deps에 있으면 모든 specialist의 system 프롬프트에 같은 ground truth로
// prepend된다. 추측 방지.
contractBlock: [
buildExtraContextPrefix(deps),
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
].filter(Boolean).join('\n\n') || undefined,
});
// 우선순위: stage > agent > global default.
const model = (stageModelOverride && stageModelOverride.trim())
@@ -696,9 +724,10 @@ async function _dispatchOne(
// 옛 dynamic import 8회 → 정적 import 로 promote (파일 상단). 모듈 자체 cyclic 없음.
const cfgRuntime = getDispatcherConfig();
if (cfgRuntime.selfReflectorExternalEnabled && rawResponse) {
const contractBlock = deps.requirementContract
? formatContractForPrompt(deps.requirementContract)
: undefined;
const contractBlock = [
buildExtraContextPrefix(deps),
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
].filter(Boolean).join('\n\n') || undefined;
const verdict = await verifyResponse(deps.ai, {
task,
response: rawResponse,
@@ -1048,11 +1077,13 @@ async function _runReviewCycle(args: {
return { verdict: 'aborted', rounds: round - 1 };
}
const startedAt = Date.now();
// contract가 있으면 검수자/CEO 모두에게 같은 ground truth를 prepend —
// 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
const contractPrefix = deps.requirementContract
? formatContractForPrompt(deps.requirementContract) + '\n\n'
: '';
// contract + 자동 수집 컨텍스트가 있으면 검수자/CEO 모두에게 같은 ground
// truth를 prepend — 검수 기준이 contract와 일치하는지를 정확히 평가할 수 있다.
const contractPrefixParts = [
buildExtraContextPrefix(deps),
deps.requirementContract ? formatContractForPrompt(deps.requirementContract) : '',
].filter(Boolean).join('\n\n');
const contractPrefix = contractPrefixParts ? contractPrefixParts + '\n\n' : '';
// ── 1) 검수자 LLM 콜 ──
const inspectorSystem = contractPrefix + '당신은 산출물 *감리*입니다. 작업자의 결과물을 객관적으로 검토하고 한국어 마크다운으로 응답하세요.\n\n반드시 첫 줄을 다음 둘 중 하나로 시작:\n - ✅ 통과 — 산출물이 task 요구 + 위 contract의 criteria를 모두 충족하면.\n - ❌ 보완 필요: <구체 항목 한 줄> — contract 기준 누락·오류·약점이 있으면.\n\n그 다음 줄들에 *구체적인* 피드백 또는 칭찬 1~3줄. 모호한 일반론 금지.';
+7
View File
@@ -95,6 +95,13 @@ export type { ChatIntent, IntentContext, IntentResult, PipelineHint } from './in
export { analyzeIntent, formatContractForPrompt } from './intentAlignment';
export type { IntentAnalysisInput, IntentAnalysisResult } from './intentAlignment';
export {
gatherEvidenceForQuestions,
selfAnswerQuestions,
saveAlignmentKnowledge,
SELF_RESEARCH_PREFIX,
} from './alignmentResearch';
export type { QuestionEvidence, SelfAnswer } from './alignmentResearch';
export type { RequirementContract } from './types';
export {
+20
View File
@@ -71,6 +71,14 @@ export interface IntentAnalysisInput {
* 없으면 undefined (첫 진입 / 모드 토글 없는 케이스).
*/
priorChatSummary?: string;
/**
* 현재 열린 워크스페이스의 아키텍처 컨텍스트 (architecture.md 요약).
* 사용자가 *지금 열어둔 프로젝트* 에 대해 요청하는 경우가 많은데, 분석기가
* 이걸 못 보면 "그 프로젝트가 뭐냐"는 — 워크스페이스가 이미 답하고 있는 —
* 질문을 던진다. 첫 라운드에만 첨부 (후속 라운드는 contract 에 흡수됨).
* 작은 모델 보호를 위해 호출자가 3,000자 내외로 절단해 전달.
*/
projectContext?: string;
}
const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입니다. 사용자의 자연어 요청을 받아 그것을 실행 가능한 작업 조건 5가지(C-G-C-F-Q)로 정리합니다.
@@ -85,6 +93,8 @@ const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입
⚠️ **[모드 전환 시 context 우선 추출]**: 입력에 \`[모드 전환 직전 일반 채팅 요약]\` 블록이 있으면, 그것을 **사용자의 한 줄과 같은 권위로** 취급하세요. 거기서 context/goal/criteria/format 을 *직접 추출* 한 뒤, 그래도 빠진 항목만 openQuestions 에 넣으세요. 사용자가 이미 일반 채팅에서 충분히 설명한 내용을 다시 물어보면 안 됩니다 — 일반 채팅에서 *명시적으로 언급* 된 항목은 추측이 아니라 **명시된 사실** 입니다.
⚠️ **[프로젝트 컨텍스트 우선 활용]**: 입력에 \`[프로젝트 컨텍스트]\` 블록이 있으면 그것은 *사용자가 지금 열어둔 워크스페이스* 의 실제 구조 요약입니다. 거기서 직접 확인되는 사실(이 프로젝트가 무엇인지, 기술 스택, 폴더 구조, 주요 기능)은 이미 알려진 정보로 취급해 context 슬롯에 반영하고, 그 블록에서 답이 확인되는 질문은 openQuestions 에 만들지 마세요. 예: 사용자가 그 프로젝트 이름·경로를 언급하며 요청하면 "그 프로젝트가 무엇인가요?" 같은 질문은 금지입니다.
confidence는 다음 기준으로 자체 판정:
- "high" : C·G·C·F 4개 모두 prompt에서 직접 추론 가능. openQuestions = [] 가능.
- "medium" : 대체로 명확하지만 1~2개 항목에서 합리적 가정 필요. 추가 질문 1~2개.
@@ -119,6 +129,16 @@ function _buildUserMessage(input: IntentAnalysisInput): string {
lines.push(input.priorChatSummary);
lines.push('---');
}
// 현재 워크스페이스 아키텍처 요약 — "이 프로젝트가 뭐냐"류의 재질문 차단.
if (input.projectContext && input.projectContext.trim()) {
lines.push('');
lines.push('[프로젝트 컨텍스트]');
lines.push('아래는 사용자가 현재 열어둔 워크스페이스의 아키텍처 요약입니다. 여기서 직접');
lines.push('확인되는 사실은 이미 알려진 정보로 취급해 context 슬롯에 반영하고, 다시 묻지 마세요.');
lines.push('---');
lines.push(input.projectContext);
lines.push('---');
}
if (input.activePipelineName) {
lines.push('');
lines.push(`(활성 파이프라인) "${input.activePipelineName}"`);
+1
View File
@@ -140,6 +140,7 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
parts.push(' • `<add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>` — 작업 추적기에 task 추가');
parts.push(' • `<update_task id="t_001" status="in_progress|blocked|done" notes="..."/>` — 진척·blocker 갱신');
parts.push(' • `<complete_task id="t_001"/>` — task 완료 처리 (done 섹션으로 이동)');
parts.push(' • `<fetch_url url="https://..."/>` — 웹 페이지 본문 가져오기 (회당 최대 2개). "사이트 방문 불가"라고 답하지 말고 이 태그를 사용할 것. 결과는 [URL CONTENT] 블록으로 주입됨.');
parts.push('');
parts.push('📋 **Task 사용 시점**:');
parts.push('- 회의록·요청 처리 중 *명확한 할일* 마다 add_task 1개씩 emit. 추측·확장 금지.');
+167
View File
@@ -0,0 +1,167 @@
/**
* Web Fetch — Bridge 무관 직접 URL fetch (vscode 의존 없음 — 테스트 용이).
*
* 배경: 일반 챗의 URL 주입(urlContext.ts)은 Datacollect Bridge(:3002)에 100%
* 의존했다. Bridge가 꺼져 있으면 — 확장은 Bridge를 자동 시작하지 않는다 —
* "접근 실패" 블록이 떠서 모델이 "사이트 방문 불가"라고 답하는 공백이 있었다.
* 이 모듈은 그 폴백: extension host의 global fetch(Node 18+, bridgeClient가
* 이미 사용 중)로 직접 페이지를 가져와 본문 텍스트를 추출한다.
*
* 설계 원칙: 절대 throw 하지 않는다 — 모든 실패는 {ok:false, error} 로 반환.
*/
/**
* 스킴 없는 도메인 인식용 보수적 TLD 목록 — 사용자는 "koritips.com 가서 분석해줘"
* 처럼 https:// 를 생략하기 마련이라, 흔한 TLD 만 허용해 파일명(utils.ts,
* package.json 등) 오인을 차단한다.
*/
const BARE_DOMAIN_TLDS = 'com|net|org|io|co|kr|jp|dev|app|ai|me|info|blog|shop|site|xyz|cc|tv|us|uk|edu|gov';
/**
* http(s) URL 추출 — dedupe + trailing 구두점 제거. 슬래시 명령은 자체 처리하므로
* 제외. https:// 가 없는 bare 도메인(koritips.com, www.foo.net 등)도 인식해
* https:// 를 붙여 반환한다.
*/
export function extractUrls(text: string, max = 2): string[] {
const t = (text || '').trim();
// 슬래시 *명령* (/wikify 등)만 제외 — 절대경로("/Volumes/... koritips.com 봐줘")로
// 시작하는 프롬프트는 URL 추출 대상이다 (startsWith('/') 는 경로를 오인했던 버그).
if (!t || /^\/[a-zA-Z][\w-]*(\s|$)/.test(t)) return [];
const seen = new Set<string>();
const out: string[] = [];
// ① 스킴 있는 URL 우선.
const re = /https?:\/\/[^\s<>"'`)\]]+/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(t)) !== null && out.length < max) {
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
const url = m[0].replace(/[.,;:!?…」』)]+$/, '');
if (!seen.has(url)) {
seen.add(url);
out.push(url);
}
}
if (out.length >= max) return out;
// ② Bare 도메인 — 이미 찾은 URL 영역은 마스킹해 이중 매칭 방지.
// 직전 문자가 @(이메일)·/(경로 일부)·.(서브파트) 면 제외.
let masked = t;
for (const u of out) masked = masked.split(u).join(' '.repeat(Math.min(u.length, 8)));
const bareRe = new RegExp(
`(^|[\\s("'\`「『<>])((?:[a-z0-9-]+\\.)+(?:${BARE_DOMAIN_TLDS})(?:\\.[a-z]{2})?(?::\\d{2,5})?(?:/[^\\s<>"'\`)\\]]*)?)`,
'gi',
);
while ((m = bareRe.exec(masked)) !== null && out.length < max) {
const candidate = m[2].replace(/[.,;:!?…」』)]+$/, '');
if (!candidate.includes('.')) continue;
const url = `https://${candidate}`;
if (!seen.has(url) && !seen.has(`http://${candidate}`)) {
seen.add(url);
out.push(url);
}
}
return out;
}
export interface WebFetchResult {
ok: boolean;
url: string;
title: string;
text: string;
error?: string;
}
const DEFAULT_TIMEOUT_MS = 15_000;
const DEFAULT_MAX_CHARS = 20_000;
/**
* URL 본문을 직접 fetch 해 텍스트로 변환. HTML 이면 태그를 걷어내고,
* 그 외(text/json 등)는 원문 그대로 cap. http/https 만 허용.
*/
export async function fetchUrlDirect(
url: string,
opts: { timeoutMs?: number; maxChars?: number } = {},
): Promise<WebFetchResult> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
const fail = (error: string): WebFetchResult => ({ ok: false, url, title: '', text: '', error });
if (!/^https?:\/\//i.test(url)) return fail('http/https URL만 지원합니다.');
if (typeof fetch !== 'function') return fail('이 환경은 직접 fetch를 지원하지 않습니다.');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal,
redirect: 'follow',
headers: {
// 일부 사이트가 UA 없는 요청을 차단 — 평범한 브라우저 UA 로.
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5',
},
});
if (!res.ok) return fail(`HTTP ${res.status} ${res.statusText || ''}`.trim());
const contentType = (res.headers.get('content-type') || '').toLowerCase();
const raw = await res.text();
if (!raw.trim()) return fail('응답 본문이 비어 있습니다.');
if (contentType.includes('html') || /^\s*<(!doctype|html)/i.test(raw)) {
const title = _extractTitle(raw);
const text = htmlToText(raw).slice(0, maxChars);
if (text.trim().length < 50) {
return fail('본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).');
}
return { ok: true, url, title, text };
}
return { ok: true, url, title: '', text: raw.slice(0, maxChars) };
} catch (e: any) {
const msg = e?.name === 'AbortError'
? `타임아웃 (${Math.round(timeoutMs / 1000)}s)`
: String(e?.message ?? e).slice(0, 120);
return fail(msg);
} finally {
clearTimeout(timer);
}
}
function _extractTitle(html: string): string {
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return m ? decodeEntities(m[1]).replace(/\s+/g, ' ').trim().slice(0, 200) : '';
}
/** HTML → 평문. script/style/noscript 제거 → 블록 태그를 줄바꿈으로 → 태그 strip → 엔티티 → 공백 정리. */
export function htmlToText(html: string): string {
let s = html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ');
// 블록 요소 경계를 줄바꿈으로 보존 — 문단 구조가 텍스트에도 남게.
s = s.replace(/<\/(p|div|section|article|li|tr|h[1-6]|blockquote|pre)>/gi, '\n')
.replace(/<(br|hr)\s*\/?>/gi, '\n');
s = s.replace(/<[^>]+>/g, ' ');
s = decodeEntities(s);
// 공백 정리: 줄 내 다중 공백 → 1개, 3개 이상 연속 줄바꿈 → 2개.
return s
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** 최소 엔티티 디코드 — 본문 가독에 필요한 흔한 것만. */
export function decodeEntities(s: string): string {
return s
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#0?39;|&apos;/gi, "'")
.replace(/&#(\d+);/g, (_, code) => {
const n = Number(code);
return n > 0 && n < 0x110000 ? String.fromCodePoint(n) : '';
});
}
+8 -3
View File
@@ -387,8 +387,6 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
excerpt: (c.content || '').slice(0, 200),
}));
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
// 살아남게.
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
const memoryBlock = deps.retrievalOrchestrator.buildContextString(result);
// [확신도 전역화] 검색 근거 강도를 평가해 답변 정책을 함께 주입 — /meet 의
@@ -409,7 +407,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
return [selfReviewBlock, groundingBlock, lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
// 옛 구현은 이 블록들을 RAG 본문과 같은 문자열에 합쳐 [CONTEXT] 안에 넣었는데,
// "실패 방지 제약"이 토큰 압박에서 배경 지식과 함께 잘려나가는 모순이 있었다.
const constraintBlock = [selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
return memoryBlock;
}
/**
@@ -83,3 +83,25 @@ export function isCasualConversationPrompt(prompt: string): boolean {
if (/^(?:ha){2,}h?$|^(?:he){2,}h?$/.test(normalized)) return true; // haha, hahaha, hehe
return false;
}
/**
* 슬래시 명령(/wikify, /benchmark 등)인지 — *명령만* 골라낸다. 절대경로
* ("/Volumes/Data/... 분석해줘")는 명령이 아니다. 옛 `startsWith('/')` 가드가
* 경로로 시작하는 프롬프트를 전부 명령으로 오인해 분석 지시·URL 주입이 통째로
* 죽는 버그가 있었다 (이 사용자 패턴의 대표형이 "경로 + 분석해줘").
* 패턴: 슬래시 + 명령어 + (공백|끝). 경로는 두 번째 '/'가 바로 이어져 미스매치.
*/
export function isSlashCommand(text: string): boolean {
return /^\/[a-zA-Z][\w-]*(\s|$)/.test((text || '').trim());
}
/**
* 분석/검토/의견형 요청 감지 — 이런 요청에서 모델이 코드를 읽지 않고 "~로
* 보입니다" 추측으로 답하는 실패 모드를 막기 위한 grounding 지시 주입 트리거.
* 보수적 휴리스틱: 분석 동사 포함 + 슬래시 명령 아님 + 최소 길이.
*/
export function isAnalysisRequest(prompt: string): boolean {
const p = (prompt || '').trim();
if (p.length < 8 || isSlashCommand(p)) return false;
return /(분석|검토|리뷰|평가|점검|진단|의견|개선점|개선 ?방향|review|analyze|audit|assess)/i.test(p);
}
+26 -4
View File
@@ -15,21 +15,38 @@ import * as fs from 'fs';
import * as path from 'path';
import { INVENTORY_FILE } from '../../extension/featureInventory';
const IMPROVE_RE = /(개선|고도화|발전|보완|제안|평가|분석|방향|방법|아이디어|로드맵|업그레이드|날카롭|강화)/i;
// "검토|리뷰|점검|진단|어때" 추가 — "아래 내용 검토해줘" 같은 실사용 재검토
// 프롬프트가 매치 안 되던 갭 (회귀 테스트로 발견).
// "학습|배우|기억|작동|어떻게" 추가 — "너는 어떻게 학습해?" 류 자기 작동 방식
// 질문이 인벤토리(학습 메커니즘 정본 포함) 없이 답변되어 허구 설명이 나오던 갭.
const IMPROVE_RE = /(개선|고도화|발전|보완|제안|평가|분석|검토|리뷰|점검|진단|어때|방향|방법|아이디어|로드맵|업그레이드|날카롭|강화|학습|배우|기억|작동|어떻게)/i;
const SELF_RE = /(기능|역량|능력|아키텍처|구조|시스템|self.?evolv|자기\s*진화|자기\s*개선|아스트라|astra|connectai|프로젝트|업무\s*능력|너(가|의|는)?|네가)/i;
const CAPABILITY_RE = /(무슨|어떤|할\s*수\s*있는)\s*(기능|일|것)|기능\s*(목록|리스트)|capabilit/i;
/**
* 자기 평가·개선·기능 질의인지. 오탐 비용이 낮으므로(인벤토리 ~3KB 추가 주입뿐)
* 누락(또 구식 제안)보다 과잉 감지를 택한다. 길이 상한 1500 — 사용자의 개선 요청은
* 배경 설명이 붙어 길어지는 경우가 많다 (600 으로는 실사용 질문을 놓침).
* 누락(또 구식 제안)보다 과잉 감지를 택한다. 길이 상한 없음 — 사용자가 이전 답변
* 전문(5천자+)을 붙여넣고 "이거 어때?"라고 묻는 재검토 패턴이 흔한데, 1500/4000
* 상한이 그런 실사용 프롬프트를 탈락시켜 인벤토리 미주입 → 기존 기능 재제안
* 버그가 반복 재발했다. 정규식 검사는 큰 문자열에도 저비용.
*/
export function isSelfAssessRequest(prompt: string): boolean {
const p = (prompt || '').trim();
if (!p || p.length > 1500) return false;
if (!p) return false;
return CAPABILITY_RE.test(p) || (IMPROVE_RE.test(p) && SELF_RE.test(p));
}
/**
* 분석/검토 대상이 ASTRA/ConnectAI *자신*인지 — isSelfAssessRequest 보다 넓은
* 보조 판정 (개선 키워드 없이 "검토해줘/어때?" 만으로 자기 분석을 요청하는 경우).
* 분석 경로(isAnalysisRequest)와 결합해 인벤토리 주입 트리거를 보강한다.
*/
export function isAboutSelf(prompt: string): boolean {
const p = (prompt || '').trim();
if (!p) return false;
return /(아스트라|astra|connectai|자기\s*진화|자기\s*개선|self.?evolv|기능\s*인벤토리)/i.test(p);
}
const MAX_INVENTORY_CHARS = 7000;
/** 인벤토리 전문 + 대조 지시 블록. 파일 없으면 정직한 안내 (지어내기 방지). */
@@ -54,6 +71,11 @@ export function buildSelfAssessContext(brainPath: string): string {
'- 제안은 "현재 X가 있고, 빠진 증분은 Y" 형태로.',
'- 아래에 없는 기능을 있다고 주장하지도 마라.',
'- 직전 대화에서 본인이 했던 제안 목록을 그대로 반복하지 마라 — 이 인벤토리가 그 제안들보다 우선하는 최신 사실이다.',
'⚠️ [의무 형식 — 위반 시 오답 처리] 개선 제안을 하나라도 포함하는 답변은, **각 제안의 첫 줄에** 아래 대조 태그를 반드시 붙여라:',
' `[인벤토리 대조: 신규]` — 아래 문서 어디에도 없는 기능',
' `[인벤토리 대조: 기구현 — <항목명>]` — 이미 있음 (이 경우 "도입" 대신 그 기능의 *증분 확장*만 제안 가능)',
' `[인벤토리 대조: 부분 구현 — <항목명>]` — 일부 겹침',
' 태그를 붙이려면 아래 문서를 실제로 읽어야 한다. 태그 없는 제안은 검증 안 된 추측으로 간주된다.',
'- 답변 끝에 "출처: ASTRA 기능 인벤토리 v<버전>" 을 표기하라 (이 블록을 실제로 읽었다는 증거).',
'',
body,
+70 -26
View File
@@ -4,16 +4,30 @@
* 문제: /wikify 는 URL 에 접근하지만(브리지 /api/web-extract), 일반 채팅에 URL 을
* 주면 추출 경로가 없어 모델이 "접근할 수 없습니다"라고 답하거나 내용을 추측했다.
*
* 수정: 강제 주입 패턴의 4번째 적용 (일정→캘린더, 자기평가→인벤토리, 정정→캡처와
* 동일 설계). 이미 검증된 브리지 추출 인프라를 재사용 — 새 크롤러 없음.
* 실패 시(브리지 다운/추출 실패) 정직한 안내 블록 — 모델이 내용을 지어내지 않게.
* v2: Bridge 100% 의존이 두 번째 공백이었다 — Bridge 가 꺼져 있으면(확장은 자동
* 시작하지 않음) 접근 실패 블록이 떠서 결국 "방문 불가"가 됐다. 이제 ① Bridge
* 추출(품질 우선) → ② 직접 fetch 폴백(webFetch.fetchUrlDirect) → ③ 정직 실패
* 블록의 3단계. 성공 결과는 5분 TTL 캐시 — chat/alignment/기업모드가 같은 URL 을
* 연달아 요청해도 네트워크 1회.
*/
import { logInfo } from '../../utils';
import { bridgeFetch, BRIDGE_API } from '../../features/datacollect/bridgeClient';
import { fetchUrlDirect } from '../../features/web/webFetch';
const URL_RE = /https?:\/\/[^\s<>"'`)\]]+/i;
const MAX_BODY_CHARS = 8000;
const EXTRACT_TIMEOUT_MS = 45_000;
// Bridge 추출 대기 — 직접 fetch 폴백이 있으므로 45s → 15s 로 단축 (총 대기 최대 ~30s).
const EXTRACT_TIMEOUT_MS = 15_000;
// 성공 블록만 캐시 (실패는 캐시하지 않음 — Bridge 재기동/일시 오류 후 재시도 가능해야).
const CACHE_TTL_MS = 5 * 60 * 1000;
const CACHE_MAX = 10;
const _cache = new Map<string, { block: string; expiresAt: number }>();
/** 테스트/세션 전환용 캐시 초기화. */
export function clearUrlContextCache(): void {
_cache.clear();
}
/**
* 프롬프트에서 추출 대상 URL 을 찾는다. 슬래시 명령(/wikify 등)은 자체 처리하므로
@@ -21,7 +35,8 @@ const EXTRACT_TIMEOUT_MS = 45_000;
*/
export function extractUrlFromPrompt(prompt: string): string | null {
const p = (prompt || '').trim();
if (!p || p.startsWith('/')) return null;
// 슬래시 *명령* 만 제외 — 절대경로 시작 프롬프트는 추출 대상 (경로 오인 버그 수정).
if (!p || /^\/[a-zA-Z][\w-]*(\s|$)/.test(p)) return null;
const m = URL_RE.exec(p);
if (!m) return null;
// 문장 끝 구두점이 URL 에 붙는 흔한 오염 제거.
@@ -30,6 +45,12 @@ export function extractUrlFromPrompt(prompt: string): string | null {
/** URL 본문을 추출해 컨텍스트 블록 생성. 실패해도 throw 하지 않는다 (정직 블록 반환). */
export async function buildUrlContext(url: string): Promise<string> {
const now = Date.now();
const cached = _cache.get(url);
if (cached && cached.expiresAt > now) return cached.block;
// ① Bridge 추출 (readability 급 품질 — 우선).
let bridgeError = '';
try {
const data = await bridgeFetch<{ success: boolean; title?: string; description?: string; text?: string; textLength?: number; truncated?: boolean }>(
BRIDGE_API.web.extract,
@@ -37,28 +58,51 @@ export async function buildUrlContext(url: string): Promise<string> {
{ timeoutMs: EXTRACT_TIMEOUT_MS },
);
const body = String(data?.text || '').slice(0, MAX_BODY_CHARS);
if (!body.trim() || body.trim().length < 50) {
return [
`[URL CONTENT — ${url}]`,
'상태: 본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링).',
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
].join('\n');
if (body.trim() && body.trim().length >= 50) {
logInfo('URL 컨텍스트 주입 (bridge).', { url, chars: body.length, truncated: !!data?.truncated });
const block = _successBlock(url, data?.title || '', data?.description || '', body, body.length >= MAX_BODY_CHARS || !!data?.truncated);
_cachePut(url, block);
return block;
}
logInfo('URL 컨텍스트 주입.', { url, chars: body.length, truncated: !!data?.truncated });
return [
`[URL CONTENT — 실데이터 · ${url}]`,
`제목: ${data?.title || '(없음)'}`,
data?.description ? `설명: ${data.description}` : '',
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
(body.length >= MAX_BODY_CHARS || data?.truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
'',
body,
].filter(Boolean).join('\n');
bridgeError = '본문 추출 실패 (콘텐츠 없음 또는 JS 전용 렌더링)';
} catch (e: any) {
return [
`[URL CONTENT — ${url}]`,
`상태: 접근 실패 — ${String(e?.message ?? e).slice(0, 120)}`,
'→ Datacollect 브리지(:3002)가 실행 중인지 확인하라고 사용자에게 안내하고, URL 내용을 추측해 답하지 마라.',
].join('\n');
bridgeError = String(e?.message ?? e).slice(0, 120);
}
// ② 직접 fetch 폴백 — Bridge 가 꺼져 있거나 추출 실패해도 웹 접근은 살아 있어야.
const direct = await fetchUrlDirect(url, { maxChars: MAX_BODY_CHARS });
if (direct.ok) {
logInfo('URL 컨텍스트 주입 (direct fetch 폴백).', { url, chars: direct.text.length, bridgeError });
const block = _successBlock(url, direct.title, '', direct.text, direct.text.length >= MAX_BODY_CHARS);
_cachePut(url, block);
return block;
}
// ③ 둘 다 실패 — 모델이 내용을 지어내지 않게 정직 블록.
return [
`[URL CONTENT — ${url}]`,
`상태: 접근 실패 — bridge: ${bridgeError || '(미시도)'} / direct: ${direct.error || '(불명)'}`,
'→ 사용자에게 이 URL 의 본문을 가져오지 못했다고 정직하게 알리고, 내용을 추측해 답하지 마라.',
].join('\n');
}
function _successBlock(url: string, title: string, description: string, body: string, truncated: boolean): string {
return [
`[URL CONTENT — 실데이터 · ${url}]`,
`제목: ${title || '(없음)'}`,
description ? `설명: ${description}` : '',
'아래 본문만 근거로 답하라. 본문에 없는 내용은 "본문에서 확인되지 않음"이라고 답하고 지어내지 마라.' +
(truncated ? ' (본문 일부 잘림 — 전체가 필요하면 /wikify 사용을 안내하라.)' : ''),
'',
body,
].filter(Boolean).join('\n');
}
function _cachePut(url: string, block: string): void {
if (_cache.size >= CACHE_MAX) {
// 가장 오래된 entry 제거 (Map 삽입 순서).
const oldest = _cache.keys().next().value;
if (oldest !== undefined) _cache.delete(oldest);
}
_cache.set(url, { block, expiresAt: Date.now() + CACHE_TTL_MS });
}
@@ -33,6 +33,10 @@ export interface DispatcherDepsInputs {
pipelineIdOverride?: string;
/** Intent Alignment 가 도출한 사용자 합의 contract. 없으면 legacy 동작. */
requirementContract?: RequirementContract;
/** 현재 워크스페이스 아키텍처 컨텍스트 (호출자가 절단해 전달). */
architectureContextBlock?: string;
/** 사용자 프롬프트 URL pre-fetch 결과 ([URL CONTENT] 블록). */
webContextBlock?: string;
}
export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDeps {
@@ -64,5 +68,7 @@ export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDep
}),
pipelineIdOverride: inputs.pipelineIdOverride,
requirementContract: inputs.requirementContract,
architectureContextBlock: inputs.architectureContextBlock,
webContextBlock: inputs.webContextBlock,
};
}
+3
View File
@@ -82,6 +82,8 @@ export interface ReadyStatusDeps {
effectiveContextLength: number;
cappedForSmallModel: boolean;
lmStudioError: string | null;
/** HealthCheckMonitor 의 마지막 환경 경고 (Bridge·볼륨·자격증명·버전 등). 없으면 []. */
healthWarnings: string[];
}
export function buildReadyStatusPayload(d: ReadyStatusDeps) {
@@ -102,6 +104,7 @@ export function buildReadyStatusPayload(d: ReadyStatusDeps) {
nominalContextLength: d.contextLength,
cappedForSmallModel: d.cappedForSmallModel,
lmStudioError: d.lmStudioError,
healthWarnings: d.healthWarnings,
},
};
}
+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을
+14 -1
View File
@@ -402,10 +402,23 @@ R7. GUESS-AND-ACT WITH STATED ASSUMPTION. When information is missing but a reas
- 진척 보고가 들어오면 즉시 update / complete. "추적해뒀어요" 라고 말만 하지 말 것.
- due 시각이 명확한 task 는 add_task + create_calendar_event 함께 emit (둘 다).
[ACTION 15: FETCH URL]
웹 페이지의 *실제 내용* 이 필요할 때 사용. 절대 "사이트에 방문할 수 없다"고
답하지 말 것 — 이 태그를 emit 하면 확장이 페이지 본문을 가져와 준다.
<fetch_url url="https://example.com/page"/>
- url: http/https URL (required)
- 사용 시점: 사용자가 언급한 링크, 또는 작업에 필요한 페이지의 최신 내용이
컨텍스트에 없을 때. 일반 지식 질문에는 쓰지 말 것.
- 회당 최대 2개. 결과는 [URL CONTENT] 블록으로 주입되며, 실패하면 실패
사실이 그대로 전달된다 — 내용을 추측해 채우지 말 것.
[OPERATIONAL RULES]
1. Reply in the same language as the user.
2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity.
3. When the user says "분석해줘", "봐줘", "확인해줘", "리뷰해줘" with a path — that IS the confirmation. Access the path immediately and run the full analysis in one continuous response. Do not pause to ask "진행할까요?" or "시작할까요?".`;
3. When the user says "분석해줘", "봐줘", "확인해줘", "리뷰해줘" with a path — that IS the confirmation. Access the path immediately and run the full analysis in one continuous response. Do not pause to ask "진행할까요?" or "시작할까요?".
4. Claims about THIS workspace's code or features must be grounded in files you actually read in this conversation (via read_file / list_files). Never describe implementation details with guesses like "~로 보입니다" — read the file first, or explicitly say you could not verify. Before proposing "add feature X", check whether X already exists in the codebase.`;
function getEnvironmentBlock(): string {
const platform = process.platform;