v2.2.308~310: Standing Rules + 조사 파이프라인 + LM Studio 추론 노출 수리

v2.2.308 — Standing Rules (Correction Loop ④): 행동/스타일 교정의 세션 영속화
- 행동 지적 감지("또 ~하네", "하지 말라고 했잖아" 등 5종) → LLM 이 명령형 규칙으로
  정규화 → .astra/growth/standing-rules.json 즉시 저장 → 매 턴 보호 구역 주입.
  self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하던 문제 해결.
- 중복 지적은 hits 증가(토큰 자카드), 상한 10개 초과 시 자동 은퇴, 재지적 시 부활.
- "Astra: 상시 행동 규칙" 커맨드 — 활성/은퇴 규칙 + 실제 주입 블록 열람(도달 증거).

v2.2.309 — 조사 파이프라인: 파일명만 보고 상상하는 헛조사 4중 차단
- <investigate_files path focus> 액션: 폴더의 텍스트 파일을 코드가 강제 정독해
  파일별 노트 주입(Map-Reduce). 내용 해시 캐시(.astra/cache/investigate)로 재조사
  시 재사용 — 온디맨드 지식화.
- 조사 증거 게이트(행동 제약) + Hollow Investigation 감지(list만 하고 read 0회로
  파일 서술 시 경고 footer — continuation 경로 포함) + actionStats turn 추적.
- g1nation.investigationModel: 조사형 요청만 상위 모델(예: claude-code:sonnet) 라우팅.

v2.2.310 — LM Studio LSEP 합성 추론 구분자 처리
- 일부 모델 조합에서 추론이 여는 마커 없이 content 앞에 흐르고
  __LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_<hex>__ 로만 경계 표시 →
  생각 과정 전체가 채팅에 노출되던 문제.
- LiveReasoningFilter: 마커 감지 시 streamReplace 신호로 화면 즉시 리셋(토큰 분절
  대응 holdback 포함), sanitizeAssistantContent: 마커 이전 추론 최종 제거(이중 방어).

