diff --git a/package.json b/package.json
index b7b1800..6c469bd 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.307",
+ "version": "2.2.310",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
@@ -54,6 +54,10 @@
"command": "g1nation.growth.report",
"title": "Astra: 성장 리포트 (Reflection 추이)"
},
+ {
+ "command": "g1nation.growth.standingRules",
+ "title": "Astra: 상시 행동 규칙 (Standing Rules)"
+ },
{
"command": "g1nation.growth.learningQueue",
"title": "Astra: 학습 큐 갱신 (Need Engine)"
@@ -352,7 +356,10 @@
"g1nation.meetTaskDateFallback": {
"type": "string",
"default": "today",
- "enum": ["today", "hold"],
+ "enum": [
+ "today",
+ "hold"
+ ],
"markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)"
},
"g1nation.meetTaskDetailExpand": {
@@ -1282,6 +1289,11 @@
"type": "number",
"default": 0,
"markdownDescription": "**Stocks 보고서 전용 텔레그램 chatId** — fallback. `g1nation.telegram.allowedChatIds` 가 비어있을 때만 사용. 0 = 미설정."
+ },
+ "g1nation.investigationModel": {
+ "type": "string",
+ "default": "",
+ "markdownDescription": "조사형 요청('~폴더 조사해줘') 전용 모델 오버라이드. 예: 'claude-code:sonnet'. 소형 로컬 모델이 파일명만 보고 내용을 상상하는 헛조사 방지책의 하나 — 비우면 현재 선택 모델 사용."
}
}
}
diff --git a/src/agent.ts b/src/agent.ts
index bda6fff..6e2925f 100644
--- a/src/agent.ts
+++ b/src/agent.ts
@@ -24,7 +24,8 @@ import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/
import { ensureFeatureInventory } from './extension/featureInventory';
import { buildUrlContext } from './lib/contextBuilders/urlContext';
import { extractUrls } from './features/web/webFetch';
-import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop';
+import { looksLikeCorrection, looksLikeBehaviorComplaint, captureCorrection } from './intelligence/correctionLoop';
+import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter } from './intelligence/investigationPipeline';
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary';
@@ -163,6 +164,7 @@ import { applyRunCommandActions } from './agent/actions/runCommand';
import { applyCalculateActions } from './agent/actions/calculate';
import { applyRunCodeActions } from './agent/actions/runCode';
import { applyListFilesActions } from './agent/actions/listFiles';
+import { applyInvestigateFilesActions } from './agent/actions/investigateFiles';
import { applyWebFetchActions } from './agent/actions/webFetch';
import { applyBrainOpsActions } from './agent/actions/brainOps';
import { applyCalendarActions } from './agent/actions/calendar';
@@ -316,6 +318,10 @@ export class AgentExecutor {
selfCheckSources: Array<{ title: string; excerpt: string }>;
/** Confidence Engine 검색 신호 (Phase 2) — memoryContext 가 채움. */
confidenceSignals: import('./intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
+ /** [v2.2.309] 이번 turn 의 액션 실행 통계 — Hollow Investigation 감지용 (loop depth 누적). */
+ actionStats: { reads: number; lists: number; investigates: number };
+ /** [v2.2.309] 조사 턴 모델 오버라이드 — depth 0 에서 결정, continuation 에도 유지. */
+ investigationModelOverride: string | null;
} = {
retrieval: null,
lessons: [],
@@ -323,6 +329,8 @@ export class AgentExecutor {
dynamicBlocks: new Map(),
selfCheckSources: [],
confidenceSignals: null,
+ actionStats: { reads: 0, lists: 0, investigates: 0 },
+ investigationModelOverride: null,
};
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
@@ -333,6 +341,10 @@ export class AgentExecutor {
this._turnCtx.dynamicBlocks.clear();
this._turnCtx.selfCheckSources = [];
this._turnCtx.confidenceSignals = null;
+ // actionStats / investigationModelOverride 는 여기서 리셋하지 않는다 —
+ // resetTurnContext 는 continuation depth 에서도 호출되는데(메모리 컨텍스트 재구축),
+ // 이 둘은 사용자 turn 전체(모든 depth)에 걸쳐 누적/유지되어야 한다.
+ // 리셋은 loopDepth === 0 진입부에서만 (handlePrompt 초입).
}
private readonly options: AgentExecutorOptions;
@@ -545,6 +557,9 @@ export class AgentExecutor {
// buildMemoryContext, the previous turn's value would otherwise leak into this turn's
// "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug).
this.resetTurnContext();
+ // [v2.2.309] turn 전체(모든 depth) 누적 상태 — depth 0 에서만 초기화.
+ this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 };
+ this._turnCtx.investigationModelOverride = null;
}
// 1. Prepare Context
@@ -618,6 +633,7 @@ export class AgentExecutor {
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 와 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* / 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
+- 폴더 전체를 조사해야 하면 파일을 하나씩 읽지 말고 태그 하나를 emit 하라 — 시스템이 폴더의 모든 텍스트 파일을 실제로 읽어 파일별 노트를 주입한다. list_files 로 받은 *파일명*만 보고 내용을 서술하는 것은 날조다.
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
- 일반론·추측으로 빈칸을 채우지 마라.`;
}
@@ -637,24 +653,28 @@ export class AgentExecutor {
}
}
- // [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*이면 fire-and-forget
- // 캡처 — 오류 분류 → 태깅 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl).
- // 정정 자체가 Ground Truth 가 되어 주간 회귀 테스트·약점 프로필의 원료가 된다.
+ // [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*(사실) 또는 *행동/스타일
+ // 지적*("또 ~하네", "하지 말라고 했잖아")이면 fire-and-forget 캡처 — 오류 분류 →
+ // 태깅 레슨 + 회귀 케이스. 행동 지적은 추가로 Standing Rule 로 정규화되어
+ // 다음 턴부터(새 세션 포함) 매 턴 주입된다 (v2.2.308 — 세션 휘발 문제 해결).
// 턴 응답을 막지 않는다 (await 없음).
- if (prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt)) {
+ const isFactCorrection = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt));
+ const isBehaviorComplaint = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeBehaviorComplaint(prompt));
+ if (isFactCorrection || isBehaviorComplaint) {
const visible = this.chatHistory.filter(m => !m.internal);
const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant');
const lastUserIdx = lastAssistant ? visible.lastIndexOf(lastAssistant) - 1 : -1;
const priorQuestion = lastUserIdx >= 0 && visible[lastUserIdx]?.role === 'user' ? visible[lastUserIdx].content : '';
if (lastAssistant && priorQuestion) {
void captureCorrection({
- brainPath: activeBrain.localBrainPath,
+ brainPath: activeBrain!.localBrainPath,
question: priorQuestion,
wrongAnswer: lastAssistant.content,
- correction: prompt,
+ correction: prompt!,
llm: { baseUrl: config.ollamaUrl, model: configDefaultModel },
+ behaviorHint: isBehaviorComplaint,
}).then(file => {
- if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file });
+ if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file, behavior: isBehaviorComplaint });
}).catch((e: any) => logError('Correction Loop 캡처 실패 (무시).', { error: e?.message ?? String(e) }));
}
}
@@ -670,7 +690,18 @@ export class AgentExecutor {
}
// 3. API Request Setup (라인 229에서 이미 추출한 ollamaUrl, configDefaultModel 재사용)
- const actualModel = (modelName && modelName.trim()) || configDefaultModel;
+ let actualModel = (modelName && modelName.trim()) || configDefaultModel;
+ // [v2.2.309-D] 조사형 요청은 설정된 상위 모델로 라우팅 (예: "claude-code:sonnet").
+ // 소형 로컬 모델의 lazy tool-use 할루시네이션 대책의 마지막 층 — depth 0 에서
+ // 결정하고 continuation(액션 결과로 이어지는 답변)에도 같은 모델을 유지한다.
+ if (loopDepth === 0 && prompt && config.investigationModel
+ && isLocalInvestigationPrompt(prompt)) {
+ this._turnCtx.investigationModelOverride = config.investigationModel;
+ logInfo('조사 요청 감지 — investigationModel 로 라우팅.', { model: config.investigationModel });
+ }
+ if (this._turnCtx.investigationModelOverride) {
+ actualModel = this._turnCtx.investigationModelOverride;
+ }
// Bound the in-memory history before building the request — shrinks bulky
// older tool-result bodies and drops the oldest messages past the cap.
capChatHistory(this.chatHistory, {
@@ -994,6 +1025,12 @@ export class AgentExecutor {
const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null;
const postLiveToken = (token: string) => {
const visible = liveFilter ? liveFilter.push(token) : token;
+ // [v2.2.310] LM Studio LSEP 구분자 — 여는 마커 없이 흐른 추론이 화면에
+ // 이미 표시된 상태. streamReplace 로 걷어내고 실제 답변부터 다시.
+ if (liveFilter?.consumeSeparatorSignal()) {
+ this.webview?.postMessage({ type: 'streamReplace', value: visible });
+ return;
+ }
if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible });
};
@@ -1499,6 +1536,7 @@ export class AgentExecutor {
engine,
selfCheckSources: this._turnCtx.selfCheckSources,
confidenceSignals: this._turnCtx.confidenceSignals,
+ actionStats: this._turnCtx.actionStats,
callNonStreaming: (p) => this.callNonStreaming(p),
getAbortSignal: () => this.abortController?.signal,
getWebview: () => this.webview,
@@ -1508,6 +1546,16 @@ export class AgentExecutor {
});
} else {
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
+ // [v2.2.309] Hollow Investigation — 액션 turn 의 최종 답변(continuation)은
+ // post-answer hooks(depth 0 전용)를 타지 않으므로 여기서 직접 검사한다.
+ // "list_files 만 하고 read/investigate 없이 파일 여러 개를 서술" = 헛조사.
+ try {
+ const hollowInv = detectHollowInvestigation(finalAssistantContent, this._turnCtx.actionStats);
+ if (hollowInv.hollow) {
+ this.webview.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(hollowInv.fileMentions) });
+ logInfo('Hollow Investigation 감지 (continuation).', { files: hollowInv.fileMentions, stats: this._turnCtx.actionStats });
+ }
+ } catch { /* 감지 실패가 답변을 막지 않음 */ }
}
} catch (error: any) {
@@ -1766,6 +1814,15 @@ export class AgentExecutor {
await applyCalculateActions(ctx);
await applyRunCodeActions(ctx);
await applyListFilesActions(ctx);
+ await applyInvestigateFilesActions(ctx);
+
+ // [v2.2.309] Hollow Investigation 감지용 액션 통계 (report 마커 기반, turn 누적).
+ for (const line of report) {
+ if (line.startsWith('📖 Read:')) this._turnCtx.actionStats.reads++;
+ else if (line.startsWith('📂 Listed:')) this._turnCtx.actionStats.lists++;
+ else if (line.startsWith('🔎 Investigated:')) this._turnCtx.actionStats.investigates++;
+ }
+
await applyWebFetchActions(ctx);
await applyBrainOpsActions(ctx);
await applyCalendarActions(ctx);
diff --git a/src/agent/actions/investigateFiles.ts b/src/agent/actions/investigateFiles.ts
new file mode 100644
index 0000000..1955a3a
--- /dev/null
+++ b/src/agent/actions/investigateFiles.ts
@@ -0,0 +1,53 @@
+/**
+ * — 폴더(또는 단일 파일) 강제 정독 조사.
+ *
+ * list_files 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접
+ * 읽어 파일별 추출 노트(Map)를 만들고 병합 노트를 컨텍스트에 주입한다 — 모델이
+ * 파일명만 보고 내용을 상상하는 헛조사(할루시네이션)를 구조적으로 차단. (v2.2.309)
+ * 노트는 내용 해시 캐시로 영속화되어 재조사 시 재사용된다 (온디맨드 지식화).
+ */
+import { HandlerContext } from './types';
+import { validatePath } from '../../security';
+import { getConfig } from '../../config';
+import { simpleChatCompletion } from '../../intelligence/llmCall';
+import { runInvestigation, formatInvestigationNotes } from '../../intelligence/investigationPipeline';
+import { logInfo, logError } from '../../utils';
+
+export async function applyInvestigateFilesActions(ctx: HandlerContext): Promise {
+ const { aiMessage, rootPath, report } = ctx;
+ const regex = /]+)['"]?(?:\s+focus=['"]?([^'">]*)['"]?)?\s*\/?>(?:<\/investigate_files>)?/gi;
+ let match: RegExpExecArray | null;
+
+ while ((match = regex.exec(aiMessage)) !== null) {
+ const relPath = match[1].trim() || '.';
+ const focus = (match[2] || '').trim();
+ try {
+ validatePath(rootPath, relPath); // 경로 탈출 방지 (결과는 pipeline 이 재해석)
+ const cfg = getConfig();
+ const llm = { baseUrl: cfg.ollamaUrl, model: cfg.defaultModel };
+ const result = await runInvestigation({
+ rootPath,
+ relDir: relPath,
+ llmCall: (system, user) => simpleChatCompletion(system, user, {
+ ...llm, temperature: 0.1, maxTokens: 600, timeoutMs: 120000,
+ }),
+ });
+
+ if (result.notes.length === 0) {
+ report.push(`🔎 Investigate: ${relPath} — 읽을 텍스트 파일 없음 (제외 ${result.skipped.length}건)`);
+ ctx.chatHistory.push({
+ role: 'system', internal: true,
+ content: `[Result of investigate_files "${relPath}"] 읽을 수 있는 텍스트 파일이 없다. 제외: ${result.skipped.slice(0, 10).join(', ') || '—'}`,
+ });
+ continue;
+ }
+
+ ctx.chatHistory.push({ role: 'system', internal: true, content: formatInvestigationNotes(result, focus) });
+ report.push(`🔎 Investigated: ${relPath} — 파일 ${result.notes.length}개 정독 (캐시 ${result.cacheHits} · LLM ${result.llmCalls} · 제외 ${result.skipped.length})`);
+ logInfo('Investigation pipeline 완료.', { relPath, files: result.notes.length, cacheHits: result.cacheHits, llmCalls: result.llmCalls });
+ } catch (err: any) {
+ report.push(`❌ Investigate failed: ${relPath} — ${err?.message ?? err}`);
+ logError('Investigation pipeline 실패.', { relPath, error: err?.message ?? String(err) });
+ }
+ }
+}
diff --git a/src/agent/llm/streamChatOnce.ts b/src/agent/llm/streamChatOnce.ts
index f51c23b..70f0da7 100644
--- a/src/agent/llm/streamChatOnce.ts
+++ b/src/agent/llm/streamChatOnce.ts
@@ -41,6 +41,12 @@ export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
const post = (token: string) => {
if (params.postLiveDeltas && token) {
const visible = liveFilter.push(token);
+ // [v2.2.310] LM Studio LSEP 구분자 — 마커 이전에 라이브로 나간 추론 텍스트를
+ // streamReplace 로 화면에서 걷어내고, 마커 이후(실제 답변)부터 다시 시작.
+ if (liveFilter.consumeSeparatorSignal()) {
+ deps.getWebview()?.postMessage({ type: 'streamReplace', value: visible });
+ return;
+ }
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
}
};
diff --git a/src/agent/postAnswerHooks/index.ts b/src/agent/postAnswerHooks/index.ts
index 5bd5da3..0abb581 100644
--- a/src/agent/postAnswerHooks/index.ts
+++ b/src/agent/postAnswerHooks/index.ts
@@ -26,6 +26,8 @@ import { appendSuccessPattern } from '../../intelligence/skillScore';
import { getConfig } from '../../config';
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
+import { detectHollowInvestigation, formatHollowInvestigationFooter } from '../../intelligence/investigationPipeline';
+import { logInfo } from '../../utils';
const devilRebuttalHook: PostAnswerHook = {
id: 'devil-rebuttal',
@@ -86,6 +88,23 @@ const termValidatorHook: PostAnswerHook = {
},
};
+/**
+ * [v2.2.309] Hollow Investigation — "목록만 조회(list_files)하고 읽지는 않은 채(read/
+ * investigate 0회) 파일 여러 개를 서술한 답변" 감지. 소형 모델의 파일명 상상 조사를
+ * 사용자에게 즉시 노출한다 (결정론적, LLM 호출 없음).
+ */
+const hollowInvestigationHook: PostAnswerHook = {
+ id: 'hollow-investigation',
+ runAsync: false,
+ run(ctx: PostAnswerHookContext): void {
+ if (!ctx.assistantAnswer?.trim()) return;
+ const { hollow, fileMentions } = detectHollowInvestigation(ctx.assistantAnswer, ctx.actionStats);
+ if (!hollow) return;
+ ctx.getWebview()?.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(fileMentions) });
+ logInfo('Hollow Investigation 감지 — list만 하고 read 없이 파일 서술.', { files: fileMentions });
+ },
+};
+
const requirementCoverageHook: PostAnswerHook = {
id: 'requirement-coverage',
runAsync: false,
@@ -253,6 +272,7 @@ export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
devilRebuttalHook,
postHocSelfCheckHook,
termValidatorHook,
+ hollowInvestigationHook,
requirementCoverageHook,
confidenceEscalationHook,
criticLoopHook,
diff --git a/src/agent/postAnswerHooks/types.ts b/src/agent/postAnswerHooks/types.ts
index a186321..5aeb329 100644
--- a/src/agent/postAnswerHooks/types.ts
+++ b/src/agent/postAnswerHooks/types.ts
@@ -29,6 +29,8 @@ export interface PostAnswerHookContext {
selfCheckSources: Array<{ title: string; excerpt: string }>;
/** Confidence Engine 검색 신호 (Phase 2). memoryContext 가 채움 — 검색 안 돈 turn 은 null. */
confidenceSignals?: import('../../intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
+ /** [v2.2.309] 이번 turn 액션 실행 통계 — Hollow Investigation(헛조사) 감지용. */
+ actionStats?: { reads: number; lists: number; investigates: number } | null;
/** Devil Agent 가 호출 — non-streaming LLM. */
callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
/** Abort signal accessor. */
diff --git a/src/config.ts b/src/config.ts
index c00ab52..2ef55f3 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -227,6 +227,12 @@ export interface IAgentConfig {
* 결과는 답변 아래 footer 한 줄로 표시.
*/
selfCheckEnabled: boolean;
+ /**
+ * [v2.2.309] 조사형 요청("~폴더 조사해줘") 전용 모델 오버라이드.
+ * 예: "claude-code:sonnet" — 소형 로컬 모델의 lazy tool-use(파일명만 보고 서술)
+ * 할루시네이션 대책. 빈 문자열이면 비활성 (현재 선택 모델 그대로).
+ */
+ investigationModel: string;
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
selfCheckModel: string;
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
@@ -576,6 +582,7 @@ export function getConfig(): IAgentConfig {
intentClarificationEnabled: cfg.get('intentClarificationEnabled', true),
intentClarificationStrictness: (cfg.get('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
citationTraceEnabled: cfg.get('citationTraceEnabled', true),
+ investigationModel: (cfg.get('investigationModel', '') || '').trim(),
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/evalCommands.ts b/src/extension/evalCommands.ts
index ab92350..11bdf4d 100644
--- a/src/extension/evalCommands.ts
+++ b/src/extension/evalCommands.ts
@@ -29,6 +29,7 @@ import { computeSkillScores, formatSkillScoresMarkdown, loadSuccessPatterns, for
import { runResearch, formatProposalMarkdown } from '../intelligence/researchAgent';
import type { ExistingKnowledgeRef } from '../intelligence/knowledgeValidation';
import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../intelligence/learningQueue';
+import { loadStandingRules, buildStandingRulesBlock, STANDING_RULES_REL_PATH } from '../intelligence/correctionLoop';
/**
* 검색 평가 명령 묶음 (Phase 1-나).
@@ -43,6 +44,7 @@ export function registerEvalCommands(): vscode.Disposable[] {
vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand),
vscode.commands.registerCommand('g1nation.eval.tasks', runTaskEvalCommand),
vscode.commands.registerCommand('g1nation.growth.report', growthReportCommand),
+ vscode.commands.registerCommand('g1nation.growth.standingRules', standingRulesCommand),
vscode.commands.registerCommand('g1nation.growth.learningQueue', learningQueueCommand),
vscode.commands.registerCommand('g1nation.knowledge.decayAudit', decayAuditCommand),
vscode.commands.registerCommand('g1nation.research.runQueue', researchRunQueueCommand),
@@ -307,6 +309,57 @@ async function runTaskEvalCommand(): Promise {
}
/** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */
+/**
+ * Standing Rules 열람 — "주입엔 항상 도달 증거를" 원칙의 확인 창구.
+ * 현재 매 턴 주입 중인 상시 행동 규칙과 실제 주입 블록 원문을 파일로 열어준다.
+ * 규칙 수정/삭제는 standing-rules.json 을 직접 편집 (Permission Based Learning).
+ */
+async function standingRulesCommand(): Promise {
+ try {
+ const brain = getActiveBrainProfile();
+ if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
+ vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다.');
+ return;
+ }
+ const rules = loadStandingRules(brain.localBrainPath);
+ const active = rules.filter(r => r.status === 'active');
+ const retired = rules.filter(r => r.status !== 'active');
+ const block = buildStandingRulesBlock(rules);
+ const md = [
+ '# 상시 행동 규칙 (Standing Rules)',
+ '',
+ `사용자 지적("또 ~하네", "하지 말라고 했잖아")이 명령형 규칙으로 정규화되어 여기 쌓이고,`,
+ `**매 턴 시스템 프롬프트 보호 구역에 주입**됩니다 — 세션을 새로 열어도 유지됩니다.`,
+ `수정/삭제: \`${STANDING_RULES_REL_PATH}\` 파일을 직접 편집하세요.`,
+ '',
+ `## 활성 규칙 ${active.length}개`,
+ '',
+ ...(active.length
+ ? active.sort((a, b) => b.hits - a.hits).map(r =>
+ `- **${r.rule}** — 지적 ${r.hits}회, 최근 ${r.lastHitAt.slice(0, 10)}\n - 원 발화: "${r.origin}"`)
+ : ['(아직 없음 — 행동/형식을 지적하면 자동으로 쌓입니다)']),
+ '',
+ `## 은퇴 규칙 ${retired.length}개`,
+ ...(retired.length ? retired.map(r => `- ${r.rule} (지적 ${r.hits}회)`) : ['(없음)']),
+ '',
+ '## 실제 주입 블록 (도달 증거)',
+ '',
+ '```',
+ block || '(활성 규칙이 없어 이번 턴에는 주입되지 않음)',
+ '```',
+ '',
+ ].join('\n');
+ const reportPath = path.join(brain.localBrainPath, '.astra', 'growth', 'standing-rules-report.md');
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
+ fs.writeFileSync(reportPath, md, 'utf8');
+ const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(reportPath));
+ await vscode.window.showTextDocument(doc, { preview: false });
+ } catch (err: any) {
+ logError('Standing rules command failed.', { error: err?.message || String(err) });
+ vscode.window.showErrorMessage(`상시 규칙 열람 실패: ${err?.message ?? err}`);
+ }
+}
+
async function growthReportCommand(): Promise {
try {
const brain = getActiveBrainProfile();
diff --git a/src/intelligence/correctionLoop.ts b/src/intelligence/correctionLoop.ts
index 3566155..c686693 100644
--- a/src/intelligence/correctionLoop.ts
+++ b/src/intelligence/correctionLoop.ts
@@ -178,8 +178,21 @@ export async function captureCorrection(opts: {
wrongAnswer: string;
correction: string;
llm: { baseUrl: string; model: string };
+ /** 행동/스타일 지적으로 감지됨 (looksLikeBehaviorComplaint) — Standing Rule 파생 대상. */
+ behaviorHint?: boolean;
}): Promise {
const { tag, title } = await classifyCorrection(opts.question, opts.wrongAnswer, opts.correction, opts.llm);
+
+ // [④ Standing Rules] 행동/스타일 교정은 명령형 규칙으로 정규화해 즉시 영속화 —
+ // 다음 턴부터(그리고 새 세션에서도) 매 턴 주입된다. 태그가 형식오류/지시불이행
+ // 이거나 감지 단계에서 행동 지적 패턴(behaviorHint)에 걸린 경우만.
+ if (opts.behaviorHint || tag === '형식오류' || tag === '지시불이행') {
+ try {
+ const rule = await deriveStandingRule(opts.wrongAnswer, opts.correction, opts.llm);
+ if (rule) upsertStandingRule(opts.brainPath, rule, opts.correction);
+ } catch { /* 규칙 파생 실패가 레슨 캡처를 막지 않음 */ }
+ }
+
const now = new Date();
const c: CorrectionCase = {
ts: now.toISOString(),
@@ -380,3 +393,165 @@ export function formatRegressionReport(results: RegressionResult[], meta: { date
}
return lines.join('\n');
}
+
+// ── ④ Standing Rules — 세션을 넘는 상시 행동 규칙 ────────────────────────────
+//
+// 배경: 기존 파이프라인(①~③)은 *사실 정정*을 영속화하지만, "결론 수정 문구 쓰지 마"
+// 같은 *행동/스타일 교정*은 (a) 감지 패턴에 안 걸리고 (b) 레슨은 유사도 검색이라
+// 질문 내용과 무관한 상시 규칙이 주입되지 않아 새 세션에서 증발했다 (v2.2.308).
+//
+// 해법: 행동 교정을 명령형 1줄 규칙으로 정규화해 파일에 영속화하고, 검색과 무관하게
+// 매 턴 behavior-constraints 보호 구역에 주입한다. 상한(10개)과 재지적 카운트로
+// 프롬프트 오염을 막고, 파일은 사람이 열어 수정/삭제 가능 (Permission Based Learning).
+
+export const STANDING_RULES_REL_PATH = path.join('.astra', 'growth', 'standing-rules.json');
+export const MAX_ACTIVE_STANDING_RULES = 10;
+
+export interface StandingRule {
+ id: string; // 정규화된 규칙의 해시
+ rule: string; // 명령형 1줄 (모든 답변에 적용)
+ origin: string; // 규칙을 만든 원 발화 (사용자 지적)
+ createdAt: string; // ISO
+ lastHitAt: string; // 마지막 재지적 ISO
+ hits: number; // 지적 누계 (1 = 최초) — 클수록 은퇴 대상에서 멂
+ status: 'active' | 'retired';
+}
+
+// 행동/스타일 지적 감지 — 사실 정정(looksLikeCorrection)과 별개의 그물.
+// "또 ~하네", "하지 말라고 했잖아", "몇 번을 말해" 같은 반복 지적이 핵심 신호다.
+const BEHAVIOR_COMPLAINT_RE = [
+ /^\s*또\s+.{1,80}(하네|하잖|이네|했네|라고\s*(썼|하)|나왔|그러)/, // "또 결론 수정이라고 썼네"
+ /(하지\s*말라(고|니까)|말라고\s*했(잖|는데)|말랬잖)/, // "하지 말라고 했잖아"
+ /(몇\s*번(을|이나)?\s*(말|얘기|이야기)|매번\s*똑같)/, // "몇 번을 말해"
+ /(왜\s*자꾸|자꾸\s*왜|계속\s*(그러|반복)|또\s*반복)/, // "왜 자꾸 그래"
+ /(붙이지\s*마|쓰지\s*마|넣지\s*마|빼\s*달라고|빼라고)/, // 출력 형식 지시
+];
+
+export function looksLikeBehaviorComplaint(prompt: string): boolean {
+ const t = (prompt || '').trim();
+ if (t.length < 4 || t.length > 1500) return false;
+ return BEHAVIOR_COMPLAINT_RE.some(re => re.test(t));
+}
+
+// LLM 으로 지적 → 명령형 규칙 정규화. 실패 시 휴리스틱 fallback.
+const RULE_SYSTEM = [
+ '너는 AI 행동 규칙 작성기다. 사용자가 AI 답변의 습관/형식/태도를 지적했다.',
+ '이 지적을 앞으로 모든 답변에 적용할 명령형 규칙 한 줄(한국어, 70자 이내)로 요약하라.',
+ '내용 지식(사실)이 아니라 행동 규칙만 만든다. 행동 규칙으로 만들 수 없으면 빈 문자열.',
+ '반드시 JSON 한 줄만 출력: {"rule":"<규칙 또는 빈 문자열>"}',
+].join('\n');
+
+export async function deriveStandingRule(
+ wrongAnswer: string,
+ correction: string,
+ llm: { baseUrl: string; model: string },
+): Promise {
+ const fallback = (): string => {
+ // "~하지 마/말라" 문장이 있으면 그 문장 자체가 규칙에 가장 가깝다.
+ const m = correction.match(/[^.!?\n]*(?:하지\s*마|말라|쓰지\s*마|붙이지\s*마|넣지\s*마)[^.!?\n]*/);
+ const base = (m ? m[0] : correction).trim().replace(/\s+/g, ' ').slice(0, 70);
+ return base ? `${base} — 이 지적을 반복하게 하지 않는다` : '';
+ };
+ try {
+ const user = [
+ `[AI 답변 발췌] ${wrongAnswer.slice(0, MAX_FIELD_CHARS)}`,
+ `[사용자 지적] ${correction.slice(0, MAX_FIELD_CHARS)}`,
+ ].join('\n');
+ const raw = await simpleChatCompletion(RULE_SYSTEM, user, {
+ baseUrl: llm.baseUrl, model: llm.model, temperature: 0.1, maxTokens: 120, timeoutMs: 30000,
+ });
+ const m = raw.match(/\{[\s\S]*?\}/);
+ if (!m) return fallback();
+ const rule = String(JSON.parse(m[0])?.rule || '').trim().replace(/\s+/g, ' ').slice(0, 90);
+ return rule || fallback();
+ } catch {
+ return fallback();
+ }
+}
+
+function normalizeRuleText(rule: string): string {
+ return rule.toLowerCase().replace(/[^a-z0-9가-힣]+/g, ' ').trim();
+}
+
+function ruleId(rule: string): string {
+ // 의존성 없는 짧은 해시 (djb2) — 파일에서 사람이 규칙을 찾을 때 쓰는 식별자.
+ const s = normalizeRuleText(rule);
+ let h = 5381;
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
+ return 'sr-' + h.toString(36);
+}
+
+// 같은 규칙인지 — 토큰 자카드 유사도 (LLM 없이, 재지적 시 hits 만 올리기 위함).
+export function isSameRule(a: string, b: string): boolean {
+ const ta = new Set(normalizeRuleText(a).split(' ').filter(w => w.length >= 2));
+ const tb = new Set(normalizeRuleText(b).split(' ').filter(w => w.length >= 2));
+ if (ta.size === 0 || tb.size === 0) return false;
+ let inter = 0;
+ for (const w of ta) if (tb.has(w)) inter++;
+ return inter / (ta.size + tb.size - inter) >= 0.6;
+}
+
+export function loadStandingRules(brainPath: string): StandingRule[] {
+ try {
+ const file = path.join(brainPath, STANDING_RULES_REL_PATH);
+ if (!fs.existsSync(file)) return [];
+ const o = JSON.parse(fs.readFileSync(file, 'utf8'));
+ return Array.isArray(o) ? o.filter(r => r && typeof r.rule === 'string') : [];
+ } catch {
+ return [];
+ }
+}
+
+export function saveStandingRules(brainPath: string, rules: StandingRule[]): boolean {
+ try {
+ const file = path.join(brainPath, STANDING_RULES_REL_PATH);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, JSON.stringify(rules, null, 2) + '\n', 'utf8');
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * 규칙 추가/재지적 반영. 중복이면 hits++ (은퇴했던 규칙도 부활), 신규면 추가.
+ * active 가 상한을 넘으면 hits 낮고 오래된 것부터 은퇴. 저장까지 수행.
+ */
+export function upsertStandingRule(brainPath: string, rule: string, origin: string, nowIso?: string): StandingRule[] {
+ const now = nowIso || new Date().toISOString();
+ const rules = loadStandingRules(brainPath);
+ const existing = rules.find(r => isSameRule(r.rule, rule));
+ if (existing) {
+ existing.hits += 1;
+ existing.lastHitAt = now;
+ existing.status = 'active'; // 재지적 = 아직 필요한 규칙
+ } else {
+ rules.push({
+ id: ruleId(rule), rule: rule.trim(), origin: origin.slice(0, 200),
+ createdAt: now, lastHitAt: now, hits: 1, status: 'active',
+ });
+ }
+ // 상한 초과 시 은퇴: hits 오름차순 → lastHitAt 오래된 순.
+ const active = rules.filter(r => r.status === 'active');
+ if (active.length > MAX_ACTIVE_STANDING_RULES) {
+ const toRetire = active
+ .sort((a, b) => (a.hits - b.hits) || a.lastHitAt.localeCompare(b.lastHitAt))
+ .slice(0, active.length - MAX_ACTIVE_STANDING_RULES);
+ for (const r of toRetire) r.status = 'retired';
+ }
+ saveStandingRules(brainPath, rules);
+ return rules;
+}
+
+/** 매 턴 주입 블록 — 검색과 무관한 상시 규칙. active 없으면 ''. */
+export function buildStandingRulesBlock(rules: StandingRule[]): string {
+ const active = rules.filter(r => r.status === 'active')
+ .sort((a, b) => (b.hits - a.hits) || b.lastHitAt.localeCompare(a.lastHitAt))
+ .slice(0, MAX_ACTIVE_STANDING_RULES);
+ if (active.length === 0) return '';
+ const lines = ['[상시 행동 규칙 — 사용자 지적으로 축적됨. 모든 답변에서 반드시 지켜라]'];
+ for (const r of active) {
+ lines.push(`- ${r.rule}${r.hits >= 2 ? ` (지적 ${r.hits}회)` : ''}`);
+ }
+ return lines.join('\n');
+}
diff --git a/src/intelligence/investigationPipeline.ts b/src/intelligence/investigationPipeline.ts
new file mode 100644
index 0000000..f851d02
--- /dev/null
+++ b/src/intelligence/investigationPipeline.ts
@@ -0,0 +1,228 @@
+/**
+ * Investigation Pipeline — "파일명만 보고 상상하는 조사" 를 구조적으로 차단. (v2.2.309)
+ *
+ * 문제: 소형 로컬 모델은 로 파일명 목록을 받으면 을 부르지
+ * 않고 이름에서 내용을 상상해 조사 결과를 지어낸다 (lazy tool-use 할루시네이션).
+ *
+ * 해법 3중:
+ * ① 증거 게이트 — 조사형 요청 감지 시 "읽지 않은 파일 서술 금지" 행동 제약 주입
+ * (buildInvestigationGateBlock → memoryContext behavior-constraints 채널).
+ * ② Map-Reduce — 액션이 폴더의 파일을 *코드가 하나씩 강제로
+ * 읽어* 파일별 추출 노트 생성(Map) 후 병합 노트를 컨텍스트에 주입(Reduce 는
+ * 본 턴 모델이 노트 기반으로 수행). 모델에게 "읽을지" 선택권 자체가 없다.
+ * ③ 온디맨드 지식화 캐시 — 파일 내용 해시를 키로 노트를 .astra/cache/investigate 에
+ * 저장. 같은 내용은 재조사 시 LLM 호출 없이 재사용, 파일이 바뀐 것만 갱신.
+ * 전량 위키화 없이 조사가 닿는 곳만 점진적으로 지식화된다.
+ *
+ * 노트는 질문 비의존(파일의 목적/핵심 사실/관계)으로 뽑는다 — 캐시 재사용률을
+ * 최대화하기 위함 (질문 특화 해석은 Reduce 단계의 본 턴 모델 몫).
+ */
+import * as crypto from 'crypto';
+import * as fs from 'fs';
+import * as path from 'path';
+import { EXCLUDED_DIRS } from '../config';
+
+// ── 상수 ────────────────────────────────────────────────────────────────────
+
+export const INVESTIGATE_CACHE_REL_DIR = path.join('.astra', 'cache', 'investigate');
+export const MAX_INVESTIGATION_FILES = 40; // 초과분은 skipped 로 보고 (조용한 누락 금지)
+export const MAX_CHARS_PER_FILE = 20_000; // 파일당 읽기 상한 (소형 모델 충실 구간)
+
+const TEXT_EXTS = new Set([
+ '.md', '.txt', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.jsonl',
+ '.html', '.css', '.py', '.yml', '.yaml', '.csv', '.xml', '.sh', '.bat', '.ps1', '.sql', '.env',
+]);
+
+export interface InvestigationFileNote {
+ relPath: string;
+ note: string;
+ fromCache: boolean;
+}
+
+export interface InvestigationResult {
+ dir: string;
+ notes: InvestigationFileNote[];
+ /** 상한·확장자·읽기실패로 제외된 파일 (조용한 누락 방지용 보고). */
+ skipped: string[];
+ cacheHits: number;
+ llmCalls: number;
+}
+
+// ── ① 조사형 요청 감지 + 증거 게이트 블록 ───────────────────────────────────
+
+const INVESTIGATE_VERB_RE = /(조사|분석|검토|파악|리뷰|정리)\s*(해|을|를|좀|부탁|해줘|해주|하고|해서)/;
+const LOCAL_TARGET_RE = /(폴더|파일|디렉토리|프로젝트|코드|문서|리소스|소스)|([A-Za-z]:\\|\.{0,2}\/[\w가-힣])/;
+
+/** 로컬 자료를 대상으로 한 조사형 요청인지 (증거 게이트·모델 라우팅 공용 감지기). */
+export function isLocalInvestigationPrompt(prompt: string): boolean {
+ const t = (prompt || '').trim();
+ if (t.length < 6) return false;
+ return INVESTIGATE_VERB_RE.test(t) && LOCAL_TARGET_RE.test(t);
+}
+
+/** 조사형 요청일 때만 행동 제약 블록 반환, 아니면 ''. */
+export function buildInvestigationGateBlock(prompt: string): string {
+ if (!isLocalInvestigationPrompt(prompt)) return '';
+ return [
+ '[조사 증거 게이트 — 로컬 자료 조사 시 필수]',
+ '- 파일명·폴더 목록만 보고 내용을 서술하는 것 금지. 내용에 대한 모든 주장은 실제로 읽은 것만 쓴다.',
+ '- 폴더 단위 조사는 한 번으로 위임하라 — 각 파일을 자동으로 읽어 파일별 노트를 만들어준다. 개별 파일은 .',
+ '- 읽지 않은 파일을 언급해야 하면 이름 뒤에 "(미확인)"을 붙여라.',
+ '- 조사 결과의 각 주장 끝에 근거 파일 경로를 붙여라 (예: [src/agent.ts]). 근거 없는 주장은 쓰지 않는다.',
+ ].join('\n');
+}
+
+// ── ②③ 파일 수집 → 노트 추출(캐시) → 병합 ──────────────────────────────────
+
+export function collectInvestigationFiles(
+ rootPath: string,
+ relDir: string,
+ maxFiles = MAX_INVESTIGATION_FILES,
+): { files: Array<{ relPath: string; absPath: string }>; skipped: string[] } {
+ const files: Array<{ relPath: string; absPath: string }> = [];
+ const skipped: string[] = [];
+ const baseAbs = path.resolve(rootPath, relDir || '.');
+
+ const walk = (abs: string, rel: string): void => {
+ let entries: fs.Dirent[];
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { skipped.push(rel + '/ (읽기 실패)'); return; }
+ // 결정적 순서 (테스트·캐시 재현성)
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+ for (const e of entries) {
+ if (e.name.startsWith('.') || EXCLUDED_DIRS.has(e.name)) continue;
+ const childAbs = path.join(abs, e.name);
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
+ if (e.isDirectory()) { walk(childAbs, childRel); continue; }
+ if (!TEXT_EXTS.has(path.extname(e.name).toLowerCase())) { skipped.push(childRel + ' (비텍스트)'); continue; }
+ if (files.length >= maxFiles) { skipped.push(childRel + ' (상한 초과)'); continue; }
+ files.push({ relPath: childRel, absPath: childAbs });
+ }
+ };
+
+ const st = fs.existsSync(baseAbs) ? fs.statSync(baseAbs) : null;
+ if (st?.isFile()) {
+ files.push({ relPath: relDir, absPath: baseAbs });
+ } else if (st?.isDirectory()) {
+ walk(baseAbs, '');
+ }
+ return { files, skipped };
+}
+
+const EXTRACT_SYSTEM = [
+ '너는 파일 내용 분석기다. 제공된 파일 *내용*만 근거로 사실을 추출하라. 내용에 없는 것을 지어내지 말 것.',
+ '출력 형식 (개조식, 명사형 종결):',
+ '목적: <이 파일이 무엇인지 한 줄>',
+ '핵심:',
+ '- <핵심 사실/설정/결정 3~8개 — 수치·이름·경로 포함>',
+ '관계: <다른 파일/모듈과의 연결이 내용에 명시되어 있으면, 없으면 "—">',
+].join('\n');
+
+function cacheKey(content: string): string {
+ return crypto.createHash('sha1').update(content).digest('hex');
+}
+
+function cachePathFor(rootPath: string, key: string): string {
+ return path.join(rootPath, INVESTIGATE_CACHE_REL_DIR, `${key}.json`);
+}
+
+/**
+ * 폴더(또는 단일 파일)를 강제로 읽어 파일별 노트 생성. llmCall 은 주입식(테스트 용이).
+ * 캐시: sha1(내용) → 노트. 내용이 같으면 질문이 달라도 재사용 (질문 비의존 노트).
+ */
+export async function runInvestigation(opts: {
+ rootPath: string;
+ relDir: string;
+ llmCall: (system: string, user: string) => Promise;
+ maxFiles?: number;
+ onProgress?: (done: number, total: number, relPath: string) => void;
+}): Promise {
+ const { files, skipped } = collectInvestigationFiles(opts.rootPath, opts.relDir, opts.maxFiles ?? MAX_INVESTIGATION_FILES);
+ const result: InvestigationResult = { dir: opts.relDir, notes: [], skipped, cacheHits: 0, llmCalls: 0 };
+
+ for (let i = 0; i < files.length; i++) {
+ const f = files[i];
+ let content: string;
+ try { content = fs.readFileSync(f.absPath, 'utf8').slice(0, MAX_CHARS_PER_FILE); }
+ catch { result.skipped.push(f.relPath + ' (읽기 실패)'); continue; }
+ if (!content.trim()) { result.skipped.push(f.relPath + ' (빈 파일)'); continue; }
+
+ const key = cacheKey(content);
+ const cacheFile = cachePathFor(opts.rootPath, key);
+ let note: string | null = null;
+ let fromCache = false;
+ try {
+ if (fs.existsSync(cacheFile)) {
+ const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
+ if (cached && typeof cached.note === 'string' && cached.note.trim()) {
+ note = cached.note;
+ fromCache = true;
+ result.cacheHits++;
+ }
+ }
+ } catch { /* 캐시 손상 → 재추출 */ }
+
+ if (note === null) {
+ try {
+ note = (await opts.llmCall(EXTRACT_SYSTEM, `[파일] ${f.relPath}\n\n${content}`)).trim();
+ result.llmCalls++;
+ try {
+ fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
+ fs.writeFileSync(cacheFile, JSON.stringify({ relPath: f.relPath, note, ts: new Date().toISOString() }, null, 2), 'utf8');
+ } catch { /* 캐시 저장 실패는 무해 */ }
+ } catch (e: any) {
+ result.skipped.push(f.relPath + ` (추출 실패: ${e?.message ?? e})`);
+ continue;
+ }
+ }
+ result.notes.push({ relPath: f.relPath, note, fromCache });
+ opts.onProgress?.(i + 1, files.length, f.relPath);
+ }
+ return result;
+}
+
+/** 병합 노트 — 액션이 chatHistory 에 internal system 메시지로 주입하는 본문. */
+export function formatInvestigationNotes(result: InvestigationResult, focus?: string): string {
+ const lines: string[] = [];
+ lines.push(`[Result of investigate_files "${result.dir}"] — 아래는 각 파일을 *실제로 읽고* 추출한 노트다 (${result.notes.length}개 파일, 캐시 재사용 ${result.cacheHits}건).`);
+ if (focus?.trim()) lines.push(`조사 목적: ${focus.trim()}`);
+ lines.push('이 노트를 근거로 답하되, 각 주장 끝에 근거 파일 경로를 붙여라. 노트에 없는 내용은 "노트에 근거 없음"으로 표기.');
+ lines.push('');
+ for (const n of result.notes) {
+ lines.push(`── [${n.relPath}]`);
+ lines.push(n.note);
+ lines.push('');
+ }
+ if (result.skipped.length) {
+ lines.push(`(미확인 파일 ${result.skipped.length}건 — 필요 시 로 개별 확인: ${result.skipped.slice(0, 10).join(', ')}${result.skipped.length > 10 ? ' 외' : ''})`);
+ }
+ return lines.join('\n');
+}
+
+// ── Hollow Investigation 감지 (postAnswerHook 용 순수 함수) ──────────────────
+
+export interface TurnActionStats { reads: number; lists: number; investigates: number }
+
+const FILE_MENTION_RE = /[\w가-힣.\-]{2,}\.(?:md|txt|ts|tsx|js|jsx|json|html|css|py|pdf|docx?|xlsx?|pptx?|ya?ml|csv)\b/gi;
+
+/**
+ * "목록만 보고(list) 읽지는 않았는데(read/investigate 0) 파일들을 서술" = 헛조사.
+ * 파일명 언급 2개 이상일 때만 (1개는 단순 지칭일 수 있음 — 오탐 방지).
+ */
+export function detectHollowInvestigation(
+ answer: string,
+ stats: TurnActionStats | null | undefined,
+): { hollow: boolean; fileMentions: string[] } {
+ if (!stats || stats.lists === 0) return { hollow: false, fileMentions: [] };
+ if (stats.reads > 0 || stats.investigates > 0) return { hollow: false, fileMentions: [] };
+ const mentions = [...new Set(Array.from((answer || '').matchAll(FILE_MENTION_RE), m => m[0]))];
+ return { hollow: mentions.length >= 2, fileMentions: mentions.slice(0, 8) };
+}
+
+export function formatHollowInvestigationFooter(fileMentions: string[]): string {
+ return [
+ '',
+ '---',
+ `⚠️ **헛조사 감지** — 이 답변은 파일 목록만 조회하고 실제 내용은 읽지 않은 채 파일 ${fileMentions.length}건(${fileMentions.slice(0, 4).join(', ')}${fileMentions.length > 4 ? ' 외' : ''})을 서술했습니다.`,
+ '내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 ``로 각 파일을 실제로 읽고 답합니다.',
+ ].join('\n');
+}
diff --git a/src/lib/contextBuilders/liveReasoningFilter.ts b/src/lib/contextBuilders/liveReasoningFilter.ts
index ae6fea0..d881165 100644
--- a/src/lib/contextBuilders/liveReasoningFilter.ts
+++ b/src/lib/contextBuilders/liveReasoningFilter.ts
@@ -27,6 +27,28 @@ const TAG_OPENERS: { literal: string; closer: RegExp }[] = [
{ literal: '', closer: /<\/analysis>/i },
];
+/**
+ * [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델/버전 조합에서 LM Studio 가
+ * 추론 텍스트를 *여는 마커 없이* content 스트림 맨 앞에 흘리고, 실제 답변과의
+ * 경계에 이 합성 마커를 삽입한다:
+ * `<추론 텍스트>__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END___<답변>`
+ * 여는 마커가 없어 앞부분을 미리 숨길 수는 없다 — 대신 END 마커를 만나는 순간
+ * `separatorSeen` 신호를 세워, 호출자(스트림 루프)가 streamReplace 로 그때까지
+ * 표시된 추론을 화면에서 걷어내게 한다. START 변형이 오면 일반 opener 처럼 숨긴다.
+ */
+const LSEP_CONST_PREFIX = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_';
+const LSEP_END_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/i;
+const LSEP_START_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_[0-9a-f]{6,64}__/i;
+
+/** tail 이 LSEP 마커의 접두사가 될 가능성 (emit 보류 판단, '<' 계열과 별도). */
+function couldBeLsepPrefix(tail: string): boolean {
+ if (!tail.startsWith('_')) return false;
+ if (tail.length <= LSEP_CONST_PREFIX.length) return LSEP_CONST_PREFIX.startsWith(tail);
+ if (!tail.startsWith(LSEP_CONST_PREFIX)) return false;
+ // 상수부 이후: (START|END 일부) + '_' + hex + 닫는 '__' 진행 중이면 보류
+ return /^[A-Z]{0,5}_?[0-9a-f]{0,64}_{0,2}$/i.test(tail.slice(LSEP_CONST_PREFIX.length));
+}
+
/** Harmony 채널 마커 — `<|channel|>` 외에 `` / `<|channel>` 변형도 (sanitize 와 동일). */
const CHANNEL_MARKER = /<\|?channel\|?>/i;
const HIDDEN_CHANNEL_WORD = /^(?:thought|analysis|commentary|reasoning)\b/i;
@@ -58,6 +80,15 @@ export class LiveReasoningFilter {
private pending = '';
/** hiddenTag 모드에서 기다리는 닫힘 패턴. */
private closer: RegExp | null = null;
+ /** [v2.2.310] LSEP END 마커 감지 신호 — 호출자가 streamReplace 로 화면 리셋. */
+ private separatorSeen = false;
+
+ /** LSEP 구분자를 만났는지 (1회성 소비 — 읽으면 리셋). */
+ consumeSeparatorSignal(): boolean {
+ const v = this.separatorSeen;
+ this.separatorSeen = false;
+ return v;
+ }
/**
* 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다.
@@ -70,6 +101,29 @@ export class LiveReasoningFilter {
// 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프.
for (;;) {
if (this.mode === 'visible') {
+ // [v2.2.310] LSEP 마커 — START 가 END 보다 먼저면 정상 블록(숨김),
+ // END 만 단독 등장(여는 마커 없이 추론이 먼저 흐른 케이스)이면 리셋 신호.
+ const sepStart = this.pending.match(LSEP_START_RE);
+ const sepEnd = this.pending.match(LSEP_END_RE);
+ const startIdx = sepStart?.index ?? -1;
+ const endIdx = sepEnd?.index ?? -1;
+ if (startIdx >= 0 && (endIdx < 0 || startIdx < endIdx)) {
+ // START 변형 — 일반 opener 처럼 END 까지 숨김.
+ out += this.pending.slice(0, startIdx);
+ this.pending = this.pending.slice(startIdx + sepStart![0].length);
+ this.mode = 'hiddenTag';
+ this.closer = LSEP_END_RE;
+ continue;
+ }
+ if (endIdx >= 0) {
+ // 여는 마커 없이 흘러온 추론의 끝 — 이미 화면에 나간 앞부분은 되돌릴
+ // 수 없으니 신호만 세우고(호출자가 streamReplace), 잔여 추론+마커 폐기.
+ this.pending = this.pending.slice(endIdx + sepEnd![0].length);
+ this.separatorSeen = true;
+ out = ''; // 마커 앞 잔여분(추론)은 방출 취소
+ continue;
+ }
+
const hit = this.findEarliestOpener(this.pending);
if (hit) {
out += this.pending.slice(0, hit.index);
@@ -127,7 +181,8 @@ export class LiveReasoningFilter {
continue;
}
// 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다.
- this.pending = this.pending.slice(-24);
+ // (LSEP END 마커는 ~85자라 넉넉히 보존 — v2.2.310)
+ this.pending = this.pending.slice(-100);
return out;
}
@@ -179,6 +234,12 @@ export class LiveReasoningFilter {
const tail = s.slice(s.length - k);
if (tail.startsWith('<') && couldBeOpenerPrefix(tail)) return k;
}
+ // [v2.2.310] LSEP 마커('_' 시작, 최대 ~110자) 접두사 보류 — 토큰 경계 분절 대응.
+ const maxLsep = Math.min(s.length, 110);
+ for (let k = 1; k <= maxLsep; k++) {
+ const tail = s.slice(s.length - k);
+ if (tail.startsWith('_') && couldBeLsepPrefix(tail)) return k;
+ }
return 0;
}
}
diff --git a/src/lib/contextBuilders/memoryContext.ts b/src/lib/contextBuilders/memoryContext.ts
index 25b6687..bcf5cd3 100644
--- a/src/lib/contextBuilders/memoryContext.ts
+++ b/src/lib/contextBuilders/memoryContext.ts
@@ -8,7 +8,8 @@ import type { RetrievalOrchestrator } from '../../retrieval';
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex';
-import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap } from '../../intelligence/correctionLoop';
+import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap, loadStandingRules, buildStandingRulesBlock } from '../../intelligence/correctionLoop';
+import { buildInvestigationGateBlock } from '../../intelligence/investigationPipeline';
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
import { resolveDomainScope, classifyKnowledgeDomain, buildDomainRoutingHint } from '../../retrieval/domainRouter';
import {
@@ -418,6 +419,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise`) 보수적으로 매칭.
*/
export function sanitizeAssistantContent(text: string): string {
- const stripped = text
+ // [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델 조합에서 추론 텍스트가
+ // *여는 마커 없이* 답변 앞에 흐르고 이 END 마커로만 경계가 표시된다.
+ // 마지막 END 마커 이전은 전부 추론 → 폐기. 남은 마커 잔재도 제거.
+ let t = text;
+ const lsepEnd = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/gi;
+ const lsepParts = t.split(lsepEnd);
+ if (lsepParts.length > 1) t = lsepParts[lsepParts.length - 1];
+ t = t.replace(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_[A-Z]+_[0-9a-f]{6,64}__/gi, '');
+
+ const stripped = t
.replace(/[\s\S]*?<\/rationale>/gi, '')
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
diff --git a/tests/investigationPipeline.test.ts b/tests/investigationPipeline.test.ts
new file mode 100644
index 0000000..7e8c570
--- /dev/null
+++ b/tests/investigationPipeline.test.ts
@@ -0,0 +1,167 @@
+/**
+ * Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309)
+ * ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지
+ */
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+ isLocalInvestigationPrompt,
+ buildInvestigationGateBlock,
+ collectInvestigationFiles,
+ runInvestigation,
+ formatInvestigationNotes,
+ detectHollowInvestigation,
+ formatHollowInvestigationFooter,
+ INVESTIGATE_CACHE_REL_DIR,
+ MAX_INVESTIGATION_FILES,
+} from '../src/intelligence/investigationPipeline';
+
+function tmpRoot(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-inv-'));
+}
+
+describe('isLocalInvestigationPrompt — 조사형 요청 감지', () => {
+ test('폴더/프로젝트 조사 요청 감지', () => {
+ expect(isLocalInvestigationPrompt('E:\\Wiki\\Weekly 프로젝트 조사해줘')).toBe(true);
+ expect(isLocalInvestigationPrompt('이 폴더의 문서들 분석해서 정리해줘')).toBe(true);
+ expect(isLocalInvestigationPrompt('./docs 검토 부탁해')).toBe(true);
+ });
+ test('로컬 대상이 없는 일반 요청은 미감지', () => {
+ expect(isLocalInvestigationPrompt('오늘 날씨 어때?')).toBe(false);
+ expect(isLocalInvestigationPrompt('삼성전자 주가 조사해줘')).toBe(false); // 로컬 자료 아님
+ });
+ test('증거 게이트 블록은 감지 시에만 생성', () => {
+ expect(buildInvestigationGateBlock('이 폴더 코드 분석해줘')).toContain('investigate_files');
+ expect(buildInvestigationGateBlock('안녕')).toBe('');
+ });
+});
+
+describe('collectInvestigationFiles — 파일 수집', () => {
+ test('텍스트 파일만, 결정적 순서, 제외 목록 보고', () => {
+ const root = tmpRoot();
+ fs.mkdirSync(path.join(root, 'docs'));
+ fs.writeFileSync(path.join(root, 'docs', 'b.md'), '내용B');
+ fs.writeFileSync(path.join(root, 'docs', 'a.md'), '내용A');
+ fs.writeFileSync(path.join(root, 'docs', 'img.png'), 'binary');
+ const { files, skipped } = collectInvestigationFiles(root, 'docs');
+ expect(files.map(f => f.relPath)).toEqual(['a.md', 'b.md']);
+ expect(skipped.some(s => s.includes('img.png'))).toBe(true);
+ });
+ test('상한 초과분은 skipped 로 (조용한 누락 금지)', () => {
+ const root = tmpRoot();
+ fs.mkdirSync(path.join(root, 'many'));
+ for (let i = 0; i < MAX_INVESTIGATION_FILES + 5; i++) {
+ fs.writeFileSync(path.join(root, 'many', `f${String(i).padStart(3, '0')}.txt`), 'x' + i);
+ }
+ const { files, skipped } = collectInvestigationFiles(root, 'many');
+ expect(files).toHaveLength(MAX_INVESTIGATION_FILES);
+ expect(skipped.filter(s => s.includes('상한 초과'))).toHaveLength(5);
+ });
+ test('단일 파일 경로도 지원', () => {
+ const root = tmpRoot();
+ fs.writeFileSync(path.join(root, 'one.md'), '단일');
+ const { files } = collectInvestigationFiles(root, 'one.md');
+ expect(files).toHaveLength(1);
+ });
+});
+
+describe('runInvestigation — 강제 정독 + 캐시', () => {
+ test('모든 파일에 LLM 추출 실행, 노트 생성', async () => {
+ const root = tmpRoot();
+ fs.mkdirSync(path.join(root, 'p'));
+ fs.writeFileSync(path.join(root, 'p', 'x.md'), 'X 파일 내용');
+ fs.writeFileSync(path.join(root, 'p', 'y.md'), 'Y 파일 내용');
+ const calls: string[] = [];
+ const r = await runInvestigation({
+ rootPath: root, relDir: 'p',
+ llmCall: async (_s, u) => { calls.push(u); return '목적: 테스트\n핵심:\n- 사실1'; },
+ });
+ expect(r.notes).toHaveLength(2);
+ expect(r.llmCalls).toBe(2);
+ expect(r.cacheHits).toBe(0);
+ // LLM 입력에 실제 파일 내용이 들어감 (파일명만이 아니라)
+ expect(calls[0]).toContain('X 파일 내용');
+ });
+
+ test('같은 내용 재조사 → 캐시 재사용 (LLM 0회) / 내용 변경 → 재추출', async () => {
+ const root = tmpRoot();
+ fs.mkdirSync(path.join(root, 'p'));
+ fs.writeFileSync(path.join(root, 'p', 'x.md'), '원본 내용');
+ const llm = jest.fn(async () => '목적: v1');
+ await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
+ expect(llm).toHaveBeenCalledTimes(1);
+
+ // 2차: 동일 내용 → 캐시
+ const r2 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
+ expect(llm).toHaveBeenCalledTimes(1); // 추가 호출 없음
+ expect(r2.cacheHits).toBe(1);
+ expect(r2.notes[0].fromCache).toBe(true);
+ expect(fs.existsSync(path.join(root, INVESTIGATE_CACHE_REL_DIR))).toBe(true);
+
+ // 3차: 내용 변경 → 재추출
+ fs.writeFileSync(path.join(root, 'p', 'x.md'), '변경된 내용');
+ const r3 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
+ expect(llm).toHaveBeenCalledTimes(2);
+ expect(r3.cacheHits).toBe(0);
+ });
+
+ test('추출 실패 파일은 skipped 로 계속 진행', async () => {
+ const root = tmpRoot();
+ fs.mkdirSync(path.join(root, 'p'));
+ fs.writeFileSync(path.join(root, 'p', 'ok.md'), '정상');
+ fs.writeFileSync(path.join(root, 'p', 'zz.md'), '실패 대상');
+ const r = await runInvestigation({
+ rootPath: root, relDir: 'p',
+ llmCall: async (_s, u) => {
+ if (u.includes('실패 대상')) throw new Error('LLM down');
+ return '목적: ok';
+ },
+ });
+ expect(r.notes).toHaveLength(1);
+ expect(r.skipped.some(s => s.includes('zz.md') && s.includes('추출 실패'))).toBe(true);
+ });
+
+ test('병합 노트에 파일 경로·미확인 목록 포함', async () => {
+ const root = tmpRoot();
+ fs.mkdirSync(path.join(root, 'p'));
+ fs.writeFileSync(path.join(root, 'p', 'a.md'), 'A');
+ fs.writeFileSync(path.join(root, 'p', 'bin.png'), 'x');
+ const r = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: async () => '목적: A' });
+ const notes = formatInvestigationNotes(r, '구조 파악');
+ expect(notes).toContain('[a.md]');
+ expect(notes).toContain('조사 목적: 구조 파악');
+ expect(notes).toContain('미확인 파일');
+ expect(notes).toContain('실제로 읽고');
+ });
+});
+
+describe('detectHollowInvestigation — 헛조사 감지', () => {
+ const answer = '이 프로젝트는 server.mjs가 백엔드이고 dashboard.html이 화면입니다. config.json은 설정입니다.';
+
+ test('list만 하고 read 0 + 파일 2개 이상 서술 = 헛조사', () => {
+ const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 });
+ expect(r.hollow).toBe(true);
+ expect(r.fileMentions.length).toBeGreaterThanOrEqual(2);
+ });
+ test('실제로 읽었으면(read>0) 정상', () => {
+ expect(detectHollowInvestigation(answer, { reads: 2, lists: 1, investigates: 0 }).hollow).toBe(false);
+ });
+ test('investigate_files 를 썼으면 정상', () => {
+ expect(detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 1 }).hollow).toBe(false);
+ });
+ test('list 자체가 없으면 판정 대상 아님', () => {
+ expect(detectHollowInvestigation(answer, { reads: 0, lists: 0, investigates: 0 }).hollow).toBe(false);
+ expect(detectHollowInvestigation(answer, null).hollow).toBe(false);
+ });
+ test('파일 언급 1개는 오탐 방지 위해 통과', () => {
+ const one = 'README.md 를 확인해보세요.';
+ expect(detectHollowInvestigation(one, { reads: 0, lists: 1, investigates: 0 }).hollow).toBe(false);
+ });
+ test('경고 footer 에 파일명 포함', () => {
+ const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 });
+ const footer = formatHollowInvestigationFooter(r.fileMentions);
+ expect(footer).toContain('헛조사 감지');
+ expect(footer).toContain('investigate_files');
+ });
+});
diff --git a/tests/liveReasoningFilter.test.ts b/tests/liveReasoningFilter.test.ts
index 2297a20..6aff295 100644
--- a/tests/liveReasoningFilter.test.ts
+++ b/tests/liveReasoningFilter.test.ts
@@ -63,3 +63,74 @@ describe('LiveReasoningFilter — 스트리밍 중 추론 구간 실시간 차
expect(run(['답변 끝 <'])).toBe('답변 끝 <');
});
});
+
+describe('[v2.2.310] LM Studio LSEP 합성 추론 구분자', () => {
+ const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
+
+ /** run + separator 신호 관찰 버전. */
+ function runWithSignal(tokens: string[]): { out: string; replacedAt: number[] } {
+ const f = new LiveReasoningFilter();
+ let out = '';
+ const replacedAt: number[] = [];
+ tokens.forEach((t, i) => {
+ const visible = f.push(t);
+ if (f.consumeSeparatorSignal()) {
+ out = visible; // streamReplace 시뮬레이션 — 화면 리셋
+ replacedAt.push(i);
+ } else {
+ out += visible;
+ }
+ });
+ out += f.flush();
+ return { out, replacedAt };
+ }
+
+ test('END 마커 뒤 텍스트만 최종 화면에 남는다 (실사례 재현)', () => {
+ const { out, replacedAt } = runWithSignal([
+ 'User message: "안녕, 잘지내?" 분석... Option 1... Final choice...',
+ MARKER + '잘 지내고 있습니다. 별일 없으셨나요?',
+ ]);
+ expect(out).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
+ expect(replacedAt).toEqual([1]); // 마커 도착 시점에 streamReplace 신호
+ });
+
+ test('마커가 토큰 경계에 잘게 쪼개져도 잡는다', () => {
+ const { out } = runWithSignal([
+ '추론 텍스트 어쩌고 ',
+ '__LM_STUDIO_INTERNAL_LSEP',
+ '_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c',
+ '9e5f3a7b8c1d2e6a__',
+ '실제 답변',
+ ]);
+ expect(out).toBe('실제 답변');
+ });
+
+ test('START 변형이 오면 END 까지 통째로 숨긴다', () => {
+ const start = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_abc123def456__';
+ const { out } = runWithSignal(['답변 앞부분 ', start + '숨길 추론 ' + MARKER, '답변 뒷부분']);
+ expect(out).toBe('답변 앞부분 답변 뒷부분');
+ });
+
+ test('마커 없는 일반 밑줄 텍스트는 그대로 통과 (오탐 없음)', () => {
+ const { out, replacedAt } = runWithSignal(['snake_case_variable 와 __init__ 메서드', ' 설명입니다']);
+ expect(out).toBe('snake_case_variable 와 __init__ 메서드 설명입니다');
+ expect(replacedAt).toEqual([]);
+ });
+});
+
+describe('[v2.2.310] sanitizeAssistantContent — LSEP 최종 정리 (이중 방어)', () => {
+ const { sanitizeAssistantContent } = require('../src/lib/contextBuilders/outputSanitization');
+ const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
+
+ test('마커 앞 추론 전체 제거, 답변만 남긴다', () => {
+ const raw = 'User message 분석... Option 1... Final choice...' + MARKER + '잘 지내고 있습니다. 별일 없으셨나요?';
+ expect(sanitizeAssistantContent(raw)).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
+ });
+ test('마커가 여러 번이면 마지막 마커 뒤만', () => {
+ const raw = '추론1' + MARKER + '중간' + MARKER + '최종 답변';
+ expect(sanitizeAssistantContent(raw)).toBe('최종 답변');
+ });
+ test('마커 없으면 기존 동작 그대로', () => {
+ expect(sanitizeAssistantContent('일반 답변입니다.')).toBe('일반 답변입니다.');
+ });
+});
diff --git a/tests/standingRules.test.ts b/tests/standingRules.test.ts
new file mode 100644
index 0000000..19d13fa
--- /dev/null
+++ b/tests/standingRules.test.ts
@@ -0,0 +1,151 @@
+/**
+ * Standing Rules (Correction Loop ④) — 행동/스타일 교정의 세션 영속화. (v2.2.308)
+ *
+ * 배경: self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하는 문제.
+ * 행동 지적 → 명령형 규칙 파일 영속화 → 매 턴 주입으로 구조적으로 해결한다.
+ */
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import {
+ looksLikeBehaviorComplaint,
+ loadStandingRules,
+ saveStandingRules,
+ upsertStandingRule,
+ buildStandingRulesBlock,
+ isSameRule,
+ MAX_ACTIVE_STANDING_RULES,
+ STANDING_RULES_REL_PATH,
+ StandingRule,
+} from '../src/intelligence/correctionLoop';
+
+function tmpBrain(): string {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-sr-'));
+}
+
+describe('looksLikeBehaviorComplaint — 행동/스타일 지적 감지', () => {
+ test('반복 지적: "또 ~라고 썼네" (이번 이슈의 원 사례)', () => {
+ expect(looksLikeBehaviorComplaint('또 결론 수정이라고 썼네')).toBe(true);
+ });
+ test('"하지 말라고 했잖아"', () => {
+ expect(looksLikeBehaviorComplaint('결론 유지 문구 넣지 말라고 했잖아')).toBe(true);
+ });
+ test('"몇 번을 말해"', () => {
+ expect(looksLikeBehaviorComplaint('몇 번을 말해야 알아들어')).toBe(true);
+ });
+ test('"왜 자꾸 ~"', () => {
+ expect(looksLikeBehaviorComplaint('왜 자꾸 영어로 답해')).toBe(true);
+ });
+ test('출력 형식 지시: "붙이지 마"', () => {
+ expect(looksLikeBehaviorComplaint('출처 표기를 답변 중간에 붙이지 마')).toBe(true);
+ });
+ test('일반 질문은 오탐하지 않음', () => {
+ expect(looksLikeBehaviorComplaint('오늘 주가 어때?')).toBe(false);
+ expect(looksLikeBehaviorComplaint('회의록 요약해줘')).toBe(false);
+ // "또"로 시작하지만 지적이 아닌 추가 요청
+ expect(looksLikeBehaviorComplaint('또 다른 방법도 알려줘')).toBe(false);
+ });
+ test('빈/초장문 입력 방어', () => {
+ expect(looksLikeBehaviorComplaint('')).toBe(false);
+ expect(looksLikeBehaviorComplaint('또 ' + 'x'.repeat(2000))).toBe(false);
+ });
+});
+
+describe('isSameRule — 재지적 판정 (토큰 자카드)', () => {
+ test('같은 규칙의 표현 변형은 동일 판정', () => {
+ expect(isSameRule(
+ '답변에 결론 수정 문구를 붙이지 않는다',
+ '답변에 결론 수정 문구를 절대 붙이지 않는다',
+ )).toBe(true);
+ });
+ test('다른 규칙은 구분', () => {
+ expect(isSameRule(
+ '답변에 결론 수정 문구를 붙이지 않는다',
+ '모든 답변은 한국어로 작성한다',
+ )).toBe(false);
+ });
+});
+
+describe('upsertStandingRule — 저장/중복/상한', () => {
+ test('신규 규칙 저장 → 파일 생성 + active', () => {
+ const brain = tmpBrain();
+ const rules = upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '또 결론 수정이라고 썼네');
+ expect(rules).toHaveLength(1);
+ expect(rules[0].status).toBe('active');
+ expect(rules[0].hits).toBe(1);
+ expect(fs.existsSync(path.join(brain, STANDING_RULES_REL_PATH))).toBe(true);
+ // 라운드트립
+ const loaded = loadStandingRules(brain);
+ expect(loaded[0].rule).toBe('결론 수정 라벨을 붙이지 않는다');
+ });
+
+ test('같은 규칙 재지적 → hits 증가 (새 항목 추가 아님)', () => {
+ const brain = tmpBrain();
+ upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '지적1');
+ const rules = upsertStandingRule(brain, '결론 수정 라벨을 절대 붙이지 않는다', '지적2');
+ expect(rules).toHaveLength(1);
+ expect(rules[0].hits).toBe(2);
+ });
+
+ test('은퇴한 규칙이 재지적되면 부활', () => {
+ const brain = tmpBrain();
+ const retired: StandingRule[] = [{
+ id: 'sr-x', rule: '영어 단어를 남발하지 않는다', origin: 'o',
+ createdAt: '2026-01-01T00:00:00Z', lastHitAt: '2026-01-01T00:00:00Z', hits: 1, status: 'retired',
+ }];
+ saveStandingRules(brain, retired);
+ const rules = upsertStandingRule(brain, '영어 단어를 남발하지 않는다', '또 영어 남발이네');
+ expect(rules[0].status).toBe('active');
+ expect(rules[0].hits).toBe(2);
+ });
+
+ test('상한 초과 시 hits 낮고 오래된 규칙부터 은퇴', () => {
+ const brain = tmpBrain();
+ // 서로 유사도 판정(isSameRule)에 걸리지 않는 실제로 구분되는 규칙 10개
+ const distinct = [
+ '답변은 항상 한국어로 작성한다',
+ '수치를 말할 때 근거 문서를 인용한다',
+ '표를 남용하지 않는다',
+ '이모지 사용을 최소화한다',
+ '결론부터 먼저 말한다',
+ '추측일 때는 추정임을 명시한다',
+ '링크는 마크다운 형식으로 쓴다',
+ '제목에 물음표를 넣지 않는다',
+ '인사말을 매번 반복하지 않는다',
+ '코드 예시에는 설명 주석을 단다',
+ ];
+ distinct.forEach((r, i) => upsertStandingRule(brain, r, `origin${i}`, `2026-01-0${(i % 9) + 1}T00:00:00Z`));
+ expect(loadStandingRules(brain)).toHaveLength(MAX_ACTIVE_STANDING_RULES);
+ // 규칙 하나에 hits 보강 (은퇴 대상에서 멀어지게)
+ upsertStandingRule(brain, '이모지 사용을 최소화한다', 'again', '2026-02-01T00:00:00Z');
+ // 상한+1 번째 신규 규칙
+ const rules = upsertStandingRule(brain, '긴 목록 대신 요약 문단을 우선한다', 'new', '2026-03-01T00:00:00Z');
+ const active = rules.filter(r => r.status === 'active');
+ expect(active).toHaveLength(MAX_ACTIVE_STANDING_RULES);
+ // hits 2인 규칙은 살아남고, 방금 들어온 신규도 active
+ expect(active.some(r => r.rule.includes('이모지'))).toBe(true);
+ expect(active.some(r => r.rule.includes('요약 문단'))).toBe(true);
+ expect(rules.some(r => r.status === 'retired')).toBe(true);
+ });
+});
+
+describe('buildStandingRulesBlock — 매 턴 주입 블록', () => {
+ test('active 규칙을 hits 내림차순으로 포함', () => {
+ const rules: StandingRule[] = [
+ { id: 'a', rule: '규칙A', origin: '', createdAt: '', lastHitAt: '2026-01-01', hits: 1, status: 'active' },
+ { id: 'b', rule: '규칙B', origin: '', createdAt: '', lastHitAt: '2026-01-02', hits: 3, status: 'active' },
+ { id: 'c', rule: '규칙C', origin: '', createdAt: '', lastHitAt: '2026-01-03', hits: 1, status: 'retired' },
+ ];
+ const block = buildStandingRulesBlock(rules);
+ expect(block).toContain('[상시 행동 규칙');
+ expect(block.indexOf('규칙B')).toBeLessThan(block.indexOf('규칙A')); // hits 순
+ expect(block).toContain('(지적 3회)');
+ expect(block).not.toContain('규칙C'); // retired 제외
+ });
+ test('규칙 없으면 빈 문자열 (프롬프트 오염 없음)', () => {
+ expect(buildStandingRulesBlock([])).toBe('');
+ expect(buildStandingRulesBlock([
+ { id: 'c', rule: 'x', origin: '', createdAt: '', lastHitAt: '', hits: 1, status: 'retired' },
+ ])).toBe('');
+ });
+});
diff --git a/tests/wikiSave.test.ts b/tests/wikiSave.test.ts
index 307a978..fc5dce2 100644
--- a/tests/wikiSave.test.ts
+++ b/tests/wikiSave.test.ts
@@ -50,7 +50,8 @@ describe('pickWikiDir — 저장 폴더 우선순위', () => {
test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => {
const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null);
- expect(r!.dir.replace(/\\/g, '/')).toBe('/Wiki/Premium/자료');
+ // Windows에서는 path.resolve가 드라이브 문자를 붙이므로(E:/Wiki/...) 끝부분만 검증
+ expect(r!.dir.replace(/\\/g, '/')).toMatch(/\/Wiki\/Premium\/자료$/);
});
test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {