v2.2.311~312: 응답 지연 근본 개선(KV 캐시 분리·제2뇌 상주 캐시) + 보고 품질 수술

v2.2.311 — 응답 지연 (실측: 출력 22토큰에 94.7초, 원인은 매 턴 13k+ 토큰 전체 재프리필)
- KV 캐시 친화 프롬프트 분리(kvCachePromptSplit, 기본 ON): message[0]을 불변
  정적 본문으로 고정, 날짜/RAG/[CONTEXT]/동적 블록을 마지막 user 메시지 직전의
  internal system 메시지로 이동 — llama.cpp prompt cache 프리픽스 재사용으로
  턴당 재프리필을 "직전 교환 + 동적 컨텍스트"로 축소. truncation 도 tail 적용.
- 검색 토큰 예산 현실화(retrievalTokenBudget, 0=자동): 창의 25%(8k~80k) →
  12%(2.5k~6k 클램프).
- continuation 은 depth-0 memoryCtx 재사용: 라운드당 재검색 3~8초 제거 +
  빈 쿼리 재검색으로 청크가 갈리던 문제 제거 + 턴 내 프롬프트 안정화.
- 제2뇌 상주 캐시(신규 brainWatch.ts): 재귀 fs.watch 세대 카운터로 변경 없으면
  디렉터리 워크·파일별 statSync 전면 생략. 수정 직후 3초 창은 신뢰 제외(이벤트
  지연 레이스 가드), 워처 불가 시 종전 폴백, 활성화 시 백그라운드 워밍,
  유휴 해제 30분→2시간.

v2.2.312 — 보고 품질 (실사례: "## 4."부터 시작하는 7줄 일반론 보고서)
- 중간 라운드 본문 표시 버그 수정: 액션과 함께 작성된 섹션(1~3)이 화면에 한 번도
  안 나가고 최종 라운드만 표시되던 근본 원인 제거 — stripForDisplay.ts 로 액션
  태그만 걷어내고 라운드 순서대로 버블에 표시.
- '분석 보고' 업무 유형 신설(requirementGraph): 보고 개요(첫 줄 자기선언)·파일
  근거(주장마다 실제 읽은 파일 인용, 일반론 금지)·구조·발견·다음 단계 강제,
  유형 감지 시 "최대 3섹션" 규칙보다 필수 요소 커버 우선.
- 근거 없는 분석 감지: 파일을 읽고도 인용 2개 미만인 장문 분석에 "근거 인용
  없음" footer 경고 (헛조사 감지의 반대 방향).

