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:
+80
-20
@@ -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 { /* 감지 실패가 답변을 막지 않음 */ }
|
||||
}
|
||||
|
||||
@@ -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) 동적 출력 상한.
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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', []) || [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user