v2.2.299~307: 아키텍처 수렴 + 채팅 정리 + 벤치마킹 강화 + Claude 구독 엔진

- v2.2.299 아키텍처 수렴: coreChat 통일(엔진 휴리스틱 3벌 제거), 기업 모드
  검색 오케스트레이터 승격, lib/execUtil(실행 래퍼 6곳·Python 탐지 3벌 단일화),
  lib/kstSchedule(워처 4개 nowInKst 통합), estimateTokens 통합, 설정 접근 규칙 명문화
- v2.2.300 채팅 화면 정리: LiveReasoningFilter(스트리밍 중 <think>/Harmony 추론
  토큰 단위 차단), 확신도·검토요청 footer 기본 숨김(계산·Reflection 은 유지)
- v2.2.301 문맥·의도 이해: [답변 전 이해 원칙] 상시 주입, 워크플로우 의도 브리핑,
  Report QA 루프(규칙 레지스트리+실측치+회귀 게이트, 블로그_v3 개념 이식)
- v2.2.302 /benchmark 비즈니스 렌즈(가격·수익·운영)+빌드 프롬프트 모드+QA 연계
- v2.2.303 handoff 모드(측정치 무손실 인수인계 문서)+/claude(Claude Code 터미널 위임)
- v2.2.304 이식성: 지식 경로 두뇌-상대 규약(pickWikiDir 상대 해석), 이사 체크리스트
- v2.2.305 Claude 구독 엔진: claude: 프로바이더(CLI 위임, 모델 드롭다운 자동 노출,
  coreChat 지원 — 워크플로우·QA도 구독 모델 가능)
- v2.2.306 Tone Guard: AI 상투어 금지 레지스트리(상담사 화법 실사례 8종+대조 예시)
- v2.2.307 /benchmark 레이아웃 골격(sectionRoles 결정론 분석, 롤링 배너 즉답),
  파트별 실패 격리, 합성 타임아웃 120→300초

