From 7d6b8b509f9a38e406693e1e7b413c778a688c0c Mon Sep 17 00:00:00 2001 From: g1nation Date: Mon, 20 Jul 2026 13:21:41 +0900 Subject: [PATCH] =?UTF-8?q?v2.2.311~312:=20=EC=9D=91=EB=8B=B5=20=EC=A7=80?= =?UTF-8?q?=EC=97=B0=20=EA=B7=BC=EB=B3=B8=20=EA=B0=9C=EC=84=A0(KV=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EB=B6=84=EB=A6=AC=C2=B7=EC=A0=9C2?= =?UTF-8?q?=EB=87=8C=20=EC=83=81=EC=A3=BC=20=EC=BA=90=EC=8B=9C)=20+=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=20=ED=92=88=EC=A7=88=20=EC=88=98=EC=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PATCHNOTES.md | 18 +++ package.json | 12 +- src/agent.ts | 100 ++++++++++--- src/agent/actions/stripForDisplay.ts | 31 ++++ .../handlePrompt/computeBudgetedRequest.ts | 49 +++++- src/config.ts | 15 ++ src/extension.ts | 11 ++ src/intelligence/investigationPipeline.ts | 28 ++++ src/intelligence/requirementGraph.ts | 39 +++++ src/lib/contextBuilders/memoryContext.ts | 12 +- src/retrieval/brainIndex.ts | 51 ++++++- src/retrieval/brainWatch.ts | 140 ++++++++++++++++++ src/retrieval/index.ts | 6 +- src/utils.ts | 32 +++- tests/brainCache.test.ts | 98 ++++++++++++ tests/computeBudgetedRequest.test.ts | 89 +++++++++++ tests/investigationPipeline.test.ts | 26 ++++ tests/promptSplit.test.ts | 60 ++++++++ tests/requirementGraph.test.ts | 20 +++ tests/stripForDisplay.test.ts | 35 +++++ 20 files changed, 834 insertions(+), 38 deletions(-) create mode 100644 src/agent/actions/stripForDisplay.ts create mode 100644 src/retrieval/brainWatch.ts create mode 100644 tests/brainCache.test.ts create mode 100644 tests/promptSplit.test.ts create mode 100644 tests/stripForDisplay.test.ts diff --git a/PATCHNOTES.md b/PATCHNOTES.md index ae1e4c3..9cb9cfd 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,23 @@ # Astra Patch Notes +## v2.2.312 (2026-07-20) +### 📋 보고 품질 — "## 4.부터 시작하는 보고서" 버그 + 일반론 총평 차단 +실사례: "connectai 프로젝트를 분석하고 보고해줘" → 답변이 "## 4. 종합 분석 결과"부터 시작(1~3 증발), 내용은 프로젝트명을 바꿔도 성립하는 7줄 일반론. +- **중간 라운드 본문 표시 버그 수정 (근본 원인)**: 액션 루프에서 액션과 *함께* 본문 섹션을 쓴 라운드는 화면에 한 번도 표시되지 않고(라이브 스트리밍은 depth 0 전용) 최종 라운드만 append 됐다. 모델은 히스토리에서 자기 이전 섹션(1~3)을 보고 "## 4."부터 이어 써서 사용자에겐 4번부터 도착. 이제 중간 라운드의 가시 본문을 액션 태그만 걷어내고(신규 stripForDisplay.ts) 버블에 순서대로 표시한다. +- **분석 보고 업무 유형 신설 (requirementGraph)**: "분석하고 보고해줘/현황 보고/검토 보고"가 어떤 유형에도 안 걸리던 갭을 막음. 필수 요소: 보고 개요(첫 줄에 대상—근거—결론 자기선언), **파일 근거**(주장마다 실제 읽은 파일 인용, 일반론 금지), 구조·현황, 핵심 발견, 다음 단계. 유형 감지 시 "최대 3섹션" 일반 규칙보다 필수 요소 커버 우선 명시(보고서가 얕아지는 상충 제거). +- **근거 없는 분석 감지 (Hollow 의 반대 방향)**: 파일을 읽고도(read/investigate ≥1) 답변에 파일 인용이 2개 미만인 장문 분석이면 "근거 인용 없음" footer 로 경고 — 그럴듯한 총평이 검증된 분석처럼 통과하는 것을 차단. +검증: tsc 무오류 + jest 954 통과(신규 13: analysis-report 3 + ungrounded 5 + stripForDisplay 5) + esbuild 정상. + +## v2.2.311 (2026-07-20) +### ⚡ 응답 지연 근본 개선 — KV 캐시 친화 프롬프트 + 제2뇌 상주 캐시 +실측(usage.jsonl): 출력 22토큰 답변에 94.7초 — 지연의 90%+ 가 생성이 아니라 **매 턴 13k+ 토큰 전체 재프리필**이었다. 원인은 (a) 시스템 프롬프트 상단의 분 단위 시각이 llama.cpp prompt cache 를 첫 토큰부터 무효화, (b) 턴마다 바뀌는 RAG/동적 블록이 전부 message[0] 에 있어 히스토리 캐시까지 연쇄 무효화, (c) 32K 창에서 검색 결과에만 8k 토큰 예산, (d) continuation 마다 빈 쿼리로 재검색(3~8초), (e) 매 질의 6,500 파일 디렉터리 워크 + statSync. +- **KV 캐시 분리 (`kvCachePromptSplit`, 기본 ON)**: message[0] 을 불변 정적 본문(페르소나·규칙·환경)으로 고정하고, 날짜/RAG/[CONTEXT]/동적 블록 전부를 *마지막 user 메시지 직전*의 internal system 메시지로 이동. 프리픽스(정적 프롬프트+과거 히스토리)가 캐시에서 재사용되어 재프리필이 "직전 교환 + 동적 컨텍스트"로 줄어든다. [CONTEXT] truncation 도 tail 에 적용. 커스텀 systemPrompt 호출자(멀티에이전트 등)는 종전 경로 유지. +- **검색 토큰 예산 현실화 (`retrievalTokenBudget`, 0=자동)**: 자동값을 창의 25%(8k~80k) → 12%(2.5k~6k 클램프)로. 매 턴 재프리필되는 tail 을 직접 얇게 만든다. +- **continuation 은 depth-0 memoryCtx 재사용**: 액션 라운드마다의 재검색 제거 — 라운드당 3~8초 절감 + 빈 쿼리 재검색으로 청크가 갈리던 문제 제거 + 턴 내 프롬프트 안정화. +- **제2뇌 상주 캐시 (신규 brainWatch.ts)**: 브레인 루트에 재귀 fs.watch 를 걸고 세대 카운터로 무효화 — 변경 없으면 디렉터리 워크와 파일별 statSync(수천 회)를 통째로 생략하고 메모리의 파일 목록·토큰 인덱스를 신뢰한다. "제2뇌를 항상 메모리에" 아이디어의 안전한 구현: 원본 대신 검색 파생물을 상주시키고 워처가 정합성을 맡는다. 수정 직후 3초 창은 신뢰 명부에서 제외(이벤트 지연 레이스 가드), 워처 불가 환경은 자동으로 종전(매번 stat) 폴백. 활성화 시 백그라운드 워밍으로 첫 질의의 인덱스 파싱 비용도 선불 처리, 유휴 해제 30분→2시간. +- 기대 효과: 동일 세션 반복 턴의 프리필이 13k → 수백~2k 토큰 수준. LM Studio 쪽 GPU 오프로드/Flash Attention 설정과 병행 권장. +검증: tsc 무오류 + jest 941 통과(신규 15: split 6 + brainCache 4 + promptSplit 5) + esbuild 정상. + ## v2.2.307 (2026-07-11) ### 🏗️ /benchmark 레이아웃 골격 분석 + 합성 안정화 koritips 실측 피드백 반영: ① 합성이 파트 2 에서 타임아웃("This operation was aborted")으로 죽으면서 105초 걸린 파트 1까지 폐기 ② 남은 원시 요약이 색·폰트뿐이라 "배너가 롤링인지, 각 페이지가 어떤 블록 순서인지" 알 길이 없었음. diff --git a/package.json b/package.json index 6c469bd..dd6f01d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.310", + "version": "2.2.312", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -1294,6 +1294,16 @@ "type": "string", "default": "", "markdownDescription": "조사형 요청('~폴더 조사해줘') 전용 모델 오버라이드. 예: 'claude-code:sonnet'. 소형 로컬 모델이 파일명만 보고 내용을 상상하는 헛조사 방지책의 하나 — 비우면 현재 선택 모델 사용." + }, + "g1nation.kvCachePromptSplit": { + "type": "boolean", + "default": true, + "markdownDescription": "**KV 캐시 친화 프롬프트 분리** — 시스템 프롬프트를 '불변 본문(message[0])'과 '턴별 동적 컨텍스트(마지막 질문 직전 주입)'로 분리해 LM Studio/llama.cpp 의 prompt cache 프리픽스 재사용을 극대화. 매 턴 전체 재프리필(1만+ 토큰, 수십 초)을 직전 교환 분량으로 줄인다. 특정 모델 템플릿에서 응답 품질 문제가 보이면 끄세요." + }, + "g1nation.retrievalTokenBudget": { + "type": "number", + "default": 0, + "markdownDescription": "**RAG 검색 토큰 예산 상한** — 두뇌/메모리 검색 결과가 프롬프트에서 차지할 최대 토큰. `0 = 자동`(컨텍스트 창의 12%, 2,500~6,000 클램프). 값이 클수록 근거는 풍부해지지만 로컬 모델 프리필이 느려진다." } } } diff --git a/src/agent.ts b/src/agent.ts index 6e2925f..bb9e354 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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 { /* 감지 실패가 답변을 막지 않음 */ } } diff --git a/src/agent/actions/stripForDisplay.ts b/src/agent/actions/stripForDisplay.ts new file mode 100644 index 0000000..d5e23e0 --- /dev/null +++ b/src/agent/actions/stripForDisplay.ts @@ -0,0 +1,31 @@ +/** + * [v2.2.312] 화면 표시용 액션 태그 제거. + * + * 액션 루프의 중간 라운드(액션과 함께 본문 섹션을 쓴 라운드)를 사용자 버블에 + * 표시할 때, 같은 실행 태그 원문이 그대로 노출되지 않도록 걷어낸다. + * 실행 자체는 이 함수와 무관 — 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) { + // 자기닫힘 → 블록 ... → 잔재 여는/닫는 태그 순. + t = t.replace(new RegExp(`<${name}\\b[^>]*/>`, 'gi'), ''); + t = t.replace(new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?`, 'gi'), ''); + t = t.replace(new RegExp(`]*>`, 'gi'), ''); + } + return t.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); +} diff --git a/src/agent/handlePrompt/computeBudgetedRequest.ts b/src/agent/handlePrompt/computeBudgetedRequest.ts index 641a276..03401ee 100644 --- a/src/agent/handlePrompt/computeBudgetedRequest.ts +++ b/src/agent/handlePrompt/computeBudgetedRequest.ts @@ -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) 동적 출력 상한. diff --git a/src/config.ts b/src/config.ts index 2ef55f3..b7761e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium', citationTraceEnabled: cfg.get('citationTraceEnabled', true), investigationModel: (cfg.get('investigationModel', '') || '').trim(), + kvCachePromptSplit: cfg.get('kvCachePromptSplit', true), + retrievalTokenBudget: Math.max(0, cfg.get('retrievalTokenBudget', 0) || 0), selfCheckEnabled: cfg.get('selfCheckEnabled', false), selfCheckModel: cfg.get('selfCheckModel', '') || '', selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get('selfCheckTimeoutSec', 6))), diff --git a/src/extension.ts b/src/extension.ts index 1117d38..e823dd3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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); diff --git a/src/intelligence/investigationPipeline.ts b/src/intelligence/investigationPipeline.ts index f851d02..885c009 100644 --- a/src/intelligence/investigationPipeline.ts +++ b/src/intelligence/investigationPipeline.ts @@ -226,3 +226,31 @@ export function formatHollowInvestigationFooter(fileMentions: string[]): string '내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 ``로 각 파일을 실제로 읽고 답합니다.', ].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'); +} diff --git a/src/intelligence/requirementGraph.ts b/src/intelligence/requirementGraph.ts index f270e04..bf08f2e 100644 --- a/src/intelligence/requirementGraph.ts +++ b/src/intelligence/requirementGraph.ts @@ -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'); } diff --git a/src/lib/contextBuilders/memoryContext.ts b/src/lib/contextBuilders/memoryContext.ts index bcf5cd3..7c1c43f 100644 --- a/src/lib/contextBuilders/memoryContext.ts +++ b/src/lib/contextBuilders/memoryContext.ts @@ -166,11 +166,13 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise 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('chat_sessions', []) || []; diff --git a/src/retrieval/brainIndex.ts b/src/retrieval/brainIndex.ts index 0522281..0e7dfb6 100644 --- a/src/retrieval/brainIndex.ts +++ b/src/retrieval/brainIndex.ts @@ -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; evictTimer?: ReturnType; + /** + * [v2.2.311] statSync 생략 최적화의 장부. statValidatedGen 세대에서 실제로 + * stat(또는 재색인)을 거친 파일만 statValidated 에 들어간다 — 세대가 그대로면 + * 그 파일들은 재-stat 없이 캐시를 신뢰한다. 세대가 바뀌면(파일시스템 변경) + * 집합을 비워 전 파일이 다시 한 번 stat 을 거친다. 파일 *단위* 로 기록하는 + * 이유: 스코프 검색(부분 목록) 뒤 전체 검색이 와도, 검증 안 된 파일이 + * 신뢰 경로로 새지 않게 하기 위함. + */ + statValidatedGen?: number; + statValidated?: Set; } const _states = new Map(); @@ -117,7 +128,16 @@ const _states = new Map(); // 확장 호스트 메모리에 세션 내내 상주했다. 인덱스는 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(); 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, diff --git a/src/retrieval/brainWatch.ts b/src/retrieval/brainWatch.ts new file mode 100644 index 0000000..02d0614 --- /dev/null +++ b/src/retrieval/brainWatch.ts @@ -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 | null; +} + +const _watches = new Map(); + +/** 인덱스 캐시 등 우리 자신의 산출물 경로 — 이 아래 변경은 무효화 사유가 아니다. */ +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); + } +} diff --git a/src/retrieval/index.ts b/src/retrieval/index.ts index 597fb2e..39a9543 100644 --- a/src/retrieval/index.ts +++ b/src/retrieval/index.ts @@ -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); diff --git a/src/utils.ts b/src/utils.ts index e9f5d97..04a05da 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -494,14 +494,19 @@ function getEnvironmentBlock(): string { - Canonical 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; diff --git a/tests/brainCache.test.ts b/tests/brainCache.test.ts new file mode 100644 index 0000000..65e217a --- /dev/null +++ b/tests/brainCache.test.ts @@ -0,0 +1,98 @@ +/** + * [v2.2.311] 제2뇌 상주 캐시 계층 — 워처 세대 기반 stat 생략 + 파일 목록 캐시. + * + * 핵심 계약: + * 1. 워처 세대가 그대로면 getBrainTokenIndex 는 파일별 statSync 를 생략한다. + * 2. 세대가 바뀌면(파일시스템 변경) 다시 stat 을 거쳐 변경분을 재색인한다. + * 3. 부분(스코프) 목록으로 검증된 상태에서 전체 목록 질의가 와도, 검증 안 된 + * 파일은 신뢰 경로로 새지 않는다 (파일 단위 검증 장부). + * 4. 워처 불가 환경(gen === -1)에서는 종전과 동일하게 매번 stat. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// brainWatch 를 결정적으로 제어 — 실제 fs.watch 이벤트 타이밍에 의존하지 않는다. +let mockGen = 0; +jest.mock('../src/retrieval/brainWatch', () => ({ + ensureBrainWatcher: jest.fn(), + getBrainGeneration: jest.fn(() => mockGen), + listBrainFilesCached: jest.fn(), + warmBrainCache: jest.fn(), + disposeBrainWatcher: jest.fn(), +})); + +import { getBrainTokenIndex } from '../src/retrieval/brainIndex'; + +function makeBrain(files: Record): { root: string; paths: string[] } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-brain-')); + const paths: string[] = []; + for (const [name, content] of Object.entries(files)) { + const p = path.join(root, name); + fs.writeFileSync(p, content, 'utf8'); + // RECENT_MTIME_GUARD(3초): 갓 수정된 파일은 신뢰 명부에 오르지 않으므로, + // "변경 없음" 시나리오를 테스트하려면 mtime 을 과거로 되돌린다. + const past = new Date(Date.now() - 60_000); + fs.utimesSync(p, past, past); + paths.push(p); + } + return { root, paths }; +} + +function countStatsFor(paths: string[], spy: jest.SpyInstance): number { + return spy.mock.calls.filter((c) => paths.includes(String(c[0]))).length; +} + +describe('getBrainTokenIndex — 워처 세대 기반 stat 생략', () => { + afterEach(() => jest.restoreAllMocks()); + + test('같은 세대의 2번째 호출은 statSync 0회 (캐시 신뢰)', () => { + mockGen = 0; + const { root, paths } = makeBrain({ 'a.md': '알파 내용', 'b.md': '베타 내용' }); + expect(getBrainTokenIndex(root, paths)).toHaveLength(2); + + const spy = jest.spyOn(fs, 'statSync'); + const r2 = getBrainTokenIndex(root, paths); + expect(r2).toHaveLength(2); + expect(countStatsFor(paths, spy)).toBe(0); + expect(r2.map((d) => d.title).sort()).toEqual(['a', 'b']); + }); + + test('세대가 바뀌면 재-stat 후 변경 내용을 재색인한다', () => { + mockGen = 0; + const { root, paths } = makeBrain({ 'a.md': '원본 토큰들' }); + getBrainTokenIndex(root, paths); + + // 파일 변경 + 세대 bump (mtime 차이를 보장하기 위해 utimesSync 로 강제) + fs.writeFileSync(paths[0], '완전히 새로운 내용입니다', 'utf8'); + const future = new Date(Date.now() + 5000); + fs.utimesSync(paths[0], future, future); + mockGen = 1; + + const spy = jest.spyOn(fs, 'statSync'); + const r = getBrainTokenIndex(root, paths); + expect(countStatsFor(paths, spy)).toBeGreaterThan(0); + expect(r[0].tokens).toEqual(expect.arrayContaining(['새로운'])); + }); + + test('스코프 검증 후 전체 질의 — 미검증 파일은 신뢰 경로로 새지 않는다', () => { + mockGen = 0; + const { root, paths } = makeBrain({ 'in-scope.md': '스코프 안', 'out-scope.md': '스코프 밖' }); + const [inScope, outScope] = paths; + getBrainTokenIndex(root, [inScope]); // 부분 목록만 검증됨 + + const spy = jest.spyOn(fs, 'statSync'); + getBrainTokenIndex(root, paths); // 전체 질의 + expect(countStatsFor([inScope], spy)).toBe(0); // 검증된 파일은 생략 + expect(countStatsFor([outScope], spy)).toBeGreaterThan(0); // 미검증 파일은 stat + }); + + test('워처 불가(gen=-1) 환경은 매번 stat (종전 동작)', () => { + mockGen = -1; + const { root, paths } = makeBrain({ 'a.md': '내용' }); + getBrainTokenIndex(root, paths); + const spy = jest.spyOn(fs, 'statSync'); + getBrainTokenIndex(root, paths); + expect(countStatsFor(paths, spy)).toBeGreaterThan(0); + }); +}); diff --git a/tests/computeBudgetedRequest.test.ts b/tests/computeBudgetedRequest.test.ts index 75bb816..82c2972 100644 --- a/tests/computeBudgetedRequest.test.ts +++ b/tests/computeBudgetedRequest.test.ts @@ -56,3 +56,92 @@ describe('computeBudgetedRequest — real-window alignment', () => { expect(run({ actualContextLength: NaN }).effectiveContextLength).toBe(32768); }); }); + +// ──────────────────────────────────────────────────────────────────────────── +// [v2.2.311] KV 캐시 분리 — dynamicContextTail 삽입 계약. +// message[0] 은 불변 정적 프롬프트로 남고, 동적 컨텍스트는 마지막 user 메시지 +// 직전의 internal system 메시지로 들어가야 프롬프트 프리픽스 캐시가 산다. +// ──────────────────────────────────────────────────────────────────────────── +describe('computeBudgetedRequest — dynamicContextTail (KV cache split)', () => { + const HEAD = 'STATIC HEAD PROMPT'; + const TAIL = '[CURRENT DATE/TIME]\nToday: 2026-07-20\n\n[CONTEXT]\nRAG chunk A\n[/CONTEXT]'; + + // 주의: default parameter 는 명시적 undefined 에도 적용되므로 쓰지 않는다 — + // "tail 없음" 케이스를 정확히 표현하기 위해 항상 명시적으로 넘긴다. + function runSplit(reqMessages: ChatMessage[], tail: string | undefined) { + return computeBudgetedRequest({ + fullSystemPrompt: HEAD, + dynamicContextTail: tail, + reqMessages, + actualModel: 'some-13b-model', + config: baseConfig, + imageCount: 0, + }); + } + + test('tail 은 마지막 user 메시지 직전에 internal system 으로 삽입된다', () => { + const msgs: ChatMessage[] = [ + { role: 'user', content: '첫 질문' }, + { role: 'assistant', content: '첫 답' }, + { role: 'user', content: '두번째 질문' }, + ]; + const r = runSplit(msgs, TAIL); + expect(r.messagesForRequest[0]).toMatchObject({ role: 'system', content: HEAD }); + const idx = r.messagesForRequest.findIndex((m) => m.content === TAIL); + expect(idx).toBeGreaterThan(0); + expect(r.messagesForRequest[idx].role).toBe('system'); + expect(r.messagesForRequest[idx + 1]).toMatchObject({ role: 'user', content: '두번째 질문' }); + }); + + test('continuation(마지막 user 뒤 액션 결과 system)이 있어도 tail 은 user 앞', () => { + const msgs: ChatMessage[] = [ + { role: 'user', content: '조사해줘' }, + { role: 'system', content: '[Result of read_file] ...', internal: true }, + ]; + const r = runSplit(msgs, TAIL); + const tailIdx = r.messagesForRequest.findIndex((m) => m.content === TAIL); + const userIdx = r.messagesForRequest.findIndex((m) => m.role === 'user'); + const actionIdx = r.messagesForRequest.findIndex((m) => String(m.content).startsWith('[Result of')); + expect(tailIdx).toBeLessThan(userIdx); + expect(actionIdx).toBeGreaterThan(userIdx); + }); + + test('user 메시지가 없으면 tail 을 히스토리 끝에 붙인다', () => { + const msgs: ChatMessage[] = [{ role: 'system', content: 'internal only', internal: true }]; + const r = runSplit(msgs, TAIL); + const last = r.messagesForRequest[r.messagesForRequest.length - 1]; + expect(last.content).toBe(TAIL); + }); + + test('tail 미지정이면 종전 단일-시스템 동작 그대로', () => { + const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }]; + const r = runSplit(msgs, undefined); + expect(r.messagesForRequest).toHaveLength(2); + expect(r.messagesForRequest[0].content).toBe(HEAD); + }); + + test('예산 초과 시 [CONTEXT] truncation 은 tail 에 적용되고 head 는 보존', () => { + const hugeTail = `[CONTEXT]\n${'긴 배경 지식 청크. '.repeat(20000)}\n[/CONTEXT]`; + const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }]; + const r = computeBudgetedRequest({ + fullSystemPrompt: HEAD, + dynamicContextTail: hugeTail, + reqMessages: msgs, + actualModel: 'some-13b-model', + config: { ...baseConfig, contextLength: 8192 }, + imageCount: 0, + }); + expect(r.systemTruncated).toBe(true); + expect(r.messagesForRequest[0].content).toBe(HEAD); + const tailMsg = r.messagesForRequest.find((m) => String(m.content).includes('[CONTEXT]')); + expect(tailMsg).toBeDefined(); + expect(String(tailMsg!.content).length).toBeLessThan(hugeTail.length); + }); + + test('systemTokens 는 head+tail 합산으로 보고된다', () => { + const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }]; + const withTail = runSplit(msgs, TAIL); + const withoutTail = runSplit(msgs, undefined); + expect(withTail.systemTokens).toBeGreaterThan(withoutTail.systemTokens); + }); +}); diff --git a/tests/investigationPipeline.test.ts b/tests/investigationPipeline.test.ts index 7e8c570..f82ad65 100644 --- a/tests/investigationPipeline.test.ts +++ b/tests/investigationPipeline.test.ts @@ -165,3 +165,29 @@ describe('detectHollowInvestigation — 헛조사 감지', () => { expect(footer).toContain('investigate_files'); }); }); + +// [v2.2.312] 근거 없는 분석 감지 — 파일을 읽고도 인용이 없는 일반론 보고. +import { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } from '../src/intelligence/investigationPipeline'; + +describe('detectUngroundedAnalysis — 일반론 분석 감지', () => { + const generic = '이 프로젝트는 로컬 LLM과 RAG 기술의 결합 시도가 매우 인상적입니다. '.repeat(10); + + test('read 를 하고도 파일 인용 없는 장문 = ungrounded', () => { + const r = detectUngroundedAnalysis(generic, { reads: 3, lists: 1, investigates: 0 }); + expect(r.ungrounded).toBe(true); + expect(r.readCount).toBe(3); + }); + test('파일 근거를 2개 이상 인용하면 정상', () => { + const grounded = `${generic} 근거: agent.ts 의 handlePrompt 와 memoryContext.ts 의 buildMemoryContext 를 확인함.`; + expect(detectUngroundedAnalysis(grounded, { reads: 3, lists: 1, investigates: 0 }).ungrounded).toBe(false); + }); + test('read 가 없으면 판정 대상 아님 (hollow 감지의 영역)', () => { + expect(detectUngroundedAnalysis(generic, { reads: 0, lists: 1, investigates: 0 }).ungrounded).toBe(false); + }); + test('짧은 확인형 응답은 오탐 방지로 제외', () => { + expect(detectUngroundedAnalysis('네, 서버 정상입니다.', { reads: 1, lists: 0, investigates: 0 }).ungrounded).toBe(false); + }); + test('footer 에 읽은 횟수 포함', () => { + expect(formatUngroundedAnalysisFooter(3)).toContain('3회'); + }); +}); diff --git a/tests/promptSplit.test.ts b/tests/promptSplit.test.ts new file mode 100644 index 0000000..919a6fb --- /dev/null +++ b/tests/promptSplit.test.ts @@ -0,0 +1,60 @@ +/** + * [v2.2.311] KV 캐시 친화 프롬프트 분리 — 정적/동적 경계 계약. + * + * 정적 프롬프트(getStaticSystemPrompt)에 분 단위 시각 같은 가변 요소가 섞이는 순간 + * llama.cpp prompt cache 는 요청 첫 토큰부터 무효화된다 (실측: 13k 토큰 전체 재프리필 + * ≈ 90초/턴). 이 테스트는 그 회귀를 막는 가드레일이다. + */ +import { getStaticSystemPrompt, getDateTimeContextBlock, getSystemPrompt } from '../src/utils'; +import { buildAstraModeSystemPrompt } from '../src/agent/handlePrompt/buildAstraModeSystemPrompt'; + +describe('정적/동적 시스템 프롬프트 분리', () => { + test('정적 프롬프트에는 날짜/시각이 없다 (캐시 무효화 가드)', () => { + const s = getStaticSystemPrompt(); + expect(s).not.toContain('[CURRENT DATE/TIME]'); + // 오늘 날짜 문자열이 어떤 형태로도 들어있으면 안 된다. + const iso = new Date().toISOString().split('T')[0]; + expect(s).not.toContain(iso); + }); + + test('정적 프롬프트는 연속 호출에서 동일하다 (프리픽스 불변)', () => { + expect(getStaticSystemPrompt()).toBe(getStaticSystemPrompt()); + }); + + test('날짜 블록은 오늘 날짜를 포함한다', () => { + const block = getDateTimeContextBlock(); + expect(block).toContain('[CURRENT DATE/TIME]'); + expect(block).toContain(new Date().toISOString().split('T')[0]); + }); + + test('레거시 getSystemPrompt 는 정적 본문 + 날짜 블록을 모두 포함 (호환)', () => { + const s = getSystemPrompt(); + expect(s).toContain('[CURRENT DATE/TIME]'); + expect(s).toContain('[출력 위생 규칙 — 반드시 준수]'); + }); + + test('빌더에 systemPrompt="" 를 넘겨도 [CONTEXT] 골격은 유지된다 (tail 생성 경로)', () => { + const tail = buildAstraModeSystemPrompt({ + prompt: '질문', + systemPrompt: '', + modeBridgeCtx: '', + priorConclusionCtx: '', + designerCtx: '', + projectArchitectureCtx: '', + secondBrainTraceCtx: '', + memoryCtx: 'RAG 본문', + knowledgeContextForPrompt: '', + contextBlock: '열린 파일 내용', + negativeCtx: '', + isCasualConversation: false, + localPathContext: '', + knowledgeMix: null, + dynamicBlocks: new Map([['behavior-constraints', '[GROUNDING] 근거 강도: 보통']]), + }); + expect(tail).toContain('[CONTEXT]'); + expect(tail).toContain('RAG 본문'); + expect(tail).toContain('열린 파일 내용'); + expect(tail).toContain('[GROUNDING]'); + expect(tail.startsWith('You are Astra')).toBe(false); // 정적 본문이 섞이면 안 됨 + }); +}); diff --git a/tests/requirementGraph.test.ts b/tests/requirementGraph.test.ts index a7d218a..65f994e 100644 --- a/tests/requirementGraph.test.ts +++ b/tests/requirementGraph.test.ts @@ -124,3 +124,23 @@ describe('DEFAULT_TASK_REQUIREMENTS 무결성', () => { } }); }); + +// [v2.2.312] 분석 보고 유형 — "프로젝트 분석하고 보고해줘" 실사례가 유형 미감지로 +// 일반론 총평이 되던 갭을 막는다. +describe('detectTaskType — analysis-report (v2.2.312)', () => { + test('실사례 문장을 정확히 감지한다', () => { + expect(detectTaskType('E:\Wiki\connectai 프로젝트를 분석하고 어떻게 되어 있는지 보고해줘')?.id).toBe('analysis-report'); + expect(detectTaskType('진행 현황 보고 부탁해')?.id).toBe('analysis-report'); + expect(detectTaskType('이 코드 검토해서 보고해줘')?.id).toBe('analysis-report'); + }); + test('더 구체적인 유형이 우선한다 (회의록·시장조사)', () => { + expect(detectTaskType('회의록으로 정리해서 보고해줘')?.id).toBe('meeting-minutes'); + expect(detectTaskType('시장조사 결과 보고해줘')?.id).toBe('market-research'); + }); + test('블록에 파일 근거 요소와 섹션 규칙 오버라이드가 포함된다', () => { + const block = buildRequirementGraphBlock('프로젝트 분석하고 보고해줘'); + expect(block).toContain('파일 근거'); + expect(block).toContain('보고 개요'); + expect(block).toContain('최대 3섹션'); + }); +}); diff --git a/tests/stripForDisplay.test.ts b/tests/stripForDisplay.test.ts new file mode 100644 index 0000000..f8a4f92 --- /dev/null +++ b/tests/stripForDisplay.test.ts @@ -0,0 +1,35 @@ +/** + * [v2.2.312] 중간 라운드 표시용 액션 태그 제거 — "## 4.부터 시작하는 보고서" 버그의 + * 표시 경로 헬퍼. 태그는 걷어내되 본문 섹션은 보존해야 한다. + */ +import { stripActionTagsForDisplay } from '../src/agent/actions/stripForDisplay'; + +describe('stripActionTagsForDisplay', () => { + test('자기닫힘 태그 제거 + 본문 보존', () => { + const input = '## 1. 프로젝트 개요\n구조를 확인한다.\n\n'; + const out = stripActionTagsForDisplay(input); + expect(out).toContain('## 1. 프로젝트 개요'); + expect(out).not.toContain(' { + const input = '분석 시작.\ncd x; npm test\n본문\n끝.'; + const out = stripActionTagsForDisplay(input); + expect(out).toContain('분석 시작.'); + expect(out).toContain('끝.'); + expect(out).not.toContain('npm test'); + expect(out).not.toContain(' { + const out = stripActionTagsForDisplay('앞 뒤'); + expect(out).not.toContain(' { + expect(stripActionTagsForDisplay('## 2. 구조\n- src/agent.ts')).toBe('## 2. 구조\n- src/agent.ts'); + }); + test('빈 입력 안전', () => { + expect(stripActionTagsForDisplay('')).toBe(''); + }); +});