기타: tests/wikiSave.test.ts 상대경로 테스트 Windows 호환 수정(드라이브 문자).
검증: 전체 테스트 926건 통과 (신규 39건: standingRules 15 + investigationPipeline 16 + LSEP 8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 18:18:49 +09:00
co-authored by Claude Fable 5
parent 137b83d8b6
commit 5feacb4799
17 changed files with 1098 additions and 16 deletions
+66 -9
View File
@@ -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 {
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
- 폴더 전체를 조사해야 하면 파일을 하나씩 읽지 말고 <investigate_files path="상대경로" focus="조사 목적"/> 태그 하나를 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);
+53
View File
@@ -0,0 +1,53 @@
/**
* <investigate_files path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사.
*
* 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<void> {
const { aiMessage, rootPath, report } = ctx;
const regex = /<investigate_files\s+path=['"]?([^'">]+)['"]?(?:\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) });
}
}
}
+6
View File
@@ -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 });
}
};
+20
View File
@@ -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,
+2
View File
@@ -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. */
+7
View File
@@ -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<boolean>('intentClarificationEnabled', true),
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
+53
View File
@@ -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<void> {
}
/** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */
/**
* Standing Rules 열람 — "주입엔 항상 도달 증거를" 원칙의 확인 창구.
* 현재 매 턴 주입 중인 상시 행동 규칙과 실제 주입 블록 원문을 파일로 열어준다.
* 규칙 수정/삭제는 standing-rules.json 을 직접 편집 (Permission Based Learning).
*/
async function standingRulesCommand(): Promise<void> {
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<void> {
try {
const brain = getActiveBrainProfile();
+175
View File
@@ -178,8 +178,21 @@ export async function captureCorrection(opts: {
wrongAnswer: string;
correction: string;
llm: { baseUrl: string; model: string };
/** 행동/스타일 지적으로 감지됨 (looksLikeBehaviorComplaint) — Standing Rule 파생 대상. */
behaviorHint?: boolean;
}): Promise<string | null> {
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<string> {
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');
}
+228
View File
@@ -0,0 +1,228 @@
/**
* Investigation Pipeline — "파일명만 보고 상상하는 조사" 를 구조적으로 차단. (v2.2.309)
*
* 문제: 소형 로컬 모델은 <list_files> 로 파일명 목록을 받으면 <read_file> 을 부르지
* 않고 이름에서 내용을 상상해 조사 결과를 지어낸다 (lazy tool-use 할루시네이션).
*
* 해법 3중:
* ① 증거 게이트 — 조사형 요청 감지 시 "읽지 않은 파일 서술 금지" 행동 제약 주입
* (buildInvestigationGateBlock → memoryContext behavior-constraints 채널).
* ② Map-Reduce — <investigate_files> 액션이 폴더의 파일을 *코드가 하나씩 강제로
* 읽어* 파일별 추출 노트 생성(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 [
'[조사 증거 게이트 — 로컬 자료 조사 시 필수]',
'- 파일명·폴더 목록만 보고 내용을 서술하는 것 금지. 내용에 대한 모든 주장은 실제로 읽은 것만 쓴다.',
'- 폴더 단위 조사는 <investigate_files path="상대경로" focus="조사 목적"/> 한 번으로 위임하라 — 각 파일을 자동으로 읽어 파일별 노트를 만들어준다. 개별 파일은 <read_file>.',
'- 읽지 않은 파일을 언급해야 하면 이름 뒤에 "(미확인)"을 붙여라.',
'- 조사 결과의 각 주장 끝에 근거 파일 경로를 붙여라 (예: [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<string>;
maxFiles?: number;
onProgress?: (done: number, total: number, relPath: string) => void;
}): Promise<InvestigationResult> {
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}건 — 필요 시 <read_file> 로 개별 확인: ${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 ? ' 외' : ''})을 서술했습니다.`,
'내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 `<investigate_files>`로 각 파일을 실제로 읽고 답합니다.',
].join('\n');
}
+62 -1
View File
@@ -27,6 +27,28 @@ const TAG_OPENERS: { literal: string; closer: RegExp }[] = [
{ literal: '<analysis>', closer: /<\/analysis>/i },
];
/**
* [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델/버전 조합에서 LM Studio 가
* 추론 텍스트를 *여는 마커 없이* content 스트림 맨 앞에 흘리고, 실제 답변과의
* 경계에 이 합성 마커를 삽입한다:
* `<추론 텍스트>__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_<hex>__<답변>`
* 여는 마커가 없어 앞부분을 미리 숨길 수는 없다 — 대신 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|>` / `<|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;
}
}
+11 -2
View File
@@ -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<strin
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
// [Correction Loop ④] Standing Rules — 행동/스타일 교정의 영속 규칙. 레슨(유사도
// 검색)과 달리 질문 내용과 무관하게 *매 턴* 주입된다. 세션을 새로 열어도 유지 —
// "self-reflection 이 단기 기억에 갇힌다" 문제의 구조적 해결 (v2.2.308).
const standingRulesBlock = buildStandingRulesBlock(loadStandingRules(deps.activeBrain.localBrainPath));
// [조사 증거 게이트 v2.2.309] 로컬 자료 조사형 요청이면 "읽지 않은 파일 서술 금지 +
// investigate_files 위임" 제약 주입 — 파일명만 보고 상상하는 헛조사 차단의 1차 방어선.
const investigationGateBlock = buildInvestigationGateBlock(deps.currentPrompt);
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
@@ -438,7 +447,7 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
'- 첫 문장은 그 목적에 바로 답하는 결론으로 시작하라. 해석이 갈리면 가장 가능성 높은 해석으로 답하되, 어떤 해석으로 답했는지 한 줄로 밝혀라.',
].join('\n');
const constraintBlock = [intentPrinciple, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
const constraintBlock = [intentPrinciple, standingRulesBlock, investigationGateBlock, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
return memoryBlock;
}
+10 -1
View File
@@ -22,7 +22,16 @@
* `<|return|>`) 보수적으로 매칭.
*/
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(/<rationale>[\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, '')