검증: 전체 테스트 954건 통과(신규 28), tsc 무오류, vsix 패키징·설치 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 13:21:41 +09:00
co-authored by Claude Fable 5
parent a31d273bfe
commit 7d6b8b509f
20 changed files with 834 additions and 38 deletions
+80 -20
View File
@@ -5,6 +5,8 @@ import * as fs from 'fs';
import {
findBrainFiles,
getSystemPrompt,
getStaticSystemPrompt,
getDateTimeContextBlock,
shouldAutoPushBrain,
buildApiUrl,
getActiveBrainProfile,
@@ -322,6 +324,14 @@ export class AgentExecutor {
actionStats: { reads: number; lists: number; investigates: number };
/** [v2.2.309] 조사 턴 모델 오버라이드 — depth 0 에서 결정, continuation 에도 유지. */
investigationModelOverride: string | null;
/**
* [v2.2.311] depth 0 에서 빌드한 memoryCtx 문자열 캐시 — continuation depth 는
* 재검색하지 않고 이걸 재사용한다. 종전엔 depth 마다 buildMemoryContext 를 다시
* 돌렸는데, (a) 검색 3~8초가 라운드마다 추가되고 (b) continuation 의 prompt 는
* null 이라 *빈 쿼리로* 재검색해 엉뚱한 청크로 갈아끼우고 (c) 프롬프트가 흔들려
* KV 캐시도 깨졌다. actionStats 와 같은 이유로 depth 0 진입부에서만 초기화.
*/
memoryCtxCache: string | null;
} = {
retrieval: null,
lessons: [],
@@ -331,6 +341,7 @@ export class AgentExecutor {
confidenceSignals: null,
actionStats: { reads: 0, lists: 0, investigates: 0 },
investigationModelOverride: null,
memoryCtxCache: null,
};
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
@@ -560,6 +571,7 @@ export class AgentExecutor {
// [v2.2.309] turn 전체(모든 depth) 누적 상태 — depth 0 에서만 초기화.
this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 };
this._turnCtx.investigationModelOverride = null;
this._turnCtx.memoryCtxCache = null;
}
// 1. Prepare Context
@@ -755,22 +767,29 @@ export class AgentExecutor {
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
: '';
const retrievalStartMs = Date.now();
const memoryCtx = isCasualConversation
? ''
: await (async () => {
this.resetTurnContext();
return buildMemoryContextFn({
currentPrompt: prompt || '',
activeBrain,
agentSkillFile: options.agentSkillFile,
chatHistory: this.chatHistory,
memoryManager: this.memoryManager,
retrievalOrchestrator: this.retrievalOrchestrator,
context: this.context,
currentTaskId: this.currentTaskId,
turnCtx: this._turnCtx,
});
})();
// [v2.2.311] continuation depth 는 depth 0 의 memoryCtx 를 재사용 — 재검색
// 3~8초 제거 + 빈 쿼리 재검색으로 청크가 갈리는 문제 제거 + 프롬프트 안정화
// (dynamicBlocks 등 turnCtx 파생물도 depth 0 것이 그대로 유지된다).
let memoryCtx: string;
if (isCasualConversation) {
memoryCtx = '';
} else if (loopDepth > 0 && this._turnCtx.memoryCtxCache !== null) {
memoryCtx = this._turnCtx.memoryCtxCache;
} else {
this.resetTurnContext();
memoryCtx = await buildMemoryContextFn({
currentPrompt: prompt || '',
activeBrain,
agentSkillFile: options.agentSkillFile,
chatHistory: this.chatHistory,
memoryManager: this.memoryManager,
retrievalOrchestrator: this.retrievalOrchestrator,
context: this.context,
currentTaskId: this.currentTaskId,
turnCtx: this._turnCtx,
});
this._turnCtx.memoryCtxCache = memoryCtx;
}
if (loopDepth === 0 && !isCasualConversation && this._turnCtx.retrieval) {
recordTelemetry({
kind: 'retrieval',
@@ -806,9 +825,19 @@ export class AgentExecutor {
? buildPriorTurnConclusionContext(this.chatHistory)
: '';
// System prompt build (agent vs astra mode) → src/agent/handlePrompt/{buildAgentModeSystemPrompt,buildAstraModeSystemPrompt}.ts
const fullSystemPrompt: string = isAgentMode
//
// [KV 캐시 분리 v2.2.311] 기본 경로(호출자가 systemPrompt 를 넘기지 않은 경우)는
// message[0] 을 *정적 본문만* 으로 고정하고, 날짜/RAG/[CONTEXT]/동적 블록 전부를
// dynamicContextTail 로 분리해 computeBudgetedRequest 가 마지막 user 메시지 직전에
// 삽입한다. llama.cpp prompt cache 가 정적 프롬프트+과거 히스토리를 재사용하게 되어
// 매 턴 전체 재프리필(실측 13k 토큰 ≈ 90초)이 "직전 교환 + tail" 로 줄어든다.
// 커스텀 systemPrompt 호출자(멀티에이전트 등)는 종전 단일-시스템 경로 유지.
const kvSplitEnabled = getConfig().kvCachePromptSplit !== false
&& options.systemPrompt === undefined;
const builderBasePrompt = kvSplitEnabled ? '' : systemPrompt;
const builtSystemPrompt: string = isAgentMode
? buildAgentModeSystemPrompt({
systemPrompt,
systemPrompt: builderBasePrompt,
agentSkillContext: options.agentSkillContext || '',
modeBridgeCtx,
priorConclusionCtx,
@@ -824,7 +853,7 @@ export class AgentExecutor {
})
: buildAstraModeSystemPrompt({
prompt,
systemPrompt,
systemPrompt: builderBasePrompt,
modeBridgeCtx,
priorConclusionCtx,
designerCtx,
@@ -839,6 +868,12 @@ export class AgentExecutor {
knowledgeMix: this._turnCtx.knowledgeMix,
dynamicBlocks: this._turnCtx.dynamicBlocks,
});
// Split 모드: head = 정적 프롬프트(불변), tail = 날짜 + 빌더 산출(동적 전부).
// Legacy 모드: 종전 그대로 head 에 전부.
const fullSystemPrompt: string = kvSplitEnabled ? getStaticSystemPrompt() : builtSystemPrompt;
const dynamicContextTail: string | undefined = kvSplitEnabled
? `${getDateTimeContextBlock()}${builtSystemPrompt}`
: undefined;
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
const imageCount = (reqMessages as any[])
.reduce((n, m) => n + (Array.isArray(m?.images) ? m.images.length : 0), 0);
@@ -874,7 +909,7 @@ export class AgentExecutor {
const lastUserIdx = reqMessages.map((m) => m.role).lastIndexOf('user');
const lastUser = lastUserIdx >= 0 ? reqMessages[lastUserIdx] : undefined;
const content = typeof lastUser?.content === 'string' ? lastUser.content : '';
const sysTokens = estimateTokens(fullSystemPrompt) + 4;
const sysTokens = estimateTokens(fullSystemPrompt) + (dynamicContextTail ? estimateTokens(dynamicContextTail) : 0) + 4;
const mrCfg = {
enabled: true,
triggerRatio: config.mapReduceTriggerRatio,
@@ -934,6 +969,7 @@ export class AgentExecutor {
const _budget = computeBudgetedRequest({
fullSystemPrompt,
dynamicContextTail,
reqMessages,
actualModel,
config,
@@ -1462,6 +1498,22 @@ export class AgentExecutor {
await this.context.workspaceState.update('lastActionStr', currentActionStr);
logInfo('Autonomous loop continuing after actions.', { loopDepth: loopDepth + 1, actions: report });
// [v2.2.312] 중간 라운드 본문 표시 — 액션과 *함께* 작성된 섹션이 화면에서
// 증발하던 버그 수정. 종전엔 액션이 있는 라운드는 여기서 return 하며 본문을
// 한 번도 webview 에 보내지 않았고(표시는 라이브 스트리밍뿐 — depth 0 전용),
// 최종 라운드만 streamChunk 로 붙었다. 그 결과 모델이 히스토리에서 자기 이전
// 섹션(1~3)을 보고 "## 4."부터 이어 써서, 사용자에게는 4번부터 시작하는
// 보고서가 도착했다 (실사례). 라이브로 이미 표시된 depth 0 는 중복 방지로 제외.
if (loopDepth > 0 || !postLiveDeltas) {
try {
const { stripActionTagsForDisplay } = await import('./agent/actions/stripForDisplay');
const roundVisible = stripActionTagsForDisplay(finalAssistantContent);
if (roundVisible) {
this.webview.postMessage({ type: 'streamChunk', value: `${loopDepth > 0 ? '\n\n' : ''}${roundVisible}` });
}
} catch { /* 표시 실패가 루프를 막지 않음 */ }
}
// Explicitly tell the AI to look at the results and continue
const continuationPrompt = `The requested local action has been executed.\nAction report:\n${report.join('\n')}\nUse the action result messages already in the conversation to answer the user's original request directly, in the user's language. Do not say you are waiting for the next instruction.`;
@@ -1554,6 +1606,14 @@ export class AgentExecutor {
if (hollowInv.hollow) {
this.webview.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(hollowInv.fileMentions) });
logInfo('Hollow Investigation 감지 (continuation).', { files: hollowInv.fileMentions, stats: this._turnCtx.actionStats });
} else {
// [v2.2.312] 반대 방향 — 파일을 읽고도 근거 인용 없는 일반론 분석 경고.
const { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } = await import('./intelligence/investigationPipeline');
const ug = detectUngroundedAnalysis(finalAssistantContent, this._turnCtx.actionStats);
if (ug.ungrounded) {
this.webview.postMessage({ type: 'streamChunk', value: formatUngroundedAnalysisFooter(ug.readCount) });
logInfo('Ungrounded Analysis 감지 (continuation).', { readCount: ug.readCount, fileRefs: ug.fileRefs });
}
}
} catch { /* 감지 실패가 답변을 막지 않음 */ }
}
+31
View File
@@ -0,0 +1,31 @@
/**
* [v2.2.312] 화면 표시용 액션 태그 제거.
*
* 액션 루프의 중간 라운드(액션과 함께 본문 섹션을 쓴 라운드)를 사용자 버블에
* 표시할 때, <read_file/> 같은 실행 태그 원문이 그대로 노출되지 않도록 걷어낸다.
* 실행 자체는 이 함수와 무관 — executeActions 는 원문(cleanedVisible)을 그대로
* 받는다. 여기는 *표시 전용* 경로다.
*/
const ACTION_TAG_NAMES = [
'read_file', 'list_files', 'investigate_files',
'create_file', 'edit_file', 'delete_file',
'run_command', 'run_code',
'read_brain', 'list_brain',
'read_sheet', 'write_sheet', 'append_sheet',
'fetch_url', 'calculate',
'create_calendar_event', 'add_task', 'update_task', 'complete_task',
// edit_file 내부 서브 태그 — 블록 제거가 실패한 잔재까지 정리.
'search', 'replace',
];
export function stripActionTagsForDisplay(text: string): string {
let t = text || '';
for (const name of ACTION_TAG_NAMES) {
// 자기닫힘 <tag ... /> → 블록 <tag ...>...</tag> → 잔재 여는/닫는 태그 순.
t = t.replace(new RegExp(`<${name}\\b[^>]*/>`, 'gi'), '');
t = t.replace(new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?</${name}>`, 'gi'), '');
t = t.replace(new RegExp(`</?${name}\\b[^>]*>`, 'gi'), '');
}
return t.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
@@ -13,6 +13,14 @@ import { buildDroppedHistorySummary } from '../../lib/contextBuilders/droppedHis
export interface ComputeBudgetedRequestInput {
fullSystemPrompt: string;
/**
* [KV 캐시 분리 v2.2.311] 턴마다 변하는 컨텍스트(RAG/[CONTEXT]/날짜/동적 블록)를
* message[0] 이 아니라 *마지막 user 메시지 직전*의 internal system 메시지로 싣는다.
* message[0](fullSystemPrompt)은 턴 사이에 불변 → llama.cpp prompt cache 가
* "정적 프롬프트 + 과거 히스토리" 프리픽스를 재사용하고, 매 턴 프리필은
* "직전 교환 + 이 블록"만으로 줄어든다. undefined 면 종전 단일-시스템 동작.
*/
dynamicContextTail?: string;
/** Caller is expected to have run `capChatHistory` on this already. */
reqMessages: ChatMessage[];
actualModel: string;
@@ -126,12 +134,27 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
1024,
Math.floor((ctxLimits.contextLength - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve) * 0.65)
);
const { prompt: budgetedSystemPrompt, truncated: systemTruncated } =
truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
// Split 모드면 [CONTEXT] 는 dynamicContextTail 쪽에 있으므로 truncation 도 tail 에 적용.
// (정적 head 에는 [CONTEXT] 마커가 없어 truncate 가 no-op — head 는 그대로 둔다.)
const splitMode = typeof input.dynamicContextTail === 'string' && input.dynamicContextTail.trim().length > 0;
let budgetedSystemPrompt = fullSystemPrompt;
let budgetedTail = splitMode ? input.dynamicContextTail! : '';
let systemTruncated = false;
if (splitMode) {
const headTokens = estimateTokens(fullSystemPrompt);
const tailCap = Math.max(512, systemCapTokens - headTokens);
const t = truncateSystemPromptContext(budgetedTail, tailCap);
budgetedTail = t.prompt;
systemTruncated = t.truncated;
} else {
const t = truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
budgetedSystemPrompt = t.prompt;
systemTruncated = t.truncated;
}
if (systemTruncated) {
logInfo('System prompt context truncated to fit the context window.', { model: actualModel, systemCapTokens });
}
const systemTokens = estimateTokens(budgetedSystemPrompt) + 4;
const systemTokens = estimateTokens(budgetedSystemPrompt) + (splitMode ? estimateTokens(budgetedTail) + 4 : 0) + 4;
// (2) 대화 기록 압축.
const historyBudget = Math.max(
@@ -156,9 +179,27 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
}
}
// Split 모드: 동적 컨텍스트를 마지막 user 메시지 *직전*에 삽입. 이 위치라야
// (a) 이전 요청과의 공통 프리픽스(정적 시스템 + 과거 히스토리)가 최대로 보존되고
// (b) continuation(액션 결과가 뒤에 붙는 라운드)에서도 같은 자리라 턴 내 캐시가 유지된다.
// user 메시지가 없으면(이론상) 히스토리 끝에 붙인다 — 생성 직전이므로 여전히 유효.
let historyWithTail = budgetedHistory;
if (splitMode) {
const tailMsg: ChatMessage = { role: 'system', content: budgetedTail, internal: true };
const lastUserIdx = (() => {
for (let i = budgetedHistory.length - 1; i >= 0; i--) {
if (budgetedHistory[i].role === 'user') return i;
}
return -1;
})();
historyWithTail = lastUserIdx >= 0
? [...budgetedHistory.slice(0, lastUserIdx), tailMsg, ...budgetedHistory.slice(lastUserIdx)]
: [...budgetedHistory, tailMsg];
}
const messagesForRequest: ChatMessage[] = [
{ role: 'system', content: budgetedSystemPrompt, internal: true },
...budgetedHistory
...historyWithTail
];
// (3) 동적 출력 상한.
+15
View File
@@ -233,6 +233,19 @@ export interface IAgentConfig {
* 할루시네이션 대책. 빈 문자열이면 비활성 (현재 선택 모델 그대로).
*/
investigationModel: string;
/**
* [v2.2.311] KV 캐시 친화 프롬프트 분리 — message[0] 을 정적 본문으로 고정하고
* 날짜/RAG/[CONTEXT]/동적 블록을 마지막 user 메시지 직전의 system 메시지로 옮긴다.
* llama.cpp prompt cache 프리픽스 재사용으로 턴당 재프리필을 대폭 축소. 기본 true.
* 특정 모델 템플릿에서 문제 시 false 로 종전 단일-시스템 프롬프트 복귀.
*/
kvCachePromptSplit: boolean;
/**
* [v2.2.311] RAG/메모리 검색 토큰 예산 상한. 0 = 자동(컨텍스트 창의 12%,
* 2,500~6,000 클램프). 종전 자동값(창의 25%, 최대 80k)은 32k 창에서 8k 토큰을
* 검색 결과에 써서 로컬 프리필만 수십 초를 태웠다 — 상한을 낮춰 답변 지연을 줄인다.
*/
retrievalTokenBudget: number;
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
selfCheckModel: string;
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
@@ -583,6 +596,8 @@ export function getConfig(): IAgentConfig {
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
kvCachePromptSplit: cfg.get<boolean>('kvCachePromptSplit', true),
retrievalTokenBudget: Math.max(0, cfg.get<number>('retrievalTokenBudget', 0) || 0),
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
+11
View File
@@ -86,6 +86,17 @@ export async function activate(context: vscode.ExtensionContext) {
// 두뇌에 기록. 자기 평가가 구식 스냅샷 대신 항상 현행 소스를 근거로 하게 한다.
void ensureFeatureInventory(context);
// [v2.2.311] 제2뇌 캐시 워밍 — 활성화 직후 백그라운드로 파일 목록 + 토큰 인덱스
// (수십 MB JSON)를 메모리에 올려, 세션 첫 질의가 그 비용(수 초)을 뒤집어쓰지
// 않게 한다. 워처가 같이 걸려 이후 턴은 변경 없으면 워크/stat 을 통째로 생략.
try {
const { getActiveBrainProfile } = require('./utils') as typeof import('./utils');
const { warmBrainCache } = require('./retrieval/brainWatch') as typeof import('./retrieval/brainWatch');
const { getBrainTokenIndex } = require('./retrieval/brainIndex') as typeof import('./retrieval/brainIndex');
const brainPath = getActiveBrainProfile()?.localBrainPath;
if (brainPath) warmBrainCache(brainPath, (files) => { getBrainTokenIndex(brainPath, files); });
} catch { /* 워밍 실패는 비치명 — 첫 질의가 종전 경로로 처리 */ }
// Initialize Astra Path Resolver (.astra → ConnectAI/.astra/)
initAstraPathResolver(context);
+28
View File
@@ -226,3 +226,31 @@ export function formatHollowInvestigationFooter(fileMentions: string[]): string
'내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 `<investigate_files>`로 각 파일을 실제로 읽고 답합니다.',
].join('\n');
}
/**
* [v2.2.312] 헛조사의 반대 방향 실패 — 파일을 *읽고도* 답변에 파일 근거를 하나도
* 인용하지 않은 "일반론 분석" 감지. 실사례: 프로젝트 분석 보고가 7줄짜리
* 프로젝트명만 바꿔도 성립하는 총평("~가 인상적입니다")으로 끝남.
* 조건: read/investigate 를 1회 이상 했고, 답변이 실질 분량(300자+)인데
* 파일 참조가 2개 미만 → ungrounded. (짧은 확인형 응답은 오탐 방지로 제외.)
*/
export function detectUngroundedAnalysis(
answer: string,
stats: TurnActionStats | null | undefined,
): { ungrounded: boolean; readCount: number; fileRefs: number } {
const readCount = (stats?.reads ?? 0) + (stats?.investigates ?? 0);
if (!stats || readCount === 0) return { ungrounded: false, readCount, fileRefs: 0 };
const body = (answer || '').trim();
if (body.length < 300) return { ungrounded: false, readCount, fileRefs: 0 };
const refs = new Set(Array.from(body.matchAll(FILE_MENTION_RE), m => m[0]));
return { ungrounded: refs.size < 2, readCount, fileRefs: refs.size };
}
export function formatUngroundedAnalysisFooter(readCount: number): string {
return [
'',
'---',
`⚠️ **근거 인용 없음** — 파일을 ${readCount}회 읽고도 답변에 실제 파일·코드 근거가 인용되지 않았습니다. 위 내용은 일반론일 수 있습니다.`,
'구체 근거가 필요하면 "실제 읽은 파일명을 인용해서 다시 정리해줘"라고 요청해 주세요.',
].join('\n');
}
+39
View File
@@ -154,6 +154,42 @@ export const DEFAULT_TASK_REQUIREMENTS: TaskRequirement[] = [
},
],
},
{
// [v2.2.312] 실사례 대응 — "E:\...프로젝트를 분석하고 어떻게 되어 있는지 보고해줘"가
// 어떤 유형에도 안 걸려 7줄짜리 일반론 총평으로 끝났다. '보고해줘/현황 보고' 류를
// 잡아 파일 근거 인용을 필수 요소로 강제한다.
id: 'analysis-report',
label: '분석 보고',
detectKeywords: ['분석[^\\n]{0,20}보고', '보고해\\s*줘?', '현황\\s*(보고|정리)', '어떻게\\s*되어\\s*있는지', '검토[^\\n]{0,10}보고'],
coverageCheck: true,
elements: [
{
id: 'overview', label: '보고 개요',
hint: '첫 줄에 "보고 개요: (분석 대상) — (확인한 근거: 실제 읽은 파일 수) — (한 줄 결론)" 형식으로 시작. 요청자가 첫 줄만 읽어도 무엇을 어떤 근거로 판단했는지 알 수 있게.',
detectPatterns: ['보고 ?개요', '분석 ?대상', '개요'],
},
{
id: 'file-evidence', label: '파일 근거',
hint: '모든 주요 주장에 이번 대화에서 실제로 읽은 파일명·경로를 인용. 프로젝트 이름을 바꿔도 성립하는 일반론("~가 인상적입니다", "~할 가능성이 높습니다")은 금지 — 근거 없는 문장은 쓰지 말 것.',
detectPatterns: ['\\.(ts|tsx|js|mjs|cjs|py|md|json|html|css|java|cs)\\b', 'src[\\\\/]', 'package\\.json'],
},
{
id: 'structure', label: '구조·현황',
hint: '프로젝트/대상의 실제 구성 — 핵심 모듈, 진행 상태, 확인된 사실. STATUS.md 등 현재 상태 기록이 있으면 그것을 기준으로.',
detectPatterns: ['구조', '구성', '모듈', '아키텍처', '현황', '상태'],
},
{
id: 'findings', label: '핵심 발견·이슈',
hint: '분석에서 드러난 강점·문제·이슈. 각 항목에 근거 파일 연결.',
detectPatterns: ['발견', '강점', '문제', '이슈', '리스크', '과제'],
},
{
id: 'next-steps', label: '다음 단계',
hint: '이 보고를 받은 사람이 취할 수 있는 구체적 다음 행동.',
detectPatterns: ['다음 ?단계', '권장', '제안', '후속'],
},
],
},
{
id: 'work-research',
label: '업무조사',
@@ -233,6 +269,9 @@ export function buildRequirementGraphBlock(
}
lines.push('');
lines.push('제출 전 위 체크리스트를 스스로 점검하고, 누락 요소가 있으면 보완 후 답변할 것.');
// [v2.2.312] 업무 유형이 감지된 답변은 일반 "최대 3섹션" 규칙보다 필수 요소 커버가
// 우선 — 보고서가 3섹션 제한 때문에 얕아지는 상충 제거.
lines.push('이 업무 유형이 감지된 답변에서는 "최대 3섹션" 일반 규칙보다 필수 요소 커버가 우선한다 — 요소별로 섹션을 나눠도 된다.');
lines.push('[/TASK REQUIREMENTS]');
return lines.join('\n');
}
+7 -5
View File
@@ -166,11 +166,13 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
if (domainScope.folders.length > 0) effectiveScopeFolders = domainScope.folders;
}
// Context 윈도우 비례 retrieval 예산. 32K → 8K, 230K → 57K, 80K cap (scoring 속도).
const scaledTotalBudget = Math.min(
80000,
Math.max(8000, Math.floor(config.contextLength * 0.25)),
);
// Context 윈도우 비례 retrieval 예산.
// [v2.2.311] 종전 자동값(창의 25%, 8k~80k)은 32K 창에서 검색 결과에만 8k 토큰을 태워
// 로컬 프리필 수십 초의 주범이었다. 자동값을 창의 12%(2.5k~6k 클램프)로 낮추고,
// 더 풍부한 근거가 필요하면 g1nation.retrievalTokenBudget 으로 명시 상향.
const scaledTotalBudget = config.retrievalTokenBudget > 0
? config.retrievalTokenBudget
: Math.min(6000, Math.max(2500, Math.floor(config.contextLength * 0.12)));
// medium-term layer 용 옛 세션 후보. sidebar 가 직접 쓰는 key 를 read-through.
const rawSessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
+50 -1
View File
@@ -17,6 +17,7 @@ import { tokenize, countConflictIndicators } from './scoring';
import { detectLessonKind } from './lessonHelpers';
import { splitIntoSections } from './chunker';
import { logInfo } from '../utils';
import { ensureBrainWatcher, getBrainGeneration } from './brainWatch';
// v5 adds optional per-file `chunks` (section-level index, Phase 1-가) alongside the
// v4 per-file `embedding`. Older indexes are auto-rebuilt on first load — no migration
@@ -109,6 +110,16 @@ interface BrainState {
diskPath: string | null; // null if we can't determine a writable path
writeTimer?: ReturnType<typeof setTimeout>;
evictTimer?: ReturnType<typeof setTimeout>;
/**
* [v2.2.311] statSync 생략 최적화의 장부. statValidatedGen 세대에서 실제로
* stat(또는 재색인)을 거친 파일만 statValidated 에 들어간다 — 세대가 그대로면
* 그 파일들은 재-stat 없이 캐시를 신뢰한다. 세대가 바뀌면(파일시스템 변경)
* 집합을 비워 전 파일이 다시 한 번 stat 을 거친다. 파일 *단위* 로 기록하는
* 이유: 스코프 검색(부분 목록) 뒤 전체 검색이 와도, 검증 안 된 파일이
* 신뢰 경로로 새지 않게 하기 위함.
*/
statValidatedGen?: number;
statValidated?: Set<string>;
}
const _states = new Map<string, BrainState>();
@@ -117,7 +128,16 @@ const _states = new Map<string, BrainState>();
// 확장 호스트 메모리에 세션 내내 상주했다. 인덱스는 mtime 키 디스크 캐시에서 언제든 싸게
// 재로드되므로, 마지막 사용 후 IDLE_EVICT_MS 가 지나면 dirty 를 flush 하고 메모리에서 내린다.
// 상시 인터벌 없이 접근할 때마다 재예약되는 one-shot unref 타이머라 그 자체의 발열 비용은 없다.
const IDLE_EVICT_MS = 30 * 60_000;
// [v2.2.311] 30분 → 2시간: 점심/회의 뒤 첫 질의가 대형 인덱스(수십 MB JSON) 재파싱을
// 뒤집어쓰는 빈도를 줄인다. 메모리 절약보다 응답 지연이 더 아픈 로컬-퍼스트 워크로드.
const IDLE_EVICT_MS = 120 * 60_000;
/**
* [v2.2.311] stat 생략 신뢰 명부에 올리지 않는 "갓 수정된 파일" 창. fs.watch 이벤트는
* 비동기(+디바운스)라, 수정 직후 같은 틱에 들어온 질의는 세대 bump 를 아직 못 본다 —
* 그 창 안의 파일은 명부에서 제외해 다음 호출에서도 stat 을 거치게 한다.
*/
const RECENT_MTIME_GUARD_MS = 3000;
function touchEviction(st: BrainState, brainPath: string): void {
if (st.evictTimer) clearTimeout(st.evictTimer);
@@ -206,8 +226,35 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
const seen = new Set<string>();
let reindexed = 0;
// [v2.2.311] 워처 세대가 마지막 stat 검증 이후 그대로면 — 파일시스템 변경 없음 —
// 그 세대에서 이미 stat 을 거친 파일의 statSync 를 생략하고 캐시 항목을 신뢰.
// 워처가 없으면(gen === -1) 종전대로 매번 stat — 정확성 저하 없음.
ensureBrainWatcher(brainPath);
const watchGen = getBrainGeneration(brainPath);
if (watchGen >= 0 && st.statValidatedGen !== watchGen) {
st.statValidatedGen = watchGen;
st.statValidated = new Set();
}
const validated = watchGen >= 0 ? (st.statValidated ?? (st.statValidated = new Set())) : null;
for (const file of files) {
seen.add(file);
const cachedFast = validated && validated.has(file) ? st.index.entries[file] : undefined;
if (cachedFast
&& Array.isArray(cachedFast.tokens)
&& Array.isArray(cachedFast.titleTokens)) {
out.push({
filePath: file,
relativePath: cachedFast.relativePath,
title: cachedFast.title,
tokens: cachedFast.tokens,
titleTokens: cachedFast.titleTokens,
conflictCount: cachedFast.conflictCount || 0,
mtimeMs: cachedFast.mtimeMs,
kind: cachedFast.kind || '',
});
continue;
}
let stat: fs.Stats;
try {
stat = fs.statSync(file);
@@ -220,6 +267,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
&& cached.size === stat.size
&& Array.isArray(cached.tokens)
&& Array.isArray(cached.titleTokens)) {
if (Date.now() - stat.mtimeMs > RECENT_MTIME_GUARD_MS) validated?.add(file);
out.push({
filePath: file,
relativePath: cached.relativePath,
@@ -255,6 +303,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
st.index.entries[file] = entry;
st.dirty = true;
reindexed++;
if (Date.now() - entry.mtimeMs > RECENT_MTIME_GUARD_MS) validated?.add(file);
out.push({
filePath: file,
relativePath,
+140
View File
@@ -0,0 +1,140 @@
/**
* ============================================================
* Brain Watch — 제2뇌 파일시스템 워처 + 상주 파일 목록 캐시 (v2.2.311)
*
* 문제: 검색은 이미 mtime 키 토큰 인덱스(brain-index.json)로 파일 *내용* 재파싱을
* 피하지만, 매 질의마다 (a) 전체 디렉터리 재귀 워크(수천 파일)와 (b) 파일별
* fs.statSync 수천 회를 다시 수행했다 — 인덱스가 신선한지 확인할 방법이 "전부
* 다시 stat" 뿐이었기 때문. 브레인 6,500 파일 기준 턴당 0.5~1.5초.
*
* 해결: 브레인 루트에 재귀 fs.watch 를 하나 걸고 변경 시 세대(generation) 카운터만
* 올린다. 세대가 그대로면 — 파일 목록도, mtime 도 안 변한 것 — 워크/stat 을 통째로
* 생략하고 메모리의 목록·인덱스를 그대로 신뢰한다. 이것이 "제2뇌를 항상 메모리에
* 올려놓기"의 안전한 형태다: 원본 파일을 통째로 상주시키는 대신(수백 MB), 검색에
* 실제로 쓰는 파생물(토큰 인덱스 + 파일 목록)을 상주시키고 워처가 무효화를 맡는다.
*
* 워처를 못 여는 환경(권한/네트워크 드라이브 등)에서는 generation 이 -1 로 고정되고
* 모든 호출자는 종전 동작(매번 stat)으로 폴백한다 — 정확성은 워처 가용성과 무관.
* ============================================================
*/
import * as fs from 'fs';
import * as path from 'path';
import { findBrainFiles, invalidateBrainFilesCache, logInfo } from '../utils';
interface WatchState {
watcher: fs.FSWatcher | null;
/** 파일시스템 변경마다 증가. 워처 없으면 상태 자체가 failed=true. */
generation: number;
failed: boolean;
/** generation 기준 파일 목록 캐시. */
listGeneration: number;
list: string[] | null;
/** 이벤트 폭주(대량 sync) 시 bump 를 묶는 디바운스 타이머. */
bumpTimer: ReturnType<typeof setTimeout> | null;
}
const _watches = new Map<string, WatchState>();
/** 인덱스 캐시 등 우리 자신의 산출물 경로 — 이 아래 변경은 무효화 사유가 아니다. */
function isSelfNoise(relPath: string): boolean {
// .astra (brain-index.json, eval, growth …) 와 .git 은 검색 대상도 아니고
// 턴마다 우리가 직접 쓰는 곳이라, 여기서 세대를 올리면 캐시가 영원히 못 산다.
return /(^|[\\/])(\.astra|\.git)([\\/]|$)/.test(relPath);
}
function bump(st: WatchState, brainPath: string): void {
if (st.bumpTimer) return; // 이미 예약됨 — 몰아서 1회
st.bumpTimer = setTimeout(() => {
st.bumpTimer = null;
st.generation++;
st.list = null;
invalidateBrainFilesCache(brainPath);
}, 300);
if (typeof (st.bumpTimer as any).unref === 'function') (st.bumpTimer as any).unref();
}
/**
* 브레인 루트에 워처를 보장한다 (idempotent). 실패해도 throw 하지 않는다 —
* 이후 getBrainGeneration() 이 -1 을 반환해 호출자들이 stat 경로로 폴백.
*/
export function ensureBrainWatcher(brainPath: string): void {
if (!brainPath) return;
if (_watches.has(brainPath)) return;
const st: WatchState = { watcher: null, generation: 0, failed: false, listGeneration: -1, list: null, bumpTimer: null };
_watches.set(brainPath, st);
try {
// recursive fs.watch 는 Windows/macOS 에서 네이티브 지원. (Linux 는 Node 20+.)
st.watcher = fs.watch(brainPath, { recursive: true }, (_event, filename) => {
const rel = filename ? String(filename) : '';
if (rel && isSelfNoise(rel)) return;
bump(st, brainPath);
});
st.watcher.on('error', () => {
st.failed = true;
try { st.watcher?.close(); } catch { /* already closed */ }
st.watcher = null;
logInfo('Brain watcher failed — falling back to per-query stat.', { brainPath });
});
logInfo('Brain watcher started — file list & stat checks now cached until change.', { brainPath });
} catch (e: any) {
st.failed = true;
st.watcher = null;
logInfo('Brain watcher unavailable — falling back to per-query stat.', { brainPath, error: e?.message ?? String(e) });
}
}
/**
* 현재 세대 번호. 워처가 없거나 죽었으면 -1 — 호출자는 캐시 신뢰 최적화를 꺼야 한다.
*/
export function getBrainGeneration(brainPath: string): number {
const st = _watches.get(brainPath);
if (!st || st.failed || !st.watcher) return -1;
return st.generation;
}
/**
* 세대 기반 파일 목록 — 세대가 그대로면 디렉터리 워크 없이 메모리 목록을 반환.
* 워처가 없으면 findBrainFiles(5초 TTL)로 폴백해 종전과 동일하게 동작.
*/
export function listBrainFilesCached(brainPath: string): string[] {
if (!brainPath) return [];
ensureBrainWatcher(brainPath);
const st = _watches.get(brainPath)!;
if (st.failed || !st.watcher) return findBrainFiles(brainPath);
if (st.list !== null && st.listGeneration === st.generation) return st.list.slice();
const files = findBrainFiles(brainPath);
st.list = files.slice();
st.listGeneration = st.generation;
return files;
}
/**
* 활성화 직후 백그라운드 워밍 — 첫 질의가 78MB 인덱스 파싱 + 전체 워크를
* 뒤집어쓰지 않도록 미리 올려 둔다. 실패는 조용히 무시 (다음 질의가 정상 경로).
*/
export function warmBrainCache(brainPath: string, warmTokenIndex: (files: string[]) => void): void {
if (!brainPath) return;
setTimeout(() => {
try {
const t0 = Date.now();
const files = listBrainFilesCached(brainPath);
warmTokenIndex(files);
logInfo('Brain cache warmed.', { brainPath, files: files.length, ms: Date.now() - t0 });
} catch (e: any) {
logInfo('Brain cache warm failed (non-fatal).', { brainPath, error: e?.message ?? String(e) });
}
}, 2500);
}
/** 테스트/브레인 전환용 — 워처 해제. */
export function disposeBrainWatcher(brainPath?: string): void {
const targets = brainPath ? [brainPath] : Array.from(_watches.keys());
for (const key of targets) {
const st = _watches.get(key);
if (!st) continue;
if (st.bumpTimer) { clearTimeout(st.bumpTimer); st.bumpTimer = null; }
try { st.watcher?.close(); } catch { /* noop */ }
_watches.delete(key);
}
}
+4 -2
View File
@@ -15,7 +15,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { BrainProfile } from '../config';
import { findBrainFiles, summarizeText } from '../utils';
import { summarizeText } from '../utils';
import { listBrainFilesCached } from './brainWatch';
import { isInside } from '../lib/paths';
import { MemoryManager } from '../memory';
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
@@ -329,7 +330,8 @@ export class RetrievalOrchestrator {
try {
const scoped = (file: string) => scopeFolders.length === 0
|| scopeFolders.some((folder) => isInside(folder, file));
const allFiles = findBrainFiles(brain.localBrainPath)
// [v2.2.311] 워처 세대 기반 상주 목록 — 변경 없으면 디렉터리 재귀 워크 생략.
const allFiles = listBrainFilesCached(brain.localBrainPath)
.filter(scoped)
.filter((file) => {
const rel = path.relative(brain.localBrainPath, file);
+27 -5
View File
@@ -494,14 +494,19 @@ function getEnvironmentBlock(): string {
- Canonical <run_command> example for this OS: ${example}`;
}
export function getSystemPrompt(): string {
const now = new Date();
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
const isoDate = now.toISOString().split('T')[0];
/**
* [KV 캐시 분리 v2.2.311] 시스템 프롬프트의 *턴 사이에 변하지 않는* 부분만 반환.
* 여기에 분 단위 시각 같은 가변 요소가 섞이면 LM Studio(llama.cpp)의 prompt cache 가
* 요청 첫 토큰부터 무효화되어 매 턴 1만+ 토큰 전체를 다시 프리필한다 — 실측 94초/턴의
* 주범. 날짜/시각은 `getDateTimeContextBlock()` 으로 분리해 요청 후미(동적 컨텍스트
* 메시지)에 싣는다.
*/
export function getStaticSystemPrompt(): string {
const envBlock = getEnvironmentBlock();
const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.${envBlock}`;
const base = `${BASE_SYSTEM_PROMPT}\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.${envBlock}`;
// Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증
// 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피.
// (설정은 세션 중 바뀔 수 있지만 드물다 — 바뀌면 그 턴만 캐시 미스, 문제 없음.)
try {
const { getConfig } = require('./config') as typeof import('./config');
const { appendSelfReflectorRule } = require('./features/selfReflector/selfReflectorPrompt') as typeof import('./features/selfReflector/selfReflectorPrompt');
@@ -513,4 +518,21 @@ export function getSystemPrompt(): string {
}
}
/** [CURRENT DATE/TIME] 블록 — 가변이므로 정적 프롬프트가 아니라 동적 컨텍스트에 주입. */
export function getDateTimeContextBlock(): string {
const now = new Date();
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
const isoDate = now.toISOString().split('T')[0];
return `[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`;
}
export function getSystemPrompt(): string {
// 기존 호출부 호환 — 정적 본문 + 날짜 블록을 합친 종전 형태 그대로.
const staticPart = getStaticSystemPrompt();
const dateBlock = getDateTimeContextBlock();
// BASE 직후·위생 규칙 앞이던 종전 위치와 달리 끝에 붙지만, 단독 사용처(브리핑·평가
// 등 1회성 호출)에는 위치가 의미 없다. 채팅 본류는 split 경로를 쓴다.
return `${staticPart}\n\n${dateBlock}`;
}
export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;