검증: tsc 무오류 + jest 888 통과 + esbuild 정상

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 21:02:15 +09:00
parent 98d533f045
commit 47b3b9f93a
66 changed files with 2421 additions and 459 deletions
@@ -0,0 +1,184 @@
/**
* LiveReasoningFilter — 스트리밍 *도중* 모델의 내부 추론을 화면에서 차단하는
* 상태 기계 (per-generation 1 인스턴스, stateful).
*
* 배경: liveStreamTokens=true(기본) 라이브 스트리밍에서 `<|channel|>thought …` /
* `<think>…</think>` 같은 추론 구간이 생성 내내 그대로 노출됐다가 완료 시
* streamReplace 로만 사라졌다. 사용자가 볼 필요 없는 텍스트가 답변 대기 내내
* 화면을 채우는 문제 — 이 필터가 토큰 단위로 추론 구간을 걸러 "보여도 되는"
* 델타만 통과시킨다. 최종 정리는 여전히 outputSanitization(streamReplace)이
* 책임지므로, 여기서 놓친 마커는 완료 시 반드시 정리된다 (이중 방어).
*
* 처리하는 마커 (outputSanitization.sanitizeAssistantContent 와 동일 계열):
* - `<think>…</think>` / `<thinking>…</thinking>` / `<analysis>…</analysis>`
* - Harmony 채널: `<|channel|>thought|analysis|commentary|reasoning …` 는 숨기고,
* `<|channel|>final` (+ 뒤따르는 `<|message|>`) 마커는 제거 후 본문 통과.
* `<|end|>` / `<|return|>` 는 숨김 종료.
*
* 토큰 경계 대응: 마커가 토큰 두 개에 걸쳐 쪼개져 와도 (`<thi` + `nk>`) 잡히도록,
* 열림 마커의 접두사가 될 수 있는 꼬리는 emit 을 보류(holdback)한다.
* "Thinking Process:" 류 평문 휴리스틱은 라이브에서는 오탐 위험이 커서 다루지
* 않는다 — 완료 시 streamReplace 가 정리.
*/
const TAG_OPENERS: { literal: string; closer: RegExp }[] = [
{ literal: '<think>', closer: /<\/think(?:ing)?>/i },
{ literal: '<thinking>', closer: /<\/think(?:ing)?>/i },
{ literal: '<analysis>', closer: /<\/analysis>/i },
];
/** Harmony 채널 마커 — `<|channel|>` 외에 `<channel|>` / `<|channel>` 변형도 (sanitize 와 동일). */
const CHANNEL_MARKER = /<\|?channel\|?>/i;
const HIDDEN_CHANNEL_WORD = /^(?:thought|analysis|commentary|reasoning)\b/i;
const FINAL_CHANNEL_WORD = /^final\b/i;
/** 채널 이름 판별에 필요한 최대 대기 글자수 — 마커 뒤 공백+가장 긴 채널명이면 충분. */
const CHANNEL_PEEK_CHARS = 16;
const END_MARKER = /<\|?(?:end|return)\|?>/i;
const MESSAGE_MARKER = /^\s*<\|?message\|?>/i;
/** tail 이 열림 마커들 중 하나의 접두사가 될 *가능성* 이 있으면 true (emit 보류 판단). */
function couldBeOpenerPrefix(tail: string): boolean {
if (!tail.startsWith('<')) return false;
const lower = tail.toLowerCase();
for (const { literal } of TAG_OPENERS) {
if (literal.startsWith(lower)) return true;
}
// 채널 마커 변형들의 접두사 여부
for (const marker of ['<|channel|>', '<channel|>', '<|channel>']) {
if (marker.startsWith(lower)) return true;
}
return false;
}
type Mode = 'visible' | 'hiddenTag' | 'hiddenChannel' | 'channelPending';
export class LiveReasoningFilter {
private mode: Mode = 'visible';
/** 아직 판정/방출하지 않은 원본 꼬리. */
private pending = '';
/** hiddenTag 모드에서 기다리는 닫힘 패턴. */
private closer: RegExp | null = null;
/**
* 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다.
* 반환값이 빈 문자열이면 이번 토큰은 전부 (아직) 숨김.
*/
push(token: string): string {
this.pending += token;
let out = '';
// 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프.
for (;;) {
if (this.mode === 'visible') {
const hit = this.findEarliestOpener(this.pending);
if (hit) {
out += this.pending.slice(0, hit.index);
this.pending = this.pending.slice(hit.index + hit.length);
if (hit.kind === 'tag') {
this.mode = 'hiddenTag';
this.closer = hit.closer!;
} else {
this.mode = 'channelPending';
}
continue;
}
// 열림 마커 없음 — 꼬리가 마커의 접두사일 수 있으면 그만큼 보류하고 방출.
const hold = this.holdbackLen(this.pending);
out += this.pending.slice(0, this.pending.length - hold);
this.pending = hold ? this.pending.slice(-hold) : '';
return out;
}
if (this.mode === 'channelPending') {
// 채널 이름이 판별될 만큼 모일 때까지 대기.
const head = this.pending.replace(/^\s+/, '');
if (FINAL_CHANNEL_WORD.test(head)) {
// final 채널 — 마커(+뒤따르는 <|message|>)만 제거하고 본문은 통과.
let rest = head.replace(FINAL_CHANNEL_WORD, '');
const m = rest.match(MESSAGE_MARKER);
if (m) rest = rest.slice(m[0].length);
else if (/^\s*<?\|?m?e?s?s?a?g?e?\|?>?$/i.test(rest) && rest.length < 12) {
// <|message|> 가 아직 다 안 온 것일 수 있음 — 더 기다린다.
return out;
}
this.pending = rest;
this.mode = 'visible';
continue;
}
if (HIDDEN_CHANNEL_WORD.test(head)) {
this.pending = head;
this.mode = 'hiddenChannel';
continue;
}
if (this.pending.length >= CHANNEL_PEEK_CHARS) {
// 아는 채널명이 아님 — 보수적으로 숨김 (완료 시 streamReplace 가 정리).
this.mode = 'hiddenChannel';
continue;
}
return out; // 더 모일 때까지 대기
}
if (this.mode === 'hiddenTag') {
const m = this.pending.match(this.closer!);
if (m && m.index !== undefined) {
this.pending = this.pending.slice(m.index + m[0].length);
this.mode = 'visible';
this.closer = null;
continue;
}
// 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다.
this.pending = this.pending.slice(-24);
return out;
}
// hiddenChannel: 다음 채널 마커(재판정) 또는 end/return(숨김 종료)까지 폐기.
const ch = this.pending.match(CHANNEL_MARKER);
const end = this.pending.match(END_MARKER);
const chIdx = ch?.index ?? -1;
const endIdx = end?.index ?? -1;
if (chIdx >= 0 && (endIdx < 0 || chIdx < endIdx)) {
this.pending = this.pending.slice(chIdx + ch![0].length);
this.mode = 'channelPending';
continue;
}
if (endIdx >= 0) {
this.pending = this.pending.slice(endIdx + end![0].length);
this.mode = 'visible';
continue;
}
this.pending = this.pending.slice(-24);
return out;
}
}
/** 스트림 종료 시 보류 중이던 visible 꼬리를 회수 (숨김 모드였다면 버린다). */
flush(): string {
const tail = this.mode === 'visible' ? this.pending : '';
this.pending = '';
return tail;
}
private findEarliestOpener(s: string): { index: number; length: number; kind: 'tag' | 'channel'; closer?: RegExp } | null {
const lower = s.toLowerCase();
let best: { index: number; length: number; kind: 'tag' | 'channel'; closer?: RegExp } | null = null;
for (const { literal, closer } of TAG_OPENERS) {
const i = lower.indexOf(literal);
if (i >= 0 && (!best || i < best.index)) best = { index: i, length: literal.length, kind: 'tag', closer };
}
const m = s.match(CHANNEL_MARKER);
if (m && m.index !== undefined && (!best || m.index < best.index)) {
best = { index: m.index, length: m[0].length, kind: 'channel' };
}
return best;
}
private holdbackLen(s: string): number {
// 뒤에서부터 '<' 를 찾아, 거기부터 끝까지가 열림 마커의 접두사일 수 있으면 보류.
const maxHold = Math.min(s.length, 12);
for (let k = 1; k <= maxHold; k++) {
const tail = s.slice(s.length - k);
if (tail.startsWith('<') && couldBeOpenerPrefix(tail)) return k;
}
return 0;
}
}
+11 -1
View File
@@ -428,7 +428,17 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
// 지식 스코프와 무관하게(에이전트 스코프가 있어도) 행동 제약으로 항상 주입.
const routingHint = buildDomainRoutingHint(classifyKnowledgeDomain(deps.currentPrompt));
const constraintBlock = [routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
// [답변 전 이해 원칙] 문자적 해석 방지 — 모든 실질 질문 턴에 주입되는 행동 제약
// (LLM 호출 없음). 조사·보고서 워크플로우의 '의도 브리핑'(LLM 1회)의 경량판으로,
// 일반 단발 질문에서도 "왜 묻는지"를 먼저 판단하고 답하게 한다. (v2.2.301)
const intentPrinciple = [
'[답변 전 이해 원칙]',
'- 이번 메시지를 문자 그대로만 읽지 말고, 최근 대화 흐름·직전 결론과 함께 해석하라.',
'- 답하기 전에 판단하라: 이 사람이 왜 지금 이걸 묻는가(목적), 표면 질문 뒤에 정말 궁금한 것은 무엇인가, 답을 받아 무엇을 하려는가.',
'- 첫 문장은 그 목적에 바로 답하는 결론으로 시작하라. 해석이 갈리면 가장 가능성 높은 해석으로 답하되, 어떤 해석으로 답했는지 한 줄로 밝혀라.',
].join('\n');
const constraintBlock = [intentPrinciple, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
return memoryBlock;
}
@@ -71,6 +71,13 @@ export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boole
// 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
// [v2.2.301] 명시적 조사·보고서 요청은 절대 임계값 게이트보다 우선 발동 —
// 짧은 "X 조사해줘"도 Report QA 파이프라인(의도 브리핑→채점→회귀 게이트)을
// 타야 하기 때문. '요약/리뷰' 류 일반 키워드는 기존 결정대로 게이트 아래 유지.
if (/(조사|리서치|보고서|레포트)/.test(prompt) || /\b(research|report)\b/i.test(prompt)) {
return true;
}
try {
const promptTokensForGate = estimateTokens(prompt);
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
+41
View File
@@ -0,0 +1,41 @@
/**
* Tone Guard — AI 상투어(클리셰) 금지 레지스트리 (v2.2.306).
*
* 배경: 잡담 턴에서 "힘든 감정을 느끼고 계시군요. 괜찮으시다면 … 저는 언제든
* 여기에 있습니다." 같은 상담사 화법이 그대로 나왔다 (실사례). 작업 턴에는
* 문체 규칙(persona R1~R7)이 있지만 casual 모드는 무방비였다.
*
* 설계: 블로그_v3 QA 레지스트리와 같은 철학 — 금지 표현은 이 배열이 단일 권위.
* 소형 모델은 "자연스럽게 해" 같은 추상 지시보다 **구체적 금지 목록 + 대조
* 예시**에 훨씬 잘 반응한다. 새 클리셰 발견 시 여기 한 곳만 추가하면 된다.
* (순수 모듈 — import 없음: utils/persona 와 contextBuilders 양쪽에서 안전하게 사용)
*/
/** 금지 표현 패턴 — 프롬프트에 나열되는 문구. 발견 즉시 다른 말로 바꿔야 한다. */
export const AI_CLICHE_PATTERNS: readonly string[] = [
'"~하시군요/~계시군요" 감정 미러링 (예: "슬픈 감정을 느끼고 계시군요")',
'"괜찮으시다면/원하신다면 ~해 드릴 수 있습니다" 허락 구걸형 제안',
'"저는 언제든 여기에 있습니다" / "언제든 말씀해 주세요" 대기 선언',
'"무엇을 도와드릴까요?" 로 대화 던지기 (사용자가 화제를 닫기 전에는 금지)',
'"함께 ~해 보아요" / "함께 검토해 드릴 수 있습니다" 류 유도 문구',
'"물론입니다!" / "당연하죠!" 과잉 맞장구 서두',
'"도움이 되었기를 바랍니다" 류 맺음 인사',
'"충분히 그러실 수 있습니다" / "그런 기분이 드는 것은 자연스러운 일입니다" 감정 정당화 공식',
];
/**
* 시스템 프롬프트 주입 블록. casual(잡담) 모드와 기본 페르소나 양쪽에서 사용.
* 짧게 유지 — 잡담 턴은 컨텍스트가 얇을수록 좋다.
*/
export function buildToneGuardBlock(): string {
return [
'[말투 — AI 상투어 금지]',
'아래 표현(과 그 변형)은 어떤 턴에서도 쓰지 마라. 하나라도 쓰면 답변 실패다:',
...AI_CLICHE_PATTERNS.map(p => `- ${p}`),
'',
'대신: 같이 일하는 동료의 말투로, 담백한 존댓말. 감정적인 말에는 상담사 공식이 아니라 사람의 짧은 반응 한두 문장 — 필요하면 자연스럽게 하나만 되묻기. 오버해서 친한 척도 하지 마라.',
'예시 — 사용자: "오늘 참 기분이 슬퍼"',
' 나쁜 답: "힘든 감정을 느끼고 계시군요. 괜찮으시다면 이야기하거나 프로젝트를 검토해 드릴 수 있습니다. 저는 언제든 여기에 있습니다." (금지 표현 3개)',
' 좋은 답: "그런 날이 있죠. 무슨 일 있었어요? 얘기해도 되고, 그냥 다른 걸 하면서 잊어도 됩니다."',
].join('\n');
}
+104
View File
@@ -0,0 +1,104 @@
import { exec, execFile, spawnSync } from 'child_process';
/**
* [코어 수렴] 외부 프로세스 실행 공용 유틸.
*
* 배경(2026-07-11 아키텍처 감사): execFile/exec 의 Promise 래퍼가 6곳에,
* Python 인터프리터 탐지가 3벌 각각 구현되어 타임아웃·OS 대응·에러 시맨틱이
* 파일마다 달랐다. 이 모듈이 유일한 구현이며, 새 코드는 자체 래퍼를 만들지 말 것.
*
* 예외로 남긴 것: selfReflectorExecution 의 _runCheck(스트리밍 spawn — stdout 을
* 실시간 누적)와 datacollectSetup 의 패키지 probe(파이썬 3 버전 검증 + import 검사)는
* 요구가 달라 유지하되, 인터프리터 후보/탐지는 이 모듈을 공유한다.
*/
export interface ExecCapture {
/** 종료 코드. 실행 자체가 실패(spawn 불가)면 -1. */
code: number;
stdout: string;
stderr: string;
/** 타임아웃으로 강제 종료됨. */
timedOut: boolean;
/** 명령을 찾지 못하는 등 spawn 자체가 실패. */
spawnFailed?: boolean;
}
/** execFile 기반 캡처 실행 — 절대 reject 하지 않고 항상 ExecCapture 를 돌려준다. */
export function execFileCapture(
cmd: string,
args: string[],
opts: { timeoutMs?: number; cwd?: string; maxBuffer?: number } = {},
): Promise<ExecCapture> {
return new Promise((resolve) => {
execFile(cmd, args, {
timeout: opts.timeoutMs ?? 15_000,
cwd: opts.cwd,
maxBuffer: opts.maxBuffer ?? 1024 * 1024,
windowsHide: true,
}, (err: any, stdout, stderr) => {
resolve({
code: err ? (typeof err.code === 'number' ? err.code : -1) : 0,
stdout: String(stdout || ''),
stderr: String(stderr || ''),
timedOut: !!err?.killed,
spawnFailed: err?.code === 'ENOENT' || undefined,
});
});
});
}
/**
* 셸 명령 실행 (exec 기반). 실패 시 reject — err.stdout/err.stderr 가 붙는
* node 표준 시맨틱 그대로 (health check 의 git 자격증명 판정 등이 의존).
*/
export function execShell(
command: string,
opts: { timeoutMs?: number; cwd?: string; env?: NodeJS.ProcessEnv } = {},
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
exec(command, {
timeout: opts.timeoutMs ?? 15_000,
cwd: opts.cwd,
env: opts.env,
windowsHide: true,
}, (err, stdout, stderr) => {
if (err) reject(Object.assign(err, { stdout: String(stdout || ''), stderr: String(stderr || '') }));
else resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
});
});
}
// ── Python 인터프리터 탐지 (단일 구현·공유 캐시) ────────────────────────────
// OS별 후보 순서: 윈도우는 python/py 가 표준, 그 외는 python3 가 안전.
export const PYTHON_CANDIDATES: readonly string[] = process.platform === 'win32'
? ['python', 'py', 'python3']
: ['python3', 'python'];
let _pythonCmd: string | null | undefined;
/** 비동기 탐지 — 프로세스 생존 동안 캐시. null = 미설치. */
export async function detectPython(): Promise<string | null> {
if (_pythonCmd !== undefined) return _pythonCmd;
for (const cmd of PYTHON_CANDIDATES) {
const r = await execFileCapture(cmd, ['--version'], { timeoutMs: 3_000 });
if (r.code === 0) { _pythonCmd = cmd; return cmd; }
}
_pythonCmd = null;
return null;
}
/** 동기 탐지 (spawnSync) — 동기 컨텍스트(selfReflector _pickTool 등)용. 같은 캐시 공유. */
export function detectPythonSync(): string | null {
if (_pythonCmd !== undefined) return _pythonCmd;
for (const cmd of PYTHON_CANDIDATES) {
try {
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
if (!r.error && (r.status === 0 || r.status === null)) { _pythonCmd = cmd; return cmd; }
} catch { /* 다음 후보 */ }
}
_pythonCmd = null;
return null;
}
/** 테스트용 — 캐시 초기화. */
export function _resetPythonCache(): void { _pythonCmd = undefined; }
+40
View File
@@ -0,0 +1,40 @@
/**
* [코어 수렴] KST(Asia/Seoul) 스케줄 공용 유틸.
*
* 배경(2026-07-11 아키텍처 감사): stocksWatcher·dailyBriefing·sleepDigest·
* growthCycleWatcher 네 워처가 각각 nowInKst()/HH:MM 파싱을 복제하고 있었다.
* "다음 발사까지 ms" 계산은 정책이 제각각(복수 시각/평일만/매일/주간)이라
* 각 워처에 남기고, 여기서는 그 계산의 공통 원료만 단일화한다.
*
* 시간대는 항상 Asia/Seoul 강제 — 사용자 OS timezone 과 무관하게 같은 시각에 동작.
*/
export interface KstNow {
hour: number;
minute: number;
/** 'YYYY-MM-DD' — 하루 1회 발사 dedupe 키로 쓰인다. */
ymd: string;
/** 요일 (0=일 … 6=토). */
weekday: number;
}
/** Asia/Seoul 기준 *지금* 의 시각/날짜/요일. */
export function nowInKst(): KstNow {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Seoul',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(new Date());
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
// 'YYYY-MM-DD' → UTC midnight Date — getUTCDay 가 그 날짜의 요일.
const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay();
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday };
}
/** 'HH:MM' 설정 문자열 파싱 — 형식이 틀리면 기본값, 범위는 23:59 로 클램프. */
export function parseHhMm(raw: string | undefined, defHour: number, defMinute: number): { hour: number; minute: number } {
const m = (raw || '').trim().match(/^(\d{1,2}):(\d{2})$/);
if (!m) return { hour: defHour, minute: defMinute };
return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) };
}