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
parent 137b83d8b6
commit 5feacb4799
17 changed files with 1098 additions and 16 deletions
+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, '')